Move goodreads functions to class to expose via the plugin api

This commit is contained in:
Evan Fiordeliso 2025-06-30 21:55:17 -04:00
parent c58f1c4bf5
commit ad990dae8a
8 changed files with 365 additions and 336 deletions

View File

@ -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.");

View File

@ -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<string, string>;
query: Record<string, string>;
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<NextData> {
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<Contributor>((edge) => apolloState[edge.node.__ref])
.map<Author>((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<OutputBook> {
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<SearchResult[]> {
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<SearchResult>((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,
};
});
}
}

View File

@ -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<string, string>;
query: Record<string, string>;
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<Contributor>((edge) => apolloState[edge.node.__ref])
.map<Author>((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<NextData> {
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<OutputBook> {
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<SearchResult[]> {
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<SearchResult>((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,
};
});
}

View File

@ -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(

View File

@ -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<SearchResult[]> {
static createAndOpen(
app: App,
goodreads: Goodreads
): Promise<SearchResult[]> {
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();
});
}

View File

@ -1,11 +1,12 @@
<script lang="ts">
import { searchBooks, type SearchResult } from "@data-sources/goodreads";
import { Goodreads, type SearchResult } from "@data-sources/Goodreads";
interface Props {
goodreads: Goodreads;
onSearch: (error: any, results?: SearchResult[]) => void;
}
let { onSearch }: Props = $props();
let { goodreads, onSearch }: Props = $props();
let query = $state("");
@ -13,7 +14,7 @@
if (event.key === "Enter") {
event.preventDefault();
try {
const results = await searchBooks(query);
const results = await goodreads.search(query);
if (results.length === 0) {
onSearch(new Error("No results found."));
return;

View File

@ -1,6 +1,6 @@
import GoodreadsSearchSuggestion from "./GoodreadsSearchSuggestion.svelte";
import { type SearchResult } from "@data-sources/goodreads";
import { App, Notice, SuggestModal } from "obsidian";
import { type SearchResult } from "@data-sources/Goodreads";
import { App, SuggestModal } from "obsidian";
import { mount } from "svelte";
export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {

View File

@ -1,53 +1,59 @@
<script lang="ts">
import type { SearchResult } from "@data-sources/goodreads";
import type { SearchResult } from "@data-sources/Goodreads";
interface Props {
searchResult: SearchResult;
}
interface Props {
searchResult: SearchResult;
}
let { searchResult }: Props = $props();
let { searchResult }: Props = $props();
</script>
<div class="obt-goodreads-search-suggestion">
<img class="cover" src={searchResult.coverImageUrl} alt={searchResult.title} />
<div class="details">
<h3 class="title">{searchResult.title}</h3>
<p class="extra-details">
<span class="authors">{searchResult.authors.join(", ")}</span>
{#if searchResult.publicationYear > 0}
<span class="publicationYear">({searchResult.publicationYear})</span>
{/if}
</p>
</div>
<img
class="cover"
src={searchResult.coverImageUrl}
alt={searchResult.title}
/>
<div class="details">
<h3 class="title">{searchResult.title}</h3>
<p class="extra-details">
<span class="authors">{searchResult.authors.join(", ")}</span>
{#if searchResult.publicationYear > 0}
<span class="publicationYear"
>({searchResult.publicationYear})</span
>
{/if}
</p>
</div>
</div>
<style lang="scss">
.obt-goodreads-search-suggestion {
display: flex;
align-items: center;
.obt-goodreads-search-suggestion {
display: flex;
align-items: center;
img.cover {
max-width: 100px;
max-height: 100px;
margin-right: var(--size-4-2);
object-fit: cover;
border-radius: var(--radius-s);
}
img.cover {
max-width: 100px;
max-height: 100px;
margin-right: var(--size-4-2);
object-fit: cover;
border-radius: var(--radius-s);
}
.details {
flex-grow: 1;
.details {
flex-grow: 1;
.title {
color: var(--text-normal);
font-size: var(--font-ui-medium);
}
.title {
color: var(--text-normal);
font-size: var(--font-ui-medium);
}
.extra-details {
color: var(--text-muted);
font-size: var(--font-ui-small);
display: flex;
gap: var(--size-4-1);
}
}
}
</style>
.extra-details {
color: var(--text-muted);
font-size: var(--font-ui-small);
display: flex;
gap: var(--size-4-1);
}
}
}
</style>