Add goodreads search capabilities

This commit is contained in:
Evan Fiordeliso 2025-06-26 13:08:49 -04:00
parent 13dba8127a
commit 13f34801ca
25 changed files with 4155 additions and 154 deletions

View File

@ -2,20 +2,19 @@ import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
@ -31,7 +30,8 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
...builtins,
],
format: "cjs",
target: "es2018",
logLevel: "info",

134
main.ts
View File

@ -1,134 +0,0 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
}

View File

@ -1,11 +1,9 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"id": "obsidian-book-tracker",
"name": "Book Tracker",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",
"description": "Simplifies tracking your reading progress and managing your book collection in Obsidian.",
"author": "FiFiTiDo",
"isDesktopOnly": false
}

View File

@ -4,20 +4,28 @@
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"dev": "run-p dev-*",
"dev-js": "node esbuild.config.mjs",
"dev-css": "sass sass/styles.scss styles.css --watch --no-source-map",
"build": "run-s build-*",
"build-js": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build-css": "sass sass/styles.scss styles.css --no-source-map",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@popperjs/core": "^2.11.8",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"handlebars": "^4.7.8",
"npm-run-all": "^4.1.5",
"obsidian": "latest",
"sass": "^1.89.2",
"tslib": "2.4.0",
"typescript": "4.7.4"
}

2881
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

5
sass/settings.scss Normal file
View File

@ -0,0 +1,5 @@
.obt-settings {
.search-input-container {
width: 100%;
}
}

3
sass/styles.scss Normal file
View File

@ -0,0 +1,3 @@
@use "views/goodreads-search.scss";
@use "views/goodreads-search-suggest.scss";
@use "settings.scss";

View File

@ -0,0 +1,29 @@
.obt-goodreads-search-suggest {
&__item {
display: flex;
align-items: center;
margin-bottom: 10px;
}
&__cover {
max-width: 100px;
max-height: 100px;
margin-right: 10px;
object-fit: cover;
border-radius: 3px;
}
&__info {
flex-grow: 1;
}
&__title {
color: var(--text-normal);
font-size: var(--font-ui-medium);
}
&__subtitle {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
}

View File

@ -0,0 +1,9 @@
.obt-goodreads-search {
&__input {
padding-bottom: 18px;
input {
width: 100%;
}
}
}

View File

@ -0,0 +1,149 @@
import { requestUrl } from "obsidian";
import {
Contributor,
NextData,
Ref,
Book as ApiBook,
SearchResult,
} from "./types";
import { Author, Book, Series } from "../../types";
export function createBookFromNextData(nextData: NextData, ref: Ref): Book {
const apolloState = nextData.props.pageProps.apolloState;
const bookData = apolloState[ref.__ref] as ApiBook;
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: Series | null = null;
if (bookData.bookSeries.length > 0) {
const bookSeries = bookData.bookSeries[0];
const seriesData = apolloState[bookSeries.series.__ref];
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,
pageLength: 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<Book> {
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");
const bookElements = doc.querySelectorAll(
"table.tableList tr[itemtype='http://schema.org/Book']"
);
const searchResults: SearchResult[] = [];
bookElements.forEach((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() || "")
.join(", ");
const coverImageUrl = el
.querySelector("img.bookCover")
?.getAttribute("src");
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
);
if (
!isNaN(legacyId) &&
title &&
authors &&
!isNaN(avgRating) &&
!isNaN(ratingCount) &&
!isNaN(publicationYear) &&
!isNaN(editionCount) &&
coverImageUrl
) {
searchResults.push({
legacyId,
title,
authors: authors.split(", "),
avgRating,
ratingCount,
publicationYear,
editionCount,
coverImageUrl,
});
}
});
return searchResults;
}

View File

@ -0,0 +1,129 @@
export interface Ref {
__ref: string;
}
export interface BookContributorEdge {
__typename: "BookContributorEdge";
node: Ref;
role: string;
}
export interface BookSeries {
__typename: "BookSeries";
userPosition: string;
series: Ref;
}
export interface Genre {
__typename: "Genre";
name: string;
webUrl: string;
}
export interface BookGenre {
__typename: "BookGenre";
genre: Genre;
}
export interface Language {
__typename: "Language";
name: string;
}
export interface BookDetails {
__typename: "BookDetails";
asin: string;
format: string;
numPages: number;
publicationTime: number;
publisher: string;
isbn: string;
isbn13: string;
language: Language;
}
export 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;
}
export interface ContributorWorksConnection {
__typename: "ContributorWorksConnection";
totalCount: number;
}
export interface ContributorFollowersConnection {
__typename: "ContributorFollowersConnection";
totalCount: number;
}
export interface Contributor {
__typename: "Contributor";
id: string;
legacyId: number;
name: string;
description: string;
isGrAuthor: boolean;
works: ContributorWorksConnection;
profileImageUrl: string;
webUrl: string;
followers: ContributorFollowersConnection;
}
export interface Series {
__typename: "Series";
id: string;
title: string;
webUrl: string;
}
export interface Work {
__typename: "Work";
id: string;
legacyId: number;
bestBook: Ref;
// ...
}
export interface Query {
__typename: "Query";
[key: string]: any;
}
export 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;
}

140
src/main.ts Normal file
View File

@ -0,0 +1,140 @@
import { Notice, Plugin, requestUrl } from "obsidian";
import {
BookTrackerPluginSettings,
BookTrackerSettingTab,
DEFAULT_SETTINGS,
} from "./settings/settings";
import { getBookByLegacyId } from "@data-sources/goodreads/scraper";
import { Templater } from "./utils/templater";
import { GoodreadsSearchModal } from "@views/goodreads-search-modal";
import { GoodreadsSearchSuggestModal } from "@views/goodreads-search-suggest-modal";
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
};
export default class BookTrackerPlugin extends Plugin {
settings: BookTrackerPluginSettings;
templater: Templater;
async onload() {
await this.loadSettings();
this.templater = new Templater(this.app);
this.addCommand({
id: "search-goodreads",
name: "Search Goodreads",
callback: () => this.searchGoodreads(),
});
this.addSettingTab(new BookTrackerSettingTab(this.app, this));
}
onunload() {}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
}
async saveSettings() {
await this.saveData(this.settings);
}
async downloadCoverImage(
coverImageUrl: string,
fileName: string
): Promise<string> {
const response = await requestUrl(coverImageUrl);
const contentType = response.headers["content-type"];
const extension = CONTENT_TYPE_EXTENSIONS[contentType || ""] || "";
if (extension === "") {
throw new Error("Unsupported content type: " + contentType);
}
let filePath = this.settings.coverDirectory + "/";
if (this.settings.groupCoversByFirstLetter) {
let groupName = fileName.charAt(0).toUpperCase();
if (!/^[A-Z]$/.test(groupName)) {
groupName = "#";
}
filePath += groupName + "/";
}
filePath += fileName + "." + extension;
const existingFile = this.app.vault.getFileByPath(filePath);
if (existingFile) {
if (this.settings.overwriteExistingCovers) {
await this.app.vault.modifyBinary(
existingFile,
response.arrayBuffer
);
} else {
new Notice("Cover image already exists: " + filePath);
return filePath;
}
}
await this.app.vault.createBinary(filePath, response.arrayBuffer);
return filePath;
}
async createEntryFromGoodreads(legacyId: number): Promise<void> {
try {
const book = await getBookByLegacyId(legacyId);
const fileName = this.templater
.renderTemplate(this.settings.fileNameFormat, {
title: book.title,
authors: book.authors.map((a) => a.name).join(", "),
})
.replace(/[/\:*?<>|""]/g, "");
const data: Record<string, unknown> = { book };
if (this.settings.downloadCovers && book.coverImageUrl) {
data.coverImagePath = await this.downloadCoverImage(
book.coverImageUrl,
fileName
);
}
const renderedContent = await this.templater.renderTemplateFile(
this.settings.templateFile,
data
);
if (renderedContent) {
await this.app.vault.create(
this.settings.tbrDirectory + "/" + fileName + ".md",
renderedContent
);
}
} catch (error) {
console.error("Failed to create book entry:", error);
}
}
async searchGoodreads(): Promise<void> {
const results = await GoodreadsSearchModal.createAndOpen(this.app);
const selectedBook = await GoodreadsSearchSuggestModal.createAndOpen(
this.app,
results
);
if (selectedBook) {
await this.createEntryFromGoodreads(selectedBook.legacyId);
} else {
new Notice("No book selected.");
}
}
}

188
src/settings/settings.ts Normal file
View File

@ -0,0 +1,188 @@
import BookTrackerPlugin from "@src/main";
import { App, PluginSettingTab, Setting } from "obsidian";
import { FileSuggest } from "./suggesters/file";
import { FolderSuggest } from "./suggesters/folder";
export interface BookTrackerPluginSettings {
templateFile: string;
tbrDirectory: string;
fileNameFormat: string;
downloadCovers: boolean;
coverDirectory: string;
groupCoversByFirstLetter: boolean;
overwriteExistingCovers: boolean;
}
export const DEFAULT_SETTINGS: BookTrackerPluginSettings = {
templateFile: "",
tbrDirectory: "books/tbr",
fileNameFormat: "{{title}} - {{authors}}",
downloadCovers: false,
coverDirectory: "images/covers",
groupCoversByFirstLetter: true,
overwriteExistingCovers: false,
};
export class BookTrackerSettingTab extends PluginSettingTab {
constructor(app: App, private plugin: BookTrackerPlugin) {
super(app, plugin);
}
heading(text: string): void {
const header = document.createDocumentFragment();
header.createEl("h2", { text });
new Setting(this.containerEl).setHeading().setName(text);
}
display(): void {
this.containerEl.empty();
this.containerEl.classList.add("obt-settings");
this.heading("Book Creation Settings");
this.templateFileSetting();
this.tbrDirectorySetting();
this.fileNameFormatSetting();
this.heading("Cover Download Settings");
this.downloadCoversSetting();
this.coverDirectorySetting();
this.groupCoversByFirstLetterSetting();
this.overwriteExistingCoversSetting();
}
overwriteExistingCoversSetting() {
return new Setting(this.containerEl)
.setName("Overwrite Existing Covers")
.setDesc("Overwrite existing book covers when downloading new ones")
.addToggle((cb) => {
cb.setValue(
this.plugin.settings.overwriteExistingCovers
).onChange(async (value) => {
this.plugin.settings.overwriteExistingCovers = value;
await this.plugin.saveSettings();
});
});
}
groupCoversByFirstLetterSetting() {
return new Setting(this.containerEl)
.setName("Group Covers by First Letter")
.setDesc(
"Organize downloaded book covers into subdirectories based on the first letter of the book title"
)
.addToggle((cb) => {
cb.setValue(
this.plugin.settings.groupCoversByFirstLetter
).onChange(async (value) => {
this.plugin.settings.groupCoversByFirstLetter = value;
await this.plugin.saveSettings();
});
});
}
coverDirectorySetting() {
return new Setting(this.containerEl)
.setName("Cover Directory")
.setDesc(
"Select the directory where downloaded book covers will be stored"
)
.addSearch((cb) => {
try {
new FolderSuggest(this.app, cb.inputEl);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("images/covers")
.setValue(this.plugin.settings.coverDirectory)
.onChange(async (value) => {
this.plugin.settings.coverDirectory = value;
await this.plugin.saveSettings();
});
});
}
downloadCoversSetting() {
return new Setting(this.containerEl)
.setName("Download Covers")
.setDesc(
"Automatically download book covers when creating new entries"
)
.addToggle((cb) => {
cb.setValue(this.plugin.settings.downloadCovers).onChange(
async (value) => {
this.plugin.settings.downloadCovers = value;
await this.plugin.saveSettings();
}
);
});
}
fileNameFormatSetting() {
const fileNameFormatDesc = document.createDocumentFragment();
fileNameFormatDesc.createDiv({
text: "Format for the file name of new book entries.",
});
fileNameFormatDesc.createDiv({
text: "Use {{title}} and {{authors}} as placeholders.",
});
new Setting(this.containerEl)
.setName("File Name Format")
.setDesc(fileNameFormatDesc)
.addText((cb) => {
cb.setPlaceholder("{{title}} - {{authors}}")
.setValue(this.plugin.settings.fileNameFormat)
.onChange(async (value) => {
this.plugin.settings.fileNameFormat = value;
await this.plugin.saveSettings();
});
});
}
tbrDirectorySetting() {
return new Setting(this.containerEl)
.setName("To Be Read Directory")
.setDesc(
"Select the directory where new book entries will be created"
)
.addSearch((cb) => {
try {
new FolderSuggest(this.app, cb.inputEl);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
const { containerEl } = this;
cb.setPlaceholder("books/tbr")
.setValue(this.plugin.settings.tbrDirectory)
.onChange(async (value) => {
this.plugin.settings.tbrDirectory = value;
await this.plugin.saveSettings();
});
});
}
templateFileSetting() {
return new Setting(this.containerEl)
.setName("Template File")
.setDesc("Select the template file to use for new book entries")
.addSearch((cb) => {
try {
new FileSuggest(this.app, cb.inputEl);
} catch {
// If the suggest fails, we can just ignore it.
// This might happen if the plugin is not fully loaded yet.
}
cb.setPlaceholder("templates/book-template")
.setValue(this.plugin.settings.templateFile)
.onChange(async (value) => {
this.plugin.settings.templateFile = value;
await this.plugin.saveSettings();
});
});
}
}

View File

@ -0,0 +1,202 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { App, ISuggestOwner, Scope } from "obsidian";
import { createPopper, Instance as PopperInstance } from "@popperjs/core";
const wrapAround = (value: number, size: number): number => {
return ((value % size) + size) % size;
};
class Suggest<T> {
private owner: ISuggestOwner<T>;
private values: T[];
private suggestions: HTMLDivElement[];
private selectedItem: number;
private containerEl: HTMLElement;
constructor(
owner: ISuggestOwner<T>,
containerEl: HTMLElement,
scope: Scope
) {
this.owner = owner;
this.containerEl = containerEl;
containerEl.on(
"click",
".suggestion-item",
this.onSuggestionClick.bind(this)
);
containerEl.on(
"mousemove",
".suggestion-item",
this.onSuggestionMouseover.bind(this)
);
scope.register([], "ArrowUp", (event) => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem - 1, true);
return false;
}
});
scope.register([], "ArrowDown", (event) => {
if (!event.isComposing) {
this.setSelectedItem(this.selectedItem + 1, true);
return false;
}
});
scope.register([], "Enter", (event) => {
if (!event.isComposing) {
this.useSelectedItem(event);
return false;
}
});
}
onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void {
event.preventDefault();
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
this.useSelectedItem(event);
}
onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void {
const item = this.suggestions.indexOf(el);
this.setSelectedItem(item, false);
}
setSuggestions(values: T[]) {
this.containerEl.empty();
const suggestionEls: HTMLDivElement[] = [];
values.forEach((value) => {
const suggestionEl = this.containerEl.createDiv("suggestion-item");
this.owner.renderSuggestion(value, suggestionEl);
suggestionEls.push(suggestionEl);
});
this.values = values;
this.suggestions = suggestionEls;
this.setSelectedItem(0, false);
}
useSelectedItem(event: MouseEvent | KeyboardEvent) {
const currentValue = this.values[this.selectedItem];
if (currentValue) {
this.owner.selectSuggestion(currentValue, event);
}
}
setSelectedItem(selectedIndex: number, scrollIntoView: boolean) {
const normalizedIndex = wrapAround(
selectedIndex,
this.suggestions.length
);
const prevSelectedSuggestion = this.suggestions[this.selectedItem];
const selectedSuggestion = this.suggestions[normalizedIndex];
prevSelectedSuggestion?.removeClass("is-selected");
selectedSuggestion?.addClass("is-selected");
this.selectedItem = normalizedIndex;
if (scrollIntoView) {
selectedSuggestion.scrollIntoView(false);
}
}
}
export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
private popper: PopperInstance;
private scope: Scope;
private suggestEl: HTMLElement;
private suggest: Suggest<T>;
constructor(
protected app: App,
protected inputEl: HTMLInputElement | HTMLTextAreaElement
) {
this.scope = new Scope();
this.suggestEl = createDiv("suggestion-container");
const suggestion = this.suggestEl.createDiv("suggestion");
this.suggest = new Suggest(this, suggestion, this.scope);
this.scope.register([], "Escape", this.close.bind(this));
this.inputEl.addEventListener("input", this.onInputChanged.bind(this));
this.inputEl.addEventListener("focus", this.onInputChanged.bind(this));
this.inputEl.addEventListener("blur", this.close.bind(this));
this.suggestEl.on(
"mousedown",
".suggestion-container",
(event: MouseEvent) => {
event.preventDefault();
}
);
}
onInputChanged(): void {
const inputStr = this.inputEl.value;
const suggestions = this.getSuggestions(inputStr);
if (!suggestions) {
this.close();
return;
}
if (suggestions.length > 0) {
this.suggest.setSuggestions(suggestions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.open((<any>this.app).dom.appContainerEl, this.inputEl);
} else {
this.close();
}
}
open(container: HTMLElement, inputEl: HTMLElement): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any>this.app).keymap.pushScope(this.scope);
container.appendChild(this.suggestEl);
this.popper = createPopper(inputEl, this.suggestEl, {
placement: "bottom-start",
modifiers: [
{
name: "sameWidth",
enabled: true,
fn: ({ state, instance }) => {
// Note: positioning needs to be calculated twice -
// first pass - positioning it according to the width of the popper
// second pass - position it with the width bound to the reference element
// we need to early exit to avoid an infinite loop
const targetWidth = `${state.rects.reference.width}px`;
if (state.styles.popper.width === targetWidth) {
return;
}
state.styles.popper.width = targetWidth;
instance.update();
},
phase: "beforeWrite",
requires: ["computeStyles"],
},
],
});
}
close(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(<any>this.app).keymap.popScope(this.scope);
this.suggest.setSuggestions([]);
if (this.popper) this.popper.destroy();
this.suggestEl.detach();
}
abstract getSuggestions(inputStr: string): T[];
abstract renderSuggestion(item: T, el: HTMLElement): void;
abstract selectSuggestion(item: T): void;
}

