generated from tpl/obsidian-sample-plugin
			Move goodreads functions to class to expose via the plugin api
This commit is contained in:
		
							parent
							
								
									c58f1c4bf5
								
							
						
					
					
						commit
						ad990dae8a
					
				| 
						 | 
				
			
			@ -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.");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
			};
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
		};
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										16
									
								
								src/main.ts
								
								
								
								
							
							
						
						
									
										16
									
								
								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(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,14 +8,23 @@ 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) => {
 | 
			
		||||
			const modal = new GoodreadsSearchModal(
 | 
			
		||||
				app,
 | 
			
		||||
				goodreads,
 | 
			
		||||
				(error, results) => {
 | 
			
		||||
					modal.close();
 | 
			
		||||
 | 
			
		||||
					if (error) {
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +33,8 @@ export class GoodreadsSearchModal extends SvelteModal<
 | 
			
		|||
					}
 | 
			
		||||
 | 
			
		||||
					resolve(results);
 | 
			
		||||
			});
 | 
			
		||||
				}
 | 
			
		||||
			);
 | 
			
		||||
			modal.open();
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
<script lang="ts">
 | 
			
		||||
	import type { SearchResult } from "@data-sources/goodreads";
 | 
			
		||||
	import type { SearchResult } from "@data-sources/Goodreads";
 | 
			
		||||
 | 
			
		||||
	interface Props {
 | 
			
		||||
		searchResult: SearchResult;
 | 
			
		||||
| 
						 | 
				
			
			@ -9,13 +9,19 @@
 | 
			
		|||
</script>
 | 
			
		||||
 | 
			
		||||
<div class="obt-goodreads-search-suggestion">
 | 
			
		||||
    <img class="cover" src={searchResult.coverImageUrl} alt={searchResult.title} />
 | 
			
		||||
	<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>
 | 
			
		||||
				<span class="publicationYear"
 | 
			
		||||
					>({searchResult.publicationYear})</span
 | 
			
		||||
				>
 | 
			
		||||
			{/if}
 | 
			
		||||
		</p>
 | 
			
		||||
	</div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue