Initial commit

This commit is contained in:
안피곤(anpigon) 2022-04-06 16:26:12 +09:00 committed by anpigon
commit bad6dccf4e
30 changed files with 8461 additions and 0 deletions

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

2
.eslintignore Normal file
View file

@ -0,0 +1,2 @@
npm node_modules
build

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

85
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,85 @@
name: Release Obsidian plugin
on:
push:
tags:
- '*'
env:
PLUGIN_NAME: obsidian-book-search
jobs:
build:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- name: Build
id: build
run: |
npm ci
npm run build
mkdir ${{ env.PLUGIN_NAME }}
cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }}
zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }}
ls
echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)"
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ github.ref }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: false
prerelease: false
- name: Upload zip file
id: upload-zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./${{ env.PLUGIN_NAME }}.zip
asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip
asset_content_type: application/zip
- name: Upload main.js
id: upload-main
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./main.js
asset_name: main.js
asset_content_type: text/javascript
- name: Upload manifest.json
id: upload-manifest
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./manifest.json
asset_name: manifest.json
asset_content_type: application/json
- name: Upload styles.css
id: upload-css
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./styles.css
asset_name: styles.css
asset_content_type: text/css

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

18
.prettierrc Normal file
View file

@ -0,0 +1,18 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 80,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false
}

9
.versionrc Normal file
View file

@ -0,0 +1,9 @@
{
"types": [
{"type":"feat","section":"Features"},
{"type":"fix","section":"Bug Fixes"},
{"type":"test","section":"Tests", "hidden": true},
{"type":"build","section":"Build System", "hidden": true},
{"type":"ci","hidden":true}
]
}

16
CHANGELOG.md Normal file
View file

@ -0,0 +1,16 @@
# Changelog
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [1.0.7](https://github.dev/anpigon/obsidian-book-search-plugin/compare/1.0.6...1.0.7) (2022-04-06)
### [1.0.6](https://github.dev/anpigon/obsidian-book-search-plugin/compare/1.0.5...1.0.6) (2022-04-06)
### [1.0.5](https://github.dev/anpigon/obsidian-book-search-plugin/compare/1.0.4...1.0.5) (2022-04-06)
### [1.0.4](https://github.dev/anpigon/obsidian-book-search-plugin/compare/0.0.3...1.0.4) (2022-04-06)
### [1.0.3](https://github.dev/anpigon/obsidian-book-search-plugin/compare/v1.0.2...v1.0.3) (2022-04-06)
- You can create notes by searching for a book.
### 1.0.2 (2022-04-06)
- first release

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# Obsidian Book Search Plugin
<br>
## Description
Use to query book using :
- A book title, author, publisher or ISBN (10 or 13).
Use Google Books API to get the book information.
<br>
## How to use
1. Set the folder to new file location in plugin options.
![](https://user-images.githubusercontent.com/3969643/161973189-e4a0b5aa-9e86-48b5-9bc9-42c3bbfc2589.png)
2. Excute the command "Create new book note".
![](https://user-images.githubusercontent.com/3969643/161973483-ab007598-e0b8-433f-9697-75ee0ef74195.png)
3. Search for books by keywords.
![](https://user-images.githubusercontent.com/3969643/161973979-51f642c9-626a-4015-a7e9-dfdbe6ec2cbc.png)
4. Select the book from the search results.
![](https://user-images.githubusercontent.com/3969643/161974310-13c3b39b-51dc-472f-b787-db64f74caf74.png)
5. Voila! A note has been created.
![](https://user-images.githubusercontent.com/3969643/161974593-1b7bfe69-cb9d-47d7-a43d-1d725295a122.png)
<br>
## Installation
Download the latest version of the zip file from the [release](https://github.com/anpigon/obsidian-book-search-plugin/releases).
And must be cloned or unzipped into your vault's .`obsidian/plugins/` directory, then enabled in the Obsidian configuration.
It's not yet registered as a standard community plugin for downloading or updating within Obsidian.

54
esbuild.config.mjs Normal file
View file

@ -0,0 +1,54 @@
import esbuild from 'esbuild';
import process from 'process';
import builtins from 'builtin-modules';
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';
esbuild
.build({
banner: {
js: banner,
},
entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/closebrackets',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/comment',
'@codemirror/fold',
'@codemirror/gutter',
'@codemirror/highlight',
'@codemirror/history',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/matchbrackets',
'@codemirror/panel',
'@codemirror/rangeset',
'@codemirror/rectangular-selection',
'@codemirror/search',
'@codemirror/state',
'@codemirror/stream-parser',
'@codemirror/text',
'@codemirror/tooltip',
'@codemirror/view',
...builtins,
],
format: 'cjs',
watch: !prod,
target: 'es2016',
logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
})
.catch(() => process.exit(1));

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "obsidian-book-search-plugin",
"name": "Book Search",
"version": "1.0.7",
"minAppVersion": "0.12.0",
"description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"isDesktopOnly": false
}

Binary file not shown.

6916
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "obsidian-book-search-plugin",
"version": "1.0.7",
"description": "This is a plugin to help you create book notes.",
"main": "main.js",
"standard-version": {
"t": ""
},
"keywords": [
"book",
"book note",
"obsidian",
"plugin"
],
"author": "anpigon",
"license": "MIT",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"release": "standard-version",
"release-test": "standard-version --dry-run",
"release-major": "standard-version --release-as major",
"release-major-test": "standard-version --dry-run --release-as major",
"release-minor": "standard-version --release-as minor",
"release-minor-test": "standard-version --dry-run --release-as minor"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.2.0",
"@typescript-eslint/parser": "^5.2.0",
"builtin-modules": "^3.2.0",
"esbuild": "0.13.12",
"obsidian": "latest",
"tslib": "2.3.1",
"typescript": "4.4.4",
"standard-version": "^9.3.2"
}
}

View file

@ -0,0 +1,78 @@
import { request } from 'obsidian';
import { Book } from 'src/models/book.model';
import { GoogleBooksResponse, VolumeInfo } from './models/google_books.model';
const API_URL = 'https://www.googleapis.com/books/v1/volumes';
export async function getByQuery(query: string): Promise<Book[]> {
try {
const searchResults = await apiGet(query);
if (searchResults.totalItems == 0) {
throw new Error('No results found.');
}
return searchResults.items.map(({ volumeInfo }) =>
formatForSuggestion(volumeInfo),
);
} catch (error) {
console.warn(error);
throw error;
}
}
function getISBN(item: VolumeInfo) {
let ISBN10 = '';
let ISBN13 = '';
let isbn10_data, isbn13_data;
if (item.industryIdentifiers) {
isbn10_data = item.industryIdentifiers.find(
element => element.type == 'ISBN_10',
);
isbn13_data = item.industryIdentifiers.find(
element => element.type == 'ISBN_13',
);
}
if (isbn10_data) ISBN10 = isbn10_data.identifier.trim();
if (isbn13_data) ISBN13 = isbn13_data.identifier.trim();
return { ISBN10, ISBN13 };
}
function formatForSuggestion(item: VolumeInfo): Book {
const ISBN = getISBN(item);
const book: Book = {
title: item.title,
author: formatList(item.authors),
category: formatList(item.categories),
publisher: item.publisher,
totalPage: item.pageCount,
coverUrl: `${item.imageLinks?.thumbnail ?? ''}`.replace('http:', 'https:'),
publishDate: item.publishedDate
? `${new Date(item.publishedDate).getFullYear()}`
: '',
isbn10: ISBN.ISBN10,
isbn13: ISBN.ISBN13,
};
return book;
}
function formatList(list?: string[]) {
if (!list || list.length === 0 || list[0] == 'N/A') return '';
if (list.length === 1) return `${list[0]}`;
return list.map(item => `"${item.trim()}"`).join(', ');
}
async function apiGet(query: string): Promise<GoogleBooksResponse> {
const finalURL = new URL(API_URL);
finalURL.searchParams.append('q', query);
const res = await request({
url: finalURL.href,
method: 'GET',
});
return JSON.parse(res) as GoogleBooksResponse;
}

View file

@ -0,0 +1,518 @@
// To parse this data:
//
// import { Convert, GoogleBooks } from "./file";
//
// const googleBooks = Convert.toGoogleBooks(json);
//
// These functions will throw an error if the JSON doesn't
// match the expected interface, even if the JSON is valid.
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface GoogleBooksResponse {
kind: string;
totalItems: number;
items: Item[];
}
export interface Item {
kind: Kind;
id: string;
etag: string;
selfLink: string;
volumeInfo: VolumeInfo;
saleInfo: SaleInfo;
accessInfo: AccessInfo;
searchInfo: SearchInfo;
}
export interface AccessInfo {
country: Country;
viewability: Viewability;
embeddable: boolean;
publicDomain: boolean;
textToSpeechPermission: TextToSpeechPermission;
epub: Epub;
pdf: Epub;
webReaderLink: string;
accessViewStatus: AccessViewStatus;
quoteSharingAllowed: boolean;
}
export enum AccessViewStatus {
None = 'NONE',
Sample = 'SAMPLE',
}
export enum Country {
Kr = 'KR',
}
export interface Epub {
isAvailable: boolean;
acsTokenLink?: string;
}
export enum TextToSpeechPermission {
Allowed = 'ALLOWED',
}
export enum Viewability {
NoPages = 'NO_PAGES',
Partial = 'PARTIAL',
}
export enum Kind {
BooksVolume = 'books#volume',
}
export interface SaleInfo {
country: Country;
saleability: Saleability;
isEbook: boolean;
listPrice?: SaleInfoListPrice;
retailPrice?: SaleInfoListPrice;
buyLink?: string;
offers?: Offer[];
}
export interface SaleInfoListPrice {
amount: number;
currencyCode: CurrencyCode;
}
export enum CurrencyCode {
Krw = 'KRW',
}
export interface Offer {
finskyOfferType: number;
listPrice: OfferListPrice;
retailPrice: OfferListPrice;
rentalDuration?: RentalDuration;
}
export interface OfferListPrice {
amountInMicros: number;
currencyCode: CurrencyCode;
}
export interface RentalDuration {
unit: string;
count: number;
}
export enum Saleability {
ForSale = 'FOR_SALE',
ForSaleAndRental = 'FOR_SALE_AND_RENTAL',
NotForSale = 'NOT_FOR_SALE',
}
export interface SearchInfo {
textSnippet: string;
}
export interface VolumeInfo {
title: string;
authors?: string[];
publisher?: string;
publishedDate: string;
industryIdentifiers: IndustryIdentifier[];
readingModes: ReadingModes;
pageCount?: number;
printType: PrintType;
categories?: string[];
maturityRating: MaturityRating;
allowAnonLogging: boolean;
contentVersion: string;
panelizationSummary: PanelizationSummary;
imageLinks: ImageLinks;
language: Language;
previewLink: string;
infoLink: string;
canonicalVolumeLink: string;
subtitle?: string;
description?: string;
averageRating?: number;
ratingsCount?: number;
}
export interface ImageLinks {
smallThumbnail: string;
thumbnail: string;
}
export interface IndustryIdentifier {
type: Type;
identifier: string;
}
export enum Type {
Isbn10 = 'ISBN_10',
Isbn13 = 'ISBN_13',
Other = 'OTHER',
}
export enum Language {
En = 'en',
Ko = 'ko',
}
export enum MaturityRating {
NotMature = 'NOT_MATURE',
}
export interface PanelizationSummary {
containsEpubBubbles: boolean;
containsImageBubbles: boolean;
}
export enum PrintType {
Book = 'BOOK',
}
export interface ReadingModes {
text: boolean;
image: boolean;
}
// Converts JSON strings to/from your types
// and asserts the results of JSON.parse at runtime
export class Convert {
public static toGoogleBooks(json: string): GoogleBooksResponse {
return cast(JSON.parse(json), r('GoogleBooks'));
}
public static googleBooksToJson(value: GoogleBooksResponse): string {
return JSON.stringify(uncast(value, r('GoogleBooks')), null, 2);
}
}
function invalidValue(typ: any, val: any, key: any = ''): never {
if (key) {
throw Error(
`Invalid value for key "${key}". Expected type ${JSON.stringify(
typ,
)} but got ${JSON.stringify(val)}`,
);
}
throw Error(
`Invalid value ${JSON.stringify(val)} for type ${JSON.stringify(typ)}`,
);
}
function jsonToJSProps(typ: any): any {
if (typ.jsonToJS === undefined) {
const map: any = {};
typ.props.forEach((p: any) => (map[p.json] = { key: p.js, typ: p.typ }));
typ.jsonToJS = map;
}
return typ.jsonToJS;
}
function jsToJSONProps(typ: any): any {
if (typ.jsToJSON === undefined) {
const map: any = {};
typ.props.forEach((p: any) => (map[p.js] = { key: p.json, typ: p.typ }));
typ.jsToJSON = map;
}
return typ.jsToJSON;
}
function transform(val: any, typ: any, getProps: any, key: any = ''): any {
function transformPrimitive(typ: string, val: any): any {
if (typeof typ === typeof val) return val;
return invalidValue(typ, val, key);
}
function transformUnion(typs: any[], val: any): any {
// val must validate against one typ in typs
const l = typs.length;
for (let i = 0; i < l; i++) {
const typ = typs[i];
try {
return transform(val, typ, getProps);
} catch (_) {}
}
return invalidValue(typs, val);
}
function transformEnum(cases: string[], val: any): any {
if (cases.indexOf(val) !== -1) return val;
return invalidValue(cases, val);
}
function transformArray(typ: any, val: any): any {
// val must be an array with no invalid elements
if (!Array.isArray(val)) return invalidValue('array', val);
return val.map(el => transform(el, typ, getProps));
}
function transformDate(val: any): any {
if (val === null) {
return null;
}
const d = new Date(val);
if (isNaN(d.valueOf())) {
return invalidValue('Date', val);
}
return d;
}
function transformObject(
props: { [k: string]: any },
additional: any,
val: any,
): any {
if (val === null || typeof val !== 'object' || Array.isArray(val)) {
return invalidValue('object', val);
}
const result: any = {};
Object.getOwnPropertyNames(props).forEach(key => {
const prop = props[key];
const v = Object.prototype.hasOwnProperty.call(val, key)
? val[key]
: undefined;
result[prop.key] = transform(v, prop.typ, getProps, prop.key);
});
Object.getOwnPropertyNames(val).forEach(key => {
if (!Object.prototype.hasOwnProperty.call(props, key)) {
result[key] = transform(val[key], additional, getProps, key);
}
});
return result;
}
if (typ === 'any') return val;
if (typ === null) {
if (val === null) return val;
return invalidValue(typ, val);
}
if (typ === false) return invalidValue(typ, val);
while (typeof typ === 'object' && typ.ref !== undefined) {
typ = typeMap[typ.ref];
}
if (Array.isArray(typ)) return transformEnum(typ, val);
if (typeof typ === 'object') {
return typ.hasOwnProperty('unionMembers')
? transformUnion(typ.unionMembers, val)
: typ.hasOwnProperty('arrayItems')
? transformArray(typ.arrayItems, val)
: typ.hasOwnProperty('props')
? transformObject(getProps(typ), typ.additional, val)
: invalidValue(typ, val);
}
// Numbers can be parsed by Date but shouldn't be.
if (typ === Date && typeof val !== 'number') return transformDate(val);
return transformPrimitive(typ, val);
}
function cast<T>(val: any, typ: any): T {
return transform(val, typ, jsonToJSProps);
}
function uncast<T>(val: T, typ: any): any {
return transform(val, typ, jsToJSONProps);
}
function a(typ: any) {
return { arrayItems: typ };
}
function u(...typs: any[]) {
return { unionMembers: typs };
}
function o(props: any[], additional: any) {
return { props, additional };
}
function r(name: string) {
return { ref: name };
}
const typeMap: any = {
GoogleBooks: o(
[
{ json: 'kind', js: 'kind', typ: '' },
{ json: 'totalItems', js: 'totalItems', typ: 0 },
{ json: 'items', js: 'items', typ: a(r('Item')) },
],
false,
),
Item: o(
[
{ json: 'kind', js: 'kind', typ: r('Kind') },
{ json: 'id', js: 'id', typ: '' },
{ json: 'etag', js: 'etag', typ: '' },
{ json: 'selfLink', js: 'selfLink', typ: '' },
{ json: 'volumeInfo', js: 'volumeInfo', typ: r('VolumeInfo') },
{ json: 'saleInfo', js: 'saleInfo', typ: r('SaleInfo') },
{ json: 'accessInfo', js: 'accessInfo', typ: r('AccessInfo') },
{ json: 'searchInfo', js: 'searchInfo', typ: r('SearchInfo') },
],
false,
),
AccessInfo: o(
[
{ json: 'country', js: 'country', typ: r('Country') },
{ json: 'viewability', js: 'viewability', typ: r('Viewability') },
{ json: 'embeddable', js: 'embeddable', typ: true },
{ json: 'publicDomain', js: 'publicDomain', typ: true },
{
json: 'textToSpeechPermission',
js: 'textToSpeechPermission',
typ: r('TextToSpeechPermission'),
},
{ json: 'epub', js: 'epub', typ: r('Epub') },
{ json: 'pdf', js: 'pdf', typ: r('Epub') },
{ json: 'webReaderLink', js: 'webReaderLink', typ: '' },
{
json: 'accessViewStatus',
js: 'accessViewStatus',
typ: r('AccessViewStatus'),
},
{ json: 'quoteSharingAllowed', js: 'quoteSharingAllowed', typ: true },
],
false,
),
Epub: o(
[
{ json: 'isAvailable', js: 'isAvailable', typ: true },
{ json: 'acsTokenLink', js: 'acsTokenLink', typ: u(undefined, '') },
],
false,
),
SaleInfo: o(
[
{ json: 'country', js: 'country', typ: r('Country') },
{ json: 'saleability', js: 'saleability', typ: r('Saleability') },
{ json: 'isEbook', js: 'isEbook', typ: true },
{
json: 'listPrice',
js: 'listPrice',
typ: u(undefined, r('SaleInfoListPrice')),
},
{
json: 'retailPrice',
js: 'retailPrice',
typ: u(undefined, r('SaleInfoListPrice')),
},
{ json: 'buyLink', js: 'buyLink', typ: u(undefined, '') },
{ json: 'offers', js: 'offers', typ: u(undefined, a(r('Offer'))) },
],
false,
),
SaleInfoListPrice: o(
[
{ json: 'amount', js: 'amount', typ: 0 },
{ json: 'currencyCode', js: 'currencyCode', typ: r('CurrencyCode') },
],
false,
),
Offer: o(
[
{ json: 'finskyOfferType', js: 'finskyOfferType', typ: 0 },
{ json: 'listPrice', js: 'listPrice', typ: r('OfferListPrice') },
{ json: 'retailPrice', js: 'retailPrice', typ: r('OfferListPrice') },
{
json: 'rentalDuration',
js: 'rentalDuration',
typ: u(undefined, r('RentalDuration')),
},
],
false,
),
OfferListPrice: o(
[
{ json: 'amountInMicros', js: 'amountInMicros', typ: 0 },
{ json: 'currencyCode', js: 'currencyCode', typ: r('CurrencyCode') },
],
false,
),
RentalDuration: o(
[
{ json: 'unit', js: 'unit', typ: '' },
{ json: 'count', js: 'count', typ: 0 },
],
false,
),
SearchInfo: o([{ json: 'textSnippet', js: 'textSnippet', typ: '' }], false),
VolumeInfo: o(
[
{ json: 'title', js: 'title', typ: '' },
{ json: 'authors', js: 'authors', typ: u(undefined, a('')) },
{ json: 'publisher', js: 'publisher', typ: u(undefined, '') },
{ json: 'publishedDate', js: 'publishedDate', typ: '' },
{
json: 'industryIdentifiers',
js: 'industryIdentifiers',
typ: a(r('IndustryIdentifier')),
},
{ json: 'readingModes', js: 'readingModes', typ: r('ReadingModes') },
{ json: 'pageCount', js: 'pageCount', typ: u(undefined, 0) },
{ json: 'printType', js: 'printType', typ: r('PrintType') },
{ json: 'categories', js: 'categories', typ: u(undefined, a('')) },
{
json: 'maturityRating',
js: 'maturityRating',
typ: r('MaturityRating'),
},
{ json: 'allowAnonLogging', js: 'allowAnonLogging', typ: true },
{ json: 'contentVersion', js: 'contentVersion', typ: '' },
{
json: 'panelizationSummary',
js: 'panelizationSummary',
typ: r('PanelizationSummary'),
},
{ json: 'imageLinks', js: 'imageLinks', typ: r('ImageLinks') },
{ json: 'language', js: 'language', typ: r('Language') },
{ json: 'previewLink', js: 'previewLink', typ: '' },
{ json: 'infoLink', js: 'infoLink', typ: '' },
{ json: 'canonicalVolumeLink', js: 'canonicalVolumeLink', typ: '' },
{ json: 'subtitle', js: 'subtitle', typ: u(undefined, '') },
{ json: 'description', js: 'description', typ: u(undefined, '') },
{ json: 'averageRating', js: 'averageRating', typ: u(undefined, 0) },
{ json: 'ratingsCount', js: 'ratingsCount', typ: u(undefined, 0) },
],
false,
),
ImageLinks: o(
[
{ json: 'smallThumbnail', js: 'smallThumbnail', typ: '' },
{ json: 'thumbnail', js: 'thumbnail', typ: '' },
],
false,
),
IndustryIdentifier: o(
[
{ json: 'type', js: 'type', typ: r('Type') },
{ json: 'identifier', js: 'identifier', typ: '' },
],
false,
),
PanelizationSummary: o(
[
{ json: 'containsEpubBubbles', js: 'containsEpubBubbles', typ: true },
{ json: 'containsImageBubbles', js: 'containsImageBubbles', typ: true },
],
false,
),
ReadingModes: o(
[
{ json: 'text', js: 'text', typ: true },
{ json: 'image', js: 'image', typ: true },
],
false,
),
AccessViewStatus: ['NONE', 'SAMPLE'],
Country: ['KR'],
TextToSpeechPermission: ['ALLOWED'],
Viewability: ['NO_PAGES', 'PARTIAL'],
Kind: ['books#volume'],
CurrencyCode: ['KRW'],
Saleability: ['FOR_SALE', 'FOR_SALE_AND_RENTAL', 'NOT_FOR_SALE'],
Type: ['ISBN_10', 'ISBN_13', 'OTHER'],
Language: ['en', 'ko'],
MaturityRating: ['NOT_MATURE'],
PrintType: ['BOOK'],
};

74
src/book_search_modal.ts Normal file
View file

@ -0,0 +1,74 @@
import { App, ButtonComponent, Modal, Setting, TextComponent } from 'obsidian';
import { Book } from './book_suggest_modal';
import { getByQuery } from './apis/google_books_api';
export class BookSearchModal extends Modal {
query: string;
isBusy: boolean;
okBtnRef: ButtonComponent;
onSubmit: (err: Error, result?: Book[]) => void;
constructor(app: App, onSubmit?: (err: Error, result?: Book[]) => void) {
super(app);
this.onSubmit = onSubmit;
}
async searchBook() {
if (!this.query) {
throw new Error('No query entered.');
}
if (!this.isBusy) {
try {
this.isBusy = true;
this.okBtnRef.setDisabled(false);
this.okBtnRef.setButtonText('Requesting...');
const searchResults = await getByQuery(this.query);
this.onSubmit(null, searchResults);
} catch (err) {
this.onSubmit(err);
} finally {
this.close();
}
}
}
submitEnterCallback(event: KeyboardEvent) {
if (event.key === 'Enter') {
this.searchBook();
}
}
onOpen() {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'Search Book' });
const placeholder = 'Search by keyword or ISBN';
const textComponent = new TextComponent(contentEl);
textComponent.inputEl.style.width = '100%';
textComponent
.setPlaceholder(placeholder ?? '')
.onChange(value => (this.query = value))
.inputEl.addEventListener('keydown', this.submitEnterCallback.bind(this));
contentEl.appendChild(textComponent.inputEl);
textComponent.inputEl.focus();
new Setting(contentEl)
.addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close()))
.addButton(btn => {
return (this.okBtnRef = btn
.setButtonText('Ok')
.setCta()
.onClick(() => {
this.searchBook();
}));
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

44
src/book_suggest_modal.ts Normal file
View file

@ -0,0 +1,44 @@
import { App, SuggestModal } from 'obsidian';
import { Book } from './models/book.model';
export class BookSuggestModal extends SuggestModal<Book> {
suggestion: Book[];
onChoose: (error: Error, result?: Book) => void;
constructor(
app: App,
suggestion: Book[],
onChoose: (error: Error, result?: Book) => void,
) {
super(app);
this.suggestion = suggestion;
this.onChoose = onChoose;
}
// Returns all available suggestions.
getSuggestions(query: string): Book[] {
return this.suggestion.filter(book => {
return (
book.title.toLowerCase().includes(query.toLowerCase()) ||
book.author.toLowerCase().includes(query.toLowerCase()) ||
book.publisher.toLowerCase().includes(query.toLowerCase())
);
});
}
// Renders each suggestion item.
renderSuggestion(book: Book, el: HTMLElement) {
const title = book.title;
const publisher = book.publisher ? `, ${book.publisher}` : '';
const publishDate = book.publishDate ? `(${book.publishDate})` : '';
const totalPage = book.totalPage ? `, p${book.totalPage}` : '';
const subtitle = `${book.author}${publisher}${publishDate}${totalPage}`;
el.createEl('div', { text: title });
el.createEl('small', { text: subtitle });
}
// Perform action on the selected suggestion.
onChooseSuggestion(book: Book, evt: MouseEvent | KeyboardEvent) {
this.onChoose(null, book);
}
}

View file

@ -0,0 +1,24 @@
import {
App,
Editor,
EditorPosition,
EditorRangeOrCaret,
EditorTransaction,
MarkdownView,
} from 'obsidian';
export class CursorJumper {
constructor(private app: App) {}
async jumpToNextCursorLocation(): Promise<void> {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView) {
return;
}
const content = await this.app.vault.cachedRead(activeView.file);
const indexOffset = content.length + 1;
const editor = activeView.editor;
editor.focus();
editor.setCursor(indexOffset, 0);
}
}

86
src/main.ts Normal file
View file

@ -0,0 +1,86 @@
import { Notice, Plugin } from 'obsidian';
import { BookSearchModal } from './book_search_modal';
import { Book, BookSuggestModal } from './book_suggest_modal';
import { CursorJumper } from './editor/corsor_jumper';
import {
BookSearchSettingTab,
BookSearchPluginSettings,
DEFAULT_SETTINGS,
} from './settings/settings';
import {
makeFrontMater,
replaceIllegalFileNameCharactersInString,
} from './utils/utils';
export default class BookSearchPlugin extends Plugin {
settings: BookSearchPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon(
'book',
'Create new book note',
(evt: MouseEvent) => this.createNewBookNote(),
);
// Perform additional things with the ribbon
ribbonIconEl.addClass('obsidian-book-search-plugin-ribbon-class');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-book-search-modal',
name: 'Create new book note',
callback: () => this.createNewBookNote(),
}); //
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new BookSearchSettingTab(this.app, this));
}
async createNewBookNote(): Promise<void> {
try {
const book = await this.openBookSearchModal();
const fileName = replaceIllegalFileNameCharactersInString(book.title);
const path = `${this.settings.folder.replace(/\/$/, '')}/${fileName}.md`;
const frontMatter = makeFrontMater(book);
const fileContent = `---\n${frontMatter}\n---\n`;
const targetFile = await this.app.vault.create(path, fileContent);
const activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf) {
console.warn('No active leaf');
return;
}
await activeLeaf.openFile(targetFile, { state: { mode: 'source' } });
activeLeaf.setEphemeralState({ rename: 'all' });
await new CursorJumper(this.app).jumpToNextCursorLocation();
} catch (err) {
console.warn(err);
new Notice(err.toString());
}
}
async openBookSearchModal(): Promise<Book> {
return new Promise((resolve, reject) => {
new BookSearchModal(this.app, (error, results) => {
if (error) return reject(error);
new BookSuggestModal(this.app, results, (error2, selectedBook) => {
if (error2) return reject(error2);
resolve(selectedBook);
}).open();
}).open();
});
}
onunload() {}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}

61
src/models/book.model.ts Normal file
View file

@ -0,0 +1,61 @@
import { camelToSnakeCase } from 'src/utils/utils';
export interface Book {
title: string; // 책 제목
author: string; // 저자
category?: string; // 카테고리
publisher?: string; // 출판사
publishDate?: string; // 출판일
totalPage?: number | string; // 전체 페이지
coverUrl?: string; // 커버 URL
status?: string; // 읽기 상태(읽기전, 읽는중, 읽기완료)
startReadDate?: string; // 읽기 시작한 일시
finishReadDate?: string; // 읽기 완료한 일시
myRate?: number | string; //나의 평점
bookNote?: string; //서평 기록 여부
isbn10?: string;
isbn13?: string;
}
export class BookModel implements Book {
title: string;
author: string;
category?: string;
publisher?: string;
publishDate?: string;
totalPage?: number | string;
coverUrl?: string;
status?: string;
startReadDate?: string;
finishReadDate?: string;
myRate?: number | string;
bookNote?: string;
isbn10?: string;
isbn13?: string;
constructor(book: Book) {
this.title = book.title ?? '';
this.author = book.author ?? '';
this.category = book.category ?? '';
this.publisher = book.publisher ?? '';
this.publishDate = book.publishDate ?? '';
this.totalPage = book.totalPage ?? '';
this.coverUrl = book.coverUrl ?? '';
this.status = book.status ?? '';
this.startReadDate = book.startReadDate ?? '';
this.finishReadDate = book.finishReadDate ?? '';
this.myRate = book.myRate ?? '';
this.bookNote = book.bookNote ?? '';
this.isbn10 = book.isbn10 ?? '';
this.isbn13 = book.isbn13 ?? '';
}
toFrontMatter() {
return Object.keys(this)
.map(key => {
const value = this[key]?.toString().trim() ?? '';
return `${camelToSnakeCase(key)}: ${value}`;
})
.join('\n');
}
}

