generated from tpl/obsidian-sample-plugin
Add goodreads search capabilities
This commit is contained in:
parent
13dba8127a
commit
13f34801ca
|
@ -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
134
main.ts
|
@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
12
package.json
12
package.json
|
@ -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"
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,2 @@
|
|||
onlyBuiltDependencies:
|
||||
- esbuild
|
|
@ -0,0 +1,5 @@
|
|||
.obt-settings {
|
||||
.search-input-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
@use "views/goodreads-search.scss";
|
||||
@use "views/goodreads-search-suggest.scss";
|
||||
@use "settings.scss";
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.obt-goodreads-search {
|
||||
&__input {
|
||||
padding-bottom: 18px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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>);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
38
styles.css
38
styles.css
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue