From ad990dae8a1fab90dda3fbe7e1b7d4f58b35d936 Mon Sep 17 00:00:00 2001 From: Evan Fiordeliso Date: Mon, 30 Jun 2025 21:55:17 -0400 Subject: [PATCH] Move goodreads functions to class to expose via the plugin api --- src/commands/SearchGoodreadsCommand.ts | 12 +- src/data-sources/Goodreads.ts | 272 ++++++++++++++++++ src/data-sources/goodreads.ts | 272 ------------------ src/main.ts | 16 +- src/ui/modals/GoodreadsSearchModal.ts | 32 ++- src/ui/modals/GoodreadsSearchModalView.svelte | 7 +- src/ui/modals/GoodreadsSearchSuggestModal.ts | 4 +- .../modals/GoodreadsSearchSuggestion.svelte | 86 +++--- 8 files changed, 365 insertions(+), 336 deletions(-) create mode 100644 src/data-sources/Goodreads.ts delete mode 100644 src/data-sources/goodreads.ts diff --git a/src/commands/SearchGoodreadsCommand.ts b/src/commands/SearchGoodreadsCommand.ts index c15378e..45b226c 100644 --- a/src/commands/SearchGoodreadsCommand.ts +++ b/src/commands/SearchGoodreadsCommand.ts @@ -1,4 +1,4 @@ -import { getBookByLegacyId, type SearchResult } from "@data-sources/goodreads"; +import type { Goodreads, SearchResult } from "@data-sources/Goodreads"; import { GoodreadsSearchModal, GoodreadsSearchSuggestModal } from "@ui/modals"; import { App, Notice } from "obsidian"; import { Command } from "./Command"; @@ -7,6 +7,7 @@ import type { Book } from "@src/types"; export class SearchGoodreadsCommand extends Command { constructor( private readonly app: App, + private readonly goodreads: Goodreads, private readonly cb: (book: Book) => void ) { super("search-goodreads", "Search Goodreads"); @@ -15,7 +16,10 @@ export class SearchGoodreadsCommand extends Command { async callback() { let results: SearchResult[]; try { - results = await GoodreadsSearchModal.createAndOpen(this.app); + results = await GoodreadsSearchModal.createAndOpen( + this.app, + this.goodreads + ); } catch (error) { console.error("Failed to search Goodreads:", error); new Notice( @@ -35,7 +39,9 @@ export class SearchGoodreadsCommand extends Command { let book: Book; try { - book = await getBookByLegacyId(selectedResult.legacyId); + book = await this.goodreads.getBookByLegacyId( + selectedResult.legacyId + ); } catch (error) { console.error("Failed to get book:", error); new Notice("Failed to get book. Check console for details."); diff --git a/src/data-sources/Goodreads.ts b/src/data-sources/Goodreads.ts new file mode 100644 index 0000000..4f7cde7 --- /dev/null +++ b/src/data-sources/Goodreads.ts @@ -0,0 +1,272 @@ +import { requestUrl } from "obsidian"; +import type { + Author, + Book as OutputBook, + Series as OutputSeries, +} from "../types"; + +interface Ref { + __ref: string; +} + +interface BookContributorEdge { + __typename: "BookContributorEdge"; + node: Ref; + role: string; +} + +interface BookSeries { + __typename: "BookSeries"; + userPosition: string; + series: Ref; +} + +interface Genre { + __typename: "Genre"; + name: string; + webUrl: string; +} + +interface BookGenre { + __typename: "BookGenre"; + genre: Genre; +} + +interface Language { + __typename: "Language"; + name: string; +} + +interface BookDetails { + __typename: "BookDetails"; + asin: string; + format: string; + numPages: number; + publicationTime: number; + publisher: string; + isbn: string; + isbn13: string; + language: Language; +} + +interface Book { + __typename: "Book"; + id: string; + legacyId: number; + webUrl: string; + title: string; + titleComplete: string; + description: string; + primaryContributorEdge: BookContributorEdge; + secondaryContributorEdges: BookContributorEdge[]; + imageUrl: string; + bookSeries: BookSeries[]; + bookGenres: BookGenre[]; + details: BookDetails; + work: Ref; +} + +interface ContributorWorksConnection { + __typename: "ContributorWorksConnection"; + totalCount: number; +} + +interface ContributorFollowersConnection { + __typename: "ContributorFollowersConnection"; + totalCount: number; +} + +interface Contributor { + __typename: "Contributor"; + id: string; + legacyId: number; + name: string; + description: string; + isGrAuthor: boolean; + works: ContributorWorksConnection; + profileImageUrl: string; + webUrl: string; + followers: ContributorFollowersConnection; +} + +interface Series { + __typename: "Series"; + id: string; + title: string; + webUrl: string; +} + +interface Work { + __typename: "Work"; + id: string; + legacyId: number; + bestBook: Ref; + // ...Book +} + +interface Query { + __typename: "Query"; + [key: string]: any; +} + +interface NextData { + props: { + pageProps: { + apolloState: { + ROOT_QUERY: Query; + [key: string]: any; + }; + params: Record; + query: Record; + jwtToken: string; + dataSource: string; + }; + }; +} + +export interface SearchResult { + legacyId: number; + title: string; + authors: string[]; + avgRating: number; + ratingCount: number; + publicationYear: number; + editionCount: number; + coverImageUrl: string; +} + +export class Goodreads { + async getNextData(legacyId: number): Promise { + const url = "https://www.goodreads.com/book/show/" + legacyId; + const res = await requestUrl({ url }); + const doc = new DOMParser().parseFromString(res.text, "text/html"); + let nextDataRaw = doc.getElementById("__NEXT_DATA__")?.textContent; + if (typeof nextDataRaw !== "string") { + throw new Error("Unable to find next data script in the document."); + } + const nextData = JSON.parse(nextDataRaw) as NextData; + + return nextData; + } + + extractBookFromNextData(nextData: NextData, ref: Ref): OutputBook { + const apolloState = nextData.props.pageProps.apolloState; + const bookData = apolloState[ref.__ref] as Book; + + const contributorEdges = [ + bookData.primaryContributorEdge, + ...bookData.secondaryContributorEdges, + ]; + const authors = contributorEdges + .filter((edge) => edge.role === "Author") + .map((edge) => apolloState[edge.node.__ref]) + .map((contributor) => ({ + id: contributor.id, + legacyId: contributor.legacyId, + name: contributor.name, + description: contributor.description, + })); + + let series: OutputSeries | null = null; + if (bookData.bookSeries.length > 0) { + const bookSeries = bookData.bookSeries[0]; + const seriesData = apolloState[bookSeries.series.__ref] as Series; + series = { + title: seriesData.title, + position: parseInt(bookSeries.userPosition, 10), + }; + } + + return { + title: bookData.title, + description: bookData.description, + authors, + series, + publisher: bookData.details.publisher, + publishedAt: new Date(bookData.details.publicationTime), + genres: bookData.bookGenres.map((genre) => genre.genre.name), + coverImageUrl: bookData.imageUrl, + pageCount: bookData.details.numPages, + isbn: bookData.details.isbn, + isbn13: bookData.details.isbn13, + }; + } + + async getBookByLegacyId(legacyId: number): Promise { + const nextData = await this.getNextData(legacyId); + const bookRef = nextData.props.pageProps.apolloState.ROOT_QUERY[ + `getBookByLegacyId({"legacyId":"${legacyId}"})` + ] as Ref | undefined; + if (bookRef === undefined) { + throw new Error("Could not find reference for book."); + } + + return this.extractBookFromNextData(nextData, bookRef); + } + + async search(q: string): Promise { + const url = + "https://www.goodreads.com/search?q=" + encodeURIComponent(q); + const res = await requestUrl({ url }); + const doc = new DOMParser().parseFromString(res.text, "text/html"); + + return Array.from( + doc.querySelectorAll( + "table.tableList tr[itemtype='http://schema.org/Book']" + ) + ).map((el) => { + const legacyId = parseInt( + el.querySelector("div.u-anchorTarget")?.id ?? "", + 10 + ); + + const title = + el.querySelector("a.bookTitle")?.textContent?.trim() || ""; + + const authors = Array.from(el.querySelectorAll("a.authorName")).map( + (a) => a.textContent?.trim() || "" + ); + + const avgRating = parseFloat( + el + .querySelector("span.minirating") + ?.textContent?.match(/(\d+\.\d+) avg rating/)?.[1] ?? "0" + ); + + const ratingCount = parseInt( + el + .querySelector("span.minirating") + ?.textContent?.match(/(\d[\d,]*) ratings/)?.[1] ?? "0", + 10 + ); + + const publicationYear = parseInt( + el + .querySelector("span.greyText") + ?.textContent?.match(/published (\d{4})/)?.[1] ?? "0", + 10 + ); + + const editionCount = parseInt( + el + .querySelector("span.greyText") + ?.textContent?.match(/(\d+) editions/)?.[1] ?? "0", + 10 + ); + + const coverImageUrl = + el.querySelector("img.bookCover")?.getAttribute("src") || ""; + + return { + legacyId, + title, + authors, + avgRating, + ratingCount, + publicationYear, + editionCount, + coverImageUrl, + }; + }); + } +} diff --git a/src/data-sources/goodreads.ts b/src/data-sources/goodreads.ts deleted file mode 100644 index 5e664a4..0000000 --- a/src/data-sources/goodreads.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { requestUrl } from "obsidian"; -import type { - Author, - Book as OutputBook, - Series as OutputSeries, -} from "../types"; - -interface Ref { - __ref: string; -} - -interface BookContributorEdge { - __typename: "BookContributorEdge"; - node: Ref; - role: string; -} - -interface BookSeries { - __typename: "BookSeries"; - userPosition: string; - series: Ref; -} - -interface Genre { - __typename: "Genre"; - name: string; - webUrl: string; -} - -interface BookGenre { - __typename: "BookGenre"; - genre: Genre; -} - -interface Language { - __typename: "Language"; - name: string; -} - -interface BookDetails { - __typename: "BookDetails"; - asin: string; - format: string; - numPages: number; - publicationTime: number; - publisher: string; - isbn: string; - isbn13: string; - language: Language; -} - -interface Book { - __typename: "Book"; - id: string; - legacyId: number; - webUrl: string; - title: string; - titleComplete: string; - description: string; - primaryContributorEdge: BookContributorEdge; - secondaryContributorEdges: BookContributorEdge[]; - imageUrl: string; - bookSeries: BookSeries[]; - bookGenres: BookGenre[]; - details: BookDetails; - work: Ref; -} - -interface ContributorWorksConnection { - __typename: "ContributorWorksConnection"; - totalCount: number; -} - -interface ContributorFollowersConnection { - __typename: "ContributorFollowersConnection"; - totalCount: number; -} - -interface Contributor { - __typename: "Contributor"; - id: string; - legacyId: number; - name: string; - description: string; - isGrAuthor: boolean; - works: ContributorWorksConnection; - profileImageUrl: string; - webUrl: string; - followers: ContributorFollowersConnection; -} - -interface Series { - __typename: "Series"; - id: string; - title: string; - webUrl: string; -} - -interface Work { - __typename: "Work"; - id: string; - legacyId: number; - bestBook: Ref; - // ...Book -} - -interface Query { - __typename: "Query"; - [key: string]: any; -} - -interface NextData { - props: { - pageProps: { - apolloState: { - ROOT_QUERY: Query; - [key: string]: any; - }; - params: Record; - query: Record; - jwtToken: string; - dataSource: string; - }; - }; -} - -export interface SearchResult { - legacyId: number; - title: string; - authors: string[]; - avgRating: number; - ratingCount: number; - publicationYear: number; - editionCount: number; - coverImageUrl: string; -} - -export function createBookFromNextData( - nextData: NextData, - ref: Ref -): OutputBook { - const apolloState = nextData.props.pageProps.apolloState; - const bookData = apolloState[ref.__ref] as Book; - - const contributorEdges = [ - bookData.primaryContributorEdge, - ...bookData.secondaryContributorEdges, - ]; - const authors = contributorEdges - .filter((edge) => edge.role === "Author") - .map((edge) => apolloState[edge.node.__ref]) - .map((contributor) => ({ - id: contributor.id, - legacyId: contributor.legacyId, - name: contributor.name, - description: contributor.description, - })); - - let series: OutputSeries | null = null; - if (bookData.bookSeries.length > 0) { - const bookSeries = bookData.bookSeries[0]; - const seriesData = apolloState[bookSeries.series.__ref] as Series; - series = { - title: seriesData.title, - position: parseInt(bookSeries.userPosition, 10), - }; - } - - return { - title: bookData.title, - description: bookData.description, - authors, - series, - publisher: bookData.details.publisher, - publishedAt: new Date(bookData.details.publicationTime), - genres: bookData.bookGenres.map((genre) => genre.genre.name), - coverImageUrl: bookData.imageUrl, - pageCount: bookData.details.numPages, - isbn: bookData.details.isbn, - isbn13: bookData.details.isbn13, - }; -} - -async function getNextData(legacyId: number): Promise { - const url = "https://www.goodreads.com/book/show/" + legacyId; - const res = await requestUrl({ url }); - const doc = new DOMParser().parseFromString(res.text, "text/html"); - let nextDataRaw = doc.getElementById("__NEXT_DATA__")?.textContent; - if (typeof nextDataRaw !== "string") { - throw new Error("Unable to find next data script in the document."); - } - const nextData = JSON.parse(nextDataRaw) as NextData; - - return nextData; -} - -export async function getBookByLegacyId(legacyId: number): Promise { - const nextData = await getNextData(legacyId); - const bookRef = nextData.props.pageProps.apolloState.ROOT_QUERY[ - `getBookByLegacyId({"legacyId":"${legacyId}"})` - ] as Ref | undefined; - if (bookRef === undefined) { - throw new Error("Could not find reference for book."); - } - - return createBookFromNextData(nextData, bookRef); -} - -export async function searchBooks(q: string): Promise { - const url = "https://www.goodreads.com/search?q=" + encodeURIComponent(q); - const res = await requestUrl({ url }); - const doc = new DOMParser().parseFromString(res.text, "text/html"); - - return Array.from( - doc.querySelectorAll( - "table.tableList tr[itemtype='http://schema.org/Book']" - ) - ).map((el) => { - const legacyId = parseInt( - el.querySelector("div.u-anchorTarget")?.id ?? "", - 10 - ); - - const title = - el.querySelector("a.bookTitle")?.textContent?.trim() || ""; - - const authors = Array.from(el.querySelectorAll("a.authorName")).map( - (a) => a.textContent?.trim() || "" - ); - - const avgRating = parseFloat( - el - .querySelector("span.minirating") - ?.textContent?.match(/(\d+\.\d+) avg rating/)?.[1] ?? "0" - ); - - const ratingCount = parseInt( - el - .querySelector("span.minirating") - ?.textContent?.match(/(\d[\d,]*) ratings/)?.[1] ?? "0", - 10 - ); - - const publicationYear = parseInt( - el - .querySelector("span.greyText") - ?.textContent?.match(/published (\d{4})/)?.[1] ?? "0", - 10 - ); - - const editionCount = parseInt( - el - .querySelector("span.greyText") - ?.textContent?.match(/(\d+) editions/)?.[1] ?? "0", - 10 - ); - - const coverImageUrl = - el.querySelector("img.bookCover")?.getAttribute("src") || ""; - - return { - legacyId, - title, - authors, - avgRating, - ratingCount, - publicationYear, - editionCount, - coverImageUrl, - }; - }); -} diff --git a/src/main.ts b/src/main.ts index 9f5febb..f55e426 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,12 +17,14 @@ 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"; export default class BookTrackerPlugin extends Plugin { - settings: BookTrackerPluginSettings; - templater: Templater; - storage: Storage; - readingLog: ReadingLog; + public settings: BookTrackerPluginSettings; + public templater: Templater; + public storage: Storage; + public readingLog: ReadingLog; + public goodreads: Goodreads = new Goodreads(); async onload() { await this.loadSettings(); @@ -32,7 +34,11 @@ export default class BookTrackerPlugin extends Plugin { this.readingLog = new ReadingLog(this.storage); this.addCommand( - new SearchGoodreadsCommand(this.app, this.createEntry.bind(this)) + new SearchGoodreadsCommand( + this.app, + this.goodreads, + this.createEntry.bind(this) + ) ); this.addCommand(new LogReadingStartedCommand(this.app, this.settings)); this.addCommand( diff --git a/src/ui/modals/GoodreadsSearchModal.ts b/src/ui/modals/GoodreadsSearchModal.ts index 433376b..fd53a8f 100644 --- a/src/ui/modals/GoodreadsSearchModal.ts +++ b/src/ui/modals/GoodreadsSearchModal.ts @@ -1,5 +1,5 @@ import GoodreadsSearchModalView from "./GoodreadsSearchModalView.svelte"; -import { type SearchResult } from "@data-sources/goodreads"; +import { Goodreads, type SearchResult } from "@data-sources/Goodreads"; import { App } from "obsidian"; import { SvelteModal } from "./SvelteModal"; @@ -8,23 +8,33 @@ export class GoodreadsSearchModal extends SvelteModal< > { constructor( app: App, + goodreads: Goodreads, onSearch: (error: any, results: SearchResult[]) => void = () => {} ) { - super(app, GoodreadsSearchModalView, { props: { onSearch } }); + super(app, GoodreadsSearchModalView, { + props: { goodreads, onSearch }, + }); } - static createAndOpen(app: App): Promise { + static createAndOpen( + app: App, + goodreads: Goodreads + ): Promise { return new Promise((resolve, reject) => { - const modal = new GoodreadsSearchModal(app, (error, results) => { - modal.close(); + const modal = new GoodreadsSearchModal( + app, + goodreads, + (error, results) => { + modal.close(); - if (error) { - reject(error); - return; + if (error) { + reject(error); + return; + } + + resolve(results); } - - resolve(results); - }); + ); modal.open(); }); } diff --git a/src/ui/modals/GoodreadsSearchModalView.svelte b/src/ui/modals/GoodreadsSearchModalView.svelte index 72d41b0..24332d3 100644 --- a/src/ui/modals/GoodreadsSearchModalView.svelte +++ b/src/ui/modals/GoodreadsSearchModalView.svelte @@ -1,11 +1,12 @@
- {searchResult.title} -
-

{searchResult.title}

-

- {searchResult.authors.join(", ")} - {#if searchResult.publicationYear > 0} - ({searchResult.publicationYear}) - {/if} -

-
+ {searchResult.title} +
+

{searchResult.title}

+

+ {searchResult.authors.join(", ")} + {#if searchResult.publicationYear > 0} + ({searchResult.publicationYear}) + {/if} +

+
\ No newline at end of file + .extra-details { + color: var(--text-muted); + font-size: var(--font-ui-small); + display: flex; + gap: var(--size-4-1); + } + } + } +