View File

@ -0,0 +1,34 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { TAbstractFile, TFile } from "obsidian";
import { TextInputSuggest } from "./core";
export class FileSuggest extends TextInputSuggest<TFile> {
getSuggestions(inputStr: string): TFile[] {
const abstractFiles = this.app.vault.getAllLoadedFiles();
const files: TFile[] = [];
const lowerCaseInputStr = inputStr.toLowerCase();
abstractFiles.forEach((file: TAbstractFile) => {
if (
file instanceof TFile &&
file.extension === "md" &&
file.path.toLowerCase().contains(lowerCaseInputStr)
) {
files.push(file);
}
});
return files;
}
renderSuggestion(file: TFile, el: HTMLElement): void {
el.setText(file.path);
}
selectSuggestion(file: TFile): void {
this.inputEl.value = file.path;
this.inputEl.trigger("input");
this.close();
}
}

View File

@ -0,0 +1,33 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { TAbstractFile, TFolder } from "obsidian";
import { TextInputSuggest } from "./core";
export class FolderSuggest extends TextInputSuggest<TFolder> {
getSuggestions(inputStr: string): TFolder[] {
const abstractFiles = this.app.vault.getAllLoadedFiles();
const folders: TFolder[] = [];
const lowerCaseInputStr = inputStr.toLowerCase();
abstractFiles.forEach((folder: TAbstractFile) => {
if (
folder instanceof TFolder &&
folder.path.toLowerCase().contains(lowerCaseInputStr)
) {
folders.push(folder);
}
});
return folders;
}
renderSuggestion(file: TFolder, el: HTMLElement): void {
el.setText(file.path);
}
selectSuggestion(file: TFolder): void {
this.inputEl.value = file.path;
this.inputEl.trigger("input");
this.close();
}
}

23
src/types.ts Normal file
View File

@ -0,0 +1,23 @@
export interface Author {
name: string;
description: string;
}
export interface Series {
title: string;
position: number;
}
export interface Book {
title: string;
description: string;
authors: Author[];
series: Series | null;
publisher: string;
publishedAt: Date;
genres: string[];
coverImageUrl: string;
pageLength: number;
isbn: string;
isbn13: string;
}

52
src/utils/event.ts Normal file
View File

@ -0,0 +1,52 @@
export class Event {
constructor(
public readonly type: string,
public readonly payload: Record<string, any> = {}
) {}
public toString(): string {
return `Event(type: ${this.type}, payload: ${JSON.stringify(
this.payload
)})`;
}
}
export type Constructor = new (...args: any[]) => any;
export let EventEmitter = <T extends Constructor>(Base: T) =>
class extends Base {
private listeners: Record<string, ((event: Event) => void)[]> = {};
public on(eventType: string, listener: (event: Event) => void): void {
if (!this.listeners[eventType]) {
this.listeners[eventType] = [];
}
this.listeners[eventType].push(listener);
}
public off(eventType: string, listener: (event: Event) => void): void {
if (!this.listeners[eventType]) return;
this.listeners[eventType] = this.listeners[eventType].filter(
(l) => l !== listener
);
}
public once(eventType: string, listener: (event: Event) => void): void {
const onceListener = (event: Event) => {
listener(event);
this.off(eventType, onceListener);
};
this.on(eventType, onceListener);
}
public emit(event: Event): void {
const listeners = this.listeners[event.type];
if (listeners) {
for (const listener of listeners) {
listener(event);
}
}
}
};

