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 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
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",
|
"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
|
||||||
}
|
}
|
||||||
|
|
12
package.json
12
package.json
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
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
|
.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%;
|
||||||
*/
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue