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 { GoodreadsSearchModal, GoodreadsSearchSuggestModal } from "@ui/modals";
 | 
				
			||||||
import { App, Notice } from "obsidian";
 | 
					import { App, Notice } from "obsidian";
 | 
				
			||||||
import { Command } from "./Command";
 | 
					import { Command } from "./Command";
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,7 @@ import type { Book } from "@src/types";
 | 
				
			||||||
export class SearchGoodreadsCommand extends Command {
 | 
					export class SearchGoodreadsCommand extends Command {
 | 
				
			||||||
	constructor(
 | 
						constructor(
 | 
				
			||||||
		private readonly app: App,
 | 
							private readonly app: App,
 | 
				
			||||||
 | 
							private readonly goodreads: Goodreads,
 | 
				
			||||||
		private readonly cb: (book: Book) => void
 | 
							private readonly cb: (book: Book) => void
 | 
				
			||||||
	) {
 | 
						) {
 | 
				
			||||||
		super("search-goodreads", "Search Goodreads");
 | 
							super("search-goodreads", "Search Goodreads");
 | 
				
			||||||
| 
						 | 
					@ -15,7 +16,10 @@ export class SearchGoodreadsCommand extends Command {
 | 
				
			||||||
	async callback() {
 | 
						async callback() {
 | 
				
			||||||
		let results: SearchResult[];
 | 
							let results: SearchResult[];
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			results = await GoodreadsSearchModal.createAndOpen(this.app);
 | 
								results = await GoodreadsSearchModal.createAndOpen(
 | 
				
			||||||
 | 
									this.app,
 | 
				
			||||||
 | 
									this.goodreads
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
		} catch (error) {
 | 
							} catch (error) {
 | 
				
			||||||
			console.error("Failed to search Goodreads:", error);
 | 
								console.error("Failed to search Goodreads:", error);
 | 
				
			||||||
			new Notice(
 | 
								new Notice(
 | 
				
			||||||
| 
						 | 
					@ -35,7 +39,9 @@ export class SearchGoodreadsCommand extends Command {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		let book: Book;
 | 
							let book: Book;
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			book = await getBookByLegacyId(selectedResult.legacyId);
 | 
								book = await this.goodreads.getBookByLegacyId(
 | 
				
			||||||
 | 
									selectedResult.legacyId
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
		} catch (error) {
 | 
							} catch (error) {
 | 
				
			||||||
			console.error("Failed to get book:", error);
 | 
								console.error("Failed to get book:", error);
 | 
				
			||||||
			new Notice("Failed to get book. Check console for details.");
 | 
								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 { ResetReadingStatusCommand } from "@commands/ResetReadingStatusCommand";
 | 
				
			||||||
import { BackupReadingLogCommand } from "@commands/CreateReadingLogBackupCommand";
 | 
					import { BackupReadingLogCommand } from "@commands/CreateReadingLogBackupCommand";
 | 
				
			||||||
import { RestoreReadingLogBackupCommand } from "@commands/RestoreReadingLogBackupCommand";
 | 
					import { RestoreReadingLogBackupCommand } from "@commands/RestoreReadingLogBackupCommand";
 | 
				
			||||||
 | 
					import { Goodreads } from "@data-sources/Goodreads";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class BookTrackerPlugin extends Plugin {
 | 
					export default class BookTrackerPlugin extends Plugin {
 | 
				
			||||||
	settings: BookTrackerPluginSettings;
 | 
						public settings: BookTrackerPluginSettings;
 | 
				
			||||||
	templater: Templater;
 | 
						public templater: Templater;
 | 
				
			||||||
	storage: Storage;
 | 
						public storage: Storage;
 | 
				
			||||||
	readingLog: ReadingLog;
 | 
						public readingLog: ReadingLog;
 | 
				
			||||||
 | 
						public goodreads: Goodreads = new Goodreads();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	async onload() {
 | 
						async onload() {
 | 
				
			||||||
		await this.loadSettings();
 | 
							await this.loadSettings();
 | 
				
			||||||
| 
						 | 
					@ -32,7 +34,11 @@ export default class BookTrackerPlugin extends Plugin {
 | 
				
			||||||
		this.readingLog = new ReadingLog(this.storage);
 | 
							this.readingLog = new ReadingLog(this.storage);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		this.addCommand(
 | 
							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(new LogReadingStartedCommand(this.app, this.settings));
 | 
				
			||||||
		this.addCommand(
 | 
							this.addCommand(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,5 @@
 | 
				
			||||||
import GoodreadsSearchModalView from "./GoodreadsSearchModalView.svelte";
 | 
					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 { App } from "obsidian";
 | 
				
			||||||
import { SvelteModal } from "./SvelteModal";
 | 
					import { SvelteModal } from "./SvelteModal";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,23 +8,33 @@ export class GoodreadsSearchModal extends SvelteModal<
 | 
				
			||||||
> {
 | 
					> {
 | 
				
			||||||
	constructor(
 | 
						constructor(
 | 
				
			||||||
		app: App,
 | 
							app: App,
 | 
				
			||||||
 | 
							goodreads: Goodreads,
 | 
				
			||||||
		onSearch: (error: any, results: SearchResult[]) => void = () => {}
 | 
							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) => {
 | 
							return new Promise((resolve, reject) => {
 | 
				
			||||||
			const modal = new GoodreadsSearchModal(app, (error, results) => {
 | 
								const modal = new GoodreadsSearchModal(
 | 
				
			||||||
				modal.close();
 | 
									app,
 | 
				
			||||||
 | 
									goodreads,
 | 
				
			||||||
 | 
									(error, results) => {
 | 
				
			||||||
 | 
										modal.close();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				if (error) {
 | 
										if (error) {
 | 
				
			||||||
					reject(error);
 | 
											reject(error);
 | 
				
			||||||
					return;
 | 
											return;
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
										resolve(results);
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
 | 
								);
 | 
				
			||||||
				resolve(results);
 | 
					 | 
				
			||||||
			});
 | 
					 | 
				
			||||||
			modal.open();
 | 
								modal.open();
 | 
				
			||||||
		});
 | 
							});
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,12 @@
 | 
				
			||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
	import { searchBooks, type SearchResult } from "@data-sources/goodreads";
 | 
						import { Goodreads, type SearchResult } from "@data-sources/Goodreads";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	interface Props {
 | 
						interface Props {
 | 
				
			||||||
 | 
							goodreads: Goodreads;
 | 
				
			||||||
		onSearch: (error: any, results?: SearchResult[]) => void;
 | 
							onSearch: (error: any, results?: SearchResult[]) => void;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	let { onSearch }: Props = $props();
 | 
						let { goodreads, onSearch }: Props = $props();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	let query = $state("");
 | 
						let query = $state("");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -13,7 +14,7 @@
 | 
				
			||||||
		if (event.key === "Enter") {
 | 
							if (event.key === "Enter") {
 | 
				
			||||||
			event.preventDefault();
 | 
								event.preventDefault();
 | 
				
			||||||
			try {
 | 
								try {
 | 
				
			||||||
				const results = await searchBooks(query);
 | 
									const results = await goodreads.search(query);
 | 
				
			||||||
				if (results.length === 0) {
 | 
									if (results.length === 0) {
 | 
				
			||||||
					onSearch(new Error("No results found."));
 | 
										onSearch(new Error("No results found."));
 | 
				
			||||||
					return;
 | 
										return;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
import GoodreadsSearchSuggestion from "./GoodreadsSearchSuggestion.svelte";
 | 
					import GoodreadsSearchSuggestion from "./GoodreadsSearchSuggestion.svelte";
 | 
				
			||||||
import { type SearchResult } from "@data-sources/goodreads";
 | 
					import { type SearchResult } from "@data-sources/Goodreads";
 | 
				
			||||||
import { App, Notice, SuggestModal } from "obsidian";
 | 
					import { App, SuggestModal } from "obsidian";
 | 
				
			||||||
import { mount } from "svelte";
 | 
					import { mount } from "svelte";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
 | 
					export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,53 +1,59 @@
 | 
				
			||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
	import type { SearchResult } from "@data-sources/goodreads";
 | 
						import type { SearchResult } from "@data-sources/Goodreads";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    interface Props {
 | 
						interface Props {
 | 
				
			||||||
        searchResult: SearchResult;
 | 
							searchResult: SearchResult;
 | 
				
			||||||
    }
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let { searchResult }: Props = $props();
 | 
						let { searchResult }: Props = $props();
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="obt-goodreads-search-suggestion">
 | 
					<div class="obt-goodreads-search-suggestion">
 | 
				
			||||||
    <img class="cover" src={searchResult.coverImageUrl} alt={searchResult.title} />
 | 
						<img
 | 
				
			||||||
    <div class="details">
 | 
							class="cover"
 | 
				
			||||||
        <h3 class="title">{searchResult.title}</h3>
 | 
							src={searchResult.coverImageUrl}
 | 
				
			||||||
        <p class="extra-details">
 | 
							alt={searchResult.title}
 | 
				
			||||||
            <span class="authors">{searchResult.authors.join(", ")}</span>
 | 
						/>
 | 
				
			||||||
            {#if searchResult.publicationYear > 0}
 | 
						<div class="details">
 | 
				
			||||||
            <span class="publicationYear">({searchResult.publicationYear})</span>
 | 
							<h3 class="title">{searchResult.title}</h3>
 | 
				
			||||||
            {/if}
 | 
							<p class="extra-details">
 | 
				
			||||||
        </p>
 | 
								<span class="authors">{searchResult.authors.join(", ")}</span>
 | 
				
			||||||
    </div>
 | 
								{#if searchResult.publicationYear > 0}
 | 
				
			||||||
 | 
									<span class="publicationYear"
 | 
				
			||||||
 | 
										>({searchResult.publicationYear})</span
 | 
				
			||||||
 | 
									>
 | 
				
			||||||
 | 
								{/if}
 | 
				
			||||||
 | 
							</p>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<style lang="scss">
 | 
					<style lang="scss">
 | 
				
			||||||
    .obt-goodreads-search-suggestion {
 | 
						.obt-goodreads-search-suggestion {
 | 
				
			||||||
        display: flex;
 | 
							display: flex;
 | 
				
			||||||
        align-items: center;
 | 
							align-items: center;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        img.cover {
 | 
							img.cover {
 | 
				
			||||||
            max-width: 100px;
 | 
								max-width: 100px;
 | 
				
			||||||
            max-height: 100px;
 | 
								max-height: 100px;
 | 
				
			||||||
            margin-right: var(--size-4-2);
 | 
								margin-right: var(--size-4-2);
 | 
				
			||||||
            object-fit: cover;
 | 
								object-fit: cover;
 | 
				
			||||||
            border-radius: var(--radius-s);
 | 
								border-radius: var(--radius-s);
 | 
				
			||||||
        }
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        .details {
 | 
							.details {
 | 
				
			||||||
            flex-grow: 1;
 | 
								flex-grow: 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            .title {
 | 
								.title {
 | 
				
			||||||
                color: var(--text-normal);
 | 
									color: var(--text-normal);
 | 
				
			||||||
                font-size: var(--font-ui-medium);
 | 
									font-size: var(--font-ui-medium);
 | 
				
			||||||
            }
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            .extra-details {
 | 
								.extra-details {
 | 
				
			||||||
                color: var(--text-muted);
 | 
									color: var(--text-muted);
 | 
				
			||||||
                font-size: var(--font-ui-small);
 | 
									font-size: var(--font-ui-small);
 | 
				
			||||||
                display: flex;
 | 
									display: flex;
 | 
				
			||||||
                gap: var(--size-4-1);
 | 
									gap: var(--size-4-1);
 | 
				
			||||||
            }
 | 
								}
 | 
				
			||||||
        }
 | 
							}
 | 
				
			||||||
    }
 | 
						}
 | 
				
			||||||
</style>
 | 
					</style>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue