mirror of
https://github.com/cmorooney/obsidian-game-search-plugin.git
synced 2026-07-22 07:50:25 +00:00
Initial commit
This commit is contained in:
commit
bad6dccf4e
30 changed files with 8461 additions and 0 deletions
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
2
.eslintignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
npm node_modules
|
||||
build
|
||||
23
.eslintrc
Normal file
23
.eslintrc
Normal 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
85
.github/workflows/release.yml
vendored
Normal 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
22
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
18
.prettierrc
Normal file
18
.prettierrc
Normal 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
9
.versionrc
Normal 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
16
CHANGELOG.md
Normal 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
41
README.md
Normal 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.
|
||||

|
||||
|
||||
2. Excute the command "Create new book note".
|
||||

|
||||
|
||||
3. Search for books by keywords.
|
||||

|
||||
|
||||
4. Select the book from the search results.
|
||||

|
||||
|
||||
5. Voila! A note has been created.
|
||||

|
||||
|
||||
<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
54
esbuild.config.mjs
Normal 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
10
manifest.json
Normal 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
|
||||
}
|
||||
BIN
obsidian-book-search-plugin.zip
Normal file
BIN
obsidian-book-search-plugin.zip
Normal file
Binary file not shown.
6916
package-lock.json
generated
Normal file
6916
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
package.json
Normal file
39
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
78
src/apis/google_books_api.ts
Normal file
78
src/apis/google_books_api.ts
Normal 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;
|
||||
}
|
||||
518
src/apis/models/google_books.model.ts
Normal file
518
src/apis/models/google_books.model.ts
Normal 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
74
src/book_search_modal.ts
Normal 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
44
src/book_suggest_modal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
24
src/editor/corsor_jumper.ts
Normal file
24
src/editor/corsor_jumper.ts
Normal 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
86
src/main.ts
Normal 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
61
src/models/book.model.ts
Normal 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
42
src/settings/settings.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
33
src/settings/suggesters/FolderSuggester.ts
Normal file
33
src/settings/suggesters/FolderSuggester.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
197
src/settings/suggesters/suggest.ts
Normal file
197
src/settings/suggesters/suggest.ts
Normal 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
17
src/utils/utils.ts
Normal 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
1
styles.css
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* Sets all the text color to red! */
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal 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
14
version-bump.mjs
Normal 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
4
versions.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"1.0.0": "0.9.7",
|
||||
"1.0.1": "0.12.0"
|
||||
}
|
||||
Loading…
Reference in a new issue