42
src/settings/settings.ts Normal file
View file

@ -0,0 +1,42 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import BookSearchPlugin from '../main';
import { FolderSuggest } from './suggesters/FolderSuggester';
export interface BookSearchPluginSettings {
folder: string;
}
export const DEFAULT_SETTINGS: BookSearchPluginSettings = {
folder: '',
};
export class BookSearchSettingTab extends PluginSettingTab {
plugin: BookSearchPlugin;
constructor(app: App, plugin: BookSearchPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Book Search Settings' });
new Setting(containerEl)
.setName('New file location')
.setDesc('New book notes will be placed here.')
.addSearch(cb => {
new FolderSuggest(this.app, cb.inputEl);
cb.setPlaceholder('Example: folder1/folder2')
.setValue(this.plugin.settings.folder)
.onChange(new_folder => {
this.plugin.settings.folder = new_folder;
this.plugin.saveSettings();
});
});
}
}

View file

@ -0,0 +1,33 @@
// Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes
import { TAbstractFile, TFolder } from 'obsidian';
import { TextInputSuggest } from './suggest';
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();
}
}

View file

@ -0,0 +1,197 @@
// 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> {
protected app: App;
protected inputEl: HTMLInputElement | HTMLTextAreaElement;
private popper: PopperInstance;
private scope: Scope;
private suggestEl: HTMLElement;
private suggest: Suggest<T>;
constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) {
this.app = app;
this.inputEl = inputEl;
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;
}

17
src/utils/utils.ts Normal file
View file

@ -0,0 +1,17 @@
import { Book, BookModel } from 'src/models/book.model';
export function replaceIllegalFileNameCharactersInString(string: string) {
return string.replace(/[\\,#%&{}/*<>$":@.]*/g, '');
}
export function isISBN(str: string) {
return /^(97(8|9))?\d{9}(\d|X)$/.test(str);
}
export function makeFrontMater(book: Book): string {
return new BookModel(book).toFrontMatter();
}
export function camelToSnakeCase(str) {
return str.replace(/[A-Z]/g, letter => `_${letter?.toLowerCase()}`);
}

1
styles.css Normal file
View file

@ -0,0 +1 @@
/* Sets all the text color to red! */

23
tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": false,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

4
versions.json Normal file
View file

@ -0,0 +1,4 @@
{
"1.0.0": "0.9.7",
"1.0.1": "0.12.0"
}