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 process from "process";
import builtins from "builtin-modules"; import builtins from "builtin-modules";
const banner = const banner = `/*
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin 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({ const context = await esbuild.context({
banner: { banner: {
js: banner, js: banner,
}, },
entryPoints: ["main.ts"], entryPoints: ["src/main.ts"],
bundle: true, bundle: true,
external: [ external: [
"obsidian", "obsidian",
@ -31,7 +30,8 @@ const context = await esbuild.context({
"@lezer/common", "@lezer/common",
"@lezer/highlight", "@lezer/highlight",
"@lezer/lr", "@lezer/lr",
...builtins], ...builtins,
],
format: "cjs", format: "cjs",
target: "es2018", target: "es2018",
logLevel: "info", 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", "id": "obsidian-book-tracker",
"name": "Sample Plugin", "name": "Book Tracker",
"version": "1.0.0", "version": "1.0.0",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.", "description": "Simplifies tracking your reading progress and managing your book collection in Obsidian.",
"author": "Obsidian", "author": "FiFiTiDo",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",
"isDesktopOnly": false "isDesktopOnly": false
} }

View File

@ -4,20 +4,28 @@
"description": "This is a sample plugin for Obsidian (https://obsidian.md)", "description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "run-p dev-*",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "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" "version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@popperjs/core": "^2.11.8",
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0", "@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0", "builtin-modules": "3.3.0",
"esbuild": "0.17.3", "esbuild": "0.17.3",
"handlebars": "^4.7.8",
"npm-run-all": "^4.1.5",
"obsidian": "latest", "obsidian": "latest",
"sass": "^1.89.2",
"tslib": "2.4.0", "tslib": "2.4.0",
"typescript": "4.7.4" "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 .obt-goodreads-search-suggest__item {
available in the app when your plugin is enabled. 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", "ES5",
"ES6", "ES6",
"ES7" "ES7"
] ],
"paths": {
"@data-sources/*": ["src/data-sources/*"],
"@settings/*": ["src/settings/*"],
"@utils/*": ["src/utils/*"],
"@views/*": ["src/views/*"],
"@src/*": ["src/*"]
}
}, },
"include": [ "include": [
"**/*.ts" "**/*.ts"