From 2f89a703d3fad041781f978abdde54b9ec30c9f6 Mon Sep 17 00:00:00 2001 From: Evan Fiordeliso Date: Thu, 11 Jun 2026 16:20:02 -0400 Subject: [PATCH] Add redownload cover command and move image functions to services --- src/commands/RedownloadCoverCommand.ts | 34 +++++ src/commands/UpdateCoverFromURLCommand.ts | 13 +- src/data-sources/Goodreads.ts | 29 ++++- src/main.ts | 132 ++++++-------------- src/services/CoverImageDownloaderService.ts | 74 +++++++++++ src/services/ImageCompressorService.ts | 76 +++++++++++ src/utils/image.ts | 70 ----------- tsconfig.json | 83 +++++++----- 8 files changed, 306 insertions(+), 205 deletions(-) create mode 100644 src/commands/RedownloadCoverCommand.ts create mode 100644 src/services/CoverImageDownloaderService.ts create mode 100644 src/services/ImageCompressorService.ts delete mode 100644 src/utils/image.ts diff --git a/src/commands/RedownloadCoverCommand.ts b/src/commands/RedownloadCoverCommand.ts new file mode 100644 index 0000000..553abe4 --- /dev/null +++ b/src/commands/RedownloadCoverCommand.ts @@ -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 { + 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."); + } +} diff --git a/src/commands/UpdateCoverFromURLCommand.ts b/src/commands/UpdateCoverFromURLCommand.ts index c119575..2536a83 100644 --- a/src/commands/UpdateCoverFromURLCommand.ts +++ b/src/commands/UpdateCoverFromURLCommand.ts @@ -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, + 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.") diff --git a/src/data-sources/Goodreads.ts b/src/data-sources/Goodreads.ts index 1eccb87..b258c90 100644 --- a/src/data-sources/Goodreads.ts +++ b/src/data-sources/Goodreads.ts @@ -130,7 +130,34 @@ export interface SearchResult { export class Goodreads { async getNextData(legacyId: number): Promise { 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") { diff --git a/src/main.ts b/src/main.ts index 1b5432a..cf078f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { - 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 { 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 ); diff --git a/src/services/CoverImageDownloaderService.ts b/src/services/CoverImageDownloaderService.ts new file mode 100644 index 0000000..e26b2ae --- /dev/null +++ b/src/services/CoverImageDownloaderService.ts @@ -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 { + 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; + } + +} diff --git a/src/services/ImageCompressorService.ts b/src/services/ImageCompressorService.ts new file mode 100644 index 0000000..6acb562 --- /dev/null +++ b/src/services/ImageCompressorService.ts @@ -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((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((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()); + } + +} diff --git a/src/utils/image.ts b/src/utils/image.ts deleted file mode 100644 index 61bf1d0..0000000 --- a/src/utils/image.ts +++ /dev/null @@ -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((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((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()); -} diff --git a/tsconfig.json b/tsconfig.json index 731f1f7..4fffc13 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,36 +1,51 @@ { - "compilerOptions": { - "baseUrl": ".", - "inlineSourceMap": true, - "inlineSources": true, - "module": "ESNext", - "target": "ES6", - "allowJs": true, - "noImplicitAny": true, - "moduleResolution": "node", - "importHelpers": true, - "isolatedModules": true, - "strictNullChecks": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "lib": [ - "DOM", - "ES5", - "ES6", - "ES7" - ], - "paths": { - "@commands/*": ["src/commands/*"], - "@data-sources/*": ["src/data-sources/*"], - "@external/*": ["src/external/*"], - "@ui/*": ["src/ui/*"], - "@utils/*": ["src/utils/*"], - "@src/*": ["src/*"] - } - }, - "include": [ - "**/*.ts", - "**/*.svelte" - ] + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7" + ], + "paths": { + "@commands/*": [ + "src/commands/*" + ], + "@data-sources/*": [ + "src/data-sources/*" + ], + "@external/*": [ + "src/external/*" + ], + "@services/*": [ + "src/services/*" + ], + "@ui/*": [ + "src/ui/*" + ], + "@utils/*": [ + "src/utils/*" + ], + "@src/*": [ + "src/*" + ] + } + }, + "include": [ + "**/*.ts", + "**/*.svelte" + ] }