21
src/utils/flatten.ts Normal file
View File

@ -0,0 +1,21 @@
export function flatten(
obj: Record<string, any>,
prefix = ""
): Record<string, any> {
return Object.keys(obj).reduce((acc, key) => {
const value = obj[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
Object.assign(acc, flatten(value, newKey));
} else {
acc[newKey] = value;
}
return acc;
}, {} as Record<string, any>);
}

51
src/utils/templater.ts Normal file
View File

@ -0,0 +1,51 @@
import { App, TFile } from "obsidian";
import * as Handlebars from "handlebars";
Handlebars.registerHelper("formatDate", (date: Date, format: string) => {
// @ts-ignore
return moment(date).format(format);
});
Handlebars.registerHelper("now", (format: string) => {
// @ts-ignore
return moment().format(format);
});
Handlebars.registerHelper("normalizeDesc", (desc: string) => {
if (!desc) return "";
return desc.replace(/(\r\n|\n|\r)/gm, " ").trim();
});
export class Templater {
public constructor(private readonly app: App) {}
private async getTemplateContent(filePath: string): Promise<string | null> {
const file = this.app.vault.getFileByPath(filePath);
if (file === null) return null;
try {
return await this.app.vault.read(file);
} catch (error) {
console.error("Failed to read template file:", error);
return null;
}
}
public renderTemplate(
templateContent: string,
data: Record<string, unknown>
): string {
const template = Handlebars.compile(templateContent);
return template(data);
}
public async renderTemplateFile(
filePath: string,
data: Record<string, any>
): Promise<string | null> {
const templateContent = await this.getTemplateContent(filePath);
if (!templateContent) return null;
return this.renderTemplate(templateContent, data);
}
}

View File

@ -0,0 +1,70 @@
import { searchBooks } from "@data-sources/goodreads/scraper";
import { SearchResult } from "@data-sources/goodreads/types";
import { App, Modal, Notice, TextComponent } from "obsidian";
export class GoodreadsSearchModal extends Modal {
private query: string;
constructor(
app: App,
private readonly onSearch: (error: any, results: SearchResult[]) => void
) {
super(app);
}
async doSearch(): Promise<void> {
if (!this.query || this.query.trim() === "") {
this.onSearch(new Error("Search query cannot be empty."), []);
return;
}
try {
const results = await searchBooks(this.query);
if (results.length === 0) {
this.onSearch(
new Error("No results found for the given query."),
[]
);
return;
}
this.onSearch(null, results);
} catch (error) {
this.onSearch(error, []);
}
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h2", { text: "Goodreads Search" });
contentEl.createDiv({ cls: "obt-goodreads-search__input" }, (el) => {
new TextComponent(el)
.setValue(this.query || "")
.setPlaceholder("Search for books on Goodreads")
.onChange((value) => {
this.query = value;
})
.inputEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault();
this.doSearch();
}
});
});
}
static createAndOpen(app: App): Promise<SearchResult[]> {
return new Promise((resolve, reject) => {
const modal = new GoodreadsSearchModal(app, (error, results) => {
if (error) {
new Notice(`Error: ${error.message}`);
reject(error);
} else {
resolve(results);
}
modal.close();
});
modal.open();
});
}
}

View File

@ -0,0 +1,75 @@
import { SearchResult } from "@data-sources/goodreads/types";
import { App, Notice, SuggestModal } from "obsidian";
export class GoodreadsSearchSuggestModal extends SuggestModal<SearchResult> {
constructor(
app: App,
private readonly results: SearchResult[],
private readonly onChoose: (error: any, results: SearchResult[]) => void
) {
super(app);
}
getSuggestions(query: string): SearchResult[] | Promise<SearchResult[]> {
return this.results;
}
renderSuggestion(value: SearchResult, el: HTMLElement): void {
el.addClass("obt-goodreads-search-suggest__item");
el.createEl("img", {
cls: "obt-goodreads-search-suggest__cover",
attr: {
src: value.coverImageUrl,
alt: `Cover of ${value.title}`,
},
});
el.createDiv(
{ cls: "obt-goodreads-search-suggest__info" },
(infoEl) => {
infoEl.createEl("h2", {
text: value.title,
cls: "obt-goodreads-search-suggest__title",
});
let subtitle = value.authors.join(", ");
if (value.publicationYear > 0) {
subtitle += ` (${value.publicationYear})`;
}
infoEl.createEl("h3", {
text: subtitle,
cls: "obt-goodreads-search-suggest__subtitle",
});
}
);
}
onChooseSuggestion(
item: SearchResult,
evt: MouseEvent | KeyboardEvent
): void {
this.onChoose(null, [item]);
}
static async createAndOpen(
app: App,
results: SearchResult[]
): Promise<SearchResult | undefined> {
return new Promise((resolve, reject) => {
const modal = new GoodreadsSearchSuggestModal(
app,
results,
(error, results) => {
if (error) {
new Notice(`Error: ${error.message}`);
reject(error);
} else {
resolve(results[0]);
}
}
);
modal.open();
});
}
}

View File

@ -1,8 +1,34 @@
/*
.obt-goodreads-search__input {
padding-bottom: 18px;
}
.obt-goodreads-search__input input {
width: 100%;
}
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
.obt-goodreads-search-suggest__item {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.obt-goodreads-search-suggest__cover {
max-width: 100px;
max-height: 100px;
margin-right: 10px;
object-fit: cover;
border-radius: 3px;
}
.obt-goodreads-search-suggest__info {
flex-grow: 1;
}
.obt-goodreads-search-suggest__title {
color: var(--text-normal);
font-size: var(--font-ui-medium);
}
.obt-goodreads-search-suggest__subtitle {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
If your plugin does not need CSS, delete this file.
*/
.obt-settings .search-input-container {
width: 100%;
}

View File

@ -16,7 +16,14 @@
"ES5",
"ES6",
"ES7"
]
],
"paths": {
"@data-sources/*": ["src/data-sources/*"],
"@settings/*": ["src/settings/*"],
"@utils/*": ["src/utils/*"],
"@views/*": ["src/views/*"],
"@src/*": ["src/*"]
}
},
"include": [
"**/*.ts"