add obsidian eslint plugin, solve major issues
This commit is contained in:
parent
84214a1c3c
commit
498e2611ae
105 changed files with 5702 additions and 5308 deletions
113
packages/obsidian/src/api/APIManager.ts
Normal file
113
packages/obsidian/src/api/APIManager.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, ok } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
export interface ApiQueryOk {
|
||||
items: MediaTypeModel[];
|
||||
warnings: AppError[];
|
||||
}
|
||||
|
||||
export class APIManager {
|
||||
apis: APIModel[];
|
||||
|
||||
constructor() {
|
||||
this.apis = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the basic info for one query string and multiple APIs.
|
||||
*
|
||||
* @param query
|
||||
* @param apisToQuery
|
||||
*/
|
||||
async query(query: string, apisToQuery: string[]): Promise<Result<ApiQueryOk, AppError>> {
|
||||
Logger.debug(`MDB | api manager queried with "${query}"`);
|
||||
|
||||
const apis = this.apis.filter(api => apisToQuery.includes(api.apiName));
|
||||
const results = await Promise.all(apis.map(api => api.searchByTitle(query)));
|
||||
|
||||
const items: MediaTypeModel[] = [];
|
||||
const warnings: AppError[] = [];
|
||||
for (const result of results) {
|
||||
if (result.ok) {
|
||||
items.push(...result.value);
|
||||
} else {
|
||||
warnings.push(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0 && warnings.length > 0) {
|
||||
// If all APIs failed, surface an error (using the first as representative)
|
||||
return err(
|
||||
toAppError(warnings[0], {
|
||||
kind: AppErrorKind.Api,
|
||||
message: 'Failed to query APIs',
|
||||
userMessage: 'Failed to query APIs',
|
||||
context: { query, apisToQuery },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const warning of warnings) {
|
||||
Logger.warn(warning);
|
||||
}
|
||||
|
||||
return ok({ items, warnings });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries detailed information for a MediaTypeModel.
|
||||
*
|
||||
* @param item
|
||||
*/
|
||||
async queryDetailedInfo(item: MediaTypeModel): Promise<Result<MediaTypeModel | undefined, AppError>> {
|
||||
return await this.queryDetailedInfoById(item.id, item.dataSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries detailed info for an id from an API.
|
||||
*
|
||||
* @param id
|
||||
* @param apiName
|
||||
*/
|
||||
async queryDetailedInfoById(id: string, apiName: string): Promise<Result<MediaTypeModel | undefined, AppError>> {
|
||||
for (const api of this.apis) {
|
||||
if (api.apiName === apiName) {
|
||||
const result = await api.getById(id);
|
||||
|
||||
if (!result.ok) {
|
||||
Logger.warn(result.error);
|
||||
}
|
||||
|
||||
return result.ok ? ok(result.value) : err(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
return err(
|
||||
toAppError(new Error(`API not found: ${apiName}`), {
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `API not found: ${apiName}`,
|
||||
userMessage: `API not found: ${apiName}`,
|
||||
context: { apiName, id },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getApiByName(name: string): APIModel | undefined {
|
||||
for (const api of this.apis) {
|
||||
if (api.apiName === name) {
|
||||
return api;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
registerAPI(api: APIModel): void {
|
||||
this.apis.push(api);
|
||||
}
|
||||
}
|
||||
33
packages/obsidian/src/api/APIModel.ts
Normal file
33
packages/obsidian/src/api/APIModel.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
export abstract class APIModel {
|
||||
apiName!: string;
|
||||
apiUrl!: string;
|
||||
apiDescription!: string;
|
||||
types!: MediaType[];
|
||||
plugin!: MediaDbPlugin;
|
||||
|
||||
/**
|
||||
* This function should query the api and return a list of matches. The matches should be capped at 20.
|
||||
*
|
||||
* @param title the title to query for
|
||||
*/
|
||||
abstract searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>>;
|
||||
|
||||
abstract getById(id: string): Promise<Result<MediaTypeModel, AppError>>;
|
||||
|
||||
abstract getDisabledMediaTypes(): MediaType[];
|
||||
|
||||
hasType(type: MediaType): boolean {
|
||||
const disabledMediaTypes = this.getDisabledMediaTypes();
|
||||
return this.types.includes(type) && !disabledMediaTypes.includes(type);
|
||||
}
|
||||
|
||||
hasTypeOverlap(types: MediaType[]): boolean {
|
||||
return types.some(type => this.hasType(type));
|
||||
}
|
||||
}
|
||||
217
packages/obsidian/src/api/apis/BoardGameGeekAPI.ts
Normal file
217
packages/obsidian/src/api/apis/BoardGameGeekAPI.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { BoardGameModel } from 'packages/obsidian/src/models/BoardGameModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
// sadly no open api schema available
|
||||
|
||||
export class BoardGameGeekAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'BoardGameGeekAPI';
|
||||
this.apiDescription = 'A free API for BoardGameGeek things.';
|
||||
this.apiUrl = 'https://boardgamegeek.com/xmlapi/';
|
||||
this.types = [MediaType.BoardGame];
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.BoardgameGeekKeyId);
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const searchUrl = `${this.apiUrl}/search?search=${encodeURIComponent(title)}`;
|
||||
const fetchDataResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: searchUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!fetchDataResult.ok) {
|
||||
return err(fetchDataResult.error);
|
||||
}
|
||||
const fetchData = fetchDataResult.value;
|
||||
|
||||
if (fetchData.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = fetchData.text;
|
||||
const response = new window.DOMParser().parseFromString(data, 'text/xml');
|
||||
|
||||
// console.debug(response);
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const boardgame of Array.from(response.querySelectorAll('boardgame'))) {
|
||||
const id = boardgame.attributes.getNamedItem('objectid')?.value;
|
||||
const title = boardgame.querySelector('name[primary=true]')?.textContent ?? boardgame.querySelector('name')?.textContent ?? undefined;
|
||||
const year = boardgame.querySelector('yearpublished')?.textContent ?? '';
|
||||
|
||||
ret.push(
|
||||
new BoardGameModel({
|
||||
dataSource: this.apiName,
|
||||
id,
|
||||
title,
|
||||
englishTitle: title,
|
||||
year,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.BoardgameGeekKeyId);
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const searchUrl = `${this.apiUrl}/boardgame/${encodeURIComponent(id)}?stats=1`;
|
||||
const fetchDataResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: searchUrl,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!fetchDataResult.ok) {
|
||||
return err(fetchDataResult.error);
|
||||
}
|
||||
const fetchData = fetchDataResult.value;
|
||||
|
||||
if (fetchData.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const data = fetchData.text;
|
||||
const response = new window.DOMParser().parseFromString(data, 'text/xml');
|
||||
// console.debug(response);
|
||||
|
||||
const boardgame = response.querySelector('boardgame');
|
||||
if (!boardgame) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received invalid data from ${this.apiName}.`,
|
||||
userMessage: `Received invalid data from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
const title = boardgame.querySelector('name[primary=true]')?.textContent;
|
||||
const year = boardgame.querySelector('yearpublished')?.textContent ?? '';
|
||||
const image = boardgame.querySelector('image')?.textContent ?? undefined;
|
||||
const onlineRating = Number.parseFloat(boardgame.querySelector('statistics ratings average')?.textContent ?? '0');
|
||||
const genres = Array.from(boardgame.querySelectorAll('boardgamecategory'))
|
||||
.map(n => n.textContent)
|
||||
.filter(n => n !== null);
|
||||
const complexityRating = Number.parseFloat(boardgame.querySelector('averageweight')?.textContent ?? '0');
|
||||
const minPlayers = Number.parseFloat(boardgame.querySelector('minplayers')?.textContent ?? '0');
|
||||
const maxPlayers = Number.parseFloat(boardgame.querySelector('maxplayers')?.textContent ?? '0');
|
||||
const playtime = (boardgame.querySelector('playingtime')?.textContent ?? 'unknown') + ' minutes';
|
||||
const publishers = Array.from(boardgame.querySelectorAll('boardgamepublisher'))
|
||||
.map(n => n.textContent)
|
||||
.filter(n => n !== null);
|
||||
|
||||
return ok(
|
||||
new BoardGameModel({
|
||||
title: title ?? undefined,
|
||||
englishTitle: title ?? undefined,
|
||||
year: year === '0' ? '' : year,
|
||||
dataSource: this.apiName,
|
||||
url: `https://boardgamegeek.com/boardgame/${id}`,
|
||||
id: id,
|
||||
|
||||
genres: genres,
|
||||
onlineRating: onlineRating,
|
||||
complexityRating: complexityRating,
|
||||
minPlayers: minPlayers,
|
||||
maxPlayers: maxPlayers,
|
||||
playtime: playtime,
|
||||
publishers: publishers,
|
||||
image: image,
|
||||
|
||||
released: true,
|
||||
|
||||
userData: {
|
||||
played: false,
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.BoardgameGeekAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
170
packages/obsidian/src/api/apis/ComicVineAPI.ts
Normal file
170
packages/obsidian/src/api/apis/ComicVineAPI.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { ComicMangaModel } from 'packages/obsidian/src/models/ComicMangaModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
// sadly no open api schema available
|
||||
|
||||
export class ComicVineAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'ComicVineAPI';
|
||||
this.apiDescription = 'A free API for comic books.';
|
||||
this.apiUrl = 'https://comicvine.gamespot.com/api';
|
||||
this.types = [MediaType.ComicManga];
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.ComicVineKeyId);
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const searchUrl = `${this.apiUrl}/search/?api_key=${key}&format=json&resources=volume&query=${encodeURIComponent(title)}`;
|
||||
const fetchDataResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: searchUrl,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
if (!fetchDataResult.ok) {
|
||||
return err(fetchDataResult.error);
|
||||
}
|
||||
const fetchData = fetchDataResult.value;
|
||||
// console.debug(fetchData);
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = await fetchData.json;
|
||||
// console.debug(data);
|
||||
const ret: MediaTypeModel[] = [];
|
||||
for (const result of data.results) {
|
||||
ret.push(
|
||||
new ComicMangaModel({
|
||||
title: result.name,
|
||||
englishTitle: result.name,
|
||||
year: result.start_year,
|
||||
dataSource: this.apiName,
|
||||
id: `4050-${result.id}`,
|
||||
publishers: result.publisher?.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.ComicVineKeyId);
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
const searchUrl = `${this.apiUrl}/volume/${encodeURIComponent(id)}/?api_key=${key}&format=json`;
|
||||
const fetchDataResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: searchUrl,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
if (!fetchDataResult.ok) {
|
||||
return err(fetchDataResult.error);
|
||||
}
|
||||
const fetchData = fetchDataResult.value;
|
||||
|
||||
Logger.debug(fetchData);
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const data = await fetchData.json;
|
||||
const result = data.results;
|
||||
|
||||
const authors = result.people as
|
||||
| {
|
||||
name: string;
|
||||
}[]
|
||||
| undefined;
|
||||
|
||||
return ok(
|
||||
new ComicMangaModel({
|
||||
type: MediaType.ComicManga,
|
||||
title: result.name,
|
||||
englishTitle: result.name,
|
||||
alternateTitles: result.aliases,
|
||||
plot: result.deck,
|
||||
year: result.start_year,
|
||||
dataSource: this.apiName,
|
||||
url: result.site_detail_url,
|
||||
id: `4050-${result.id}`,
|
||||
|
||||
authors: authors?.map(x => x.name),
|
||||
chapters: result.count_of_issues,
|
||||
image: result.image?.original_url,
|
||||
|
||||
released: true,
|
||||
publishers: result.publisher?.name,
|
||||
publishedFrom: result.start_year,
|
||||
status: result.status,
|
||||
|
||||
userData: {
|
||||
read: false,
|
||||
lastRead: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.ComicVineAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
169
packages/obsidian/src/api/apis/GiantBombAPI.ts
Normal file
169
packages/obsidian/src/api/apis/GiantBombAPI.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// Temporarily removed until they get their API working again.
|
||||
|
||||
// import createClient from 'openapi-fetch';
|
||||
// import type MediaDbPlugin from '../../main';
|
||||
// import { GameModel } from '../../models/GameModel';
|
||||
// import type { MediaTypeModel } from '../../models/MediaTypeModel';
|
||||
// import { MediaType } from '../../utils/MediaType';
|
||||
// import { obsidianFetch } from '../../utils/Utils';
|
||||
// import { APIModel } from '../APIModel';
|
||||
// import type { paths } from '../schemas/GiantBomb';
|
||||
|
||||
// export class GiantBombAPI extends APIModel {
|
||||
// plugin: MediaDbPlugin;
|
||||
// apiDateFormat: string = 'YYYY-MM-DD';
|
||||
|
||||
// constructor(plugin: MediaDbPlugin) {
|
||||
// super();
|
||||
|
||||
// this.plugin = plugin;
|
||||
// this.apiName = 'GiantBombAPI';
|
||||
// this.apiDescription = 'A free API for games.';
|
||||
// this.apiUrl = 'https://www.giantbomb.com/api';
|
||||
// this.types = [MediaType.Game];
|
||||
// }
|
||||
|
||||
// async searchByTitle(title: string): Promise<MediaTypeModel[]> {
|
||||
// console.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
// const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.GiantBombKeyId);
|
||||
|
||||
// if (!key) {
|
||||
// throw Error(`MDB | API key for ${this.apiName} missing.`);
|
||||
// }
|
||||
|
||||
// const client = createClient<paths>({ baseUrl: 'https://www.giantbomb.com/api/' });
|
||||
// const response = await client.GET('/games', {
|
||||
// params: {
|
||||
// query: {
|
||||
// api_key: key,
|
||||
// filter: `name:${title}`,
|
||||
// format: 'json',
|
||||
// limit: 20,
|
||||
// },
|
||||
// },
|
||||
// fetch: obsidianFetch,
|
||||
// });
|
||||
|
||||
// if (response.response.status === 401) {
|
||||
// throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
|
||||
// }
|
||||
// if (response.response.status === 429) {
|
||||
// throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`);
|
||||
// }
|
||||
// if (response.response.status !== 200) {
|
||||
// throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
|
||||
// }
|
||||
|
||||
// const data = response.data?.results;
|
||||
|
||||
// const ret: MediaTypeModel[] = [];
|
||||
// for (const result of data ?? []) {
|
||||
// const year = result.original_release_date ? new Date(result.original_release_date).getFullYear().toString() : undefined;
|
||||
|
||||
// ret.push(
|
||||
// new GameModel({
|
||||
// title: result.name,
|
||||
// englishTitle: result.name,
|
||||
// year: year,
|
||||
// dataSource: this.apiName,
|
||||
// id: result.guid?.toString(),
|
||||
// }),
|
||||
// );
|
||||
// }
|
||||
|
||||
// return ret;
|
||||
// }
|
||||
|
||||
// async getById(id: string): Promise<MediaTypeModel> {
|
||||
// console.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
// const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.GiantBombKeyId);
|
||||
|
||||
// if (!key) {
|
||||
// throw Error(`MDB | API key for ${this.apiName} missing.`);
|
||||
// }
|
||||
|
||||
// const client = createClient<paths>({ baseUrl: 'https://www.giantbomb.com/api/' });
|
||||
// const response = await client.GET('/game/{guid}', {
|
||||
// params: {
|
||||
// path: {
|
||||
// guid: id,
|
||||
// },
|
||||
// query: {
|
||||
// api_key: key,
|
||||
// format: 'json',
|
||||
// },
|
||||
// },
|
||||
// fetch: obsidianFetch,
|
||||
// });
|
||||
|
||||
// if (response.response.status === 401) {
|
||||
// throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`);
|
||||
// }
|
||||
// if (response.response.status === 429) {
|
||||
// throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`);
|
||||
// }
|
||||
// if (response.response.status !== 200) {
|
||||
// throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`);
|
||||
// }
|
||||
|
||||
// const result = response.data?.results;
|
||||
|
||||
// if (!result) {
|
||||
// throw Error(`MDB | No results found for ID ${id} in ${this.apiName}.`);
|
||||
// }
|
||||
|
||||
// console.log(result);
|
||||
|
||||
// // sadly the only OpenAPI definition I could find doesn't have the right types
|
||||
// const year = result.original_release_date ? new Date(result.original_release_date).getFullYear().toString() : undefined;
|
||||
// const developers = result.developers as
|
||||
// | {
|
||||
// name: string;
|
||||
// }[]
|
||||
// | undefined;
|
||||
// const publishers = result.publishers as
|
||||
// | {
|
||||
// name: string;
|
||||
// }[]
|
||||
// | undefined;
|
||||
// const genres = result.genres as
|
||||
// | {
|
||||
// name: string;
|
||||
// }[]
|
||||
// | undefined;
|
||||
// const image = result.image as
|
||||
// | {
|
||||
// small_url: string;
|
||||
// medium_url: string;
|
||||
// super_url: string;
|
||||
// }
|
||||
// | undefined;
|
||||
|
||||
// return new GameModel({
|
||||
// type: MediaType.Game,
|
||||
// title: result.name,
|
||||
// englishTitle: result.name,
|
||||
// year: year,
|
||||
// dataSource: this.apiName,
|
||||
// url: result.site_detail_url,
|
||||
// id: result.guid?.toString(),
|
||||
// developers: developers?.map(x => x.name),
|
||||
// publishers: publishers?.map(x => x.name),
|
||||
// genres: genres?.map(x => x.name),
|
||||
// onlineRating: 0,
|
||||
// image: image?.super_url,
|
||||
|
||||
// released: true,
|
||||
// releaseDate: result.original_release_date,
|
||||
|
||||
// userData: {
|
||||
// played: false,
|
||||
|
||||
// personalRating: 0,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// getDisabledMediaTypes(): MediaType[] {
|
||||
// return this.plugin.settings.GiantBombAPI_disabledMediaTypes;
|
||||
// }
|
||||
// }
|
||||
264
packages/obsidian/src/api/apis/IGDBAPI.ts
Normal file
264
packages/obsidian/src/api/apis/IGDBAPI.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { GameModel } from 'packages/obsidian/src/models/GameModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
interface IGDBCover {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface IGDBGenre {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface IGDBCompany {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface IGDBInvolvedCompany {
|
||||
company: IGDBCompany;
|
||||
developer: boolean;
|
||||
publisher: boolean;
|
||||
}
|
||||
|
||||
interface IGDBGame {
|
||||
id: number;
|
||||
name: string;
|
||||
cover?: IGDBCover;
|
||||
first_release_date?: number;
|
||||
summary?: string;
|
||||
total_rating?: number;
|
||||
url?: string;
|
||||
genres?: IGDBGenre[];
|
||||
involved_companies?: IGDBInvolvedCompany[];
|
||||
}
|
||||
|
||||
interface TwitchAuthResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export class IGDBAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
apiDateFormat: string = 'YYYY-MM-DD';
|
||||
private accessToken: string = '';
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'IGDBAPI';
|
||||
this.apiDescription = 'A free API for games (Requires Twitch Client ID & Secret).';
|
||||
this.apiUrl = 'https://api.igdb.com/v4';
|
||||
this.types = [MediaType.Game];
|
||||
}
|
||||
|
||||
private async getAuthToken(): Promise<Result<string, AppError>> {
|
||||
const currentTime = Date.now();
|
||||
if (this.accessToken && currentTime < this.tokenExpiry) return ok(this.accessToken);
|
||||
|
||||
const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId);
|
||||
const clientSecret = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientSecret);
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | Client ID or Client Secret for ${this.apiName} missing.`,
|
||||
userMessage: `Client ID or Client Secret for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
Logger.log(`MDB | Refreshing Twitch Auth Token for ${this.apiName}`);
|
||||
const responseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`,
|
||||
method: 'POST',
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying Twitch auth for ${this.apiName}`,
|
||||
userMessage: `Network error querying Twitch auth for ${this.apiName}`,
|
||||
context: { apiName: this.apiName },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
if (response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Auth failed for ${this.apiName}. Check Credentials.`,
|
||||
userMessage: `Auth failed for ${this.apiName}. Check Credentials.`,
|
||||
context: { apiName: this.apiName, status: response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.json as TwitchAuthResponse;
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = currentTime + data.expires_in * 1000 - 60000;
|
||||
return ok(this.accessToken);
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId);
|
||||
if (!clientId) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | Client ID for ${this.apiName} missing.`,
|
||||
userMessage: `Client ID for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
const tokenResult = await this.getAuthToken();
|
||||
if (!tokenResult.ok) {
|
||||
return err(tokenResult.error);
|
||||
}
|
||||
const token = tokenResult.value;
|
||||
const queryBody = `search "${title}"; fields name, cover.url, first_release_date, summary, total_rating; limit 20;`;
|
||||
const responseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: `${this.apiUrl}/games`,
|
||||
method: 'POST',
|
||||
headers: { 'Client-ID': clientId, Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
body: queryBody,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
if (response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.json as IGDBGame[];
|
||||
return ok(
|
||||
data.map(result => {
|
||||
const year = result.first_release_date ? new Date(result.first_release_date * 1000).getFullYear().toString() : '';
|
||||
const image = result.cover?.url ? 'https:' + result.cover.url.replace('t_thumb', 't_cover_big') : '';
|
||||
return new GameModel({
|
||||
type: MediaType.Game,
|
||||
title: result.name,
|
||||
englishTitle: result.name,
|
||||
year: year,
|
||||
dataSource: this.apiName,
|
||||
id: result.id.toString(),
|
||||
image: image,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId);
|
||||
if (!clientId) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | Client ID for ${this.apiName} missing.`,
|
||||
userMessage: `Client ID for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
const tokenResult = await this.getAuthToken();
|
||||
if (!tokenResult.ok) {
|
||||
return err(tokenResult.error);
|
||||
}
|
||||
const token = tokenResult.value;
|
||||
const queryBody = `fields name, cover.url, first_release_date, summary, total_rating, url, genres.name, involved_companies.company.name, involved_companies.developer, involved_companies.publisher; where id = ${id};`;
|
||||
const responseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: `${this.apiUrl}/games`,
|
||||
method: 'POST',
|
||||
headers: { 'Client-ID': clientId, Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
body: queryBody,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
if (response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.json as IGDBGame[];
|
||||
if (!data || data.length === 0) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No result found for ID ${id}`,
|
||||
userMessage: `No result found for ID ${id}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
const result = data[0];
|
||||
|
||||
const developers: string[] = [];
|
||||
const publishers: string[] = [];
|
||||
result.involved_companies?.forEach(c => {
|
||||
if (c.developer) developers.push(c.company.name);
|
||||
if (c.publisher) publishers.push(c.company.name);
|
||||
});
|
||||
const dateStr = result.first_release_date ? new Date(result.first_release_date * 1000).toISOString().split('T')[0] : '';
|
||||
const image = result.cover?.url ? 'https:' + result.cover.url.replace('t_thumb', 't_cover_big') : '';
|
||||
|
||||
return ok(
|
||||
new GameModel({
|
||||
type: MediaType.Game,
|
||||
title: result.name,
|
||||
englishTitle: result.name,
|
||||
year: result.first_release_date ? new Date(result.first_release_date * 1000).getFullYear().toString() : '',
|
||||
dataSource: this.apiName,
|
||||
url: result.url,
|
||||
id: result.id.toString(),
|
||||
developers: developers,
|
||||
publishers: publishers,
|
||||
genres: result.genres?.map(g => g.name) ?? [],
|
||||
onlineRating: result.total_rating,
|
||||
image: image,
|
||||
released: true,
|
||||
releaseDate: dateStr ? this.plugin.dateFormatter.format(dateStr, this.apiDateFormat) : '',
|
||||
userData: { played: false, personalRating: 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.IGDBAPI_disabledMediaTypes ?? [];
|
||||
}
|
||||
}
|
||||
260
packages/obsidian/src/api/apis/MALAPI.ts
Normal file
260
packages/obsidian/src/api/apis/MALAPI.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import createClient from 'openapi-fetch';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MovieModel } from 'packages/obsidian/src/models/MovieModel';
|
||||
import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, ok } from 'packages/obsidian/src/utils/result';
|
||||
import { isTruthy, obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||
import type { paths } from 'packages/schemas/src/MALAPI';
|
||||
|
||||
export class MALAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
typeMappings: Map<string, string>;
|
||||
apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'MALAPI';
|
||||
this.apiDescription = 'A free API for Anime. Some results may take a long time to load.';
|
||||
this.apiUrl = 'https://jikan.moe/';
|
||||
this.types = [MediaType.Movie, MediaType.Series];
|
||||
this.typeMappings = new Map<string, string>();
|
||||
this.typeMappings.set('movie', 'movie');
|
||||
this.typeMappings.set('special', 'special');
|
||||
this.typeMappings.set('tv', 'series');
|
||||
this.typeMappings.set('ova', 'ova');
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
|
||||
|
||||
const response = await client.GET('/anime', {
|
||||
params: {
|
||||
query: {
|
||||
q: title,
|
||||
limit: 20,
|
||||
sfw: this.plugin.settings.sfwFilter ? true : false,
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
});
|
||||
|
||||
if (response.error !== undefined) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.data?.data;
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data ?? []) {
|
||||
const resType = result.type?.toLowerCase();
|
||||
const type = resType ? this.typeMappings.get(resType) : undefined;
|
||||
const year = result.year?.toString() ?? result.aired?.prop?.from?.year?.toString() ?? '';
|
||||
const id = result.mal_id?.toString();
|
||||
|
||||
if (type === undefined) {
|
||||
ret.push(
|
||||
new MovieModel({
|
||||
subType: '',
|
||||
title: result.title,
|
||||
englishTitle: result.title_english ?? result.title,
|
||||
year,
|
||||
dataSource: this.apiName,
|
||||
id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (type === 'movie' || type === 'special') {
|
||||
ret.push(
|
||||
new MovieModel({
|
||||
subType: type,
|
||||
title: result.title,
|
||||
englishTitle: result.title_english ?? result.title,
|
||||
year,
|
||||
dataSource: this.apiName,
|
||||
id,
|
||||
}),
|
||||
);
|
||||
} else if (type === 'series' || type === 'ova') {
|
||||
ret.push(
|
||||
new SeriesModel({
|
||||
subType: type,
|
||||
title: result.title,
|
||||
englishTitle: result.title_english ?? result.title,
|
||||
year,
|
||||
dataSource: this.apiName,
|
||||
id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
|
||||
|
||||
const response = await client.GET('/anime/{id}/full', {
|
||||
params: {
|
||||
path: {
|
||||
id: id as unknown as number, // This is fine
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
});
|
||||
|
||||
if (response.error !== undefined) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const result = response.data?.data;
|
||||
|
||||
if (result === undefined) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data found for ID ${id} in ${this.apiName}.`,
|
||||
userMessage: `No data found for ID ${id} in ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
const resType = result.type?.toLowerCase();
|
||||
const type = resType ? this.typeMappings.get(resType) : undefined;
|
||||
const year = result.year?.toString() ?? result.aired?.prop?.from?.year?.toString();
|
||||
const new_id = result.mal_id?.toString();
|
||||
|
||||
if (type === undefined) {
|
||||
return ok(
|
||||
new MovieModel({
|
||||
subType: undefined,
|
||||
title: result.title,
|
||||
englishTitle: result.title_english ?? result.title,
|
||||
japaneseTitle: result.title_japanese,
|
||||
year: year,
|
||||
dataSource: this.apiName,
|
||||
url: result.url,
|
||||
id: new_id,
|
||||
|
||||
plot: result.synopsis,
|
||||
genres: result.genres?.map(x => x.name).filter(isTruthy),
|
||||
studio: result.studios?.map(x => x.name).filter(isTruthy),
|
||||
duration: result.duration,
|
||||
onlineRating: result.score,
|
||||
image: result.images?.jpg?.image_url,
|
||||
|
||||
released: true,
|
||||
ageRating: result.rating,
|
||||
premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
|
||||
streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
|
||||
|
||||
userData: {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'movie' || type === 'special') {
|
||||
return ok(
|
||||
new MovieModel({
|
||||
subType: type,
|
||||
title: result.title,
|
||||
englishTitle: result.title_english ?? result.title,
|
||||
japaneseTitle: result.title_japanese,
|
||||
year: year,
|
||||
dataSource: this.apiName,
|
||||
url: result.url,
|
||||
id: new_id,
|
||||
|
||||
plot: result.synopsis,
|
||||
genres: result.genres?.map(x => x.name).filter(isTruthy),
|
||||
studio: result.studios?.map(x => x.name).filter(isTruthy),
|
||||
duration: result.duration,
|
||||
onlineRating: result.score,
|
||||
image: result.images?.jpg?.image_url,
|
||||
|
||||
released: true,
|
||||
ageRating: result.rating,
|
||||
premiere: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
|
||||
streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
|
||||
|
||||
userData: {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (type === 'series' || type === 'ova') {
|
||||
return ok(
|
||||
new SeriesModel({
|
||||
subType: type,
|
||||
title: result.title,
|
||||
englishTitle: result.title_english ?? result.title,
|
||||
japaneseTitle: result.title_japanese,
|
||||
year: year,
|
||||
dataSource: this.apiName,
|
||||
url: result.url,
|
||||
id: new_id,
|
||||
|
||||
plot: result.synopsis,
|
||||
genres: result.genres?.map(x => x.name).filter(isTruthy),
|
||||
studio: result.studios?.map(x => x.name).filter(isTruthy),
|
||||
episodes: result.episodes,
|
||||
duration: result.duration,
|
||||
onlineRating: result.score,
|
||||
streamingServices: result.streaming?.map(x => x.name).filter(isTruthy),
|
||||
image: result.images?.jpg?.image_url,
|
||||
|
||||
released: true,
|
||||
ageRating: result.rating,
|
||||
airedFrom: this.plugin.dateFormatter.format(result.aired?.from, this.apiDateFormat),
|
||||
airedTo: this.plugin.dateFormatter.format(result.aired?.to, this.apiDateFormat),
|
||||
airing: result.airing,
|
||||
|
||||
userData: {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return err({
|
||||
kind: AppErrorKind.Unexpected,
|
||||
message: `MDB | Unknown media type for id ${id}`,
|
||||
userMessage: `Unknown media type for id ${id}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.MALAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
183
packages/obsidian/src/api/apis/MALAPIManga.ts
Normal file
183
packages/obsidian/src/api/apis/MALAPIManga.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import createClient from 'openapi-fetch';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { ComicMangaModel } from 'packages/obsidian/src/models/ComicMangaModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, ok } from 'packages/obsidian/src/utils/result';
|
||||
import { isTruthy, obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||
import type { paths } from 'packages/schemas/src/MALAPI';
|
||||
|
||||
export class MALAPIManga extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
typeMappings: Map<string, string>;
|
||||
apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'MALAPI Manga';
|
||||
this.apiDescription = 'A free API for Manga. Some results may take a long time to load.';
|
||||
this.apiUrl = 'https://jikan.moe/';
|
||||
this.types = [MediaType.ComicManga];
|
||||
this.typeMappings = new Map<string, string>();
|
||||
this.typeMappings.set('manga', 'manga');
|
||||
this.typeMappings.set('manhwa', 'manhwa');
|
||||
this.typeMappings.set('doujinshi', 'doujin');
|
||||
this.typeMappings.set('one-shot', 'oneshot');
|
||||
this.typeMappings.set('manhua', 'manhua');
|
||||
this.typeMappings.set('light novel', 'light-novel');
|
||||
this.typeMappings.set('novel', 'novel');
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
|
||||
|
||||
const response = await client.GET('/manga', {
|
||||
params: {
|
||||
query: {
|
||||
q: title,
|
||||
limit: 20,
|
||||
sfw: this.plugin.settings.sfwFilter ? true : false,
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
});
|
||||
|
||||
if (response.error !== undefined) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.data?.data;
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data ?? []) {
|
||||
const resType = result.type?.toLowerCase();
|
||||
const type = resType ? this.typeMappings.get(resType) : undefined;
|
||||
const year = result.published?.prop?.from?.year?.toString() ?? '';
|
||||
const id = result.mal_id?.toString();
|
||||
|
||||
ret.push(
|
||||
new ComicMangaModel({
|
||||
subType: type,
|
||||
title: result.title,
|
||||
plot: result.synopsis ?? undefined,
|
||||
englishTitle: result.title_english ?? result.title,
|
||||
alternateTitles: result.titles?.map(x => x.title).filter(isTruthy),
|
||||
year: year,
|
||||
dataSource: this.apiName,
|
||||
url: result.url,
|
||||
id: id,
|
||||
|
||||
genres: result.genres?.map(x => x.name).filter(isTruthy),
|
||||
authors: result.authors?.map(x => x.name).filter(isTruthy),
|
||||
chapters: result.chapters,
|
||||
volumes: result.volumes,
|
||||
onlineRating: result.score,
|
||||
image: result.images?.jpg?.image_url,
|
||||
|
||||
released: true,
|
||||
publishedFrom: this.plugin.dateFormatter.format(result.published?.from, this.apiDateFormat),
|
||||
publishedTo: this.plugin.dateFormatter.format(result.published?.to, this.apiDateFormat),
|
||||
status: result.status,
|
||||
|
||||
userData: {
|
||||
read: false,
|
||||
lastRead: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.jikan.moe/v4/' });
|
||||
|
||||
const response = await client.GET('/manga/{id}/full', {
|
||||
params: {
|
||||
path: {
|
||||
id: id as unknown as number, // This is fine
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
});
|
||||
|
||||
if (response.error !== undefined) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const result = response.data?.data;
|
||||
|
||||
if (!result) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data found for ID ${id} in ${this.apiName}.`,
|
||||
userMessage: `No data found for ID ${id} in ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
const resType = result.type?.toLowerCase();
|
||||
const type = resType ? this.typeMappings.get(resType) : undefined;
|
||||
const year = result.published?.prop?.from?.year?.toString() ?? '';
|
||||
const new_id = result.mal_id?.toString();
|
||||
|
||||
return ok(
|
||||
new ComicMangaModel({
|
||||
subType: type,
|
||||
title: result.title,
|
||||
plot: result.synopsis ?? undefined,
|
||||
englishTitle: result.title_english ?? result.title,
|
||||
alternateTitles: result.titles?.map(x => x.title).filter(isTruthy),
|
||||
year: year,
|
||||
dataSource: this.apiName,
|
||||
url: result.url,
|
||||
id: new_id,
|
||||
|
||||
genres: result.genres?.map(x => x.name).filter(isTruthy),
|
||||
authors: result.authors?.map(x => x.name).filter(isTruthy),
|
||||
chapters: result.chapters,
|
||||
volumes: result.volumes,
|
||||
onlineRating: result.score,
|
||||
image: result.images?.jpg?.image_url,
|
||||
|
||||
released: true,
|
||||
publishedFrom: this.plugin.dateFormatter.format(result.published?.from, this.apiDateFormat),
|
||||
publishedTo: this.plugin.dateFormatter.format(result.published?.to, this.apiDateFormat),
|
||||
status: result.status,
|
||||
|
||||
userData: {
|
||||
read: false,
|
||||
lastRead: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.MALAPIManga_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
327
packages/obsidian/src/api/apis/MusicBrainzAPI.ts
Normal file
327
packages/obsidian/src/api/apis/MusicBrainzAPI.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MusicReleaseModel } from 'packages/obsidian/src/models/MusicReleaseModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
import { contactEmail, getLanguageName, mediaDbVersion, pluginName } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
// sadly no open api schema available
|
||||
|
||||
interface Tag {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
interface Genre {
|
||||
name: string;
|
||||
count: number;
|
||||
id: string;
|
||||
disambiguation: string;
|
||||
}
|
||||
interface Release {
|
||||
id: string;
|
||||
'status-id': string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ArtistCredit {
|
||||
name: string;
|
||||
artist: {
|
||||
tags: Tag[];
|
||||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
'short-name': string;
|
||||
country: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
id: string;
|
||||
'type-id': string;
|
||||
score: number;
|
||||
'primary-type-id': string;
|
||||
'artists-credit-id': string;
|
||||
count: number;
|
||||
title: string;
|
||||
'first-release-date': string;
|
||||
'primary-type': string;
|
||||
'artist-credit': ArtistCredit[];
|
||||
releases: Release[];
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
interface IdResponse {
|
||||
id: string;
|
||||
tags: Tag[];
|
||||
'primary-type-id': string;
|
||||
'artist-credit': ArtistCredit[];
|
||||
title: string;
|
||||
genres: Genre[];
|
||||
'first-release-date': string;
|
||||
releases: Release[];
|
||||
'primary-type': string;
|
||||
rating: {
|
||||
value: number;
|
||||
'votes-count': number;
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaResponse {
|
||||
media: {
|
||||
'track-count': number;
|
||||
tracks: {
|
||||
'artist-credit': ArtistCredit[];
|
||||
length: number | null;
|
||||
number: string;
|
||||
position: number;
|
||||
title: string;
|
||||
recording: {
|
||||
length: number;
|
||||
title: string;
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
'text-representation': {
|
||||
language: string;
|
||||
script: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class MusicBrainzAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
apiDateFormat: string = 'YYYY-MM-DD';
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'MusicBrainz API';
|
||||
this.apiDescription = 'Free API for music albums.';
|
||||
this.apiUrl = 'https://musicbrainz.org/';
|
||||
this.types = [MediaType.MusicRelease];
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
|
||||
const searchUrl = `https://musicbrainz.org/ws/2/release-group?query=${encodeURIComponent(title)}&limit=20&fmt=json`;
|
||||
|
||||
const fetchDataResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: searchUrl,
|
||||
headers: {
|
||||
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
|
||||
},
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
if (!fetchDataResult.ok) {
|
||||
return err(fetchDataResult.error);
|
||||
}
|
||||
const fetchData = fetchDataResult.value;
|
||||
|
||||
// console.debug(fetchData);
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = (await fetchData.json) as {
|
||||
'release-groups': SearchResponse[];
|
||||
};
|
||||
// console.debug(data);
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data['release-groups']) {
|
||||
ret.push(
|
||||
new MusicReleaseModel({
|
||||
type: 'musicRelease',
|
||||
title: result.title,
|
||||
englishTitle: result.title,
|
||||
year: new Date(result['first-release-date']).getFullYear().toString(),
|
||||
releaseDate: this.plugin.dateFormatter.format(result['first-release-date'], this.apiDateFormat) ?? 'unknown',
|
||||
dataSource: this.apiName,
|
||||
url: 'https://musicbrainz.org/release-group/' + result.id,
|
||||
id: result.id,
|
||||
image: 'https://coverartarchive.org/release-group/' + result.id + '/front-500.jpg',
|
||||
|
||||
artists: result['artist-credit'].map(a => a.name),
|
||||
subType: result['primary-type'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
|
||||
// Fetch release group
|
||||
const groupUrl = `https://musicbrainz.org/ws/2/release-group/${encodeURIComponent(id)}?inc=releases+artists+tags+ratings+genres&fmt=json`;
|
||||
const groupResponseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: groupUrl,
|
||||
headers: {
|
||||
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
|
||||
},
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
if (!groupResponseResult.ok) {
|
||||
return err(groupResponseResult.error);
|
||||
}
|
||||
const groupResponse = groupResponseResult.value;
|
||||
|
||||
if (groupResponse.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${groupResponse.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${groupResponse.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: groupResponse.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const result = (await groupResponse.json) as IdResponse;
|
||||
|
||||
// Get ID of the first release
|
||||
const firstRelease = result.releases?.[0];
|
||||
if (!firstRelease) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: 'MDB | No releases found in release group.',
|
||||
userMessage: 'No releases found in release group.',
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch recordings for the first release
|
||||
const releaseUrl = `https://musicbrainz.org/ws/2/release/${firstRelease.id}?inc=recordings+artists&fmt=json`;
|
||||
Logger.log(`MDB | Fetching release recordings from: ${releaseUrl}`);
|
||||
|
||||
const releaseResponseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: releaseUrl,
|
||||
headers: {
|
||||
'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`,
|
||||
},
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id, releaseId: firstRelease.id },
|
||||
}),
|
||||
);
|
||||
if (!releaseResponseResult.ok) {
|
||||
return err(releaseResponseResult.error);
|
||||
}
|
||||
const releaseResponse = releaseResponseResult.value;
|
||||
|
||||
if (releaseResponse.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${releaseResponse.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${releaseResponse.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: releaseResponse.status, id, releaseId: firstRelease.id },
|
||||
});
|
||||
}
|
||||
|
||||
const releaseData = (await releaseResponse.json) as MediaResponse;
|
||||
const tracks = extractTracksFromMedia(releaseData.media);
|
||||
|
||||
// Calculate total album length for the first release
|
||||
const totalrawLength =
|
||||
releaseData.media[0]?.tracks.reduce((sum, track) => {
|
||||
const len = track.length ?? track.recording?.length;
|
||||
return typeof len === 'number' && !isNaN(len) ? sum + len : sum;
|
||||
}, 0) ?? 0;
|
||||
const albumLengthCalc = millisecondsToMinutes(totalrawLength);
|
||||
|
||||
Logger.debug(releaseData);
|
||||
|
||||
return ok(
|
||||
new MusicReleaseModel({
|
||||
type: 'musicRelease',
|
||||
title: result.title,
|
||||
englishTitle: result.title,
|
||||
year: new Date(result['first-release-date']).getFullYear().toString(),
|
||||
releaseDate: this.plugin.dateFormatter.format(result['first-release-date'], this.apiDateFormat) ?? 'unknown',
|
||||
dataSource: this.apiName,
|
||||
url: 'https://musicbrainz.org/release-group/' + result.id,
|
||||
id: result.id,
|
||||
image: 'https://coverartarchive.org/release-group/' + result.id + '/front-500.jpg',
|
||||
|
||||
artists: result['artist-credit'].map(a => a.name),
|
||||
language: releaseData['text-representation'].language ? getLanguageName(releaseData['text-representation'].language) : 'Unknown',
|
||||
genres: result.genres.map(g => g.name),
|
||||
subType: result['primary-type'],
|
||||
albumDuration: albumLengthCalc,
|
||||
trackCount: releaseData.media[0]?.['track-count'] ?? 0,
|
||||
tracks: tracks,
|
||||
rating: result.rating.value * 2,
|
||||
|
||||
userData: {
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.MusicBrainzAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTracksFromMedia(media: MediaResponse['media']): {
|
||||
number: number;
|
||||
title: string;
|
||||
duration: string;
|
||||
featuredArtists: string[];
|
||||
}[] {
|
||||
if (!media || media.length === 0 || !media[0].tracks) return [];
|
||||
|
||||
return media[0].tracks.map((track, index) => {
|
||||
const title = track.title ?? track.recording?.title ?? 'Unknown Title';
|
||||
const rawLength = track.length ?? track.recording?.length;
|
||||
const duration = rawLength ? millisecondsToMinutes(rawLength) : 'unknown';
|
||||
const featuredArtists = track['artist-credit']?.map(ac => ac.name) ?? [];
|
||||
|
||||
return {
|
||||
number: index + 1,
|
||||
title,
|
||||
duration,
|
||||
featuredArtists,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function millisecondsToMinutes(milliseconds: number): string {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = Math.floor((milliseconds % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
388
packages/obsidian/src/api/apis/OMDbAPI.ts
Normal file
388
packages/obsidian/src/api/apis/OMDbAPI.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { GameModel } from 'packages/obsidian/src/models/GameModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MovieModel } from 'packages/obsidian/src/models/MovieModel';
|
||||
import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
interface ErrorResponse {
|
||||
Response: 'False';
|
||||
Error: string;
|
||||
}
|
||||
|
||||
type SearchResponse =
|
||||
| {
|
||||
Response: 'True';
|
||||
totalResults: string;
|
||||
Search: {
|
||||
Title: string;
|
||||
Year: string;
|
||||
Poster: string;
|
||||
imdbID: string;
|
||||
Type: string;
|
||||
}[];
|
||||
}
|
||||
| ErrorResponse;
|
||||
|
||||
type IdResponse =
|
||||
| {
|
||||
Response: 'True';
|
||||
Title: string;
|
||||
Year: string;
|
||||
Rated: string;
|
||||
Released: string;
|
||||
Runtime: string;
|
||||
Genre: string;
|
||||
Director: string;
|
||||
Writer: string;
|
||||
Actors: string;
|
||||
Plot: string;
|
||||
Language: string;
|
||||
Country: string;
|
||||
Awards: string;
|
||||
Poster: string;
|
||||
Metascore: string;
|
||||
imdbRating: string;
|
||||
imdbVotes: string;
|
||||
imdbID: string;
|
||||
Type: string;
|
||||
DVD: string;
|
||||
BoxOffice: string;
|
||||
Production: string;
|
||||
Website: string;
|
||||
}
|
||||
| ErrorResponse;
|
||||
|
||||
export class OMDbAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
typeMappings: Map<string, string>;
|
||||
apiDateFormat: string = 'DD MMM YYYY';
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'OMDbAPI';
|
||||
this.apiDescription = 'A free API for Movies, Series and Games.';
|
||||
this.apiUrl = 'https://www.omdbapi.com/';
|
||||
this.types = [MediaType.Movie, MediaType.Series, MediaType.Game];
|
||||
this.typeMappings = new Map<string, string>();
|
||||
this.typeMappings.set('movie', 'movie');
|
||||
this.typeMappings.set('series', 'series');
|
||||
this.typeMappings.set('game', 'game');
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.OMDbKeyId);
|
||||
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const responseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: `https://www.omdbapi.com/?s=${encodeURIComponent(title)}&apikey=${key}`,
|
||||
method: 'GET',
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
if (response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
if (response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.json as SearchResponse | undefined;
|
||||
|
||||
if (!data) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.Response === 'False') {
|
||||
if (data.Error === 'Movie not found!') {
|
||||
return ok([]);
|
||||
}
|
||||
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received error from ${this.apiName}: ${data.Error}`,
|
||||
userMessage: `${data.Error}`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
if (!data.Search) {
|
||||
return ok([]);
|
||||
}
|
||||
|
||||
// console.debug(data.Search);
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data.Search) {
|
||||
const type = this.typeMappings.get(result.Type.toLowerCase());
|
||||
if (type === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (type === 'movie') {
|
||||
ret.push(
|
||||
new MovieModel({
|
||||
type: type,
|
||||
title: result.Title,
|
||||
englishTitle: result.Title,
|
||||
year: result.Year,
|
||||
dataSource: this.apiName,
|
||||
id: result.imdbID,
|
||||
}),
|
||||
);
|
||||
} else if (type === 'series') {
|
||||
ret.push(
|
||||
new SeriesModel({
|
||||
type: type,
|
||||
title: result.Title,
|
||||
englishTitle: result.Title,
|
||||
year: result.Year,
|
||||
dataSource: this.apiName,
|
||||
id: result.imdbID,
|
||||
}),
|
||||
);
|
||||
} else if (type === 'game') {
|
||||
ret.push(
|
||||
new GameModel({
|
||||
type: type,
|
||||
title: result.Title,
|
||||
englishTitle: result.Title,
|
||||
year: result.Year,
|
||||
dataSource: this.apiName,
|
||||
id: result.imdbID,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.OMDbKeyId);
|
||||
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const responseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: `https://www.omdbapi.com/?i=${encodeURIComponent(id)}&apikey=${key}`,
|
||||
method: 'GET',
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
if (response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
if (response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const result = response.json as IdResponse | undefined;
|
||||
|
||||
if (!result) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.Response === 'False') {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received error from ${this.apiName}: ${result.Error}`,
|
||||
userMessage: `${result.Error}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
const type = this.typeMappings.get(result.Type.toLowerCase());
|
||||
if (type === undefined) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `${result.Type.toLowerCase()} is an unsupported type.`,
|
||||
userMessage: `${result.Type.toLowerCase()} is an unsupported type.`,
|
||||
context: { apiName: this.apiName, id, type: result.Type },
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'movie') {
|
||||
return ok(
|
||||
new MovieModel({
|
||||
type: type,
|
||||
title: result.Title,
|
||||
englishTitle: result.Title,
|
||||
year: result.Year,
|
||||
dataSource: this.apiName,
|
||||
url: `https://www.imdb.com/title/${result.imdbID}/`,
|
||||
id: result.imdbID,
|
||||
|
||||
plot: result.Plot,
|
||||
genres: result.Genre?.split(', '),
|
||||
director: result.Director?.split(', '),
|
||||
writer: result.Writer?.split(', '),
|
||||
duration: result.Runtime,
|
||||
onlineRating: Number.parseFloat(result.imdbRating ?? 0),
|
||||
actors: result.Actors?.split(', '),
|
||||
image: result.Poster.replace('_SX300', '_SX600'),
|
||||
|
||||
released: true,
|
||||
country: result.Country?.split(', '),
|
||||
boxOffice: result.BoxOffice,
|
||||
ageRating: result.Rated,
|
||||
premiere: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
|
||||
|
||||
userData: {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (type === 'series') {
|
||||
return ok(
|
||||
new SeriesModel({
|
||||
type: type,
|
||||
title: result.Title,
|
||||
englishTitle: result.Title,
|
||||
year: result.Year,
|
||||
dataSource: this.apiName,
|
||||
url: `https://www.imdb.com/title/${result.imdbID}/`,
|
||||
id: result.imdbID,
|
||||
|
||||
plot: result.Plot,
|
||||
genres: result.Genre?.split(', '),
|
||||
writer: result.Writer?.split(', '),
|
||||
studio: [],
|
||||
episodes: 0,
|
||||
duration: result.Runtime,
|
||||
onlineRating: Number.parseFloat(result.imdbRating ?? 0),
|
||||
actors: result.Actors?.split(', '),
|
||||
image: result.Poster.replace('_SX300', '_SX600'),
|
||||
|
||||
released: true,
|
||||
country: result.Country?.split(', '),
|
||||
ageRating: result.Rated,
|
||||
airedFrom: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
|
||||
|
||||
userData: {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (type === 'game') {
|
||||
return ok(
|
||||
new GameModel({
|
||||
type: type,
|
||||
title: result.Title,
|
||||
englishTitle: result.Title,
|
||||
year: result.Year,
|
||||
dataSource: this.apiName,
|
||||
url: `https://www.imdb.com/title/${result.imdbID}/`,
|
||||
id: result.imdbID,
|
||||
|
||||
genres: result.Genre?.split(', '),
|
||||
onlineRating: Number.parseFloat(result.imdbRating ?? 0),
|
||||
image: result.Poster.replace('_SX300', '_SX600'),
|
||||
|
||||
released: true,
|
||||
releaseDate: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat),
|
||||
|
||||
userData: {
|
||||
played: false,
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return err({
|
||||
kind: AppErrorKind.Unexpected,
|
||||
message: `MDB | Unknown media type for id ${id}`,
|
||||
userMessage: `Unknown media type for id ${id}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.OMDbAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
214
packages/obsidian/src/api/apis/OpenLibraryAPI.ts
Normal file
214
packages/obsidian/src/api/apis/OpenLibraryAPI.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import createClient from 'openapi-fetch';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { BookModel } from 'packages/obsidian/src/models/BookModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||
import type { paths } from 'packages/schemas/src/OpenLibrary';
|
||||
|
||||
interface SearchResponse {
|
||||
editions: {
|
||||
docs: {
|
||||
key?: string;
|
||||
title?: string;
|
||||
cover_i?: number;
|
||||
isbn?: string[];
|
||||
}[];
|
||||
};
|
||||
cover_i?: number;
|
||||
has_fulltext?: boolean;
|
||||
edition_count?: number;
|
||||
title?: string;
|
||||
author_name?: string[];
|
||||
first_publish_year?: number;
|
||||
key: string;
|
||||
description?: string;
|
||||
|
||||
number_of_pages_median?: number;
|
||||
isbn?: string[];
|
||||
ratings_average?: number;
|
||||
}
|
||||
|
||||
export class OpenLibraryAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'OpenLibraryAPI';
|
||||
this.apiDescription = 'A free API for books';
|
||||
this.apiUrl = 'https://openlibrary.org/';
|
||||
this.types = [MediaType.Book];
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
|
||||
|
||||
const responseResult = await fromPromise(
|
||||
client.GET('/search.json', {
|
||||
params: {
|
||||
query: {
|
||||
q: title,
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
if (response.error !== undefined) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.data as {
|
||||
docs: SearchResponse[];
|
||||
};
|
||||
|
||||
// console.debug(data);
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data.docs) {
|
||||
ret.push(
|
||||
new BookModel({
|
||||
title: result.title,
|
||||
englishTitle: result.title,
|
||||
year: result.first_publish_year?.toString() ?? 'unknown',
|
||||
dataSource: this.apiName,
|
||||
id: result.key,
|
||||
author: result.author_name?.join(', '),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://openlibrary.org/' });
|
||||
|
||||
const responseResult = await fromPromise(
|
||||
client.GET('/search.json', {
|
||||
params: {
|
||||
query: {
|
||||
q: `${id}`,
|
||||
fields: 'key,title,author_name,number_of_pages_median,first_publish_year,isbn,ratings_score,first_sentence,title_suggest,rating*,cover*,editions,description',
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
if (response.error !== undefined) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.data as {
|
||||
docs: SearchResponse[];
|
||||
q?: string;
|
||||
};
|
||||
|
||||
const result = data.docs?.[0];
|
||||
if (!result) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data found for ID ${id} in ${this.apiName}.`,
|
||||
userMessage: `No data found for ID ${id}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
let key = result.key;
|
||||
let title = result.title;
|
||||
let cover_i = result.cover_i;
|
||||
let isbnArr = result.isbn;
|
||||
|
||||
// Check if the query is for /isbn/ or /books/ and extract from editions.docs if present
|
||||
const q = data.q ?? '';
|
||||
if ((q.includes('/isbn/') || q.includes('/books/')) && result.editions && Array.isArray(result.editions.docs) && result.editions.docs.length > 0) {
|
||||
const edition = result.editions.docs[0];
|
||||
key = edition.key ?? key;
|
||||
title = edition.title ?? title;
|
||||
cover_i = edition.cover_i ?? cover_i;
|
||||
isbnArr = edition.isbn ?? isbnArr;
|
||||
}
|
||||
|
||||
const pages = Number(result.number_of_pages_median);
|
||||
const isbn = Number((isbnArr ?? []).find((el: string) => el.length <= 10));
|
||||
const isbn13 = Number((isbnArr ?? []).find((el: string) => el.length == 13));
|
||||
|
||||
return ok(
|
||||
new BookModel({
|
||||
title: title,
|
||||
year: result.first_publish_year?.toString() ?? 'unknown',
|
||||
dataSource: this.apiName,
|
||||
url: `https://openlibrary.org` + key,
|
||||
id: key,
|
||||
isbn: Number.isNaN(isbn) ? undefined : isbn,
|
||||
isbn13: Number.isNaN(isbn13) ? undefined : isbn13,
|
||||
englishTitle: title,
|
||||
|
||||
author: result.author_name?.join(', '),
|
||||
plot: result.description ?? undefined,
|
||||
pages: Number.isNaN(pages) ? undefined : pages,
|
||||
onlineRating: result.ratings_average,
|
||||
image: cover_i ? `https://covers.openlibrary.org/b/id/` + cover_i + `-L.jpg` : undefined,
|
||||
|
||||
released: true,
|
||||
|
||||
userData: {
|
||||
read: false,
|
||||
lastRead: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.OpenLibraryAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
161
packages/obsidian/src/api/apis/RAWGAPI.ts
Normal file
161
packages/obsidian/src/api/apis/RAWGAPI.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { GameModel } from 'packages/obsidian/src/models/GameModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
interface RAWGGame {
|
||||
id: number;
|
||||
name: string;
|
||||
released?: string;
|
||||
background_image?: string;
|
||||
name_original?: string;
|
||||
website?: string;
|
||||
slug?: string;
|
||||
metacritic?: number;
|
||||
developers?: { name: string }[];
|
||||
publishers?: { name: string }[];
|
||||
genres?: { name: string }[];
|
||||
}
|
||||
|
||||
interface RAWGSearchResponse {
|
||||
results: RAWGGame[];
|
||||
}
|
||||
|
||||
export class RAWGAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
apiDateFormat: string = 'YYYY-MM-DD';
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'RAWGAPI';
|
||||
this.apiDescription = 'A large open video game database.';
|
||||
this.apiUrl = 'https://api.rawg.io/api';
|
||||
this.types = [MediaType.Game];
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.RAWGAPIKeyId);
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const responseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: `${this.apiUrl}/games?key=${key}&search=${encodeURIComponent(title)}&page_size=20`,
|
||||
method: 'GET',
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
if (response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Error ${response.status} from ${this.apiName}.`,
|
||||
userMessage: `Error ${response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.json as RAWGSearchResponse;
|
||||
return ok(
|
||||
data.results.map(
|
||||
result =>
|
||||
new GameModel({
|
||||
type: MediaType.Game,
|
||||
title: result.name,
|
||||
englishTitle: result.name,
|
||||
year: result.released ? new Date(result.released).getFullYear().toString() : '',
|
||||
dataSource: this.apiName,
|
||||
id: result.id.toString(),
|
||||
image: result.background_image,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.RAWGAPIKeyId);
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const responseResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: `${this.apiUrl}/games/${id}?key=${key}`,
|
||||
method: 'GET',
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
if (response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Error ${response.status} from ${this.apiName}.`,
|
||||
userMessage: `Error ${response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const result = response.json as RAWGGame;
|
||||
return ok(
|
||||
new GameModel({
|
||||
type: MediaType.Game,
|
||||
title: result.name,
|
||||
englishTitle: result.name_original ?? result.name,
|
||||
year: result.released ? new Date(result.released).getFullYear().toString() : '',
|
||||
dataSource: this.apiName,
|
||||
url: result.website ?? `https://rawg.io/games/${result.slug}`,
|
||||
id: result.id.toString(),
|
||||
developers: result.developers?.map(d => d.name) ?? [],
|
||||
publishers: result.publishers?.map(p => p.name) ?? [],
|
||||
genres: result.genres?.map(g => g.name) ?? [],
|
||||
onlineRating: result.metacritic,
|
||||
image: result.background_image,
|
||||
released: result.released != null,
|
||||
releaseDate: result.released,
|
||||
userData: { played: false, personalRating: 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.RAWGAPI_disabledMediaTypes ?? [];
|
||||
}
|
||||
}
|
||||
301
packages/obsidian/src/api/apis/SteamAPI.ts
Normal file
301
packages/obsidian/src/api/apis/SteamAPI.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { GameModel } from 'packages/obsidian/src/models/GameModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
import { imageUrlExists } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
interface SearchResponse {
|
||||
appid: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
type IdResponse = Record<
|
||||
string,
|
||||
{
|
||||
success: boolean;
|
||||
data: GameDetails;
|
||||
}
|
||||
>;
|
||||
|
||||
interface GameDetails {
|
||||
type: string;
|
||||
name: string;
|
||||
steam_appid: number;
|
||||
required_age: string;
|
||||
is_free: boolean;
|
||||
controller_support: string;
|
||||
dlc: number[];
|
||||
detailed_description: string;
|
||||
about_the_game: string;
|
||||
short_description: string;
|
||||
supported_languages: string;
|
||||
reviews: string;
|
||||
header_image: string;
|
||||
capsule_image: string;
|
||||
capsule_imagev5: string;
|
||||
website: string;
|
||||
pc_requirements: Requirements;
|
||||
mac_requirements: Requirements;
|
||||
linux_requirements: Requirements;
|
||||
legal_notice: string;
|
||||
drm_notice: string;
|
||||
developers: string[];
|
||||
publishers: string[];
|
||||
price_overview: PriceOverview;
|
||||
packages: number[];
|
||||
platforms: Platforms;
|
||||
metacritic?: {
|
||||
score: number;
|
||||
url: string;
|
||||
};
|
||||
categories: Category[];
|
||||
genres: Genre[];
|
||||
recommendations: {
|
||||
total: number;
|
||||
};
|
||||
achievements: {
|
||||
total: number;
|
||||
highlighted: Achievement[];
|
||||
};
|
||||
release_date: {
|
||||
coming_soon: boolean;
|
||||
date: string;
|
||||
};
|
||||
support_info: {
|
||||
url: string;
|
||||
email: string;
|
||||
};
|
||||
background: string;
|
||||
background_raw: string;
|
||||
content_descriptors: {
|
||||
ids: number[];
|
||||
notes: string;
|
||||
};
|
||||
ratings: Ratings;
|
||||
}
|
||||
|
||||
interface Requirements {
|
||||
minimum: string;
|
||||
recommended: string;
|
||||
}
|
||||
|
||||
interface PriceOverview {
|
||||
currency: string;
|
||||
initial: number;
|
||||
final: number;
|
||||
discount_percent: number;
|
||||
initial_formatted: string;
|
||||
final_formatted: string;
|
||||
}
|
||||
|
||||
interface Platforms {
|
||||
windows: boolean;
|
||||
mac: boolean;
|
||||
linux: boolean;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Genre {
|
||||
id: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
type Ratings = Record<
|
||||
string,
|
||||
{
|
||||
rating: string;
|
||||
descriptors: string;
|
||||
use_age_gate: string;
|
||||
required_age: string;
|
||||
rating_id?: string;
|
||||
banned?: string;
|
||||
rating_generated?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export class SteamAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
typeMappings: Map<string, string>;
|
||||
apiDateFormat: string = 'DD MMM, YYYY';
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'SteamAPI';
|
||||
this.apiDescription = 'A free API for all Steam games.';
|
||||
this.apiUrl = 'https://www.steampowered.com/';
|
||||
this.types = [MediaType.Game];
|
||||
this.typeMappings = new Map<string, string>();
|
||||
this.typeMappings.set('game', 'game');
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
|
||||
const searchUrl = `https://steamcommunity.com/actions/SearchApps/${encodeURIComponent(title)}`;
|
||||
const fetchDataResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: searchUrl,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
if (!fetchDataResult.ok) {
|
||||
return err(fetchDataResult.error);
|
||||
}
|
||||
const fetchData = fetchDataResult.value;
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = (await fetchData.json) as SearchResponse[];
|
||||
|
||||
// console.debug(data);
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data) {
|
||||
ret.push(
|
||||
new GameModel({
|
||||
type: MediaType.Game,
|
||||
title: result.name,
|
||||
englishTitle: result.name,
|
||||
year: '',
|
||||
dataSource: this.apiName,
|
||||
id: result.appid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
|
||||
const searchUrl = `https://store.steampowered.com/api/appdetails?appids=${encodeURIComponent(id)}&l=en`;
|
||||
const fetchDataResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: searchUrl,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
if (!fetchDataResult.ok) {
|
||||
return err(fetchDataResult.error);
|
||||
}
|
||||
const fetchData = fetchDataResult.value;
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
// console.debug(await fetchData.json);
|
||||
const data = (await fetchData.json) as IdResponse;
|
||||
|
||||
let result: GameDetails | undefined = undefined;
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// after some testing I found out that id is somehow a number despite that it's defined as string...
|
||||
if (key === String(id)) {
|
||||
result = value.data;
|
||||
}
|
||||
}
|
||||
if (!result) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: 'MDB | API returned invalid data.',
|
||||
userMessage: 'Steam returned invalid data.',
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
// console.debug(result);
|
||||
|
||||
// Check if a poster version of the image exists, else use the header image
|
||||
const imageUrl = `https://steamcdn-a.akamaihd.net/steam/apps/${result.steam_appid}/library_600x900_2x.jpg`;
|
||||
const existsResult = await fromPromise(imageUrlExists(imageUrl), cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Failed to validate image URL for ${this.apiName}`,
|
||||
userMessage: `Failed to validate image URL for ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id, imageUrl },
|
||||
}),
|
||||
);
|
||||
const exists = existsResult.ok ? existsResult.value : false;
|
||||
let finalimageurl;
|
||||
if (exists) {
|
||||
finalimageurl = imageUrl;
|
||||
} else {
|
||||
finalimageurl = result.header_image ?? '';
|
||||
}
|
||||
|
||||
return ok(
|
||||
new GameModel({
|
||||
type: MediaType.Game,
|
||||
title: result.name,
|
||||
englishTitle: result.name,
|
||||
year: new Date(result.release_date.date).getFullYear().toString(),
|
||||
dataSource: this.apiName,
|
||||
url: `https://store.steampowered.com/app/${result.steam_appid}`,
|
||||
id: result.steam_appid.toString(),
|
||||
|
||||
developers: result.developers,
|
||||
publishers: result.publishers,
|
||||
genres: result.genres?.map(x => x.description),
|
||||
onlineRating: result.metacritic?.score,
|
||||
image: finalimageurl,
|
||||
|
||||
released: !result.release_date?.coming_soon,
|
||||
releaseDate: this.plugin.dateFormatter.format(result.release_date?.date, this.apiDateFormat),
|
||||
|
||||
userData: {
|
||||
played: false,
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.SteamAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
265
packages/obsidian/src/api/apis/TMDBMovieAPI.ts
Normal file
265
packages/obsidian/src/api/apis/TMDBMovieAPI.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import createClient from 'openapi-fetch';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MovieModel } from 'packages/obsidian/src/models/MovieModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||
import type { paths } from 'packages/schemas/src/TMDB';
|
||||
|
||||
interface TMDBCreditMember {
|
||||
name?: string | null;
|
||||
job?: string | null;
|
||||
}
|
||||
|
||||
interface TMDBCreditsResponse {
|
||||
credits?: {
|
||||
cast?: TMDBCreditMember[];
|
||||
crew?: TMDBCreditMember[];
|
||||
};
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
function getTopCastNames(credits: TMDBCreditsResponse['credits'], size: number): string[] {
|
||||
return (credits?.cast ?? [])
|
||||
.map(c => c.name)
|
||||
.filter(isNonEmptyString)
|
||||
.slice(0, size);
|
||||
}
|
||||
|
||||
function getCrewNamesByJob(credits: TMDBCreditsResponse['credits'], job: string): string[] {
|
||||
return (credits?.crew ?? [])
|
||||
.filter(c => c.job === job)
|
||||
.map(c => c.name)
|
||||
.filter(isNonEmptyString);
|
||||
}
|
||||
|
||||
export class TMDBMovieAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
typeMappings: Map<string, string>;
|
||||
apiDateFormat: string = 'YYYY-MM-DD';
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'TMDBMovieAPI';
|
||||
this.apiDescription = 'A community built Movie DB.';
|
||||
this.apiUrl = 'https://www.themoviedb.org/';
|
||||
this.types = [MediaType.Movie];
|
||||
this.typeMappings = new Map<string, string>();
|
||||
this.typeMappings.set('movie', 'movie');
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
|
||||
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
|
||||
const responseResult = await fromPromise(
|
||||
client.GET('/3/search/movie', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
query: {
|
||||
query: encodeURIComponent(title),
|
||||
include_adult: this.plugin.settings.sfwFilter ? false : true,
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
|
||||
const response = responseResult.value;
|
||||
|
||||
if (response.response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
if (response.response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (!data) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.total_results === 0 || !data.results) {
|
||||
return ok([]);
|
||||
}
|
||||
|
||||
// console.debug(data.results);
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data.results) {
|
||||
ret.push(
|
||||
new MovieModel({
|
||||
type: 'movie',
|
||||
title: result.original_title,
|
||||
englishTitle: result.title,
|
||||
year: result.release_date ? new Date(result.release_date).getFullYear().toString() : 'unknown',
|
||||
dataSource: this.apiName,
|
||||
id: result.id.toString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
|
||||
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
|
||||
const responseResult = await fromPromise(
|
||||
client.GET('/3/movie/{movie_id}', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
path: { movie_id: parseInt(id) },
|
||||
query: {
|
||||
append_to_response: 'credits',
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
|
||||
const response = responseResult.value;
|
||||
|
||||
if (response.response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
if (response.response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
|
||||
if (!result) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
// console.debug(result);
|
||||
const credits = (result as TMDBCreditsResponse).credits;
|
||||
|
||||
return ok(
|
||||
new MovieModel({
|
||||
type: 'movie',
|
||||
title: result.title,
|
||||
englishTitle: result.title,
|
||||
year: result.release_date ? new Date(result.release_date).getFullYear().toString() : 'unknown',
|
||||
premiere: this.plugin.dateFormatter.format(result.release_date, this.apiDateFormat) ?? 'unknown',
|
||||
dataSource: this.apiName,
|
||||
url: `https://www.themoviedb.org/movie/${result.id}`,
|
||||
id: result.id.toString(),
|
||||
|
||||
plot: result.overview ?? '',
|
||||
genres: result.genres?.map(g => g.name).filter(isNonEmptyString) ?? [],
|
||||
writer: getCrewNamesByJob(credits, 'Screenplay'),
|
||||
director: getCrewNamesByJob(credits, 'Director'),
|
||||
studio: result.production_companies?.map(s => s.name).filter(isNonEmptyString) ?? [],
|
||||
|
||||
duration: result.runtime?.toString() ?? 'unknown',
|
||||
onlineRating: result.vote_average,
|
||||
actors: getTopCastNames(credits, 5),
|
||||
image: `https://image.tmdb.org/t/p/w780${result.poster_path}`,
|
||||
|
||||
released: ['Released'].includes(result.status!),
|
||||
streamingServices: [],
|
||||
|
||||
userData: {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.TMDBMovieAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
438
packages/obsidian/src/api/apis/TMDBSeasonAPI.ts
Normal file
438
packages/obsidian/src/api/apis/TMDBSeasonAPI.ts
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
import createClient from 'openapi-fetch';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { SeasonModel } from 'packages/obsidian/src/models/SeasonModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||
import type { paths } from 'packages/schemas/src/TMDB';
|
||||
|
||||
interface NamedEntity {
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface CastMember {
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface CreditsLike {
|
||||
cast?: CastMember[] | null;
|
||||
}
|
||||
|
||||
function extractNames(items: (NamedEntity | null | undefined)[] | null | undefined): string[] {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return items.map(item => item?.name?.trim() ?? '').filter(name => name.length > 0);
|
||||
}
|
||||
|
||||
function getTopActorNames(credits: CreditsLike | null | undefined, limit: number = 5): string[] {
|
||||
if (!credits || !Array.isArray(credits.cast)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return credits.cast
|
||||
.map(member => {
|
||||
const name = member?.name;
|
||||
return typeof name === 'string' ? name : '';
|
||||
})
|
||||
.filter(name => name.length > 0)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export class TMDBSeasonAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
typeMappings: Map<string, string>;
|
||||
apiDateFormat: string = 'YYYY-MM-DD';
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'TMDBSeasonAPI';
|
||||
this.apiDescription = 'A community built Series DB (seasons).';
|
||||
this.apiUrl = 'https://www.themoviedb.org/';
|
||||
this.types = [MediaType.Season];
|
||||
this.typeMappings = new Map<string, string>();
|
||||
this.typeMappings.set('tv', 'season');
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
|
||||
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
|
||||
const searchResponseResult = await fromPromise(
|
||||
client.GET('/3/search/tv', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
query: {
|
||||
query: encodeURIComponent(title),
|
||||
include_adult: this.plugin.settings.sfwFilter ? false : true,
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!searchResponseResult.ok) {
|
||||
return err(searchResponseResult.error);
|
||||
}
|
||||
const searchResponse = searchResponseResult.value;
|
||||
|
||||
if (searchResponse.response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
if (searchResponse.response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${searchResponse.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${searchResponse.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: searchResponse.response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const searchData = searchResponse.data;
|
||||
|
||||
if (!searchData?.results || searchData.total_results === 0) {
|
||||
return ok([]);
|
||||
}
|
||||
|
||||
const topResults = searchData.results.slice(0, 20);
|
||||
|
||||
const items = await Promise.all(
|
||||
topResults.map(async result => {
|
||||
let totalSeasons = 0;
|
||||
if (typeof result.id === 'number') {
|
||||
try {
|
||||
const detailsResponse = await client.GET('/3/tv/{series_id}', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
path: { series_id: result.id },
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
});
|
||||
|
||||
if (detailsResponse.response.status === 200 && Array.isArray(detailsResponse.data?.seasons)) {
|
||||
totalSeasons = detailsResponse.data.seasons.length;
|
||||
}
|
||||
} catch {
|
||||
// Ignore detail errors and use 0 as fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return new SeasonModel({
|
||||
title: `${result.name ?? result.original_name ?? ''}`,
|
||||
englishTitle: result.name ?? result.original_name ?? '',
|
||||
year: result.first_air_date ? new Date(result.first_air_date).getFullYear().toString() : 'unknown',
|
||||
dataSource: this.apiName,
|
||||
id: result.id?.toString() ?? '',
|
||||
seasonTitle: result.name ?? result.original_name ?? '',
|
||||
seasonNumber: totalSeasons,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return ok(items);
|
||||
}
|
||||
|
||||
// Fetch all seasons for a given series
|
||||
async getSeasonsForSeries(tvId: string): Promise<Result<SeasonModel[], AppError>> {
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName, tvId },
|
||||
});
|
||||
}
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
|
||||
const seriesResponseResult = await fromPromise(
|
||||
client.GET('/3/tv/{series_id}', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
path: { series_id: Number.parseInt(tvId, 10) },
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, tvId },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!seriesResponseResult.ok) {
|
||||
return err(seriesResponseResult.error);
|
||||
}
|
||||
const seriesResponse = seriesResponseResult.value;
|
||||
|
||||
if (seriesResponse.response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName, tvId },
|
||||
});
|
||||
}
|
||||
|
||||
if (seriesResponse.response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${seriesResponse.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${seriesResponse.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: seriesResponse.response.status, tvId },
|
||||
});
|
||||
}
|
||||
|
||||
const seriesData = seriesResponse.data;
|
||||
const seriesName = seriesData?.name ?? '';
|
||||
|
||||
const ret: SeasonModel[] = [];
|
||||
|
||||
if (Array.isArray(seriesData?.seasons)) {
|
||||
for (const season of seriesData.seasons) {
|
||||
const seasonNumber = season.season_number ?? 0;
|
||||
const titleText = `${seriesName} - Season ${seasonNumber}`;
|
||||
|
||||
ret.push(
|
||||
new SeasonModel({
|
||||
title: titleText,
|
||||
englishTitle: titleText,
|
||||
year: season.air_date ? new Date(season.air_date).getFullYear().toString() : 'unknown',
|
||||
dataSource: this.apiName,
|
||||
id: `${tvId}/season/${seasonNumber}`,
|
||||
seasonTitle: season.name ?? titleText,
|
||||
seasonNumber: seasonNumber,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
|
||||
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
// Expect season ids like "12345/season/2"
|
||||
const m = /^(\d+)\/season\/(\d+)$/.exec(id);
|
||||
if (!m) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | Invalid season id "${id}". Expected format "<series_id>/season/<season_number>".`,
|
||||
userMessage: `Invalid season id "${id}".`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
const tvId = Number.parseInt(m[1], 10);
|
||||
const seasonNumber = Number.parseInt(m[2], 10);
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
|
||||
|
||||
// Fetch season details
|
||||
const seasonResponseResult = await fromPromise(
|
||||
client.GET('/3/tv/{series_id}/season/{season_number}', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
path: {
|
||||
series_id: tvId,
|
||||
season_number: seasonNumber,
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!seasonResponseResult.ok) {
|
||||
return err(seasonResponseResult.error);
|
||||
}
|
||||
const seasonResponse = seasonResponseResult.value;
|
||||
|
||||
if (seasonResponse.response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
if (seasonResponse.response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${seasonResponse.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${seasonResponse.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: seasonResponse.response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const seasonData = seasonResponse.data;
|
||||
if (!seasonData) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
// Fetch parent series to build consistent titles and inherit fields
|
||||
const seriesResponseResult = await fromPromise(
|
||||
client.GET('/3/tv/{series_id}', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
path: { series_id: tvId },
|
||||
query: {
|
||||
append_to_response: 'credits',
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!seriesResponseResult.ok) {
|
||||
return err(seriesResponseResult.error);
|
||||
}
|
||||
const seriesResponse = seriesResponseResult.value;
|
||||
|
||||
if (seriesResponse.response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
if (seriesResponse.response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${seriesResponse.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${seriesResponse.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: seriesResponse.response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const seriesData = seriesResponse.data;
|
||||
|
||||
if (!seriesData) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
const seriesName = seriesData?.name ?? '';
|
||||
const airDate = seasonData.air_date ?? '';
|
||||
const titleText = `${seriesName} - Season ${seasonData.season_number}`;
|
||||
|
||||
// Get airedTo as the air_date of the last episode, if available
|
||||
let airedTo = 'unknown';
|
||||
if (Array.isArray(seasonData.episodes) && seasonData.episodes.length > 0) {
|
||||
const lastEp = seasonData.episodes[seasonData.episodes.length - 1];
|
||||
if (lastEp?.air_date) airedTo = lastEp.air_date;
|
||||
}
|
||||
const formattedAiredTo = airedTo === 'unknown' ? 'unknown' : (this.plugin.dateFormatter.format(airedTo, this.apiDateFormat) ?? airedTo);
|
||||
|
||||
return ok(
|
||||
new SeasonModel({
|
||||
title: titleText,
|
||||
englishTitle: titleText,
|
||||
year: airDate ? new Date(airDate).getFullYear().toString() : 'unknown',
|
||||
dataSource: this.apiName,
|
||||
url: `https://www.themoviedb.org/tv/${tvId.toString()}/season/${seasonData.season_number}`,
|
||||
id: `${tvId.toString()}/season/${seasonData.season_number}`,
|
||||
seasonTitle: seasonData.name ?? titleText,
|
||||
seasonNumber: seasonData.season_number ?? seasonNumber,
|
||||
episodes: Array.isArray(seasonData.episodes) ? seasonData.episodes.length : 0,
|
||||
airedFrom: this.plugin.dateFormatter.format(airDate, this.apiDateFormat) ?? 'unknown',
|
||||
airedTo: formattedAiredTo,
|
||||
plot: seasonData.overview ?? '',
|
||||
image: seasonData.poster_path ? `https://image.tmdb.org/t/p/w780${seasonData.poster_path}` : '',
|
||||
genres: extractNames(seriesData.genres),
|
||||
writer: extractNames(seriesData.created_by),
|
||||
studio: extractNames(seriesData.production_companies),
|
||||
duration: seriesData.episode_run_time?.[0]?.toString() ?? '',
|
||||
onlineRating: seasonData.vote_average ?? 0,
|
||||
actors: getTopActorNames((seriesData as { credits?: CreditsLike }).credits),
|
||||
released: ['Returning Series', 'Cancelled', 'Ended'].includes(seriesData.status ?? ''),
|
||||
streamingServices: [],
|
||||
airing: ['Returning Series'].includes(seriesData.status ?? ''),
|
||||
userData: { watched: false, lastWatched: '', personalRating: 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
255
packages/obsidian/src/api/apis/TMDBSeriesAPI.ts
Normal file
255
packages/obsidian/src/api/apis/TMDBSeriesAPI.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import createClient from 'openapi-fetch';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
import { obsidianFetch } from 'packages/obsidian/src/utils/Utils';
|
||||
import type { paths } from 'packages/schemas/src/TMDB';
|
||||
|
||||
interface TMDBCreditMember {
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface TMDBCreditsResponse {
|
||||
credits?: {
|
||||
cast?: TMDBCreditMember[];
|
||||
};
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
function getTopCastNames(credits: TMDBCreditsResponse['credits'], size: number): string[] {
|
||||
return (credits?.cast ?? [])
|
||||
.map(c => c.name)
|
||||
.filter(isNonEmptyString)
|
||||
.slice(0, size);
|
||||
}
|
||||
|
||||
export class TMDBSeriesAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
typeMappings: Map<string, string>;
|
||||
apiDateFormat: string = 'YYYY-MM-DD';
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'TMDBSeriesAPI';
|
||||
this.apiDescription = 'A community built Series DB.';
|
||||
this.apiUrl = 'https://www.themoviedb.org/';
|
||||
this.types = [MediaType.Series];
|
||||
this.typeMappings = new Map<string, string>();
|
||||
this.typeMappings.set('tv', 'series');
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
|
||||
const responseResult = await fromPromise(
|
||||
client.GET('/3/search/tv', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
query: {
|
||||
query: encodeURIComponent(title),
|
||||
include_adult: this.plugin.settings.sfwFilter ? false : true,
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, title },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
if (response.response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
if (response.response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status },
|
||||
});
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
|
||||
if (!data) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.total_results === 0 || !data.results) {
|
||||
return ok([]);
|
||||
}
|
||||
|
||||
// console.debug(data.results);
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data.results) {
|
||||
ret.push(
|
||||
new SeriesModel({
|
||||
type: 'series',
|
||||
title: result.original_name,
|
||||
englishTitle: result.name,
|
||||
year: result.first_air_date ? new Date(result.first_air_date).getFullYear().toString() : 'unknown',
|
||||
dataSource: this.apiName,
|
||||
id: result.id.toString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId);
|
||||
|
||||
if (!key) {
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | API key for ${this.apiName} missing.`,
|
||||
userMessage: `API key for ${this.apiName} missing.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
const client = createClient<paths>({ baseUrl: 'https://api.themoviedb.org' });
|
||||
const responseResult = await fromPromise(
|
||||
client.GET('/3/tv/{series_id}', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
params: {
|
||||
path: { series_id: parseInt(id) },
|
||||
query: {
|
||||
append_to_response: 'credits',
|
||||
},
|
||||
},
|
||||
fetch: obsidianFetch,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
|
||||
if (!responseResult.ok) {
|
||||
return err(responseResult.error);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
if (response.response.status === 401) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
userMessage: `Authentication for ${this.apiName} failed. Check the API key.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
if (response.response.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${response.response.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: response.response.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const result = response.data;
|
||||
|
||||
if (!result) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
// console.debug(result);
|
||||
const credits = (result as TMDBCreditsResponse).credits;
|
||||
|
||||
return ok(
|
||||
new SeriesModel({
|
||||
type: 'series',
|
||||
title: result.original_name,
|
||||
englishTitle: result.name,
|
||||
year: result.first_air_date ? new Date(result.first_air_date).getFullYear().toString() : 'unknown',
|
||||
dataSource: this.apiName,
|
||||
url: `https://www.themoviedb.org/tv/${result.id}`,
|
||||
id: result.id.toString(),
|
||||
|
||||
plot: result.overview ?? '',
|
||||
genres: result.genres?.map(g => g.name).filter(isNonEmptyString) ?? [],
|
||||
writer: result.created_by?.map(c => c.name).filter(isNonEmptyString) ?? [],
|
||||
studio: result.production_companies?.map(s => s.name).filter(isNonEmptyString) ?? [],
|
||||
episodes: result.number_of_episodes,
|
||||
duration: result.episode_run_time?.[0]?.toString() ?? 'unknown',
|
||||
onlineRating: result.vote_average,
|
||||
actors: getTopCastNames(credits, 5),
|
||||
image: result.poster_path ? `https://image.tmdb.org/t/p/w780${result.poster_path}` : null,
|
||||
|
||||
released: ['Returning Series', 'Cancelled', 'Ended'].includes(result.status!),
|
||||
streamingServices: [],
|
||||
airing: ['Returning Series'].includes(result.status!),
|
||||
airedFrom: this.plugin.dateFormatter.format(result.first_air_date, this.apiDateFormat) ?? 'unknown',
|
||||
airedTo: ['Returning Series'].includes(result.status!) ? 'unknown' : (this.plugin.dateFormatter.format(result.last_air_date, this.apiDateFormat) ?? 'unknown'),
|
||||
|
||||
userData: {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.TMDBSeriesAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
334
packages/obsidian/src/api/apis/VNDBAPI.ts
Normal file
334
packages/obsidian/src/api/apis/VNDBAPI.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { GameModel } from 'packages/obsidian/src/models/GameModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
enum VNDevStatus {
|
||||
Finished,
|
||||
InDevelopment,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
enum TagSpoiler {
|
||||
None,
|
||||
Minor,
|
||||
Major,
|
||||
}
|
||||
|
||||
enum TagCategory {
|
||||
Content = 'cont',
|
||||
Sexual = 'ero',
|
||||
Technical = 'tech',
|
||||
}
|
||||
|
||||
/**
|
||||
* A partial `POST /vn` response payload; desired fields should be listed in the request body.
|
||||
*/
|
||||
interface VNJSONResponse {
|
||||
more: boolean;
|
||||
results: [
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
titles: [
|
||||
{
|
||||
title: string;
|
||||
lang: string;
|
||||
},
|
||||
];
|
||||
devstatus: VNDevStatus;
|
||||
released: string | 'TBA' | null; // eslint-disable-line @typescript-eslint/no-redundant-type-constituents
|
||||
image: {
|
||||
url: string;
|
||||
sexual: number;
|
||||
} | null;
|
||||
rating: number | null;
|
||||
tags: [
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
category: TagCategory;
|
||||
rating: number;
|
||||
spoiler: TagSpoiler;
|
||||
},
|
||||
];
|
||||
developers: [
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A partial `POST /release` response payload; desired fields should be listed in the request body.
|
||||
*/
|
||||
interface ReleaseJSONResponse {
|
||||
more: boolean;
|
||||
results: [
|
||||
{
|
||||
id: string;
|
||||
producers: [
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
developer: boolean;
|
||||
publisher: boolean;
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export class VNDBAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
apiDateFormat: string = 'YYYY-MM-DD'; // Can also return YYYY-MM or YYYY
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'VNDB API';
|
||||
this.apiDescription = 'A free API for visual novels.';
|
||||
this.apiUrl = 'https://api.vndb.org/kana';
|
||||
this.types = [MediaType.Game];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a `POST` request to the VNDB API.
|
||||
* @param endpoint The API endpoint to query. E.g. "/vn".
|
||||
* @param body A JSON object defining the query, following the VNDB API structure.
|
||||
* @returns A JSON object representing the query response.
|
||||
* @see {@link https://api.vndb.org/kana#api-structure}
|
||||
*/
|
||||
private async postQuery(endpoint: string, body: string): Promise<Result<unknown, AppError>> {
|
||||
const fetchDataResult = await fromPromise(
|
||||
requestUrl({
|
||||
url: `${this.apiUrl}${endpoint}`,
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
body: body,
|
||||
throw: false,
|
||||
}),
|
||||
cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Network error querying ${this.apiName}`,
|
||||
userMessage: `Network error querying ${this.apiName}`,
|
||||
context: { apiName: this.apiName, endpoint },
|
||||
}),
|
||||
);
|
||||
if (!fetchDataResult.ok) {
|
||||
return err(fetchDataResult.error);
|
||||
}
|
||||
const fetchData = fetchDataResult.value;
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
switch (fetchData.status) {
|
||||
case 400:
|
||||
return err({
|
||||
kind: AppErrorKind.Validation,
|
||||
message: `MDB | Invalid request body or query [${fetchData.text}].`,
|
||||
userMessage: 'Invalid VNDB request.',
|
||||
context: { apiName: this.apiName, endpoint, status: fetchData.status },
|
||||
});
|
||||
case 404:
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: 'MDB | Invalid API path or HTTP method.',
|
||||
userMessage: 'VNDB endpoint not found.',
|
||||
context: { apiName: this.apiName, endpoint, status: fetchData.status },
|
||||
});
|
||||
case 429:
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: 'MDB | VNDB throttled the request.',
|
||||
userMessage: 'VNDB throttled the request. Please try again later.',
|
||||
context: { apiName: this.apiName, endpoint, status: fetchData.status },
|
||||
});
|
||||
case 500:
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: 'MDB | VNDB server error.',
|
||||
userMessage: 'VNDB server error.',
|
||||
context: { apiName: this.apiName, endpoint, status: fetchData.status },
|
||||
});
|
||||
case 502:
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: 'MDB | VNDB server is down.',
|
||||
userMessage: 'VNDB server is down.',
|
||||
context: { apiName: this.apiName, endpoint, status: fetchData.status },
|
||||
});
|
||||
default:
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, endpoint, status: fetchData.status },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ok(fetchData.json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a `POST` request to the `/vn` endpoint.
|
||||
* Queries visual novel entries.
|
||||
* @see {@link https://api.vndb.org/kana#post-vn}
|
||||
*/
|
||||
private async postVNQuery(body: string): Promise<Result<VNJSONResponse, AppError>> {
|
||||
const result = await this.postQuery('/vn', body);
|
||||
return result.ok ? ok(result.value as VNJSONResponse) : err(result.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a `POST` request to the `/release` endpoint.
|
||||
* Queries release entries.
|
||||
* @see {@link https://api.vndb.org/kana#post-release}
|
||||
*/
|
||||
private async postReleaseQuery(body: string): Promise<Result<ReleaseJSONResponse, AppError>> {
|
||||
const result = await this.postQuery('/release', body);
|
||||
return result.ok ? ok(result.value as ReleaseJSONResponse) : err(result.error);
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
|
||||
/* SFW Filter: has ANY official&&complete&&standalone&&SFW release
|
||||
OR has NO official&&standalone&&NSFW release
|
||||
OR has the `In-game Sexual Content Toggle` (g2708) tag */
|
||||
// prettier-ignore
|
||||
const vnDataResult = await this.postVNQuery(`{
|
||||
"filters": ["and" ${!this.plugin.settings.sfwFilter ? `` :
|
||||
`, ["or"
|
||||
, ["release", "=", ["and"
|
||||
, ["official", "=", "1"]
|
||||
, ["rtype", "=", "complete"]
|
||||
, ["patch", "!=", "1"]
|
||||
, ["has_ero", "!=", "1"]
|
||||
]]
|
||||
, ["release", "!=", ["and"
|
||||
, ["official", "=", "1"]
|
||||
, ["patch", "!=", "1"]
|
||||
, ["has_ero", "=", "1"]
|
||||
]]
|
||||
, ["tag", "=", "g2708"]
|
||||
]`}
|
||||
, ["search", "=", "${title}"]
|
||||
],
|
||||
"fields": "title, titles{title, lang}, released",
|
||||
"sort": "searchrank",
|
||||
"results": 20
|
||||
}`);
|
||||
if (!vnDataResult.ok) {
|
||||
return err(vnDataResult.error);
|
||||
}
|
||||
const vnData = vnDataResult.value;
|
||||
|
||||
const ret: MediaTypeModel[] = [];
|
||||
for (const vn of vnData.results) {
|
||||
ret.push(
|
||||
new GameModel({
|
||||
type: MediaType.Game,
|
||||
title: vn.title,
|
||||
englishTitle: vn.titles.find(t => t.lang === 'en')?.title ?? vn.title,
|
||||
year: vn.released && vn.released !== 'TBA' ? new Date(vn.released).getFullYear().toString() : 'TBA',
|
||||
dataSource: this.apiName,
|
||||
id: vn.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
|
||||
const vnDataResult = await this.postVNQuery(`{
|
||||
"filters": ["id", "=", "${id}"],
|
||||
"fields": "title, titles{title, lang}, devstatus, released, image{url, sexual}, rating, tags{name, category, rating, spoiler}, developers{name}"
|
||||
}`);
|
||||
if (!vnDataResult.ok) {
|
||||
return err(vnDataResult.error);
|
||||
}
|
||||
const vnData = vnDataResult.value;
|
||||
|
||||
if (vnData.results.length !== 1) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Expected 1 result from query, got ${vnData.results.length}.`,
|
||||
userMessage: 'Unexpected VNDB response.',
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
const vn = vnData.results[0];
|
||||
const releasedIsDate = vn.released !== null && vn.released !== 'TBA';
|
||||
vn.released ??= 'Unknown';
|
||||
|
||||
const releaseDataResult = await this.postReleaseQuery(`{
|
||||
"filters": ["and"
|
||||
, ["vn", "="
|
||||
, ["id", "=", "${id}"]
|
||||
]
|
||||
, ["official", "=", 1]
|
||||
],
|
||||
"fields": "producers.name, producers.publisher, producers.developer",
|
||||
"results": 100
|
||||
}`);
|
||||
if (!releaseDataResult.ok) {
|
||||
return err(releaseDataResult.error);
|
||||
}
|
||||
const releaseData = releaseDataResult.value;
|
||||
|
||||
return ok(
|
||||
new GameModel({
|
||||
type: MediaType.Game,
|
||||
title: vn.title,
|
||||
englishTitle: vn.titles.find(t => t.lang === 'en')?.title ?? vn.title,
|
||||
year: releasedIsDate ? new Date(vn.released).getFullYear().toString() : vn.released,
|
||||
dataSource: this.apiName,
|
||||
url: `https://vndb.org/${vn.id}`,
|
||||
id: vn.id,
|
||||
|
||||
developers: vn.developers.map(d => d.name),
|
||||
publishers: releaseData.results
|
||||
.flatMap(r => r.producers)
|
||||
.filter(p => p.publisher)
|
||||
.sort((p1, p2) => Number(p2.developer) - Number(p1.developer)) // Place developer-publishers first in publisher list
|
||||
.map(p => p.name)
|
||||
.unique(),
|
||||
genres: vn.tags
|
||||
.filter(t => t.category === TagCategory.Content && t.spoiler === TagSpoiler.None && t.rating >= 2)
|
||||
.sort((t1, t2) => t2.rating - t1.rating)
|
||||
.map(t => t.name),
|
||||
onlineRating: vn.rating ?? NaN,
|
||||
// TODO: Ideally we should simply flag a sensitive image, then let the user handle it non-destructively
|
||||
image: this.plugin.settings.sfwFilter && (vn.image?.sexual ?? 0) > 0.5 ? 'NSFW' : vn.image?.url,
|
||||
|
||||
released: vn.devstatus === VNDevStatus.Finished,
|
||||
releaseDate: releasedIsDate ? (this.plugin.dateFormatter.format(vn.released, this.apiDateFormat) ?? vn.released) : vn.released,
|
||||
|
||||
userData: {
|
||||
played: false,
|
||||
personalRating: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.VNDBAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
170
packages/obsidian/src/api/apis/WikipediaAPI.ts
Normal file
170
packages/obsidian/src/api/apis/WikipediaAPI.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { requestUrl } from 'obsidian';
|
||||
import { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { WikiModel } from 'packages/obsidian/src/models/WikiModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, fromPromise, ok } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
interface SearchResponse {
|
||||
query: {
|
||||
search: {
|
||||
title: string;
|
||||
pageid: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface IdResponse {
|
||||
query: {
|
||||
pages: Record<string, WikipediaPage>;
|
||||
};
|
||||
}
|
||||
|
||||
interface WikipediaPage {
|
||||
pageid: number;
|
||||
title: string;
|
||||
contentmodel: string;
|
||||
pagelanguage: string;
|
||||
pagelanguagehtmlcode: string;
|
||||
pagelanguagedir: string;
|
||||
touched: string; // ISO date string
|
||||
lastrevid: number;
|
||||
length: number;
|
||||
fullurl: string;
|
||||
editurl: string;
|
||||
canonicalurl: string;
|
||||
}
|
||||
export class WikipediaAPI extends APIModel {
|
||||
plugin: MediaDbPlugin;
|
||||
apiDateFormat: string = 'YYYY-MM-DDTHH:mm:ssZ'; // ISO
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
super();
|
||||
|
||||
this.plugin = plugin;
|
||||
this.apiName = 'Wikipedia API';
|
||||
this.apiDescription = 'The API behind Wikipedia';
|
||||
this.apiUrl = 'https://www.wikipedia.com';
|
||||
this.types = [MediaType.Wiki];
|
||||
}
|
||||
|
||||
async searchByTitle(title: string): Promise<Result<MediaTypeModel[], AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by Title`);
|
||||
|
||||
const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(title)}&srlimit=20&utf8=&format=json&origin=*`;
|
||||
const fetchData = await requestUrl({
|
||||
url: searchUrl,
|
||||
method: 'GET',
|
||||
throw: false,
|
||||
});
|
||||
// console.debug(fetchData);
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status },
|
||||
});
|
||||
}
|
||||
|
||||
const response = fetchData as { status: number; json(): Promise<unknown> };
|
||||
const dataResult = await fromPromise(response.json(), cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Failed to parse response from ${this.apiName}`,
|
||||
userMessage: `Failed to parse response from ${this.apiName}`,
|
||||
context: { apiName: this.apiName },
|
||||
}),
|
||||
);
|
||||
if (!dataResult.ok) {
|
||||
return err(dataResult.error);
|
||||
}
|
||||
const data = dataResult.value as SearchResponse;
|
||||
Logger.debug(data);
|
||||
const ret: MediaTypeModel[] = [];
|
||||
|
||||
for (const result of data.query.search) {
|
||||
ret.push(
|
||||
new WikiModel({
|
||||
type: 'wiki',
|
||||
title: result.title,
|
||||
englishTitle: result.title,
|
||||
year: '',
|
||||
dataSource: this.apiName,
|
||||
id: result.pageid.toString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(ret);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Result<MediaTypeModel, AppError>> {
|
||||
Logger.log(`MDB | api "${this.apiName}" queried by ID`);
|
||||
|
||||
const searchUrl = `https://en.wikipedia.org/w/api.php?action=query&prop=info&pageids=${encodeURIComponent(id)}&inprop=url&format=json&origin=*`;
|
||||
const fetchData = await requestUrl({
|
||||
url: searchUrl,
|
||||
method: 'GET',
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (fetchData.status !== 200) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
userMessage: `Received status code ${fetchData.status} from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, status: fetchData.status, id },
|
||||
});
|
||||
}
|
||||
|
||||
const response = fetchData as { status: number; json(): Promise<unknown> };
|
||||
const dataResult = await fromPromise(response.json(), cause =>
|
||||
toAppError(cause, {
|
||||
kind: AppErrorKind.Network,
|
||||
message: `MDB | Failed to parse response from ${this.apiName}`,
|
||||
userMessage: `Failed to parse response from ${this.apiName}`,
|
||||
context: { apiName: this.apiName, id },
|
||||
}),
|
||||
);
|
||||
if (!dataResult.ok) {
|
||||
return err(dataResult.error);
|
||||
}
|
||||
const data = dataResult.value as IdResponse;
|
||||
// console.debug(data);
|
||||
const result = Object.values(data?.query?.pages)[0];
|
||||
if (!result) {
|
||||
return err({
|
||||
kind: AppErrorKind.Api,
|
||||
message: `MDB | No data received from ${this.apiName}.`,
|
||||
userMessage: `No data received from ${this.apiName}.`,
|
||||
context: { apiName: this.apiName, id },
|
||||
});
|
||||
}
|
||||
|
||||
return ok(
|
||||
new WikiModel({
|
||||
title: result.title,
|
||||
englishTitle: result.title,
|
||||
dataSource: this.apiName,
|
||||
url: result.fullurl,
|
||||
id: result.pageid.toString(),
|
||||
|
||||
wikiUrl: result.fullurl,
|
||||
lastUpdated: this.plugin.dateFormatter.format(result.touched, this.apiDateFormat),
|
||||
length: result.length,
|
||||
|
||||
userData: {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
getDisabledMediaTypes(): MediaType[] {
|
||||
return this.plugin.settings.WikipediaAPI_disabledMediaTypes;
|
||||
}
|
||||
}
|
||||
209
packages/obsidian/src/main.ts
Normal file
209
packages/obsidian/src/main.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import 'packages/obsidian/src/styles.css';
|
||||
import { Plugin, TFolder } from 'obsidian';
|
||||
import { APIManager } from 'packages/obsidian/src/api/APIManager';
|
||||
import { BoardGameGeekAPI } from 'packages/obsidian/src/api/apis/BoardGameGeekAPI';
|
||||
import { ComicVineAPI } from 'packages/obsidian/src/api/apis/ComicVineAPI';
|
||||
import { IGDBAPI } from 'packages/obsidian/src/api/apis/IGDBAPI';
|
||||
import { MALAPI } from 'packages/obsidian/src/api/apis/MALAPI';
|
||||
import { MALAPIManga } from 'packages/obsidian/src/api/apis/MALAPIManga';
|
||||
import { MusicBrainzAPI } from 'packages/obsidian/src/api/apis/MusicBrainzAPI';
|
||||
import { OMDbAPI } from 'packages/obsidian/src/api/apis/OMDbAPI';
|
||||
import { OpenLibraryAPI } from 'packages/obsidian/src/api/apis/OpenLibraryAPI';
|
||||
import { RAWGAPI } from 'packages/obsidian/src/api/apis/RAWGAPI';
|
||||
import { SteamAPI } from 'packages/obsidian/src/api/apis/SteamAPI';
|
||||
import { TMDBMovieAPI } from 'packages/obsidian/src/api/apis/TMDBMovieAPI';
|
||||
import { TMDBSeasonAPI } from 'packages/obsidian/src/api/apis/TMDBSeasonAPI';
|
||||
import { TMDBSeriesAPI } from 'packages/obsidian/src/api/apis/TMDBSeriesAPI';
|
||||
import { VNDBAPI } from 'packages/obsidian/src/api/apis/VNDBAPI';
|
||||
import { WikipediaAPI } from 'packages/obsidian/src/api/apis/WikipediaAPI';
|
||||
import { PropertyMapper } from 'packages/obsidian/src/settings/PropertyMapper';
|
||||
import { PropertyMappingModel } from 'packages/obsidian/src/settings/PropertyMapping';
|
||||
import type { MediaDbPluginSettings } from 'packages/obsidian/src/settings/Settings';
|
||||
import { MediaDbSettingTab } from 'packages/obsidian/src/settings/Settings';
|
||||
import { getDefaultSettings } from 'packages/obsidian/src/settings/Settings';
|
||||
import { BulkImportHelper } from 'packages/obsidian/src/utils/BulkImportHelper';
|
||||
import { DateFormatter } from 'packages/obsidian/src/utils/DateFormatter';
|
||||
import { ErrorReporter } from 'packages/obsidian/src/utils/ErrorReporter';
|
||||
import { MediaDbEntryHelper } from 'packages/obsidian/src/utils/MediaDbEntryHelper';
|
||||
import { MediaDbFileHelper } from 'packages/obsidian/src/utils/MediaDbFileHelper';
|
||||
import { MediaTypeManager } from 'packages/obsidian/src/utils/MediaTypeManager';
|
||||
import { MEDIA_TYPES } from 'packages/obsidian/src/utils/MediaTypeManager';
|
||||
import { ModalHelper } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
import { unCamelCase } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export default class MediaDbPlugin extends Plugin {
|
||||
settings!: MediaDbPluginSettings;
|
||||
apiManager!: APIManager;
|
||||
mediaTypeManager!: MediaTypeManager;
|
||||
modelPropertyMapper!: PropertyMapper;
|
||||
modalHelper!: ModalHelper;
|
||||
fileHelper!: MediaDbFileHelper;
|
||||
entryHelper!: MediaDbEntryHelper;
|
||||
bulkImportHelper!: BulkImportHelper;
|
||||
dateFormatter!: DateFormatter;
|
||||
errorReporter!: ErrorReporter;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
this.mediaTypeManager = new MediaTypeManager();
|
||||
this.modelPropertyMapper = new PropertyMapper(this);
|
||||
this.errorReporter = new ErrorReporter();
|
||||
this.modalHelper = new ModalHelper(this);
|
||||
this.fileHelper = new MediaDbFileHelper(this);
|
||||
this.entryHelper = new MediaDbEntryHelper(this);
|
||||
this.bulkImportHelper = new BulkImportHelper(this);
|
||||
this.dateFormatter = new DateFormatter();
|
||||
|
||||
await this.loadSettings();
|
||||
this.registerDefaultApis();
|
||||
this.addSettingTab(new MediaDbSettingTab(this.app, this));
|
||||
this.registerRibbonAndFileMenu();
|
||||
this.registerCommands();
|
||||
}
|
||||
|
||||
onunload(): void {}
|
||||
|
||||
private registerDefaultApis(): void {
|
||||
this.apiManager = new APIManager();
|
||||
this.apiManager.registerAPI(new OMDbAPI(this));
|
||||
this.apiManager.registerAPI(new MALAPI(this));
|
||||
this.apiManager.registerAPI(new MALAPIManga(this));
|
||||
this.apiManager.registerAPI(new WikipediaAPI(this));
|
||||
this.apiManager.registerAPI(new MusicBrainzAPI(this));
|
||||
this.apiManager.registerAPI(new SteamAPI(this));
|
||||
this.apiManager.registerAPI(new TMDBSeriesAPI(this));
|
||||
this.apiManager.registerAPI(new TMDBSeasonAPI(this));
|
||||
this.apiManager.registerAPI(new TMDBMovieAPI(this));
|
||||
this.apiManager.registerAPI(new BoardGameGeekAPI(this));
|
||||
this.apiManager.registerAPI(new OpenLibraryAPI(this));
|
||||
this.apiManager.registerAPI(new ComicVineAPI(this));
|
||||
this.apiManager.registerAPI(new IGDBAPI(this));
|
||||
this.apiManager.registerAPI(new RAWGAPI(this));
|
||||
this.apiManager.registerAPI(new VNDBAPI(this));
|
||||
}
|
||||
|
||||
private registerRibbonAndFileMenu(): void {
|
||||
const ribbonIconEl = this.addRibbonIcon('database', 'Add new Media DB entry', () => this.entryHelper.createEntryWithAdvancedSearchModal());
|
||||
ribbonIconEl.addClass('obsidian-media-db-plugin-ribbon-class');
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('file-menu', (menu, file) => {
|
||||
if (file instanceof TFolder) {
|
||||
menu.addItem(item => {
|
||||
item.setTitle('Import folder as Media DB entries')
|
||||
.setIcon('database')
|
||||
.onClick(() => this.bulkImportHelper.import(file));
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private registerCommands(): void {
|
||||
this.addCommand({
|
||||
id: 'open-media-db-search-modal',
|
||||
name: 'Create entry',
|
||||
callback: () => this.entryHelper.createEntryWithSearchModal(),
|
||||
});
|
||||
|
||||
for (const mediaType of MEDIA_TYPES) {
|
||||
this.addCommand({
|
||||
id: `open-media-db-search-modal-with-${mediaType}`,
|
||||
name: `Create entry (${unCamelCase(mediaType)})`,
|
||||
callback: () => this.entryHelper.createEntryWithSearchModal({ preselectedTypes: [mediaType] }),
|
||||
});
|
||||
}
|
||||
|
||||
this.addCommand({
|
||||
id: 'open-media-db-advanced-search-modal',
|
||||
name: 'Create entry (advanced search)',
|
||||
callback: () => this.entryHelper.createEntryWithAdvancedSearchModal(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'open-media-db-id-search-modal',
|
||||
name: 'Create entry by id',
|
||||
callback: () => this.entryHelper.createEntryWithIdSearchModal(),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'update-media-db-note',
|
||||
name: 'Update open note (this will recreate the note)',
|
||||
checkCallback: (checking: boolean) => {
|
||||
if (!this.app.workspace.getActiveFile()) {
|
||||
return false;
|
||||
}
|
||||
if (!checking) {
|
||||
void this.fileHelper.updateActiveNote(false);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'update-media-db-note-metadata',
|
||||
name: 'Update metadata',
|
||||
checkCallback: (checking: boolean) => {
|
||||
if (!this.app.workspace.getActiveFile()) {
|
||||
return false;
|
||||
}
|
||||
if (!checking) {
|
||||
void this.fileHelper.updateActiveNote(true);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'add-media-db-link',
|
||||
name: 'Insert link',
|
||||
checkCallback: (checking: boolean) => {
|
||||
if (!this.app.workspace.getActiveFile()) {
|
||||
return false;
|
||||
}
|
||||
if (!checking) {
|
||||
void this.entryHelper.createLinkWithSearchModal();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
const diskSettings: MediaDbPluginSettings = (await this.loadData()) as MediaDbPluginSettings;
|
||||
const defaultSettings: MediaDbPluginSettings = getDefaultSettings(this);
|
||||
const loadedSettings: MediaDbPluginSettings = Object.assign({}, defaultSettings, diskSettings);
|
||||
|
||||
// delete old api keys
|
||||
// @ts-ignore
|
||||
delete loadedSettings.BoardgameGeekKey;
|
||||
// @ts-ignore
|
||||
delete loadedSettings.ComicVineKey;
|
||||
// @ts-ignore
|
||||
delete loadedSettings.GiantBombKey;
|
||||
// @ts-ignore
|
||||
delete loadedSettings.MobyGamesKey;
|
||||
// @ts-ignore
|
||||
delete loadedSettings.OMDbKey;
|
||||
// @ts-ignore
|
||||
delete loadedSettings.TMDBKey;
|
||||
|
||||
const migratedModels = PropertyMappingModel.migrateModels(
|
||||
loadedSettings.propertyMappingModels || [],
|
||||
defaultSettings.propertyMappingModels.map(m => PropertyMappingModel.fromJSON(m)),
|
||||
);
|
||||
|
||||
loadedSettings.propertyMappingModels = migratedModels.map(m => m.toJSON());
|
||||
|
||||
this.settings = loadedSettings;
|
||||
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
this.mediaTypeManager.updateTemplates(this.settings);
|
||||
this.mediaTypeManager.updateFolders(this.settings);
|
||||
this.dateFormatter.setFormat(this.settings.customDateFormat);
|
||||
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
44
packages/obsidian/src/modals/ConfirmOverwriteModal.ts
Normal file
44
packages/obsidian/src/modals/ConfirmOverwriteModal.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { App } from 'obsidian';
|
||||
import { Modal, Setting } from 'obsidian';
|
||||
|
||||
export class ConfirmOverwriteModal extends Modal {
|
||||
result: boolean = false;
|
||||
onSubmit: (result: boolean) => void;
|
||||
fileName: string;
|
||||
|
||||
constructor(app: App, fileName: string, onSubmit: (result: boolean) => void) {
|
||||
super(app);
|
||||
this.fileName = fileName;
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h2', { text: 'File already exists' });
|
||||
contentEl.createEl('p', { text: `The file "${this.fileName}" already exists. Do you want to overwrite it?` });
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
|
||||
const bottomSettingRow = new Setting(contentEl);
|
||||
bottomSettingRow.addButton(btn => {
|
||||
btn.setButtonText('No');
|
||||
btn.onClick(() => this.close());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
});
|
||||
bottomSettingRow.addButton(btn => {
|
||||
btn.setButtonText('Yes');
|
||||
btn.setCta();
|
||||
btn.onClick(() => {
|
||||
this.result = true;
|
||||
this.close();
|
||||
});
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.onSubmit(this.result);
|
||||
}
|
||||
}
|
||||
133
packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts
Normal file
133
packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import type { ButtonComponent } from 'obsidian';
|
||||
import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { AdvancedSearchModalData, AdvancedSearchModalOptions } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
import { ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
|
||||
export class MediaDbAdvancedSearchModal extends Modal {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
query: string;
|
||||
isBusy: boolean;
|
||||
title: string;
|
||||
selectedApis: string[];
|
||||
|
||||
searchBtn?: ButtonComponent;
|
||||
|
||||
submitCallback?: (res: AdvancedSearchModalData) => void;
|
||||
closeCallback?: (err?: Error) => void;
|
||||
|
||||
constructor(plugin: MediaDbPlugin, advancedSearchModalOptions: AdvancedSearchModalOptions) {
|
||||
advancedSearchModalOptions = Object.assign({}, ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS, advancedSearchModalOptions);
|
||||
super(plugin.app);
|
||||
|
||||
this.plugin = plugin;
|
||||
this.selectedApis = [];
|
||||
this.title = advancedSearchModalOptions.modalTitle ?? '';
|
||||
this.query = advancedSearchModalOptions.prefilledSearchString ?? '';
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
setSubmitCb(submitCallback: (res: AdvancedSearchModalData) => void): void {
|
||||
this.submitCallback = submitCallback;
|
||||
}
|
||||
|
||||
setCloseCb(closeCallback: (err?: Error) => void): void {
|
||||
this.closeCallback = closeCallback;
|
||||
}
|
||||
|
||||
keyPressCallback(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
void this.search();
|
||||
}
|
||||
}
|
||||
|
||||
async search(): Promise<void> {
|
||||
if (!this.query || this.query.length < 3) {
|
||||
new Notice('MDB | Query too short');
|
||||
return;
|
||||
}
|
||||
|
||||
const apis: string[] = this.selectedApis;
|
||||
|
||||
if (apis.length === 0) {
|
||||
new Notice('MDB | No API selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isBusy) {
|
||||
this.isBusy = true;
|
||||
this.searchBtn?.setDisabled(false);
|
||||
this.searchBtn?.setButtonText('Searching...');
|
||||
|
||||
this.submitCallback?.({ query: this.query, apis: apis });
|
||||
}
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
|
||||
contentEl.createEl('h2', { text: this.title });
|
||||
|
||||
const placeholder = 'Search by title';
|
||||
const searchComponent = new TextComponent(contentEl);
|
||||
searchComponent.inputEl.addClass('media-db-plugin-search-input');
|
||||
searchComponent.setPlaceholder(placeholder);
|
||||
searchComponent.setValue(this.query);
|
||||
searchComponent.onChange(value => (this.query = value));
|
||||
searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this));
|
||||
|
||||
contentEl.appendChild(searchComponent.inputEl);
|
||||
searchComponent.inputEl.focus();
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
contentEl.createEl('h3', { text: 'APIs to search' });
|
||||
|
||||
// const apiToggleComponents: Component[] = [];
|
||||
for (const api of this.plugin.apiManager.apis) {
|
||||
const apiToggleListElementWrapper = contentEl.createDiv({ cls: 'media-db-plugin-list-wrapper' });
|
||||
|
||||
const apiToggleTextWrapper = apiToggleListElementWrapper.createDiv({ cls: 'media-db-plugin-list-text-wrapper' });
|
||||
apiToggleTextWrapper.createSpan({ text: api.apiName, cls: 'media-db-plugin-list-text' });
|
||||
apiToggleTextWrapper.createEl('small', { text: api.apiDescription, cls: 'media-db-plugin-list-text' });
|
||||
|
||||
const apiToggleComponentWrapper = apiToggleListElementWrapper.createDiv({ cls: 'media-db-plugin-list-toggle' });
|
||||
|
||||
const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper);
|
||||
apiToggleComponent.setTooltip(api.apiName);
|
||||
apiToggleComponent.setValue(this.selectedApis.some(x => x === api.apiName));
|
||||
apiToggleComponent.onChange(value => {
|
||||
if (value) {
|
||||
this.selectedApis.push(api.apiName);
|
||||
} else {
|
||||
this.selectedApis = this.selectedApis.filter(x => x !== api.apiName);
|
||||
}
|
||||
});
|
||||
apiToggleComponentWrapper.appendChild(apiToggleComponent.toggleEl);
|
||||
}
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton(btn => {
|
||||
btn.setButtonText('Cancel');
|
||||
btn.onClick(() => this.close());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
})
|
||||
.addButton(btn => {
|
||||
btn.setButtonText('Ok');
|
||||
btn.setCta();
|
||||
btn.onClick(() => {
|
||||
void this.search();
|
||||
});
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
this.searchBtn = btn;
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.closeCallback?.();
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
134
packages/obsidian/src/modals/MediaDbBulkImportModal.ts
Normal file
134
packages/obsidian/src/modals/MediaDbBulkImportModal.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import type { ButtonComponent } from 'obsidian';
|
||||
import { DropdownComponent, Modal, Setting, TextComponent, ToggleComponent } from 'obsidian';
|
||||
import type { APIModel } from 'packages/obsidian/src/api/APIModel';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { BulkImportLookupMethod } from 'packages/obsidian/src/utils/BulkImportHelper';
|
||||
|
||||
export class MediaDbBulkImportModal extends Modal {
|
||||
plugin: MediaDbPlugin;
|
||||
onSubmit: (selectedAPI: string, lookupMethod: BulkImportLookupMethod, fieldName: string, appendContent: boolean) => void;
|
||||
selectedApi: string;
|
||||
searchBtn?: ButtonComponent;
|
||||
lookupMethod: BulkImportLookupMethod;
|
||||
fieldName: string;
|
||||
appendContent: boolean;
|
||||
|
||||
constructor(plugin: MediaDbPlugin, onSubmit: (selectedAPI: string, lookupMethod: BulkImportLookupMethod, fieldName: string, appendContent: boolean) => void) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
this.onSubmit = onSubmit;
|
||||
this.selectedApi = plugin.apiManager.apis[0].apiName;
|
||||
this.lookupMethod = BulkImportLookupMethod.TITLE;
|
||||
this.fieldName = '';
|
||||
this.appendContent = false;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.onSubmit(this.selectedApi, this.lookupMethod, this.fieldName, this.appendContent);
|
||||
this.close();
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
|
||||
contentEl.createEl('h2', { text: 'Import folder as Media DB entries' });
|
||||
|
||||
this.createDropdownEl(
|
||||
contentEl,
|
||||
'API to search',
|
||||
(value: string) => {
|
||||
this.selectedApi = value;
|
||||
},
|
||||
this.plugin.apiManager.apis.map((api: APIModel) => {
|
||||
return { value: api.apiName, display: api.apiName };
|
||||
}),
|
||||
);
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
contentEl.createEl('h3', { text: 'Append note content to Media DB entry?' });
|
||||
|
||||
const appendContentToggleElementWrapper = contentEl.createDiv({ cls: 'media-db-plugin-list-wrapper' });
|
||||
const appendContentToggleTextWrapper = appendContentToggleElementWrapper.createDiv({ cls: 'media-db-plugin-list-text-wrapper' });
|
||||
appendContentToggleTextWrapper.createSpan({
|
||||
text: 'If this is enabled, the plugin will override metadata fields with the same name.',
|
||||
cls: 'media-db-plugin-list-text',
|
||||
});
|
||||
|
||||
const appendContentToggleComponentWrapper = appendContentToggleElementWrapper.createDiv({ cls: 'media-db-plugin-list-toggle' });
|
||||
|
||||
const appendContentToggle = new ToggleComponent(appendContentToggleElementWrapper);
|
||||
appendContentToggle.setValue(false);
|
||||
appendContentToggle.onChange(value => (this.appendContent = value));
|
||||
appendContentToggleComponentWrapper.appendChild(appendContentToggle.toggleEl);
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
contentEl.createEl('h3', { text: 'Media lookup method' });
|
||||
contentEl.createEl('p', {
|
||||
text: 'Choose whether to search the API by title (can return multiple results) or lookup directly using an ID (returns at most one result), and specify the name of the frontmatter property which contains the title or ID of the media.',
|
||||
});
|
||||
|
||||
this.createDropdownEl(
|
||||
contentEl,
|
||||
'Lookup media by',
|
||||
(value: string) => {
|
||||
this.lookupMethod = value as BulkImportLookupMethod;
|
||||
},
|
||||
[
|
||||
{ value: BulkImportLookupMethod.TITLE, display: 'Title' },
|
||||
{ value: BulkImportLookupMethod.ID, display: 'ID' },
|
||||
],
|
||||
);
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
|
||||
const fieldNameWrapperEl = contentEl.createDiv({ cls: 'media-db-plugin-list-wrapper' });
|
||||
const fieldNameLabelWrapperEl = fieldNameWrapperEl.createDiv({ cls: 'media-db-plugin-list-text-wrapper' });
|
||||
fieldNameLabelWrapperEl.createSpan({ text: 'Using the property named', cls: 'media-db-plugin-list-text' });
|
||||
|
||||
const fieldNameComponent = new TextComponent(fieldNameWrapperEl);
|
||||
fieldNameComponent.setPlaceholder('title / id');
|
||||
fieldNameComponent.onChange(value => (this.fieldName = value));
|
||||
fieldNameComponent.inputEl.addEventListener('keydown', ke => {
|
||||
if (ke.key === 'Enter') {
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
contentEl.appendChild(fieldNameWrapperEl);
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton(btn => {
|
||||
btn.setButtonText('Cancel');
|
||||
btn.onClick(() => this.close());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
})
|
||||
.addButton(btn => {
|
||||
btn.setButtonText('Ok');
|
||||
btn.setCta();
|
||||
btn.onClick(() => {
|
||||
this.submit();
|
||||
});
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
this.searchBtn = btn;
|
||||
});
|
||||
}
|
||||
|
||||
createDropdownEl(parentEl: HTMLElement, label: string, onChange: (value: string) => void, options: { value: string; display: string }[]): void {
|
||||
const wrapperEl = parentEl.createDiv({ cls: 'media-db-plugin-list-wrapper' });
|
||||
const labelWrapperEl = wrapperEl.createDiv({ cls: 'media-db-plugin-list-text-wrapper' });
|
||||
labelWrapperEl.createSpan({ text: label, cls: 'media-db-plugin-list-text' });
|
||||
|
||||
const dropDownComponent = new DropdownComponent(wrapperEl);
|
||||
dropDownComponent.onChange(onChange);
|
||||
for (const option of options) {
|
||||
dropDownComponent.addOption(option.value, option.display);
|
||||
}
|
||||
wrapperEl.appendChild(dropDownComponent.selectEl);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
119
packages/obsidian/src/modals/MediaDbIdSearchModal.ts
Normal file
119
packages/obsidian/src/modals/MediaDbIdSearchModal.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { ButtonComponent } from 'obsidian';
|
||||
import { DropdownComponent, Modal, Notice, Setting, TextComponent } from 'obsidian';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { IdSearchModalData, IdSearchModalOptions } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
import { ID_SEARCH_MODAL_DEFAULT_OPTIONS } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
|
||||
export class MediaDbIdSearchModal extends Modal {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
query: string;
|
||||
isBusy: boolean;
|
||||
title: string;
|
||||
selectedApi: string;
|
||||
|
||||
searchBtn?: ButtonComponent;
|
||||
|
||||
submitCallback?: (res: IdSearchModalData, err?: Error) => void;
|
||||
closeCallback?: (err?: Error) => void;
|
||||
|
||||
constructor(plugin: MediaDbPlugin, idSearchModalOptions: IdSearchModalOptions) {
|
||||
idSearchModalOptions = Object.assign({}, ID_SEARCH_MODAL_DEFAULT_OPTIONS, idSearchModalOptions);
|
||||
super(plugin.app);
|
||||
|
||||
this.plugin = plugin;
|
||||
this.title = idSearchModalOptions.modalTitle ?? '';
|
||||
this.selectedApi = idSearchModalOptions.preselectedAPI ?? plugin.apiManager.apis[0].apiName;
|
||||
this.query = '';
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
setSubmitCb(submitCallback: (res: IdSearchModalData, err?: Error) => void): void {
|
||||
this.submitCallback = submitCallback;
|
||||
}
|
||||
|
||||
setCloseCb(closeCallback: (err?: Error) => void): void {
|
||||
this.closeCallback = closeCallback;
|
||||
}
|
||||
|
||||
keyPressCallback(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
void this.search();
|
||||
}
|
||||
}
|
||||
|
||||
async search(): Promise<void> {
|
||||
if (!this.query) {
|
||||
new Notice('MDB | no Id entered');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedApi) {
|
||||
new Notice('MDB | No API selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isBusy) {
|
||||
this.isBusy = true;
|
||||
this.searchBtn?.setDisabled(false);
|
||||
this.searchBtn?.setButtonText('Searching...');
|
||||
|
||||
this.submitCallback?.({ query: this.query, api: this.selectedApi });
|
||||
}
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
|
||||
contentEl.createEl('h2', { text: this.title });
|
||||
|
||||
const placeholder = 'Search by id';
|
||||
const searchComponent = new TextComponent(contentEl);
|
||||
searchComponent.inputEl.addClass('media-db-plugin-search-input');
|
||||
searchComponent.setPlaceholder(placeholder);
|
||||
searchComponent.onChange(value => (this.query = value));
|
||||
searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this));
|
||||
|
||||
contentEl.appendChild(searchComponent.inputEl);
|
||||
searchComponent.inputEl.focus();
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
|
||||
const apiSelectorWrapper = contentEl.createDiv({ cls: 'media-db-plugin-list-wrapper' });
|
||||
const apiSelectorTExtWrapper = apiSelectorWrapper.createDiv({ cls: 'media-db-plugin-list-text-wrapper' });
|
||||
apiSelectorTExtWrapper.createSpan({ text: 'API to search', cls: 'media-db-plugin-list-text' });
|
||||
|
||||
const apiSelectorComponent = new DropdownComponent(apiSelectorWrapper);
|
||||
apiSelectorComponent.onChange((value: string) => {
|
||||
this.selectedApi = value;
|
||||
});
|
||||
for (const api of this.plugin.apiManager.apis) {
|
||||
apiSelectorComponent.addOption(api.apiName, api.apiName);
|
||||
}
|
||||
apiSelectorWrapper.appendChild(apiSelectorComponent.selectEl);
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton(btn => {
|
||||
btn.setButtonText('Cancel');
|
||||
btn.onClick(() => this.close());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
})
|
||||
.addButton(btn => {
|
||||
btn.setButtonText('Ok');
|
||||
btn.setCta();
|
||||
btn.onClick(() => {
|
||||
void this.search();
|
||||
});
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
this.searchBtn = btn;
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.closeCallback?.();
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
87
packages/obsidian/src/modals/MediaDbPreviewModal.ts
Normal file
87
packages/obsidian/src/modals/MediaDbPreviewModal.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { Component, MarkdownRenderer, Modal, Setting } from 'obsidian';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import type { PreviewModalData, PreviewModalOptions } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
import { PREVIEW_MODAL_DEFAULT_OPTIONS } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
|
||||
export class MediaDbPreviewModal extends Modal {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
elements: MediaTypeModel[];
|
||||
title: string;
|
||||
markdownComponent: Component;
|
||||
|
||||
submitCallback?: (previewModalData: PreviewModalData) => void;
|
||||
closeCallback?: (err?: Error) => void;
|
||||
|
||||
constructor(plugin: MediaDbPlugin, previewModalOptions: PreviewModalOptions) {
|
||||
previewModalOptions = Object.assign({}, PREVIEW_MODAL_DEFAULT_OPTIONS, previewModalOptions);
|
||||
|
||||
super(plugin.app);
|
||||
|
||||
this.plugin = plugin;
|
||||
this.title = previewModalOptions.modalTitle ?? '';
|
||||
this.elements = previewModalOptions.elements ?? [];
|
||||
|
||||
this.markdownComponent = new Component();
|
||||
}
|
||||
|
||||
setSubmitCb(submitCallback: (previewModalData: PreviewModalData) => void): void {
|
||||
this.submitCallback = submitCallback;
|
||||
}
|
||||
|
||||
setCloseCb(closeCallback: (err?: Error) => void): void {
|
||||
this.closeCallback = closeCallback;
|
||||
}
|
||||
|
||||
async preview(): Promise<void> {
|
||||
const { contentEl } = this;
|
||||
contentEl.addClass('media-db-plugin-preview-modal');
|
||||
|
||||
contentEl.createEl('h2', { text: this.title });
|
||||
|
||||
const previewWrapper = contentEl.createDiv({ cls: 'media-db-plugin-preview-wrapper' });
|
||||
|
||||
this.markdownComponent.load();
|
||||
|
||||
for (const result of this.elements) {
|
||||
previewWrapper.createEl('h3', { text: result.englishTitle });
|
||||
const fileDiv = previewWrapper.createDiv({ cls: 'media-db-plugin-preview' });
|
||||
|
||||
let fileContent = this.plugin.fileHelper.generateMediaDbNoteFrontmatterPreview(result);
|
||||
fileContent = `\`\`\`yaml\n${fileContent}\`\`\``;
|
||||
|
||||
try {
|
||||
// TODO: fix this not rendering the frontmatter any more
|
||||
await MarkdownRenderer.render(this.app, fileContent, fileDiv, '', this.markdownComponent);
|
||||
} catch (e) {
|
||||
Logger.warn(`mdb | error during rendering of preview`, e);
|
||||
}
|
||||
}
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
|
||||
const bottomSettingRow = new Setting(contentEl);
|
||||
bottomSettingRow.addButton(btn => {
|
||||
btn.setButtonText('Cancel');
|
||||
btn.onClick(() => this.close());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
});
|
||||
bottomSettingRow.addButton(btn => {
|
||||
btn.setButtonText('Ok');
|
||||
btn.setCta();
|
||||
btn.onClick(() => this.submitCallback?.({ confirmed: true }));
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
});
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
void this.preview();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.markdownComponent.unload();
|
||||
this.closeCallback?.();
|
||||
}
|
||||
}
|
||||
145
packages/obsidian/src/modals/MediaDbSearchModal.ts
Normal file
145
packages/obsidian/src/modals/MediaDbSearchModal.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import type { ButtonComponent } from 'obsidian';
|
||||
import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import { MEDIA_TYPES } from 'packages/obsidian/src/utils/MediaTypeManager';
|
||||
import type { SearchModalData, SearchModalOptions } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
import { SEARCH_MODAL_DEFAULT_OPTIONS } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
import { unCamelCase } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export class MediaDbSearchModal extends Modal {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
query: string;
|
||||
isBusy: boolean;
|
||||
title: string;
|
||||
selectedTypes: MediaType[];
|
||||
|
||||
searchBtn?: ButtonComponent;
|
||||
|
||||
submitCallback?: (res: SearchModalData) => void;
|
||||
closeCallback?: (err?: Error) => void;
|
||||
|
||||
constructor(plugin: MediaDbPlugin, searchModalOptions: SearchModalOptions) {
|
||||
searchModalOptions = Object.assign({}, SEARCH_MODAL_DEFAULT_OPTIONS, searchModalOptions);
|
||||
super(plugin.app);
|
||||
|
||||
this.plugin = plugin;
|
||||
this.selectedTypes = [...(searchModalOptions.preselectedTypes ?? [])];
|
||||
this.title = searchModalOptions.modalTitle ?? '';
|
||||
this.query = searchModalOptions.prefilledSearchString ?? '';
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
setSubmitCb(submitCallback: (res: SearchModalData) => void): void {
|
||||
this.submitCallback = submitCallback;
|
||||
}
|
||||
|
||||
setCloseCb(closeCallback: (err?: Error) => void): void {
|
||||
this.closeCallback = closeCallback;
|
||||
}
|
||||
|
||||
keyPressCallback(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
void this.search();
|
||||
}
|
||||
}
|
||||
|
||||
async search(): Promise<void> {
|
||||
if (!this.query || this.query.length < 3) {
|
||||
new Notice('MDB | Query too short');
|
||||
return;
|
||||
}
|
||||
|
||||
const types: MediaType[] = this.selectedTypes;
|
||||
|
||||
if (types.length === 0) {
|
||||
new Notice('MDB | No Type selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isBusy) {
|
||||
this.isBusy = true;
|
||||
this.searchBtn?.setDisabled(false);
|
||||
this.searchBtn?.setButtonText('Searching...');
|
||||
|
||||
this.submitCallback?.({ query: this.query, types: types });
|
||||
}
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
|
||||
contentEl.createEl('h2', { text: this.title });
|
||||
|
||||
const placeholder = 'Search by title';
|
||||
const searchComponent = new TextComponent(contentEl);
|
||||
let currentToggle: ToggleComponent | undefined = undefined;
|
||||
|
||||
searchComponent.inputEl.addClass('media-db-plugin-search-input');
|
||||
searchComponent.setPlaceholder(placeholder);
|
||||
searchComponent.setValue(this.query);
|
||||
searchComponent.onChange(value => (this.query = value));
|
||||
searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this));
|
||||
|
||||
contentEl.appendChild(searchComponent.inputEl);
|
||||
searchComponent.inputEl.focus();
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
contentEl.createEl('h3', { text: 'APIs to search' });
|
||||
|
||||
for (const mediaType of MEDIA_TYPES) {
|
||||
const apiToggleListElementWrapper = contentEl.createDiv({ cls: 'media-db-plugin-list-wrapper' });
|
||||
|
||||
const apiToggleTextWrapper = apiToggleListElementWrapper.createDiv({ cls: 'media-db-plugin-list-text-wrapper' });
|
||||
apiToggleTextWrapper.createSpan({ text: unCamelCase(mediaType), cls: 'media-db-plugin-list-text' });
|
||||
|
||||
const apiToggleComponentWrapper = apiToggleListElementWrapper.createDiv({ cls: 'media-db-plugin-list-toggle' });
|
||||
|
||||
const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper);
|
||||
apiToggleComponent.setTooltip(unCamelCase(mediaType));
|
||||
apiToggleComponent.setValue(this.selectedTypes.includes(mediaType));
|
||||
if (apiToggleComponent.getValue()) {
|
||||
currentToggle = apiToggleComponent;
|
||||
}
|
||||
apiToggleComponent.onChange(value => {
|
||||
if (value) {
|
||||
if (currentToggle && currentToggle !== apiToggleComponent) {
|
||||
currentToggle.setValue(false);
|
||||
this.selectedTypes = this.selectedTypes.filter(x => x !== mediaType);
|
||||
}
|
||||
currentToggle = apiToggleComponent;
|
||||
this.selectedTypes.push(mediaType);
|
||||
} else {
|
||||
currentToggle = undefined;
|
||||
this.selectedTypes = this.selectedTypes.filter(x => x !== mediaType);
|
||||
}
|
||||
});
|
||||
apiToggleComponentWrapper.appendChild(apiToggleComponent.toggleEl);
|
||||
}
|
||||
|
||||
contentEl.createDiv({ cls: 'media-db-plugin-spacer' });
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton(btn => {
|
||||
btn.setButtonText('Cancel');
|
||||
btn.onClick(() => this.close());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
})
|
||||
.addButton(btn => {
|
||||
btn.setButtonText('Ok');
|
||||
btn.setCta();
|
||||
btn.onClick(() => {
|
||||
void this.search();
|
||||
});
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
this.searchBtn = btn;
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.closeCallback?.();
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
66
packages/obsidian/src/modals/MediaDbSearchResultModal.ts
Normal file
66
packages/obsidian/src/modals/MediaDbSearchResultModal.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { SelectModal } from 'packages/obsidian/src/modals/SelectModal';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { SelectModalData, SelectModalOptions } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
import { SELECTMODALOPTIONSDEFAULT } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
|
||||
export class MediaDbSearchResultModal extends SelectModal<MediaTypeModel> {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
busy: boolean;
|
||||
sendCallback: boolean;
|
||||
|
||||
submitCallback?: (res: SelectModalData) => void;
|
||||
closeCallback?: (err?: Error) => void;
|
||||
skipCallback?: () => void;
|
||||
submitButtonText: string;
|
||||
|
||||
constructor(plugin: MediaDbPlugin, selectModalOptions: SelectModalOptions) {
|
||||
selectModalOptions = Object.assign({}, SELECTMODALOPTIONSDEFAULT, selectModalOptions);
|
||||
super(plugin.app, selectModalOptions.elements ?? [], selectModalOptions.multiSelect);
|
||||
this.plugin = plugin;
|
||||
this.title = selectModalOptions.modalTitle ?? '';
|
||||
this.description = selectModalOptions.description ?? 'Select one or multiple search results.';
|
||||
this.addSkipButton = selectModalOptions.skipButton ?? false;
|
||||
this.submitButtonText = selectModalOptions.submitButtonText ?? 'Ok';
|
||||
this.busy = false;
|
||||
this.sendCallback = false;
|
||||
}
|
||||
|
||||
setSubmitCb(submitCallback: (res: SelectModalData) => void): void {
|
||||
this.submitCallback = submitCallback;
|
||||
}
|
||||
|
||||
setCloseCb(closeCallback: (err?: Error) => void): void {
|
||||
this.closeCallback = closeCallback;
|
||||
}
|
||||
|
||||
setSkipCallback(skipCallback: () => void): void {
|
||||
this.skipCallback = skipCallback;
|
||||
}
|
||||
|
||||
// Renders each suggestion item.
|
||||
renderElement(item: MediaTypeModel, el: HTMLElement): void {
|
||||
el.createDiv({ text: this.plugin.mediaTypeManager.getFileName(item) });
|
||||
el.createEl('small', { text: `${item.getSummary()}\n` });
|
||||
el.createEl('small', { text: `${item.type.toUpperCase() + (item.subType ? ` (${item.subType})` : '')} from ${item.dataSource}` });
|
||||
}
|
||||
|
||||
// Perform action on the selected suggestion.
|
||||
submit(): void {
|
||||
if (!this.busy) {
|
||||
this.busy = true;
|
||||
this.submitButton?.setButtonText('Creating entry...');
|
||||
this.submitCallback?.({ selected: this.selectModalElements.filter(x => x.isActive()).map(x => x.value) });
|
||||
}
|
||||
}
|
||||
|
||||
skip(): void {
|
||||
this.skipButton?.setButtonText('Skipping...');
|
||||
this.skipCallback?.();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.closeCallback?.();
|
||||
}
|
||||
}
|
||||
50
packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts
Normal file
50
packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { SelectModal } from 'packages/obsidian/src/modals/SelectModal';
|
||||
|
||||
export interface SeasonSelectModalElement {
|
||||
season_number: number;
|
||||
name: string;
|
||||
air_date?: string;
|
||||
poster_path?: string;
|
||||
}
|
||||
|
||||
export class MediaDbSeasonSelectModal extends SelectModal<SeasonSelectModalElement> {
|
||||
plugin: MediaDbPlugin;
|
||||
submitCallback?: (selectedSeasons: SeasonSelectModalElement[]) => void;
|
||||
closeCallback?: (err?: Error) => void;
|
||||
seriesName?: string;
|
||||
|
||||
constructor(plugin: MediaDbPlugin, seasons: SeasonSelectModalElement[], multiSelect = true, seriesName?: string) {
|
||||
super(plugin.app, seasons, multiSelect);
|
||||
this.plugin = plugin;
|
||||
this.seriesName = seriesName;
|
||||
this.title = `Select seasons for${seriesName ? ` ${seriesName}` : ''}`;
|
||||
this.description = 'Select one or more seasons to create notes for.';
|
||||
this.submitButtonText = 'Create Entry';
|
||||
}
|
||||
|
||||
renderElement(season: SeasonSelectModalElement, el: HTMLElement): void {
|
||||
el.createDiv({ text: `${season.name}` });
|
||||
if (season.air_date) {
|
||||
el.createEl('small', { text: `Air date: ${season.air_date}` });
|
||||
}
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const selected = this.selectModalElements.filter(x => x.isActive()).map(x => x.value);
|
||||
this.submitCallback?.(selected);
|
||||
this.close();
|
||||
}
|
||||
|
||||
skip(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
setSubmitCb(cb: (selectedSeasons: SeasonSelectModalElement[]) => void): void {
|
||||
this.submitCallback = cb;
|
||||
}
|
||||
|
||||
setCloseCb(cb: (err?: Error) => void): void {
|
||||
this.closeCallback = cb;
|
||||
}
|
||||
}
|
||||
176
packages/obsidian/src/modals/SelectModal.ts
Normal file
176
packages/obsidian/src/modals/SelectModal.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import type { App, ButtonComponent } from 'obsidian';
|
||||
import { Modal, Setting } from 'obsidian';
|
||||
import { SelectModalElement } from 'packages/obsidian/src/modals/SelectModalElement';
|
||||
import { mod } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export abstract class SelectModal<T> extends Modal {
|
||||
allowMultiSelect: boolean;
|
||||
|
||||
title: string;
|
||||
description: string;
|
||||
addSkipButton: boolean;
|
||||
cancelButton?: ButtonComponent;
|
||||
skipButton?: ButtonComponent;
|
||||
submitButton?: ButtonComponent;
|
||||
submitButtonText: string;
|
||||
|
||||
elementWrapper?: HTMLDivElement;
|
||||
|
||||
elements: T[];
|
||||
selectModalElements: SelectModalElement<T>[];
|
||||
|
||||
protected constructor(app: App, elements: T[], allowMultiSelect: boolean = true) {
|
||||
super(app);
|
||||
this.allowMultiSelect = allowMultiSelect;
|
||||
|
||||
this.title = '';
|
||||
this.description = '';
|
||||
this.addSkipButton = false;
|
||||
this.submitButtonText = 'Ok';
|
||||
this.cancelButton = undefined;
|
||||
this.skipButton = undefined;
|
||||
this.submitButton = undefined;
|
||||
|
||||
this.elementWrapper = undefined;
|
||||
|
||||
this.elements = elements;
|
||||
this.selectModalElements = [];
|
||||
|
||||
this.scope.register([], 'ArrowUp', evt => {
|
||||
this.highlightUp();
|
||||
evt.preventDefault();
|
||||
});
|
||||
this.scope.register([], 'ArrowDown', evt => {
|
||||
this.highlightDown();
|
||||
evt.preventDefault();
|
||||
});
|
||||
this.scope.register([], 'ArrowRight', () => {
|
||||
this.activateHighlighted();
|
||||
});
|
||||
this.scope.register([], ' ', evt => {
|
||||
if (this.elementWrapper && this.elementWrapper === activeDocument.activeElement) {
|
||||
this.activateHighlighted();
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
this.scope.register([], 'Enter', () => this.submit());
|
||||
}
|
||||
|
||||
abstract renderElement(value: T, el: HTMLElement): void;
|
||||
|
||||
abstract submit(): void;
|
||||
|
||||
abstract skip(): void;
|
||||
|
||||
disableAllOtherElements(elementId: number): void {
|
||||
for (const selectModalElement of this.selectModalElements) {
|
||||
if (selectModalElement.id !== elementId) {
|
||||
selectModalElement.setActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deHighlightAllOtherElements(elementId: number): void {
|
||||
for (const selectModalElement of this.selectModalElements) {
|
||||
if (selectModalElement.id !== elementId) {
|
||||
selectModalElement.setHighlighted(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl, titleEl } = this;
|
||||
|
||||
titleEl.createEl('h2', { text: this.title });
|
||||
contentEl.addClass('media-db-plugin-select-modal');
|
||||
contentEl.createEl('p', { text: this.description });
|
||||
|
||||
this.elementWrapper = contentEl.createDiv({ cls: 'media-db-plugin-select-wrapper' });
|
||||
this.elementWrapper.tabIndex = 0;
|
||||
|
||||
let i = 0;
|
||||
for (const element of this.elements) {
|
||||
const selectModalElement = new SelectModalElement(element, this.elementWrapper, i, this, false);
|
||||
|
||||
this.selectModalElements.push(selectModalElement);
|
||||
|
||||
this.renderElement(element, selectModalElement.element);
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
this.selectModalElements.first()?.element.scrollIntoView();
|
||||
|
||||
const bottomSettingRow = new Setting(contentEl);
|
||||
bottomSettingRow.addButton(btn => {
|
||||
btn.setButtonText('Cancel');
|
||||
btn.onClick(() => this.close());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
this.cancelButton = btn;
|
||||
});
|
||||
if (this.addSkipButton) {
|
||||
bottomSettingRow.addButton(btn => {
|
||||
btn.setButtonText('Skip');
|
||||
btn.onClick(() => this.skip());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
this.skipButton = btn;
|
||||
});
|
||||
}
|
||||
bottomSettingRow.addButton(btn => {
|
||||
btn.setButtonText(this.submitButtonText);
|
||||
btn.setCta();
|
||||
btn.onClick(() => this.submit());
|
||||
btn.buttonEl.addClass('media-db-plugin-button');
|
||||
this.submitButton = btn;
|
||||
});
|
||||
}
|
||||
|
||||
activateHighlighted(): void {
|
||||
for (const selectModalElement of this.selectModalElements) {
|
||||
if (selectModalElement.isHighlighted()) {
|
||||
selectModalElement.setActive(!selectModalElement.isActive());
|
||||
if (!this.allowMultiSelect) {
|
||||
this.disableAllOtherElements(selectModalElement.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlightUp(): void {
|
||||
for (const selectModalElement of this.selectModalElements) {
|
||||
if (selectModalElement.isHighlighted()) {
|
||||
this.getPreviousSelectModalElement(selectModalElement).setHighlighted(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// nothing is highlighted
|
||||
this.selectModalElements.last()?.setHighlighted(true);
|
||||
}
|
||||
|
||||
highlightDown(): void {
|
||||
for (const selectModalElement of this.selectModalElements) {
|
||||
if (selectModalElement.isHighlighted()) {
|
||||
this.getNextSelectModalElement(selectModalElement).setHighlighted(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// nothing is highlighted
|
||||
this.selectModalElements.first()?.setHighlighted(true);
|
||||
}
|
||||
|
||||
private getNextSelectModalElement(selectModalElement: SelectModalElement<T>): SelectModalElement<T> {
|
||||
let nextId = selectModalElement.id + 1;
|
||||
nextId = mod(nextId, this.selectModalElements.length);
|
||||
|
||||
return this.selectModalElements.find(x => x.id === nextId)!;
|
||||
}
|
||||
|
||||
private getPreviousSelectModalElement(selectModalElement: SelectModalElement<T>): SelectModalElement<T> {
|
||||
let nextId = selectModalElement.id - 1;
|
||||
nextId = mod(nextId, this.selectModalElements.length);
|
||||
|
||||
return this.selectModalElements.find(x => x.id === nextId)!;
|
||||
}
|
||||
}
|
||||
88
packages/obsidian/src/modals/SelectModalElement.ts
Normal file
88
packages/obsidian/src/modals/SelectModalElement.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { SelectModal } from 'packages/obsidian/src/modals/SelectModal';
|
||||
|
||||
export class SelectModalElement<T> {
|
||||
selectModal: SelectModal<T>;
|
||||
value: T;
|
||||
readonly id: number;
|
||||
element: HTMLDivElement;
|
||||
cssClass: string;
|
||||
activeClass: string;
|
||||
hoverClass: string;
|
||||
private active: boolean;
|
||||
private highlighted: boolean;
|
||||
|
||||
constructor(value: T, parentElement: HTMLElement, id: number, selectModal: SelectModal<T>, active: boolean = false) {
|
||||
this.value = value;
|
||||
this.id = id;
|
||||
this.active = active;
|
||||
this.selectModal = selectModal;
|
||||
|
||||
this.cssClass = 'media-db-plugin-select-element';
|
||||
this.activeClass = 'media-db-plugin-select-element-selected';
|
||||
this.hoverClass = 'media-db-plugin-select-element-hover';
|
||||
|
||||
this.element = parentElement.createDiv({ cls: this.cssClass });
|
||||
this.element.id = this.getHTMLId();
|
||||
this.element.on('click', '#' + this.getHTMLId(), () => {
|
||||
this.setActive(!this.active);
|
||||
if (!this.selectModal.allowMultiSelect) {
|
||||
this.selectModal.disableAllOtherElements(this.id);
|
||||
}
|
||||
});
|
||||
this.element.on('mouseenter', '#' + this.getHTMLId(), () => {
|
||||
this.setHighlighted(true);
|
||||
});
|
||||
this.element.on('mouseleave', '#' + this.getHTMLId(), () => {
|
||||
this.setHighlighted(false);
|
||||
});
|
||||
|
||||
this.highlighted = false;
|
||||
}
|
||||
|
||||
getHTMLId(): string {
|
||||
return `media-db-plugin-select-element-${this.id}`;
|
||||
}
|
||||
|
||||
isHighlighted(): boolean {
|
||||
return this.highlighted;
|
||||
}
|
||||
|
||||
setHighlighted(value: boolean): void {
|
||||
this.highlighted = value;
|
||||
if (this.highlighted) {
|
||||
this.addClass(this.hoverClass);
|
||||
this.selectModal.deHighlightAllOtherElements(this.id);
|
||||
} else {
|
||||
this.removeClass(this.hoverClass);
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
setActive(active: boolean): void {
|
||||
this.active = active;
|
||||
this.update();
|
||||
}
|
||||
|
||||
update(): void {
|
||||
if (this.active) {
|
||||
this.addClass(this.activeClass);
|
||||
} else {
|
||||
this.removeClass(this.activeClass);
|
||||
}
|
||||
}
|
||||
|
||||
addClass(cssClass: string): void {
|
||||
if (!this.element.hasClass(cssClass)) {
|
||||
this.element.addClass(cssClass);
|
||||
}
|
||||
}
|
||||
|
||||
removeClass(cssClass: string): void {
|
||||
if (this.element.hasClass(cssClass)) {
|
||||
this.element.removeClass(cssClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
packages/obsidian/src/models/BoardGameModel.ts
Normal file
64
packages/obsidian/src/models/BoardGameModel.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type BoardGameData = ModelToData<BoardGameModel>;
|
||||
|
||||
export class BoardGameModel extends MediaTypeModel {
|
||||
genres: string[];
|
||||
onlineRating: number;
|
||||
complexityRating: number;
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
playtime: string;
|
||||
publishers: string[];
|
||||
image?: string;
|
||||
|
||||
released: boolean;
|
||||
|
||||
userData: {
|
||||
played: boolean;
|
||||
personalRating: number;
|
||||
};
|
||||
|
||||
constructor(obj: BoardGameData) {
|
||||
super();
|
||||
|
||||
this.genres = [];
|
||||
this.onlineRating = 0;
|
||||
this.complexityRating = 0;
|
||||
this.minPlayers = 0;
|
||||
this.maxPlayers = 0;
|
||||
this.playtime = '';
|
||||
this.publishers = [];
|
||||
this.image = '';
|
||||
|
||||
this.released = false;
|
||||
|
||||
this.userData = {
|
||||
played: false,
|
||||
personalRating: 0,
|
||||
};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
return [mediaDbTag, 'boardgame'];
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.BoardGame;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
return this.englishTitle + ' (' + this.year + ')';
|
||||
}
|
||||
}
|
||||
64
packages/obsidian/src/models/BookModel.ts
Normal file
64
packages/obsidian/src/models/BookModel.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type BookData = ModelToData<BookModel>;
|
||||
|
||||
export class BookModel extends MediaTypeModel {
|
||||
author: string;
|
||||
plot: string;
|
||||
pages: number;
|
||||
image: string;
|
||||
onlineRating: number;
|
||||
isbn: number;
|
||||
isbn13: number;
|
||||
|
||||
released: boolean;
|
||||
|
||||
userData: {
|
||||
read: boolean;
|
||||
lastRead: string;
|
||||
personalRating: number;
|
||||
};
|
||||
|
||||
constructor(obj: BookData) {
|
||||
super();
|
||||
|
||||
this.author = '';
|
||||
this.plot = '';
|
||||
this.pages = 0;
|
||||
this.image = '';
|
||||
this.onlineRating = 0;
|
||||
this.isbn = 0;
|
||||
this.isbn13 = 0;
|
||||
|
||||
this.released = false;
|
||||
|
||||
this.userData = {
|
||||
read: false,
|
||||
lastRead: '',
|
||||
personalRating: 0,
|
||||
};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
return [mediaDbTag, 'book'];
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.Book;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
return this.englishTitle + ' (' + this.year + ') - ' + this.author;
|
||||
}
|
||||
}
|
||||
80
packages/obsidian/src/models/ComicMangaModel.ts
Normal file
80
packages/obsidian/src/models/ComicMangaModel.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type ComicMangaData = ModelToData<ComicMangaModel>;
|
||||
|
||||
export class ComicMangaModel extends MediaTypeModel {
|
||||
plot: string;
|
||||
alternateTitles: string[];
|
||||
genres: string[];
|
||||
authors: string[];
|
||||
chapters: number;
|
||||
volumes: number;
|
||||
onlineRating: number;
|
||||
image: string;
|
||||
|
||||
released: boolean;
|
||||
status: string;
|
||||
publishers: string[];
|
||||
publishedFrom: string;
|
||||
publishedTo: string;
|
||||
|
||||
userData: {
|
||||
read: boolean;
|
||||
lastRead: string;
|
||||
personalRating: number;
|
||||
};
|
||||
|
||||
constructor(obj: ComicMangaData) {
|
||||
super();
|
||||
|
||||
this.plot = '';
|
||||
this.alternateTitles = [];
|
||||
this.genres = [];
|
||||
this.authors = [];
|
||||
this.chapters = 0;
|
||||
this.volumes = 0;
|
||||
this.onlineRating = 0;
|
||||
this.image = '';
|
||||
|
||||
this.released = false;
|
||||
this.status = '';
|
||||
this.publishers = [];
|
||||
this.publishedFrom = '';
|
||||
this.publishedTo = '';
|
||||
|
||||
this.userData = {
|
||||
read: false,
|
||||
lastRead: '',
|
||||
personalRating: 0,
|
||||
};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
const tags = [mediaDbTag];
|
||||
if (this.subType) {
|
||||
tags.push(this.subType);
|
||||
} else {
|
||||
tags.push('comicManga');
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.ComicManga;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
return this.title + ' (' + this.year + ')';
|
||||
}
|
||||
}
|
||||
60
packages/obsidian/src/models/GameModel.ts
Normal file
60
packages/obsidian/src/models/GameModel.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type GameData = ModelToData<GameModel>;
|
||||
|
||||
export class GameModel extends MediaTypeModel {
|
||||
developers: string[];
|
||||
publishers: string[];
|
||||
genres: string[];
|
||||
onlineRating: number;
|
||||
image: string;
|
||||
|
||||
released: boolean;
|
||||
releaseDate: string;
|
||||
|
||||
userData: {
|
||||
played: boolean;
|
||||
personalRating: number;
|
||||
};
|
||||
|
||||
constructor(obj: GameData) {
|
||||
super();
|
||||
|
||||
this.developers = [];
|
||||
this.publishers = [];
|
||||
this.genres = [];
|
||||
this.onlineRating = 0;
|
||||
this.image = '';
|
||||
|
||||
this.released = false;
|
||||
this.releaseDate = '';
|
||||
|
||||
this.userData = {
|
||||
played: false,
|
||||
personalRating: 0,
|
||||
};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
return [mediaDbTag, 'game'];
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.Game;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
return this.englishTitle + ' (' + this.year + ')';
|
||||
}
|
||||
}
|
||||
46
packages/obsidian/src/models/MediaTypeModel.ts
Normal file
46
packages/obsidian/src/models/MediaTypeModel.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
|
||||
export abstract class MediaTypeModel {
|
||||
type: string;
|
||||
subType: string;
|
||||
title: string;
|
||||
englishTitle: string;
|
||||
year: string;
|
||||
dataSource: string;
|
||||
url: string;
|
||||
id: string;
|
||||
image?: string;
|
||||
|
||||
userData: object;
|
||||
|
||||
protected constructor() {
|
||||
this.type = '';
|
||||
this.subType = '';
|
||||
this.title = '';
|
||||
this.englishTitle = '';
|
||||
this.year = '';
|
||||
this.dataSource = '';
|
||||
this.url = '';
|
||||
this.id = '';
|
||||
this.image = '';
|
||||
|
||||
this.userData = {};
|
||||
}
|
||||
|
||||
abstract getMediaType(): MediaType;
|
||||
|
||||
//a string that contains enough info to disambiguate from similar media
|
||||
abstract getSummary(): string;
|
||||
|
||||
abstract getTags(): string[];
|
||||
|
||||
toMetaDataObject(): Record<string, unknown> {
|
||||
return { ...this.getWithOutUserData(), ...this.userData, tags: this.getTags().join('/') };
|
||||
}
|
||||
|
||||
getWithOutUserData(): Record<string, unknown> {
|
||||
const copy = structuredClone(this) as Record<string, unknown>;
|
||||
delete copy.userData;
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
80
packages/obsidian/src/models/MovieModel.ts
Normal file
80
packages/obsidian/src/models/MovieModel.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type MovieData = ModelToData<MovieModel>;
|
||||
|
||||
export class MovieModel extends MediaTypeModel {
|
||||
japaneseTitle: string;
|
||||
plot: string;
|
||||
genres: string[];
|
||||
director: string[];
|
||||
writer: string[];
|
||||
studio: string[];
|
||||
duration: string;
|
||||
onlineRating: number;
|
||||
actors: string[];
|
||||
image: string;
|
||||
|
||||
released: boolean;
|
||||
country: string[];
|
||||
boxOffice: string;
|
||||
ageRating: string;
|
||||
streamingServices: string[];
|
||||
premiere: string;
|
||||
|
||||
userData: {
|
||||
watched: boolean;
|
||||
lastWatched: string;
|
||||
personalRating: number;
|
||||
};
|
||||
|
||||
constructor(obj: MovieData) {
|
||||
super();
|
||||
|
||||
this.japaneseTitle = '';
|
||||
this.plot = '';
|
||||
this.genres = [];
|
||||
this.director = [];
|
||||
this.writer = [];
|
||||
this.studio = [];
|
||||
this.duration = '';
|
||||
this.onlineRating = 0;
|
||||
this.actors = [];
|
||||
this.image = '';
|
||||
|
||||
this.released = false;
|
||||
this.country = [];
|
||||
this.boxOffice = '';
|
||||
this.ageRating = '';
|
||||
this.streamingServices = [];
|
||||
this.premiere = '';
|
||||
|
||||
this.userData = {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
return [mediaDbTag, 'tv', 'movie'];
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.Movie;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
return this.englishTitle + ' (' + this.year + ')';
|
||||
}
|
||||
}
|
||||
67
packages/obsidian/src/models/MusicReleaseModel.ts
Normal file
67
packages/obsidian/src/models/MusicReleaseModel.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type MusicReleaseData = ModelToData<MusicReleaseModel>;
|
||||
|
||||
export class MusicReleaseModel extends MediaTypeModel {
|
||||
genres: string[];
|
||||
artists: string[];
|
||||
language: string;
|
||||
image: string;
|
||||
rating: number;
|
||||
releaseDate: string;
|
||||
albumDuration: string;
|
||||
trackCount: number;
|
||||
tracks: {
|
||||
number: number;
|
||||
title: string;
|
||||
duration: string;
|
||||
featuredArtists: string[];
|
||||
}[];
|
||||
|
||||
userData: {
|
||||
personalRating: number;
|
||||
};
|
||||
|
||||
constructor(obj: MusicReleaseData) {
|
||||
super();
|
||||
|
||||
this.genres = [];
|
||||
this.artists = [];
|
||||
this.image = '';
|
||||
this.rating = 0;
|
||||
this.releaseDate = '';
|
||||
|
||||
this.userData = {
|
||||
personalRating: 0,
|
||||
};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
this.albumDuration = obj.albumDuration ?? '0:00';
|
||||
this.trackCount = obj.trackCount ?? 0;
|
||||
this.tracks = obj.tracks ?? [];
|
||||
this.language = obj.language ?? '';
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
return [mediaDbTag, 'music', this.subType];
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.MusicRelease;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
let summary = this.title + ' (' + this.year + ')';
|
||||
if (this.artists.length > 0) summary += ' - ' + this.artists.join(', ');
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
80
packages/obsidian/src/models/SeasonModel.ts
Normal file
80
packages/obsidian/src/models/SeasonModel.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type SeasonData = ModelToData<SeasonModel>;
|
||||
|
||||
export class SeasonModel extends MediaTypeModel {
|
||||
seasonNumber: number;
|
||||
seasonTitle: string;
|
||||
episodes: number;
|
||||
|
||||
plot: string;
|
||||
genres: string[];
|
||||
writer: string[];
|
||||
studio: string[];
|
||||
duration: string;
|
||||
onlineRating: number;
|
||||
actors: string[];
|
||||
image: string;
|
||||
|
||||
released: boolean;
|
||||
streamingServices: string[];
|
||||
airing: boolean;
|
||||
airedFrom: string;
|
||||
airedTo: string;
|
||||
|
||||
userData: {
|
||||
watched: boolean;
|
||||
lastWatched: string;
|
||||
personalRating: number;
|
||||
};
|
||||
|
||||
constructor(obj: SeasonData) {
|
||||
super();
|
||||
this.seasonTitle = '';
|
||||
this.seasonNumber = 0;
|
||||
this.episodes = 0;
|
||||
this.plot = '';
|
||||
this.genres = [];
|
||||
this.writer = [];
|
||||
this.studio = [];
|
||||
this.duration = '';
|
||||
this.onlineRating = 0;
|
||||
this.actors = [];
|
||||
this.image = '';
|
||||
|
||||
this.released = false;
|
||||
this.streamingServices = [];
|
||||
this.airing = false;
|
||||
this.airedFrom = '';
|
||||
this.airedTo = '';
|
||||
|
||||
this.userData = {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
return [mediaDbTag, 'tv', 'season'];
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.Season;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
return this.seasonNumber + ' seasons';
|
||||
}
|
||||
}
|
||||
82
packages/obsidian/src/models/SeriesModel.ts
Normal file
82
packages/obsidian/src/models/SeriesModel.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type SeriesData = ModelToData<SeriesModel>;
|
||||
|
||||
export class SeriesModel extends MediaTypeModel {
|
||||
japaneseTitle: string;
|
||||
plot: string;
|
||||
genres: string[];
|
||||
writer: string[];
|
||||
studio: string[];
|
||||
episodes: number;
|
||||
duration: string;
|
||||
onlineRating: number;
|
||||
actors: string[];
|
||||
image: string;
|
||||
|
||||
released: boolean;
|
||||
country: string[];
|
||||
ageRating: string;
|
||||
streamingServices: string[];
|
||||
airing: boolean;
|
||||
airedFrom: string;
|
||||
airedTo: string;
|
||||
|
||||
userData: {
|
||||
watched: boolean;
|
||||
lastWatched: string;
|
||||
personalRating: number;
|
||||
};
|
||||
|
||||
constructor(obj: SeriesData) {
|
||||
super();
|
||||
|
||||
this.japaneseTitle = '';
|
||||
this.plot = '';
|
||||
this.genres = [];
|
||||
this.writer = [];
|
||||
this.studio = [];
|
||||
this.episodes = 0;
|
||||
this.duration = '';
|
||||
this.onlineRating = 0;
|
||||
this.actors = [];
|
||||
this.image = '';
|
||||
|
||||
this.released = false;
|
||||
this.country = [];
|
||||
this.ageRating = '';
|
||||
this.streamingServices = [];
|
||||
this.airing = false;
|
||||
this.airedFrom = '';
|
||||
this.airedTo = '';
|
||||
|
||||
this.userData = {
|
||||
watched: false,
|
||||
lastWatched: '',
|
||||
personalRating: 0,
|
||||
};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
return [mediaDbTag, 'tv', 'series'];
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.Series;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
return this.title + ' (' + this.year + ')';
|
||||
}
|
||||
}
|
||||
52
packages/obsidian/src/models/WikiModel.ts
Normal file
52
packages/obsidian/src/models/WikiModel.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { ModelToData } from 'packages/obsidian/src/utils/Utils';
|
||||
import { mediaDbTag, migrateObject } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type WikiData = ModelToData<WikiModel>;
|
||||
|
||||
export class WikiModel extends MediaTypeModel {
|
||||
wikiUrl: string;
|
||||
lastUpdated: string;
|
||||
length: number;
|
||||
article: string;
|
||||
|
||||
userData: Record<string, unknown>;
|
||||
|
||||
constructor(obj: WikiData) {
|
||||
super();
|
||||
|
||||
this.wikiUrl = '';
|
||||
this.lastUpdated = '';
|
||||
this.length = 0;
|
||||
this.article = '';
|
||||
this.userData = {};
|
||||
|
||||
migrateObject(this, obj, this);
|
||||
|
||||
if (!Object.hasOwn(obj, 'userData')) {
|
||||
migrateObject(this.userData, obj, this.userData);
|
||||
}
|
||||
|
||||
this.type = this.getMediaType();
|
||||
}
|
||||
|
||||
getTags(): string[] {
|
||||
return [mediaDbTag, 'wiki'];
|
||||
}
|
||||
|
||||
getMediaType(): MediaType {
|
||||
return MediaType.Wiki;
|
||||
}
|
||||
|
||||
override getWithOutUserData(): Record<string, unknown> {
|
||||
const copy = structuredClone(this) as Record<string, unknown>;
|
||||
delete copy.userData;
|
||||
delete copy.article;
|
||||
return copy;
|
||||
}
|
||||
|
||||
getSummary(): string {
|
||||
return this.title;
|
||||
}
|
||||
}
|
||||
24
packages/obsidian/src/settings/Icon.tsx
Normal file
24
packages/obsidian/src/settings/Icon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { onMount, Show } from 'solid-js';
|
||||
import { setIcon } from 'obsidian';
|
||||
|
||||
interface IconProps {
|
||||
iconName?: string;
|
||||
}
|
||||
|
||||
export default function Icon(props: IconProps) {
|
||||
let iconEl: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
if (iconEl) {
|
||||
setIcon(iconEl, props.iconName || '');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={(props.iconName || '').length > 0}>
|
||||
<div class="icon-wrapper">
|
||||
<div ref={iconEl} class="icon"></div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
108
packages/obsidian/src/settings/PropertyMapper.ts
Normal file
108
packages/obsidian/src/settings/PropertyMapper.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { PropertyMappingOption } from 'packages/obsidian/src/settings/PropertyMapping';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import { MEDIA_TYPES } from 'packages/obsidian/src/utils/MediaTypeManager';
|
||||
|
||||
export class PropertyMapper {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an object using the conversion rules for its type.
|
||||
* Returns an unaltered object if object.type is null or undefined or if there are no conversion rules for the type.
|
||||
*
|
||||
* @param obj
|
||||
*/
|
||||
convertObject(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!Object.hasOwn(obj, 'type')) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (!MEDIA_TYPES.includes(obj.type as MediaType)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type);
|
||||
if (!propertyMappingModel) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const propertyMappings = propertyMappingModel.properties;
|
||||
const propertyMappingByProperty = new Map(propertyMappings.map(mapping => [mapping.property, mapping]));
|
||||
|
||||
const newObj: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const propertyMapping = propertyMappingByProperty.get(key);
|
||||
|
||||
if (!propertyMapping) {
|
||||
newObj[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
let finalValue = value;
|
||||
if (propertyMapping.wikilink) {
|
||||
if (typeof value === 'string') {
|
||||
finalValue = `[[${value}]]`;
|
||||
} else if (Array.isArray(value)) {
|
||||
finalValue = (value as unknown[]).map((v: unknown) => (typeof v === 'string' ? `[[${v}]]` : v));
|
||||
}
|
||||
}
|
||||
|
||||
if (propertyMapping.mapping === PropertyMappingOption.Map) {
|
||||
newObj[propertyMapping.newProperty] = finalValue;
|
||||
} else if (propertyMapping.mapping === PropertyMappingOption.Default) {
|
||||
newObj[key] = finalValue;
|
||||
}
|
||||
}
|
||||
|
||||
return newObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an object back using the conversion rules for its type.
|
||||
* Returns an unaltered object if object.type is null or undefined or if there are no conversion rules for the type.
|
||||
*
|
||||
* @param obj
|
||||
*/
|
||||
convertObjectBack(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
if (!Object.hasOwn(obj, 'type')) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj.type === 'manga') {
|
||||
obj.type = 'comicManga';
|
||||
Logger.debug(`MDB | updated metadata type`, obj.type);
|
||||
}
|
||||
if (!MEDIA_TYPES.includes(obj.type as MediaType)) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type);
|
||||
const propertyMappings = propertyMappingModel?.properties ?? [];
|
||||
const propertyMappingByOriginal = new Map(propertyMappings.map(mapping => [mapping.property, mapping]));
|
||||
const propertyMappingByMapped = new Map(propertyMappings.map(mapping => [mapping.newProperty, mapping]));
|
||||
|
||||
const originalObj: Record<string, unknown> = { ...obj };
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const normalProperty = propertyMappingByOriginal.get(key);
|
||||
if (normalProperty) {
|
||||
originalObj[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const mappedProperty = propertyMappingByMapped.get(key);
|
||||
if (mappedProperty) {
|
||||
originalObj[mappedProperty.property] = value;
|
||||
delete originalObj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return originalObj;
|
||||
}
|
||||
}
|
||||
253
packages/obsidian/src/settings/PropertyMapping.ts
Normal file
253
packages/obsidian/src/settings/PropertyMapping.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import { containsOnlyLettersAndUnderscores, PropertyMappingNameConflictError, PropertyMappingValidationError } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
// Plain object interfaces for serialization
|
||||
export interface PropertyMappingData {
|
||||
property: string;
|
||||
newProperty: string;
|
||||
mapping: PropertyMappingOption;
|
||||
locked?: boolean;
|
||||
wikilink?: boolean;
|
||||
}
|
||||
|
||||
export interface PropertyMappingModelData {
|
||||
type: MediaType;
|
||||
properties: PropertyMappingData[];
|
||||
}
|
||||
|
||||
export enum PropertyMappingOption {
|
||||
Default = 'default',
|
||||
Map = 'remap',
|
||||
Remove = 'remove',
|
||||
}
|
||||
|
||||
export const propertyMappingOptions = [PropertyMappingOption.Default, PropertyMappingOption.Map, PropertyMappingOption.Remove];
|
||||
|
||||
export class PropertyMappingModel {
|
||||
type: MediaType;
|
||||
properties: PropertyMapping[];
|
||||
|
||||
constructor(type: MediaType, properties?: PropertyMapping[]) {
|
||||
this.type = type;
|
||||
this.properties = properties ?? [];
|
||||
}
|
||||
|
||||
validate(): { res: boolean; err?: Error } {
|
||||
Logger.debug(`MDB | validated property mappings for ${this.type}`);
|
||||
|
||||
// check properties
|
||||
for (const property of this.properties) {
|
||||
const propertyValidation = property.validate();
|
||||
if (!propertyValidation.res) {
|
||||
return {
|
||||
res: false,
|
||||
err: propertyValidation.err,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// check for name collisions
|
||||
for (const property of this.getMappedProperties()) {
|
||||
const propertiesWithSameTarget = this.getMappedProperties().filter(x => x.newProperty === property.newProperty);
|
||||
if (propertiesWithSameTarget.length === 0) {
|
||||
// if we get there, then something in this code is wrong
|
||||
} else if (propertiesWithSameTarget.length === 1) {
|
||||
// all good
|
||||
} else {
|
||||
// two or more properties are mapped to the same property
|
||||
return {
|
||||
res: false,
|
||||
err: new PropertyMappingNameConflictError(
|
||||
`Multiple remapped properties (${propertiesWithSameTarget.map(x => x.toString()).toString()}) may not share the same name.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
// remapped properties may not have the same name as any original property
|
||||
for (const property of this.getMappedProperties()) {
|
||||
const propertiesWithSameTarget = this.properties.filter(x => x.newProperty === property.property);
|
||||
if (propertiesWithSameTarget.length === 0) {
|
||||
// all good
|
||||
} else {
|
||||
// a mapped property shares the same name with an original property
|
||||
return {
|
||||
res: false,
|
||||
err: new PropertyMappingNameConflictError(`Remapped property (${property}) may not share it's new name with an existing property.`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
res: true,
|
||||
};
|
||||
}
|
||||
|
||||
getMappedProperties(): PropertyMapping[] {
|
||||
return this.properties.filter(x => x.mapping === PropertyMappingOption.Map);
|
||||
}
|
||||
|
||||
copy(): PropertyMappingModel {
|
||||
const copy = new PropertyMappingModel(this.type);
|
||||
for (const property of this.properties) {
|
||||
const propertyCopy = new PropertyMapping(property.property, property.newProperty, property.mapping, property.locked, property.wikilink);
|
||||
copy.properties.push(propertyCopy);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
// Serialization - returns a plain object that can be JSON.stringify'd
|
||||
toJSON(): PropertyMappingModelData {
|
||||
return {
|
||||
type: this.type,
|
||||
properties: this.properties.map(p => p.toJSON()),
|
||||
};
|
||||
}
|
||||
|
||||
// Deserialization - creates a PropertyMappingModel from a plain object
|
||||
static fromJSON(json: PropertyMappingModelData): PropertyMappingModel {
|
||||
return new PropertyMappingModel(
|
||||
json.type,
|
||||
json.properties.map(p => PropertyMapping.fromJSON(p)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates loaded settings to match the structure of default settings.
|
||||
* - Adds new properties from defaults that don't exist in loaded settings
|
||||
* - Preserves user customizations from loaded settings
|
||||
* - Updates locked status from defaults
|
||||
*
|
||||
* @param loadedModels - Models loaded from disk (may be outdated)
|
||||
* @param defaultModels - Current default models (source of truth for structure)
|
||||
* @returns Migrated models with correct structure and preserved user settings
|
||||
*/
|
||||
static migrateModels(loadedModels: PropertyMappingModelData[], defaultModels: PropertyMappingModel[]): PropertyMappingModel[] {
|
||||
const migratedModels: PropertyMappingModel[] = [];
|
||||
|
||||
for (const defaultModel of defaultModels) {
|
||||
const loadedModel = loadedModels.find(m => m.type === defaultModel.type);
|
||||
|
||||
if (!loadedModel) {
|
||||
// New model type - use default
|
||||
migratedModels.push(defaultModel);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Migrate properties
|
||||
const migratedProperties: PropertyMapping[] = [];
|
||||
for (const defaultProperty of defaultModel.properties) {
|
||||
const loadedProperty = loadedModel.properties.find(p => p.property === defaultProperty.property);
|
||||
|
||||
if (!loadedProperty) {
|
||||
// New property - use default
|
||||
migratedProperties.push(defaultProperty);
|
||||
} else {
|
||||
// Existing property - merge: take locked from default, customizations from loaded
|
||||
migratedProperties.push(
|
||||
new PropertyMapping(
|
||||
loadedProperty.property,
|
||||
loadedProperty.newProperty,
|
||||
loadedProperty.mapping,
|
||||
defaultProperty.locked, // locked status from default
|
||||
loadedProperty.wikilink ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
migratedModels.push(new PropertyMappingModel(defaultModel.type, migratedProperties));
|
||||
}
|
||||
|
||||
return migratedModels;
|
||||
}
|
||||
}
|
||||
|
||||
export class PropertyMapping {
|
||||
property: string;
|
||||
newProperty: string;
|
||||
locked: boolean;
|
||||
mapping: PropertyMappingOption;
|
||||
wikilink: boolean;
|
||||
|
||||
constructor(property: string, newProperty: string, mapping: PropertyMappingOption, locked?: boolean, wikilink?: boolean) {
|
||||
this.property = property;
|
||||
this.newProperty = newProperty;
|
||||
this.mapping = mapping;
|
||||
this.locked = locked ?? false;
|
||||
this.wikilink = wikilink ?? false;
|
||||
}
|
||||
|
||||
validate(): { res: boolean; err?: Error } {
|
||||
// locked property may only be default
|
||||
if (this.locked) {
|
||||
if (this.mapping === PropertyMappingOption.Remove) {
|
||||
return {
|
||||
res: false,
|
||||
err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be removed.`),
|
||||
};
|
||||
}
|
||||
if (this.mapping === PropertyMappingOption.Map) {
|
||||
return {
|
||||
res: false,
|
||||
err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be remapped.`),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mapping === PropertyMappingOption.Default) {
|
||||
return { res: true };
|
||||
}
|
||||
if (this.mapping === PropertyMappingOption.Remove) {
|
||||
return { res: true };
|
||||
}
|
||||
|
||||
if (!this.property || !containsOnlyLettersAndUnderscores(this.property)) {
|
||||
return {
|
||||
res: false,
|
||||
err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": property may not be empty and may only contain letters and underscores.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.newProperty || !containsOnlyLettersAndUnderscores(this.newProperty)) {
|
||||
return {
|
||||
res: false,
|
||||
err: new PropertyMappingValidationError(
|
||||
`Error in property mapping "${this.toString()}": new property may not be empty and may only contain letters and underscores.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
res: true,
|
||||
};
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
if (this.mapping === PropertyMappingOption.Default) {
|
||||
return this.property;
|
||||
} else if (this.mapping === PropertyMappingOption.Map) {
|
||||
return `${this.property} -> ${this.newProperty}`;
|
||||
} else if (this.mapping === PropertyMappingOption.Remove) {
|
||||
return `remove ${this.property}`;
|
||||
}
|
||||
|
||||
return this.property;
|
||||
}
|
||||
|
||||
// Serialization - returns a plain object
|
||||
toJSON(): PropertyMappingData {
|
||||
return {
|
||||
property: this.property,
|
||||
newProperty: this.newProperty,
|
||||
mapping: this.mapping,
|
||||
locked: this.locked,
|
||||
wikilink: this.wikilink,
|
||||
};
|
||||
}
|
||||
|
||||
// Deserialization - creates a PropertyMapping from a plain object
|
||||
static fromJSON(json: PropertyMappingData): PropertyMapping {
|
||||
return new PropertyMapping(json.property, json.newProperty, json.mapping, json.locked, json.wikilink);
|
||||
}
|
||||
}
|
||||
138
packages/obsidian/src/settings/PropertyMappingModelComponent.tsx
Normal file
138
packages/obsidian/src/settings/PropertyMappingModelComponent.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { createSignal, createMemo, For, Show } from 'solid-js';
|
||||
import { createStore } from 'solid-js/store';
|
||||
import { PropertyMappingModel, PropertyMappingOption, propertyMappingOptions, type PropertyMappingModelData } from './PropertyMapping';
|
||||
import { capitalizeFirstLetter } from '../utils/Utils';
|
||||
import Icon from './Icon';
|
||||
|
||||
interface PropertyMappingModelComponentProps {
|
||||
model: PropertyMappingModelData;
|
||||
save: (model: PropertyMappingModelData) => void;
|
||||
}
|
||||
|
||||
export default function PropertyMappingModelComponent(props: PropertyMappingModelComponentProps) {
|
||||
const [unsavedChanges, setUnsavedChanges] = createSignal(false);
|
||||
|
||||
// Create a store from the model's plain data
|
||||
const [modelData, setModelData] = createStore(props.model);
|
||||
|
||||
// Derive the validation result reactively
|
||||
const validationResult = createMemo(() => {
|
||||
const model = PropertyMappingModel.fromJSON(modelData);
|
||||
return model.validate();
|
||||
});
|
||||
|
||||
const onModelUpdate = () => {
|
||||
setUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const model = PropertyMappingModel.fromJSON(modelData);
|
||||
if (model.validate().res) {
|
||||
props.save(model);
|
||||
setUnsavedChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="media-db-plugin-property-mappings-model-container">
|
||||
<div class="media-db-plugin-property-mappings-model-header">
|
||||
<div class="setting-item-name">{capitalizeFirstLetter(modelData.type)}</div>
|
||||
|
||||
<div class="media-db-plugin-property-mappings-model-actions">
|
||||
<Show when={unsavedChanges()}>
|
||||
<div class="media-db-plugin-property-mapping-unsaved-changes">Unsaved changes</div>
|
||||
</Show>
|
||||
|
||||
<button class={`media-db-plugin-property-mappings-save-button ${validationResult().res ? 'mod-cta' : 'mod-muted'}`} onClick={handleSave}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!validationResult().res}>
|
||||
<div class="media-db-plugin-property-mapping-validation">{validationResult().err?.message}</div>
|
||||
</Show>
|
||||
|
||||
<div class="media-db-plugin-property-mappings-table-container">
|
||||
<table class="media-db-plugin-property-mappings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-property">Property</th>
|
||||
<th class="col-mapping">Mapping</th>
|
||||
<th class="col-new-name">New name</th>
|
||||
<th class="col-wikilink">Wikilink</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={modelData.properties}>
|
||||
{(property, index) => (
|
||||
<tr>
|
||||
<td class="col-property">
|
||||
<code>{property.property}</code>
|
||||
</td>
|
||||
|
||||
<Show
|
||||
when={!property.locked}
|
||||
fallback={
|
||||
<td class="col-locked" colspan={3}>
|
||||
<div class="media-db-plugin-property-binding-text">property cannot be remapped</div>
|
||||
</td>
|
||||
}
|
||||
>
|
||||
<td class="col-mapping">
|
||||
<select
|
||||
class="dropdown"
|
||||
value={property.mapping}
|
||||
onChange={e => {
|
||||
setModelData('properties', index(), 'mapping', e.currentTarget.value as PropertyMappingOption);
|
||||
setModelData('properties', index(), 'newProperty', '');
|
||||
onModelUpdate();
|
||||
}}
|
||||
>
|
||||
<For each={propertyMappingOptions}>{remappingOption => <option value={remappingOption}>{remappingOption}</option>}</For>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td class="col-new-name">
|
||||
<Show
|
||||
when={property.mapping === PropertyMappingOption.Map}
|
||||
fallback={<span class="media-db-plugin-property-mapping-to-disabled">N/A</span>}
|
||||
>
|
||||
<div class="media-db-plugin-property-mapping-to">
|
||||
<Icon iconName="arrow-right" />
|
||||
<input
|
||||
class="media-db-plugin-property-mapping-input"
|
||||
type="text"
|
||||
spellcheck={false}
|
||||
value={property.newProperty}
|
||||
onInput={e => {
|
||||
setModelData('properties', index(), 'newProperty', e.currentTarget.value);
|
||||
onModelUpdate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
|
||||
<td class="col-wikilink">
|
||||
<label class="media-db-plugin-property-mapping-wikilink-label" title="Convert value to wikilink ([[value]])">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={property.wikilink}
|
||||
onChange={e => {
|
||||
setModelData('properties', index(), 'wikilink', e.currentTarget.checked);
|
||||
onModelUpdate();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
</Show>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { For } from 'solid-js';
|
||||
import { type PropertyMappingModelData } from './PropertyMapping';
|
||||
import PropertyMappingModelComponent from './PropertyMappingModelComponent';
|
||||
|
||||
interface PropertyMappingModelsComponentProps {
|
||||
models?: PropertyMappingModelData[];
|
||||
save: (model: PropertyMappingModelData) => void;
|
||||
}
|
||||
|
||||
export default function PropertyMappingModelsComponent(props: PropertyMappingModelsComponentProps) {
|
||||
return (
|
||||
<div class="setting-item" style={{ display: 'flex', gap: '10px', 'flex-direction': 'column', 'align-items': 'stretch' }}>
|
||||
<For each={props.models || []}>{model => <PropertyMappingModelComponent model={model} save={props.save} />}</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
861
packages/obsidian/src/settings/Settings.ts
Normal file
861
packages/obsidian/src/settings/Settings.ts
Normal file
|
|
@ -0,0 +1,861 @@
|
|||
import type { App } from 'obsidian';
|
||||
import { Notice, PluginSettingTab, SecretComponent, SettingGroup } from 'obsidian';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { PropertyMappingModelData } from 'packages/obsidian/src/settings/PropertyMapping';
|
||||
import { PropertyMapping, PropertyMappingModel, PropertyMappingOption } from 'packages/obsidian/src/settings/PropertyMapping';
|
||||
import PropertyMappingModelsComponent from 'packages/obsidian/src/settings/PropertyMappingModelsComponent';
|
||||
import { FileSuggest } from 'packages/obsidian/src/settings/suggesters/FileSuggest';
|
||||
import { FolderSuggest } from 'packages/obsidian/src/settings/suggesters/FolderSuggest';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import { MEDIA_TYPES } from 'packages/obsidian/src/utils/MediaTypeManager';
|
||||
import { unCamelCase } from 'packages/obsidian/src/utils/Utils';
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
function createDateFormatDescription(preview: string): DocumentFragment {
|
||||
return createFragment(frag => {
|
||||
const container = frag.createDiv();
|
||||
container.appendText('Your custom date format. Use ');
|
||||
container.createEl('em', { text: "'YYYY-MM-DD'" });
|
||||
container.appendText(' for example.');
|
||||
container.createEl('br');
|
||||
container.appendText('For more syntax, refer to ');
|
||||
container.createEl('a', {
|
||||
href: 'https://momentjs.com/docs/#/displaying/format/',
|
||||
text: 'format reference',
|
||||
});
|
||||
container.appendText('.');
|
||||
container.createEl('br');
|
||||
container.appendText('Your current syntax looks like this: ');
|
||||
container.createEl('em', { text: preview });
|
||||
});
|
||||
}
|
||||
|
||||
function createPropertyMappingsDescription(): DocumentFragment {
|
||||
return createFragment(frag => {
|
||||
const container = frag.createDiv();
|
||||
container.createEl('p', {
|
||||
text: 'Here you can customize how metadata fields are mapped to property names in the front matter of the created notes.',
|
||||
});
|
||||
container.createEl('p', {
|
||||
text: 'You can choose to keep the original name, rename the property, or remove it entirely.',
|
||||
});
|
||||
const paragraph = container.createEl('p');
|
||||
paragraph.createEl('strong', {
|
||||
text: 'Remember to save your changes using the save button for each individual category.',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// MARK: Settings
|
||||
export interface MediaDbPluginSettings {
|
||||
OMDbKeyId: string;
|
||||
TMDBKeyId: string;
|
||||
MobyGamesKeyId: string;
|
||||
GiantBombKeyId: string;
|
||||
IGDBClientId: string;
|
||||
IGDBClientSecret: string;
|
||||
RAWGAPIKeyId: string;
|
||||
ComicVineKeyId: string;
|
||||
BoardgameGeekKeyId: string;
|
||||
|
||||
sfwFilter: boolean;
|
||||
templates: boolean;
|
||||
customDateFormat: string;
|
||||
openNoteInNewTab: boolean;
|
||||
useDefaultFrontMatter: boolean;
|
||||
enableTemplaterIntegration: boolean;
|
||||
imageDownload: boolean;
|
||||
imageFolder: string;
|
||||
|
||||
BoardgameGeekAPI_disabledMediaTypes: MediaType[];
|
||||
ComicVineAPI_disabledMediaTypes: MediaType[];
|
||||
GiantBombAPI_disabledMediaTypes: MediaType[];
|
||||
IGDBAPI_disabledMediaTypes: MediaType[];
|
||||
RAWGAPI_disabledMediaTypes: MediaType[];
|
||||
MALAPI_disabledMediaTypes: MediaType[];
|
||||
MALAPIManga_disabledMediaTypes: MediaType[];
|
||||
MobyGamesAPI_disabledMediaTypes: MediaType[];
|
||||
MusicBrainzAPI_disabledMediaTypes: MediaType[];
|
||||
OMDbAPI_disabledMediaTypes: MediaType[];
|
||||
OpenLibraryAPI_disabledMediaTypes: MediaType[];
|
||||
SteamAPI_disabledMediaTypes: MediaType[];
|
||||
TMDBMovieAPI_disabledMediaTypes: MediaType[];
|
||||
TMDBSeasonAPI_disabledMediaTypes: MediaType[];
|
||||
TMDBSeriesAPI_disabledMediaTypes: MediaType[];
|
||||
VNDBAPI_disabledMediaTypes: MediaType[];
|
||||
WikipediaAPI_disabledMediaTypes: MediaType[];
|
||||
|
||||
movieTemplate: string;
|
||||
seriesTemplate: string;
|
||||
seasonTemplate: string;
|
||||
mangaTemplate: string;
|
||||
gameTemplate: string;
|
||||
wikiTemplate: string;
|
||||
musicReleaseTemplate: string;
|
||||
boardgameTemplate: string;
|
||||
bookTemplate: string;
|
||||
|
||||
movieFileNameTemplate: string;
|
||||
seriesFileNameTemplate: string;
|
||||
seasonFileNameTemplate: string;
|
||||
mangaFileNameTemplate: string;
|
||||
gameFileNameTemplate: string;
|
||||
wikiFileNameTemplate: string;
|
||||
musicReleaseFileNameTemplate: string;
|
||||
boardgameFileNameTemplate: string;
|
||||
bookFileNameTemplate: string;
|
||||
|
||||
movieFolder: string;
|
||||
seriesFolder: string;
|
||||
seasonFolder: string;
|
||||
mangaFolder: string;
|
||||
gameFolder: string;
|
||||
wikiFolder: string;
|
||||
musicReleaseFolder: string;
|
||||
boardgameFolder: string;
|
||||
bookFolder: string;
|
||||
|
||||
propertyMappingModels: PropertyMappingModelData[];
|
||||
|
||||
// DEPRECATED: Use propertyMappingModels instead
|
||||
moviePropertyConversionRules: string;
|
||||
seriesPropertyConversionRules: string;
|
||||
seasonPropertyConversionRules: string;
|
||||
mangaPropertyConversionRules: string;
|
||||
gamePropertyConversionRules: string;
|
||||
wikiPropertyConversionRules: string;
|
||||
musicReleasePropertyConversionRules: string;
|
||||
boardgamePropertyConversionRules: string;
|
||||
bookPropertyConversionRules: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to get/set settings for a specific media type.
|
||||
*/
|
||||
class MediaTypeMappedSettings {
|
||||
mediaType: MediaType;
|
||||
|
||||
constructor(mediaType: MediaType) {
|
||||
this.mediaType = mediaType;
|
||||
}
|
||||
|
||||
getTemplate(settings: MediaDbPluginSettings): string {
|
||||
switch (this.mediaType) {
|
||||
case MediaType.Movie:
|
||||
return settings.movieTemplate;
|
||||
case MediaType.Series:
|
||||
return settings.seriesTemplate;
|
||||
case MediaType.Season:
|
||||
return settings.seasonTemplate;
|
||||
case MediaType.ComicManga:
|
||||
return settings.mangaTemplate;
|
||||
case MediaType.Game:
|
||||
return settings.gameTemplate;
|
||||
case MediaType.Wiki:
|
||||
return settings.wikiTemplate;
|
||||
case MediaType.MusicRelease:
|
||||
return settings.musicReleaseTemplate;
|
||||
case MediaType.BoardGame:
|
||||
return settings.boardgameTemplate;
|
||||
case MediaType.Book:
|
||||
return settings.bookTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
setTemplate(settings: MediaDbPluginSettings, template: string): void {
|
||||
switch (this.mediaType) {
|
||||
case MediaType.Movie:
|
||||
settings.movieTemplate = template;
|
||||
break;
|
||||
case MediaType.Series:
|
||||
settings.seriesTemplate = template;
|
||||
break;
|
||||
case MediaType.Season:
|
||||
settings.seasonTemplate = template;
|
||||
break;
|
||||
case MediaType.ComicManga:
|
||||
settings.mangaTemplate = template;
|
||||
break;
|
||||
case MediaType.Game:
|
||||
settings.gameTemplate = template;
|
||||
break;
|
||||
case MediaType.Wiki:
|
||||
settings.wikiTemplate = template;
|
||||
break;
|
||||
case MediaType.MusicRelease:
|
||||
settings.musicReleaseTemplate = template;
|
||||
break;
|
||||
case MediaType.BoardGame:
|
||||
settings.boardgameTemplate = template;
|
||||
break;
|
||||
case MediaType.Book:
|
||||
settings.bookTemplate = template;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getFileNameTemplate(settings: MediaDbPluginSettings): string {
|
||||
switch (this.mediaType) {
|
||||
case MediaType.Movie:
|
||||
return settings.movieFileNameTemplate;
|
||||
case MediaType.Series:
|
||||
return settings.seriesFileNameTemplate;
|
||||
case MediaType.Season:
|
||||
return settings.seasonFileNameTemplate;
|
||||
case MediaType.ComicManga:
|
||||
return settings.mangaFileNameTemplate;
|
||||
case MediaType.Game:
|
||||
return settings.gameFileNameTemplate;
|
||||
case MediaType.Wiki:
|
||||
return settings.wikiFileNameTemplate;
|
||||
case MediaType.MusicRelease:
|
||||
return settings.musicReleaseFileNameTemplate;
|
||||
case MediaType.BoardGame:
|
||||
return settings.boardgameFileNameTemplate;
|
||||
case MediaType.Book:
|
||||
return settings.bookFileNameTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
setFileNameTemplate(settings: MediaDbPluginSettings, template: string): void {
|
||||
switch (this.mediaType) {
|
||||
case MediaType.Movie:
|
||||
settings.movieFileNameTemplate = template;
|
||||
break;
|
||||
case MediaType.Series:
|
||||
settings.seriesFileNameTemplate = template;
|
||||
break;
|
||||
case MediaType.Season:
|
||||
settings.seasonFileNameTemplate = template;
|
||||
break;
|
||||
case MediaType.ComicManga:
|
||||
settings.mangaFileNameTemplate = template;
|
||||
break;
|
||||
case MediaType.Game:
|
||||
settings.gameFileNameTemplate = template;
|
||||
break;
|
||||
case MediaType.Wiki:
|
||||
settings.wikiFileNameTemplate = template;
|
||||
break;
|
||||
case MediaType.MusicRelease:
|
||||
settings.musicReleaseFileNameTemplate = template;
|
||||
break;
|
||||
case MediaType.BoardGame:
|
||||
settings.boardgameFileNameTemplate = template;
|
||||
break;
|
||||
case MediaType.Book:
|
||||
settings.bookFileNameTemplate = template;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getFolder(settings: MediaDbPluginSettings): string {
|
||||
switch (this.mediaType) {
|
||||
case MediaType.Movie:
|
||||
return settings.movieFolder;
|
||||
case MediaType.Series:
|
||||
return settings.seriesFolder;
|
||||
case MediaType.Season:
|
||||
return settings.seasonFolder;
|
||||
case MediaType.ComicManga:
|
||||
return settings.mangaFolder;
|
||||
case MediaType.Game:
|
||||
return settings.gameFolder;
|
||||
case MediaType.Wiki:
|
||||
return settings.wikiFolder;
|
||||
case MediaType.MusicRelease:
|
||||
return settings.musicReleaseFolder;
|
||||
case MediaType.BoardGame:
|
||||
return settings.boardgameFolder;
|
||||
case MediaType.Book:
|
||||
return settings.bookFolder;
|
||||
}
|
||||
}
|
||||
|
||||
setFolder(settings: MediaDbPluginSettings, folder: string): void {
|
||||
switch (this.mediaType) {
|
||||
case MediaType.Movie:
|
||||
settings.movieFolder = folder;
|
||||
break;
|
||||
case MediaType.Series:
|
||||
settings.seriesFolder = folder;
|
||||
break;
|
||||
case MediaType.Season:
|
||||
settings.seasonFolder = folder;
|
||||
break;
|
||||
case MediaType.ComicManga:
|
||||
settings.mangaFolder = folder;
|
||||
break;
|
||||
case MediaType.Game:
|
||||
settings.gameFolder = folder;
|
||||
break;
|
||||
case MediaType.Wiki:
|
||||
settings.wikiFolder = folder;
|
||||
break;
|
||||
case MediaType.MusicRelease:
|
||||
settings.musicReleaseFolder = folder;
|
||||
break;
|
||||
case MediaType.BoardGame:
|
||||
settings.boardgameFolder = folder;
|
||||
break;
|
||||
case MediaType.Book:
|
||||
settings.bookFolder = folder;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Defaults
|
||||
const DEFAULT_SETTINGS: MediaDbPluginSettings = {
|
||||
OMDbKeyId: '',
|
||||
TMDBKeyId: '',
|
||||
MobyGamesKeyId: '',
|
||||
GiantBombKeyId: '',
|
||||
IGDBClientId: '',
|
||||
IGDBClientSecret: '',
|
||||
RAWGAPIKeyId: '',
|
||||
ComicVineKeyId: '',
|
||||
BoardgameGeekKeyId: '',
|
||||
|
||||
sfwFilter: true,
|
||||
templates: true,
|
||||
customDateFormat: 'L',
|
||||
openNoteInNewTab: true,
|
||||
useDefaultFrontMatter: true,
|
||||
enableTemplaterIntegration: false,
|
||||
imageDownload: false,
|
||||
imageFolder: 'Media DB/images',
|
||||
|
||||
BoardgameGeekAPI_disabledMediaTypes: [],
|
||||
ComicVineAPI_disabledMediaTypes: [],
|
||||
GiantBombAPI_disabledMediaTypes: [],
|
||||
IGDBAPI_disabledMediaTypes: [],
|
||||
RAWGAPI_disabledMediaTypes: [],
|
||||
MALAPI_disabledMediaTypes: [],
|
||||
MALAPIManga_disabledMediaTypes: [],
|
||||
MobyGamesAPI_disabledMediaTypes: [],
|
||||
MusicBrainzAPI_disabledMediaTypes: [],
|
||||
OMDbAPI_disabledMediaTypes: [],
|
||||
OpenLibraryAPI_disabledMediaTypes: [],
|
||||
SteamAPI_disabledMediaTypes: [],
|
||||
TMDBMovieAPI_disabledMediaTypes: [],
|
||||
TMDBSeasonAPI_disabledMediaTypes: [],
|
||||
TMDBSeriesAPI_disabledMediaTypes: [],
|
||||
VNDBAPI_disabledMediaTypes: [],
|
||||
WikipediaAPI_disabledMediaTypes: [],
|
||||
|
||||
movieTemplate: '',
|
||||
seriesTemplate: '',
|
||||
seasonTemplate: '',
|
||||
mangaTemplate: '',
|
||||
gameTemplate: '',
|
||||
wikiTemplate: '',
|
||||
musicReleaseTemplate: '',
|
||||
boardgameTemplate: '',
|
||||
bookTemplate: '',
|
||||
|
||||
movieFileNameTemplate: '{{ title }} ({{ year }})',
|
||||
seriesFileNameTemplate: '{{ title }} ({{ year }})',
|
||||
seasonFileNameTemplate: '{{ title }} ({{ year }})',
|
||||
mangaFileNameTemplate: '{{ title }} ({{ year }})',
|
||||
gameFileNameTemplate: '{{ title }} ({{ year }})',
|
||||
wikiFileNameTemplate: '{{ title }}',
|
||||
musicReleaseFileNameTemplate: '{{ title }} (by {{ ENUM:artists }} - {{ year }})',
|
||||
boardgameFileNameTemplate: '{{ title }} ({{ year }})',
|
||||
bookFileNameTemplate: '{{ title }} ({{ year }})',
|
||||
|
||||
movieFolder: 'Media DB/movies',
|
||||
seriesFolder: 'Media DB/series',
|
||||
seasonFolder: 'Media DB/series',
|
||||
mangaFolder: 'Media DB/comics',
|
||||
gameFolder: 'Media DB/games',
|
||||
wikiFolder: 'Media DB/wiki',
|
||||
musicReleaseFolder: 'Media DB/music',
|
||||
boardgameFolder: 'Media DB/boardgames',
|
||||
bookFolder: 'Media DB/books',
|
||||
|
||||
propertyMappingModels: [],
|
||||
|
||||
// DEPRECATED
|
||||
moviePropertyConversionRules: '',
|
||||
seriesPropertyConversionRules: '',
|
||||
seasonPropertyConversionRules: '',
|
||||
mangaPropertyConversionRules: '',
|
||||
gamePropertyConversionRules: '',
|
||||
wikiPropertyConversionRules: '',
|
||||
musicReleasePropertyConversionRules: '',
|
||||
boardgamePropertyConversionRules: '',
|
||||
bookPropertyConversionRules: '',
|
||||
};
|
||||
|
||||
export const lockedPropertyMappings: string[] = ['type', 'id', 'dataSource'];
|
||||
|
||||
export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings {
|
||||
const defaultSettings = DEFAULT_SETTINGS;
|
||||
|
||||
// construct property mapping defaults
|
||||
const propertyMappingModels: PropertyMappingModelData[] = [];
|
||||
for (const mediaType of MEDIA_TYPES) {
|
||||
const model: MediaTypeModel = plugin.mediaTypeManager.createMediaTypeModelFromMediaType({}, mediaType);
|
||||
const metadataObj = model.toMetaDataObject();
|
||||
|
||||
const propertyMappingModel: PropertyMappingModel = new PropertyMappingModel(mediaType);
|
||||
|
||||
for (const key of Object.keys(metadataObj)) {
|
||||
propertyMappingModel.properties.push(
|
||||
new PropertyMapping(
|
||||
key,
|
||||
'',
|
||||
PropertyMappingOption.Default,
|
||||
lockedPropertyMappings.includes(key),
|
||||
false, // wikilink default
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to plain data for serialization
|
||||
propertyMappingModels.push(propertyMappingModel.toJSON());
|
||||
}
|
||||
|
||||
defaultSettings.propertyMappingModels = propertyMappingModels;
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
// MARK: Settings Tab
|
||||
export class MediaDbSettingTab extends PluginSettingTab {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
constructor(app: App, plugin: MediaDbPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
const mediaTypeSettings = MEDIA_TYPES.map(mt => new MediaTypeMappedSettings(mt));
|
||||
|
||||
// MARK: General settings
|
||||
const generalGroup = new SettingGroup(containerEl);
|
||||
|
||||
generalGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('SFW filter')
|
||||
.setDesc('Only shows SFW results for APIs that offer filtering.')
|
||||
.addToggle(cb => {
|
||||
cb.setValue(this.plugin.settings.sfwFilter).onChange(data => {
|
||||
this.plugin.settings.sfwFilter = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
generalGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Resolve {{ tags }} in templates')
|
||||
.setDesc('Whether to resolve {{ tags }} in templates. The spaces inside the curly braces are important.')
|
||||
.addToggle(cb => {
|
||||
cb.setValue(this.plugin.settings.templates).onChange(data => {
|
||||
this.plugin.settings.templates = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
generalGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Date format')
|
||||
.setDesc(createDateFormatDescription(this.plugin.dateFormatter.getPreview()))
|
||||
.addText(cb => {
|
||||
cb.setPlaceholder(DEFAULT_SETTINGS.customDateFormat)
|
||||
.setValue(this.plugin.settings.customDateFormat === DEFAULT_SETTINGS.customDateFormat ? '' : this.plugin.settings.customDateFormat)
|
||||
.onChange(data => {
|
||||
const newDateFormat = data ? data : DEFAULT_SETTINGS.customDateFormat;
|
||||
this.plugin.settings.customDateFormat = newDateFormat;
|
||||
const previewEl = activeDocument.getElementById('media-db-dateformat-preview');
|
||||
if (previewEl) {
|
||||
previewEl.textContent = this.plugin.dateFormatter.getPreview(newDateFormat); // update preview
|
||||
}
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
generalGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Open note in new tab')
|
||||
.setDesc('Open the newly created note in a new tab.')
|
||||
.addToggle(cb => {
|
||||
cb.setValue(this.plugin.settings.openNoteInNewTab).onChange(data => {
|
||||
this.plugin.settings.openNoteInNewTab = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
generalGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Use default front matter')
|
||||
.setDesc('Whether to use the default front matter. If disabled, the front matter from the template will be used. Same as mapping everything to remove.')
|
||||
.addToggle(cb => {
|
||||
cb.setValue(this.plugin.settings.useDefaultFrontMatter).onChange(data => {
|
||||
this.plugin.settings.useDefaultFrontMatter = data;
|
||||
void this.plugin.saveSettings();
|
||||
// Redraw settings to display/remove the property mappings
|
||||
this.display();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
generalGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Enable Templater integration')
|
||||
.setDesc(
|
||||
'Enable integration with the templater plugin, this also needs templater to be installed. Warning: Templater allows you to execute arbitrary JavaScript code and system commands.',
|
||||
)
|
||||
.addToggle(cb => {
|
||||
cb.setValue(this.plugin.settings.enableTemplaterIntegration).onChange(data => {
|
||||
this.plugin.settings.enableTemplaterIntegration = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
generalGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Download images')
|
||||
.setDesc('Downloads images for new notes in the folder below')
|
||||
.addToggle(cb => {
|
||||
cb.setValue(this.plugin.settings.imageDownload).onChange(data => {
|
||||
this.plugin.settings.imageDownload = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
generalGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Image folder')
|
||||
.setDesc('Where downloaded images should be stored.')
|
||||
.addSearch(cb => {
|
||||
const suggester = new FolderSuggest(this.app, cb.inputEl);
|
||||
suggester.onSelect(folder => {
|
||||
cb.setValue(folder.path);
|
||||
this.plugin.settings.imageFolder = folder.path;
|
||||
void this.plugin.saveSettings();
|
||||
suggester.close();
|
||||
});
|
||||
cb.setPlaceholder(DEFAULT_SETTINGS.imageFolder)
|
||||
.setValue(this.plugin.settings.imageFolder)
|
||||
.onChange(data => {
|
||||
this.plugin.settings.imageFolder = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// MARK: API keys
|
||||
const apiKeyGroup = new SettingGroup(containerEl);
|
||||
apiKeyGroup.setHeading('API Keys');
|
||||
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('OMDb API key')
|
||||
.setDesc('API key for "www.omdbapi.com".')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.OMDbKeyId).onChange(data => {
|
||||
this.plugin.settings.OMDbKeyId = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('TMDB API key')
|
||||
.setDesc('API Read Access Token for "https://www.themoviedb.org".')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.TMDBKeyId).onChange(data => {
|
||||
this.plugin.settings.TMDBKeyId = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Moby Games key')
|
||||
.setDesc('API key for "www.mobygames.com".')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.MobyGamesKeyId).onChange(data => {
|
||||
this.plugin.settings.MobyGamesKeyId = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Giant Bomb Key')
|
||||
.setDesc('API key for "www.giantbomb.com".')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.GiantBombKeyId).onChange(data => {
|
||||
this.plugin.settings.GiantBombKeyId = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('IGDB Client ID')
|
||||
.setDesc('Client ID for IGDB API (Required for Twitch OAuth).')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.IGDBClientId).onChange(data => {
|
||||
this.plugin.settings.IGDBClientId = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('IGDB Client Secret')
|
||||
.setDesc('Client Secret for IGDB API.')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.IGDBClientSecret).onChange(data => {
|
||||
this.plugin.settings.IGDBClientSecret = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('RAWG API Key')
|
||||
.setDesc('API key for "rawg.io".')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.RAWGAPIKeyId).onChange(data => {
|
||||
this.plugin.settings.RAWGAPIKeyId = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Comic Vine Key')
|
||||
.setDesc('API key for "www.comicvine.gamespot.com".')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.ComicVineKeyId).onChange(data => {
|
||||
this.plugin.settings.ComicVineKeyId = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
apiKeyGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName('Boardgame Geek Key')
|
||||
.setDesc('API key for "www.boardgamegeek.com".')
|
||||
.addComponent(el => {
|
||||
const component = new SecretComponent(this.app, el);
|
||||
|
||||
component.setValue(this.plugin.settings.BoardgameGeekKeyId).onChange(data => {
|
||||
this.plugin.settings.BoardgameGeekKeyId = data;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
|
||||
return component;
|
||||
}),
|
||||
);
|
||||
|
||||
// MARK: Media type settings
|
||||
|
||||
// Create a map to store APIs for each media type
|
||||
const mediaTypeApiMap = new Map<MediaType, string[]>();
|
||||
|
||||
// Populate the map with APIs for each media type dynamically
|
||||
for (const api of this.plugin.apiManager.apis) {
|
||||
for (const mediaType of api.types) {
|
||||
if (!mediaTypeApiMap.has(mediaType)) {
|
||||
mediaTypeApiMap.set(mediaType, []);
|
||||
}
|
||||
mediaTypeApiMap.get(mediaType)!.push(api.apiName);
|
||||
}
|
||||
}
|
||||
|
||||
for (const mediaTypeSetting of mediaTypeSettings) {
|
||||
const mediaTypeGroup = new SettingGroup(containerEl);
|
||||
const mediaType = mediaTypeSetting.mediaType;
|
||||
const mediaTypeName = unCamelCase(mediaTypeSetting.mediaType);
|
||||
const mediaTypeNameLower = mediaTypeName.toLowerCase();
|
||||
|
||||
mediaTypeGroup.setHeading(`${mediaTypeName} settings`);
|
||||
|
||||
// Folder
|
||||
mediaTypeGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName(`Import Folder`)
|
||||
.setDesc(`Where newly imported ${mediaTypeNameLower} should be placed.`)
|
||||
.addSearch(cb => {
|
||||
const suggester = new FolderSuggest(this.app, cb.inputEl);
|
||||
suggester.onSelect(folder => {
|
||||
cb.setValue(folder.path);
|
||||
mediaTypeSetting.setFolder(this.plugin.settings, folder.path);
|
||||
void this.plugin.saveSettings();
|
||||
suggester.close();
|
||||
});
|
||||
cb.setPlaceholder(mediaTypeSetting.getFolder(DEFAULT_SETTINGS))
|
||||
.setValue(mediaTypeSetting.getFolder(this.plugin.settings))
|
||||
.onChange(data => {
|
||||
mediaTypeSetting.setFolder(this.plugin.settings, data);
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Template
|
||||
mediaTypeGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName(`Template`)
|
||||
.setDesc(`Template file to be used when creating a new note for a ${mediaTypeNameLower}.`)
|
||||
.addSearch(cb => {
|
||||
const suggester = new FileSuggest(this.app, cb.inputEl);
|
||||
suggester.onSelect(file => {
|
||||
cb.setValue(file.path);
|
||||
mediaTypeSetting.setTemplate(this.plugin.settings, file.path);
|
||||
void this.plugin.saveSettings();
|
||||
suggester.close();
|
||||
});
|
||||
cb.setPlaceholder(`Example: ${mediaTypeNameLower}Template.md`)
|
||||
.setValue(mediaTypeSetting.getTemplate(this.plugin.settings))
|
||||
.onChange(data => {
|
||||
mediaTypeSetting.setTemplate(this.plugin.settings, data);
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// File name template
|
||||
mediaTypeGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName(`File name template`)
|
||||
.setDesc(`Template for the file name used when creating a new note for a ${mediaTypeNameLower}.`)
|
||||
.addText(cb => {
|
||||
cb.setPlaceholder(`Example: ${mediaTypeSetting.getFileNameTemplate(DEFAULT_SETTINGS)}`)
|
||||
.setValue(mediaTypeSetting.getFileNameTemplate(this.plugin.settings))
|
||||
.onChange(data => {
|
||||
mediaTypeSetting.setFileNameTemplate(this.plugin.settings, data);
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// APIs
|
||||
const apis = mediaTypeApiMap.get(mediaType) ?? [];
|
||||
if (apis.length > 1) {
|
||||
for (const apiName of apis) {
|
||||
const api = this.plugin.apiManager.apis.find(api => api.apiName === apiName);
|
||||
if (api) {
|
||||
const disabledMediaTypes = api.getDisabledMediaTypes();
|
||||
|
||||
mediaTypeGroup.addSetting(
|
||||
setting =>
|
||||
void setting
|
||||
.setName(apiName)
|
||||
.setDesc(`Use ${apiName} API for ${unCamelCase(mediaType)}.`)
|
||||
.addToggle(cb => {
|
||||
cb.setValue(!disabledMediaTypes.includes(mediaType)).onChange(data => {
|
||||
if (data) {
|
||||
const index = disabledMediaTypes.indexOf(mediaType);
|
||||
if (index != -1) {
|
||||
disabledMediaTypes.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
disabledMediaTypes.push(mediaType);
|
||||
}
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Property mappings
|
||||
|
||||
if (this.plugin.settings.useDefaultFrontMatter) {
|
||||
const mappingGroup = new SettingGroup(containerEl);
|
||||
mappingGroup.setHeading('Property mappings');
|
||||
mappingGroup.addSetting(setting => {
|
||||
setting.setName('Property mappings explanation').setDesc(createPropertyMappingsDescription());
|
||||
|
||||
render(
|
||||
() =>
|
||||
PropertyMappingModelsComponent({
|
||||
models: structuredClone(this.plugin.settings.propertyMappingModels),
|
||||
save: (model: PropertyMappingModelData): void => {
|
||||
// Update the matching model in settings (stored as plain data)
|
||||
const index = this.plugin.settings.propertyMappingModels.findIndex(m => m.type === model.type);
|
||||
if (index !== -1) {
|
||||
this.plugin.settings.propertyMappingModels[index] = model;
|
||||
}
|
||||
|
||||
new Notice(`MDB: Property mappings for ${model.type} saved successfully.`);
|
||||
void this.plugin.saveSettings();
|
||||
},
|
||||
}),
|
||||
setting.descEl,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/obsidian/src/settings/suggesters/FileSuggest.ts
Normal file
17
packages/obsidian/src/settings/suggesters/FileSuggest.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { AbstractInputSuggest, TFile } from 'obsidian';
|
||||
|
||||
export class FileSuggest extends AbstractInputSuggest<TFile> {
|
||||
protected getSuggestions(query: string): TFile[] | Promise<TFile[]> {
|
||||
const lowerCaseInputStr = query.toLowerCase();
|
||||
|
||||
// we do two filters because otherwise TS type inference does convert the array to TFile[]
|
||||
return this.app.vault
|
||||
.getAllLoadedFiles()
|
||||
.filter(file => file instanceof TFile)
|
||||
.filter(file => file.path.toLowerCase().includes(lowerCaseInputStr));
|
||||
}
|
||||
|
||||
renderSuggestion(value: TFile, el: HTMLElement): void {
|
||||
el.setText(value.path);
|
||||
}
|
||||
}
|
||||
17
packages/obsidian/src/settings/suggesters/FolderSuggest.ts
Normal file
17
packages/obsidian/src/settings/suggesters/FolderSuggest.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { AbstractInputSuggest, TFolder } from 'obsidian';
|
||||
|
||||
export class FolderSuggest extends AbstractInputSuggest<TFolder> {
|
||||
protected getSuggestions(query: string): TFolder[] | Promise<TFolder[]> {
|
||||
const lowerCaseInputStr = query.toLowerCase();
|
||||
|
||||
// we do two filters because otherwise TS type inference does convert the array to TFolder[]
|
||||
return this.app.vault
|
||||
.getAllLoadedFiles()
|
||||
.filter(file => file instanceof TFolder)
|
||||
.filter(file => file.path.toLowerCase().includes(lowerCaseInputStr));
|
||||
}
|
||||
|
||||
renderSuggestion(value: TFolder, el: HTMLElement): void {
|
||||
el.setText(value.path);
|
||||
}
|
||||
}
|
||||
263
packages/obsidian/src/styles.css
Normal file
263
packages/obsidian/src/styles.css
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
.media-db-plugin-list-wrapper {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.media-db-plugin-list-toggle {
|
||||
}
|
||||
|
||||
.media-db-plugin-list-text-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-db-plugin-list-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
small.media-db-plugin-list-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.media-db-plugin-select-modal {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.media-db-plugin-select-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 5px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.media-db-plugin-select-element {
|
||||
cursor: pointer;
|
||||
border-left: 5px solid transparent;
|
||||
padding: 5px;
|
||||
margin: 5px 0 5px 0;
|
||||
border-radius: 5px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.media-db-plugin-select-element-selected {
|
||||
border-left: 5px solid var(--interactive-accent) !important;
|
||||
background: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.media-db-plugin-select-element-hover {
|
||||
background: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.media-db-plugin-preview-modal {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.media-db-plugin-preview-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.media-db-plugin-spacer {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.media-db-plugin-search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-db-plugin-button:focus {
|
||||
/*outline: 1px solid white;*/
|
||||
}
|
||||
|
||||
.media-db-plugin-preview {
|
||||
border-radius: var(--modal-radius);
|
||||
border: var(--modal-border-width) solid var(--modal-border-color);
|
||||
padding: var(--size-4-4);
|
||||
}
|
||||
|
||||
/* Icon Component Styles */
|
||||
.icon-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
top: calc(50% - 10px);
|
||||
}
|
||||
|
||||
/* Property Mapping Component Styles */
|
||||
.media-db-plugin-property-mappings-model-container {
|
||||
margin-bottom: var(--size-4-8);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-model-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--size-4-4);
|
||||
gap: var(--size-4-3);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-model-header .setting-item-name {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--font-ui-medium);
|
||||
color: var(--text-normal);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-model-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-3);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mapping-unsaved-changes {
|
||||
color: var(--text-warning);
|
||||
font-size: var(--font-ui-small);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-save-button {
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-save-button.mod-muted {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mapping-validation {
|
||||
color: var(--text-error);
|
||||
background: rgba(var(--color-red-rgb), 0.1);
|
||||
padding: var(--size-4-3) var(--size-4-4);
|
||||
margin-bottom: var(--size-4-4);
|
||||
border-left: 3px solid var(--text-error);
|
||||
font-size: var(--font-ui-small);
|
||||
line-height: 1.5;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
font-size: var(--font-ui-small);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table thead {
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table th {
|
||||
padding: var(--size-4-2) var(--size-4-3);
|
||||
padding-left: 0;
|
||||
text-align: left;
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table tbody tr {
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table td {
|
||||
padding: var(--size-4-3) var(--size-4-3) var(--size-4-3) 0;
|
||||
border-bottom: 1px solid var(--background-modifier-border-hover);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.col-property {
|
||||
width: 25%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.col-mapping {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.col-new-name {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.col-wikilink {
|
||||
width: 15%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.col-locked {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table code {
|
||||
padding: var(--size-4-1) var(--size-4-2);
|
||||
margin: 0;
|
||||
background: var(--code-background);
|
||||
color: var(--code-normal);
|
||||
border-radius: var(--radius-s);
|
||||
font-size: var(--font-ui-smaller);
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-binding-text {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mappings-table select.dropdown {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mapping-to {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mapping-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mapping-to-disabled {
|
||||
color: var(--text-faint);
|
||||
font-size: var(--font-ui-medium);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mapping-wikilink-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: var(--size-4-1);
|
||||
}
|
||||
|
||||
.media-db-plugin-property-mapping-wikilink-label input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
width: var(--checkbox-size);
|
||||
height: var(--checkbox-size);
|
||||
}
|
||||
24
packages/obsidian/src/utils/AppError.ts
Normal file
24
packages/obsidian/src/utils/AppError.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export enum AppErrorKind {
|
||||
Validation = 'Validation',
|
||||
Api = 'Api',
|
||||
Network = 'Network',
|
||||
Vault = 'Vault',
|
||||
Modal = 'Modal',
|
||||
Cancelled = 'Cancelled',
|
||||
Unexpected = 'Unexpected',
|
||||
}
|
||||
|
||||
export interface AppError {
|
||||
kind: AppErrorKind;
|
||||
message: string;
|
||||
userMessage?: string;
|
||||
cause?: unknown;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const appError = (error: AppError): AppError => error;
|
||||
|
||||
export const toAppError = (cause: unknown, fallback: Omit<AppError, 'cause'>): AppError => {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return { ...fallback, message: fallback.message || message, cause };
|
||||
};
|
||||
144
packages/obsidian/src/utils/BulkImportHelper.ts
Normal file
144
packages/obsidian/src/utils/BulkImportHelper.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import type { TFolder } from 'obsidian';
|
||||
import { TFile } from 'obsidian';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { MediaDbBulkImportModal as MediaDbBulkImportModal } from 'packages/obsidian/src/modals/MediaDbBulkImportModal';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { OutcomeStatus } from 'packages/obsidian/src/utils/result';
|
||||
import { dateTimeToString, markdownTable } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export enum BulkImportLookupMethod {
|
||||
ID = 'id',
|
||||
TITLE = 'title',
|
||||
}
|
||||
|
||||
interface BulkImportError {
|
||||
filePath: string;
|
||||
error: string;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export class BulkImportHelper {
|
||||
readonly plugin: MediaDbPlugin;
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
async import(folder: TFolder): Promise<void> {
|
||||
const erroredFiles: BulkImportError[] = [];
|
||||
let canceled: boolean = false;
|
||||
|
||||
const { selectedAPI, lookupMethod, fieldName, appendContent } = await new Promise<{
|
||||
selectedAPI: string;
|
||||
lookupMethod: BulkImportLookupMethod;
|
||||
fieldName: string;
|
||||
appendContent: boolean;
|
||||
}>(resolve => {
|
||||
new MediaDbBulkImportModal(this.plugin, (selectedAPI: string, lookupMethod: BulkImportLookupMethod, fieldName: string, appendContent: boolean) => {
|
||||
resolve({ selectedAPI, lookupMethod, fieldName, appendContent });
|
||||
}).open();
|
||||
});
|
||||
|
||||
for (const child of folder.children) {
|
||||
if (!(child instanceof TFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file: TFile = child;
|
||||
if (canceled) {
|
||||
erroredFiles.push({ filePath: file.path, error: 'user canceled' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = this.plugin.fileHelper.getMetadataFromFileCache(file);
|
||||
const lookupValue = metadata[fieldName];
|
||||
|
||||
if (!lookupValue || typeof lookupValue !== 'string') {
|
||||
erroredFiles.push({ filePath: file.path, error: `metadata field '${fieldName}' not found, empty, or not a string` });
|
||||
continue;
|
||||
} else if (lookupMethod === BulkImportLookupMethod.ID) {
|
||||
const error = await this.importById(file, lookupValue, selectedAPI, appendContent);
|
||||
if (error) {
|
||||
erroredFiles.push(error);
|
||||
}
|
||||
} else if (lookupMethod === BulkImportLookupMethod.TITLE) {
|
||||
const error = await this.importByTitle(file, lookupValue, selectedAPI, appendContent);
|
||||
if (error) {
|
||||
if (error.canceled) {
|
||||
canceled = true;
|
||||
}
|
||||
erroredFiles.push(error);
|
||||
}
|
||||
} else {
|
||||
erroredFiles.push({ filePath: file.path, error: `invalid lookup type` });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (erroredFiles.length > 0) {
|
||||
await this.createErroredFilesReport(erroredFiles);
|
||||
}
|
||||
}
|
||||
|
||||
private async importById(file: TFile, lookupValue: string, selectedAPI: string, appendContent: boolean): Promise<BulkImportError | undefined> {
|
||||
const modelResult = await this.plugin.apiManager.queryDetailedInfoById(lookupValue, selectedAPI);
|
||||
if (!modelResult.ok) {
|
||||
return { filePath: file.path, error: modelResult.error.userMessage ?? modelResult.error.message };
|
||||
}
|
||||
|
||||
if (modelResult.value) {
|
||||
await this.plugin.fileHelper.createMediaDbNotes([modelResult.value], appendContent ? file : undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { filePath: file.path, error: `Failed to query API with id: ${lookupValue}` };
|
||||
}
|
||||
|
||||
private async importByTitle(file: TFile, lookupValue: string, selectedAPI: string, appendContent: boolean): Promise<BulkImportError | undefined> {
|
||||
const resultsResult = await this.plugin.apiManager.query(lookupValue, [selectedAPI]);
|
||||
if (!resultsResult.ok) {
|
||||
return { filePath: file.path, error: resultsResult.error.userMessage ?? resultsResult.error.message };
|
||||
}
|
||||
|
||||
const results: MediaTypeModel[] = resultsResult.value.items;
|
||||
if (!results || results.length === 0) {
|
||||
return { filePath: file.path, error: `no search results` };
|
||||
}
|
||||
|
||||
const selectModalResult = await this.plugin.modalHelper.createSelectModalOutcome({
|
||||
elements: results,
|
||||
skipButton: true,
|
||||
modalTitle: `Results for '${lookupValue}'`,
|
||||
});
|
||||
|
||||
if (selectModalResult.status === OutcomeStatus.Error) {
|
||||
return { filePath: file.path, error: selectModalResult.error.userMessage ?? selectModalResult.error.message };
|
||||
}
|
||||
|
||||
if (selectModalResult.status === OutcomeStatus.Cancelled) {
|
||||
return { filePath: file.path, error: 'user canceled', canceled: true };
|
||||
}
|
||||
|
||||
if (selectModalResult.status === OutcomeStatus.Skipped) {
|
||||
return { filePath: file.path, error: 'user skipped' };
|
||||
}
|
||||
|
||||
if (selectModalResult.data.selected.length === 0) {
|
||||
return { filePath: file.path, error: `no search results selected` };
|
||||
}
|
||||
|
||||
const detailedResults = await this.plugin.entryHelper.queryDetails(selectModalResult.data.selected);
|
||||
await this.plugin.fileHelper.createMediaDbNotes(detailedResults, appendContent ? file : undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async createErroredFilesReport(erroredFiles: BulkImportError[]): Promise<void> {
|
||||
const title = `MDB - bulk import error report ${dateTimeToString(new Date())}`;
|
||||
const filePath = `${title}.md`;
|
||||
|
||||
const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error]));
|
||||
|
||||
const fileContent = `# ${title}\n\n${markdownTable(table)}`;
|
||||
await this.plugin.app.vault.create(filePath, fileContent);
|
||||
}
|
||||
}
|
||||
73
packages/obsidian/src/utils/DateFormatter.ts
Normal file
73
packages/obsidian/src/utils/DateFormatter.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { moment } from 'obsidian';
|
||||
|
||||
const momentFn = 'default' in moment ? moment.default : moment;
|
||||
|
||||
export class DateFormatter {
|
||||
private static readonly RFC2822_FORMAT = 'ddd, DD MMM YYYY HH:mm:ss ZZ';
|
||||
|
||||
toFormat: string;
|
||||
locale: string;
|
||||
|
||||
constructor() {
|
||||
this.toFormat = 'YYYY-MM-DD';
|
||||
// get locale of this machine (e.g. en, en-gb, de, fr, etc.)
|
||||
this.locale = new Intl.DateTimeFormat().resolvedOptions().locale;
|
||||
}
|
||||
|
||||
setFormat(format: string): void {
|
||||
this.toFormat = format;
|
||||
}
|
||||
|
||||
getPreview(format?: string): string {
|
||||
const today = momentFn();
|
||||
|
||||
format ??= this.toFormat;
|
||||
|
||||
return today.locale(this.locale).format(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to format a given date string with the currently set date format.
|
||||
* You can set a date format by calling `setFormat()`.
|
||||
*
|
||||
* @param dateString the date string to be formatted
|
||||
* @param dateFormat the current format of `dateString`. When this is `null` and the actual format of the
|
||||
* given date string is not `C2822` or `ISO` format, this function will try to guess the format by using the native `Date` module.
|
||||
* @param locale the locale of `dateString`. This is needed when `dateString` includes a month or day name and its locale format differs
|
||||
* from the locale of this machine.
|
||||
* @returns formatted date string or null if `dateString` is not a valid date
|
||||
*/
|
||||
format(dateString: string | null | undefined, dateFormat?: string, locale: string = 'en'): string | null {
|
||||
if (!dateString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let date: moment.Moment;
|
||||
|
||||
if (!dateFormat) {
|
||||
date = this.parseWithoutFormat(dateString);
|
||||
} else {
|
||||
date = momentFn(dateString, dateFormat, locale);
|
||||
}
|
||||
|
||||
// format date (if it is valid)
|
||||
return date.isValid() ? date.locale(this.locale).format(this.toFormat) : null;
|
||||
}
|
||||
|
||||
private parseWithoutFormat(dateString: string): moment.Moment {
|
||||
// reading date formats other than C2822 or ISO with moment is deprecated
|
||||
// see https://momentjs.com/docs/#/parsing/string/
|
||||
if (this.hasMomentFormat(dateString)) {
|
||||
// expect C2822 or ISO format
|
||||
return momentFn(dateString);
|
||||
}
|
||||
|
||||
// fall back to native Date parsing for unknown formats
|
||||
return momentFn(new Date(dateString));
|
||||
}
|
||||
|
||||
private hasMomentFormat(dateString: string): boolean {
|
||||
const date = momentFn(dateString, [moment.ISO_8601, DateFormatter.RFC2822_FORMAT], true); // strict mode
|
||||
return date.isValid();
|
||||
}
|
||||
}
|
||||
23
packages/obsidian/src/utils/ErrorReporter.ts
Normal file
23
packages/obsidian/src/utils/ErrorReporter.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Notice } from 'obsidian';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
|
||||
export class ErrorReporter {
|
||||
notice(error: AppError): void {
|
||||
const message = error.userMessage ?? error.message;
|
||||
new Notice(message);
|
||||
}
|
||||
|
||||
log(error: AppError): void {
|
||||
Logger.warn('MDB | error', error);
|
||||
}
|
||||
|
||||
report(error: AppError): void {
|
||||
this.log(error);
|
||||
|
||||
if (error.kind !== AppErrorKind.Cancelled) {
|
||||
this.notice(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
1185
packages/obsidian/src/utils/IconList.ts
Normal file
1185
packages/obsidian/src/utils/IconList.ts
Normal file
File diff suppressed because it is too large
Load diff
16
packages/obsidian/src/utils/IllegalFilenameCharactersList.ts
Normal file
16
packages/obsidian/src/utils/IllegalFilenameCharactersList.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Illegal characters in the form `[illegal_character, replacement][]`
|
||||
export const ILLEGAL_FILENAME_CHARACTERS = [
|
||||
['/', '-'],
|
||||
['\\', '-'],
|
||||
['<', ''],
|
||||
['>', ''],
|
||||
[':', ' - '],
|
||||
['"', "'"],
|
||||
['|', ' - '],
|
||||
['?', ''],
|
||||
['*', ''],
|
||||
['[', '('],
|
||||
[']', ')'],
|
||||
['^', ''],
|
||||
['#', ''],
|
||||
];
|
||||
38
packages/obsidian/src/utils/Logger.ts
Normal file
38
packages/obsidian/src/utils/Logger.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
declare const __LOG_LEVEL__: number;
|
||||
|
||||
enum LogLevel {
|
||||
NONE = 0,
|
||||
ERROR = 1,
|
||||
WARN = 2,
|
||||
LOG = 3,
|
||||
DEBUG = 4,
|
||||
}
|
||||
|
||||
const logLevel: LogLevel = typeof __LOG_LEVEL__ !== 'undefined' ? __LOG_LEVEL__ : LogLevel.LOG;
|
||||
|
||||
export class Logger {
|
||||
static debug(...args: unknown[]): void {
|
||||
if (logLevel >= LogLevel.DEBUG) {
|
||||
console.debug(...args);
|
||||
}
|
||||
}
|
||||
|
||||
static log(...args: unknown[]): void {
|
||||
if (logLevel >= LogLevel.LOG) {
|
||||
// eslint-disable-next-line obsidianmd/rule-custom-message -- These log statements are disabled for production builds.
|
||||
console.log(...args);
|
||||
}
|
||||
}
|
||||
|
||||
static warn(...args: unknown[]): void {
|
||||
if (logLevel >= LogLevel.WARN) {
|
||||
console.warn(...args);
|
||||
}
|
||||
}
|
||||
|
||||
static error(...args: unknown[]): void {
|
||||
if (logLevel >= LogLevel.ERROR) {
|
||||
console.error(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
289
packages/obsidian/src/utils/MediaDbEntryHelper.ts
Normal file
289
packages/obsidian/src/utils/MediaDbEntryHelper.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { MarkdownView, Notice } from 'obsidian';
|
||||
import type { TMDBSeasonAPI } from 'packages/obsidian/src/api/apis/TMDBSeasonAPI';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import type { SeasonSelectModalElement } from 'packages/obsidian/src/modals/MediaDbSeasonSelectModal';
|
||||
import { MediaDbSeasonSelectModal } from 'packages/obsidian/src/modals/MediaDbSeasonSelectModal';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { SeasonModel } from 'packages/obsidian/src/models/SeasonModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind } from 'packages/obsidian/src/utils/AppError';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { SearchModalOptions } from 'packages/obsidian/src/utils/ModalHelper';
|
||||
|
||||
export class MediaDbEntryHelper {
|
||||
readonly plugin: MediaDbPlugin;
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
private reportAppError(error: AppError): void {
|
||||
this.plugin.errorReporter.report(error);
|
||||
}
|
||||
|
||||
async createLinkWithSearchModal(): Promise<void> {
|
||||
const advancedSearch = await this.plugin.modalHelper.promptAdvancedSearchModal({});
|
||||
if (!advancedSearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiSearchResults = await this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis);
|
||||
if (!apiSearchResults.ok) {
|
||||
this.reportAppError(apiSearchResults.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiSearchResults.value.items || apiSearchResults.value.items.length === 0) {
|
||||
this.reportAppError({ kind: AppErrorKind.Validation, message: 'No results found.', userMessage: 'No results found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectResults = await this.plugin.modalHelper.promptSelectModal({ elements: apiSearchResults.value.items, multiSelect: false });
|
||||
if (!selectResults || selectResults.selected.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailedResults = await this.queryDetails(selectResults.selected);
|
||||
if (detailedResults.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = `[${detailedResults[0].title}](${detailedResults[0].url})`;
|
||||
const view = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
|
||||
if (view) {
|
||||
view.editor.replaceRange(link, view.editor.getCursor());
|
||||
}
|
||||
}
|
||||
|
||||
async createEntryWithSearchModal(searchModalOptions?: SearchModalOptions): Promise<void> {
|
||||
const searchData = await this.plugin.modalHelper.promptSearchModal(searchModalOptions ?? {});
|
||||
if (!searchData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const types = searchData.types;
|
||||
const apis = this.plugin.apiManager.apis.filter(api => api.hasTypeOverlap(types)).map(api => api.apiName);
|
||||
const apiSearchResults = await this.plugin.apiManager.query(searchData.query, apis);
|
||||
if (!apiSearchResults.ok) {
|
||||
this.reportAppError(apiSearchResults.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiSearchResults.value.items || apiSearchResults.value.items.length === 0) {
|
||||
this.reportAppError({ kind: AppErrorKind.Validation, message: 'No results found.', userMessage: 'No results found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredSearchResults = apiSearchResults.value.items.filter(result => types.includes(result.getMediaType()));
|
||||
if (filteredSearchResults.length === 0) {
|
||||
this.reportAppError({ kind: AppErrorKind.Validation, message: 'No results found for the selected types.', userMessage: 'No results found for the selected types.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectResultsData =
|
||||
types.length === 1 && types[0] === MediaType.Season
|
||||
? await this.plugin.modalHelper.promptSelectModal({
|
||||
elements: filteredSearchResults,
|
||||
description: 'Select one search result to proceed.',
|
||||
submitButtonText: 'Ok',
|
||||
})
|
||||
: await this.plugin.modalHelper.promptSelectModal({ elements: filteredSearchResults });
|
||||
|
||||
if (!selectResultsData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectResults = types.length === 1 && types[0] === MediaType.Season ? selectResultsData.selected : await this.queryDetails(selectResultsData.selected);
|
||||
|
||||
if (selectResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seasonHandlingResult = await this.handleSeasonWorkflow(types, selectResults);
|
||||
if (seasonHandlingResult.handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.plugin.modalHelper.promptPreviewModal({ elements: selectResults });
|
||||
if (!confirmed?.confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.plugin.fileHelper.createMediaDbNotes(selectResults);
|
||||
}
|
||||
|
||||
async createEntryWithAdvancedSearchModal(): Promise<void> {
|
||||
const advancedSearch = await this.plugin.modalHelper.promptAdvancedSearchModal({});
|
||||
if (!advancedSearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiSearchResults = await this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis);
|
||||
if (!apiSearchResults.ok) {
|
||||
this.reportAppError(apiSearchResults.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiSearchResults.value.items || apiSearchResults.value.items.length === 0) {
|
||||
this.reportAppError({ kind: AppErrorKind.Validation, message: 'No results found.', userMessage: 'No results found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectResultsData = await this.plugin.modalHelper.promptSelectModal({ elements: apiSearchResults.value.items });
|
||||
if (!selectResultsData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectResults = await this.queryDetails(selectResultsData.selected);
|
||||
if (selectResults.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.plugin.modalHelper.promptPreviewModal({ elements: selectResults });
|
||||
if (!confirmed?.confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.plugin.fileHelper.createMediaDbNotes(selectResults);
|
||||
}
|
||||
|
||||
async createEntryWithIdSearchModal(): Promise<void> {
|
||||
let idSearchResult: MediaTypeModel | undefined = undefined;
|
||||
let proceed = false;
|
||||
|
||||
while (!proceed) {
|
||||
const idSearchData = await this.plugin.modalHelper.promptIdSearchModal({});
|
||||
if (!idSearchData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queriedIdResult = await this.plugin.apiManager.queryDetailedInfoById(idSearchData.query, idSearchData.api);
|
||||
if (!queriedIdResult.ok) {
|
||||
this.reportAppError(queriedIdResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
idSearchResult = queriedIdResult.value;
|
||||
if (!idSearchResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewData = await this.plugin.modalHelper.promptPreviewModal({ elements: [idSearchResult] });
|
||||
if (!previewData) {
|
||||
return;
|
||||
}
|
||||
|
||||
proceed = previewData.confirmed;
|
||||
}
|
||||
|
||||
if (!idSearchResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createNoteResult = await this.plugin.fileHelper.createMediaDbNoteFromModel(idSearchResult, { attachTemplate: true, openNote: true });
|
||||
if (!createNoteResult.ok) {
|
||||
this.reportAppError(createNoteResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
async queryDetails(models: MediaTypeModel[]): Promise<MediaTypeModel[]> {
|
||||
const results = await Promise.all(models.map(model => this.plugin.apiManager.queryDetailedInfo(model)));
|
||||
|
||||
const detailModels: MediaTypeModel[] = [];
|
||||
for (const result of results) {
|
||||
if (result.ok && result.value) {
|
||||
detailModels.push(result.value);
|
||||
} else if (!result.ok) {
|
||||
this.reportAppError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
return detailModels;
|
||||
}
|
||||
|
||||
private async handleSeasonWorkflow(types: string[], selectResults: MediaTypeModel[]): Promise<{ handled: boolean; seasonsCreated?: boolean }> {
|
||||
if (types.length === 1 && types[0] === 'season' && selectResults.length === 1 && selectResults[0].dataSource === 'TMDBSeasonAPI') {
|
||||
const created = await this.showSeasonSelectAndCreate(selectResults[0].id, selectResults[0].englishTitle || selectResults[0].title);
|
||||
return { handled: true, seasonsCreated: created };
|
||||
}
|
||||
|
||||
if (types.includes('series') && selectResults.some(result => result.dataSource === 'TMDBSeriesAPI')) {
|
||||
const seriesResults = selectResults.filter(result => result.dataSource === 'TMDBSeriesAPI');
|
||||
if (seriesResults.length === 1 && types.includes('season')) {
|
||||
const created = await this.showSeasonSelectAndCreate(seriesResults[0].id, seriesResults[0].title);
|
||||
return { handled: true, seasonsCreated: created };
|
||||
}
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
private async showSeasonSelectAndCreate(seriesId: string, seriesTitle: string): Promise<boolean> {
|
||||
const tmdbSeasonAPI = this.plugin.apiManager.getApiByName('TMDBSeasonAPI') as TMDBSeasonAPI | undefined;
|
||||
if (!tmdbSeasonAPI) {
|
||||
new Notice('TMDBSeasonAPI not available.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const allSeasonsResult = await tmdbSeasonAPI.getSeasonsForSeries(seriesId);
|
||||
if (!allSeasonsResult.ok) {
|
||||
this.reportAppError(allSeasonsResult.error);
|
||||
new Notice(`Error loading seasons: ${allSeasonsResult.error.userMessage}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const allSeasons = allSeasonsResult.value;
|
||||
if (allSeasons.length === 0) {
|
||||
new Notice('No seasons found for this series.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedSeasons = await this.showSeasonSelectModal(allSeasons, seriesTitle);
|
||||
if (!selectedSeasons || selectedSeasons.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.createNotesForSelectedSeasons(selectedSeasons, allSeasons, tmdbSeasonAPI);
|
||||
new Notice(`Successfully created ${selectedSeasons.length} season ${selectedSeasons.length === 1 ? 'entry' : 'entries'}.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async showSeasonSelectModal(allSeasons: SeasonModel[], seriesTitle: string): Promise<SeasonSelectModalElement[] | undefined> {
|
||||
const modal = new MediaDbSeasonSelectModal(
|
||||
this.plugin,
|
||||
allSeasons.map(season => ({
|
||||
season_number: season.seasonNumber,
|
||||
name: season.seasonTitle || season.title,
|
||||
episode_count: season.episodes || 0,
|
||||
air_date: season.year,
|
||||
poster_path: season.image,
|
||||
})),
|
||||
true,
|
||||
seriesTitle,
|
||||
);
|
||||
|
||||
return await new Promise(resolve => {
|
||||
modal.setSubmitCb(resolve);
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
private async createNotesForSelectedSeasons(selectedSeasons: SeasonSelectModalElement[], allSeasons: SeasonModel[], tmdbSeasonAPI: TMDBSeasonAPI): Promise<void> {
|
||||
await Promise.all(
|
||||
selectedSeasons.map(async selectedSeason => {
|
||||
const seasonModel = allSeasons.find(season => season.seasonNumber === selectedSeason.season_number);
|
||||
if (seasonModel) {
|
||||
const fullMetadataResult = await tmdbSeasonAPI.getById(seasonModel.id);
|
||||
if (!fullMetadataResult.ok) {
|
||||
this.reportAppError(fullMetadataResult.error);
|
||||
new Notice(`Failed to load season ${selectedSeason.season_number}: ${fullMetadataResult.error.userMessage}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.plugin.fileHelper.createMediaDbNotes([fullMetadataResult.value]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
325
packages/obsidian/src/utils/MediaDbFileHelper.ts
Normal file
325
packages/obsidian/src/utils/MediaDbFileHelper.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import type { TFile } from 'obsidian';
|
||||
import { TFolder } from 'obsidian';
|
||||
import { Notice, normalizePath, parseYaml, requestUrl, stringifyYaml } from 'obsidian';
|
||||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { ConfirmOverwriteModal } from 'packages/obsidian/src/modals/ConfirmOverwriteModal';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { Logger } from 'packages/obsidian/src/utils/Logger';
|
||||
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Result } from 'packages/obsidian/src/utils/result';
|
||||
import { err, ok } from 'packages/obsidian/src/utils/result';
|
||||
import type { CreateNoteOptions } from 'packages/obsidian/src/utils/Utils';
|
||||
import { hasTemplaterPlugin, replaceIllegalFileNameCharactersInString, useTemplaterPluginInFile } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
export type Metadata = Record<string, unknown>;
|
||||
|
||||
export interface MediaTypeModelObj {
|
||||
id: string;
|
||||
type: MediaType;
|
||||
dataSource: string;
|
||||
}
|
||||
|
||||
export class MediaDbFileHelper {
|
||||
readonly plugin: MediaDbPlugin;
|
||||
private readonly frontMatterRegExpPattern = '^(---)\\n[\\s\\S]*?\\n---';
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
async createMediaDbNotes(models: MediaTypeModel[], attachFile?: TFile): Promise<Result<void, AppError>> {
|
||||
const results = await Promise.all(models.map(model => this.createMediaDbNoteFromModel(model, { attachTemplate: true, attachFile })));
|
||||
|
||||
const failures = results.filter(result => !result.ok);
|
||||
if (failures.length > 0) {
|
||||
Logger.warn(
|
||||
'MDB | Some notes failed to create:',
|
||||
failures.map(result => result.error),
|
||||
);
|
||||
new Notice(`${models.length - failures.length} of ${models.length} notes created successfully.`);
|
||||
return err(failures[0].error);
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<Result<void, AppError>> {
|
||||
Logger.debug('MDB | creating new note');
|
||||
|
||||
options.openNote = this.plugin.settings.openNoteInNewTab;
|
||||
|
||||
if (this.plugin.settings.imageDownload) {
|
||||
const imageResult = await this.downloadImageForMediaModel(mediaTypeModel);
|
||||
if (!imageResult.ok) {
|
||||
return imageResult;
|
||||
}
|
||||
}
|
||||
|
||||
const fileContentResult = await this.attempt(() => this.generateMediaDbNoteContents(mediaTypeModel, options), {
|
||||
kind: AppErrorKind.Unexpected,
|
||||
message: 'Failed to generate note contents',
|
||||
userMessage: 'Failed to generate note contents',
|
||||
});
|
||||
if (!fileContentResult.ok) {
|
||||
return fileContentResult;
|
||||
}
|
||||
|
||||
const folderResult = await this.attempt(() => this.plugin.mediaTypeManager.getFolder(mediaTypeModel, this.plugin.app), {
|
||||
kind: AppErrorKind.Vault,
|
||||
message: 'Failed to determine note folder',
|
||||
userMessage: 'Failed to determine note folder',
|
||||
});
|
||||
if (!folderResult.ok) {
|
||||
return folderResult;
|
||||
}
|
||||
|
||||
options.folder ??= folderResult.value;
|
||||
|
||||
const targetFileResult = await this.createNote(this.plugin.mediaTypeManager.getFileName(mediaTypeModel), fileContentResult.value, options);
|
||||
if (!targetFileResult.ok) {
|
||||
return targetFileResult;
|
||||
}
|
||||
|
||||
if (this.plugin.settings.enableTemplaterIntegration) {
|
||||
const templaterResult = await this.attempt(() => useTemplaterPluginInFile(this.plugin.app, targetFileResult.value), {
|
||||
kind: AppErrorKind.Unexpected,
|
||||
message: 'Failed to apply templater to the note',
|
||||
userMessage: 'Failed to apply templater to the note',
|
||||
});
|
||||
if (!templaterResult.ok) {
|
||||
return templaterResult;
|
||||
}
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async updateActiveNote(onlyMetadata: boolean = false): Promise<void> {
|
||||
const activeFile = this.plugin.app.workspace.getActiveFile() ?? undefined;
|
||||
if (!activeFile) {
|
||||
throw new Error('MDB | there is no active note');
|
||||
}
|
||||
|
||||
let metadata = this.getMetadataFromFileCache(activeFile);
|
||||
metadata = this.plugin.modelPropertyMapper.convertObjectBack(metadata);
|
||||
|
||||
Logger.debug(`MDB | read metadata`, metadata);
|
||||
|
||||
if (!metadata?.type || !metadata?.dataSource || !metadata?.id) {
|
||||
throw new Error('MDB | active note is not a Media DB entry or is missing metadata');
|
||||
}
|
||||
|
||||
const validOldMetadata: MediaTypeModelObj = metadata as unknown as MediaTypeModelObj;
|
||||
Logger.debug(`MDB | validOldMetadata`, validOldMetadata);
|
||||
|
||||
const oldMediaTypeModel = this.plugin.mediaTypeManager.createMediaTypeModelFromMediaType(validOldMetadata, validOldMetadata.type);
|
||||
Logger.debug(`MDB | oldMediaTypeModel created`, oldMediaTypeModel);
|
||||
|
||||
const newMediaTypeModelResult = await this.plugin.apiManager.queryDetailedInfoById(validOldMetadata.id, validOldMetadata.dataSource);
|
||||
if (!newMediaTypeModelResult.ok) {
|
||||
this.plugin.errorReporter.report(newMediaTypeModelResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
let newMediaTypeModel = newMediaTypeModelResult.value;
|
||||
if (!newMediaTypeModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
newMediaTypeModel = Object.assign(oldMediaTypeModel, newMediaTypeModel.getWithOutUserData());
|
||||
Logger.debug(`MDB | newMediaTypeModel after merge`, newMediaTypeModel);
|
||||
|
||||
if (onlyMetadata) {
|
||||
const result = await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachFile: activeFile, folder: activeFile.parent ?? undefined, openNote: true });
|
||||
if (!result.ok) {
|
||||
this.plugin.errorReporter.report(result.error);
|
||||
}
|
||||
} else {
|
||||
const result = await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachTemplate: true, folder: activeFile.parent ?? undefined, openNote: true });
|
||||
if (!result.ok) {
|
||||
this.plugin.errorReporter.report(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateMediaDbNoteFrontmatterPreview(mediaTypeModel: MediaTypeModel): string {
|
||||
const fileMetadata = this.plugin.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject());
|
||||
return stringifyYaml(fileMetadata);
|
||||
}
|
||||
|
||||
async generateMediaDbNoteContents(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<string> {
|
||||
let template = await this.plugin.mediaTypeManager.getTemplate(mediaTypeModel, this.plugin.app);
|
||||
let fileMetadata: Record<string, unknown>;
|
||||
|
||||
if (this.plugin.settings.useDefaultFrontMatter) {
|
||||
fileMetadata = this.plugin.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject());
|
||||
} else {
|
||||
fileMetadata = {
|
||||
id: mediaTypeModel.id,
|
||||
type: mediaTypeModel.type,
|
||||
dataSource: mediaTypeModel.dataSource,
|
||||
};
|
||||
}
|
||||
|
||||
let fileContent = '';
|
||||
template = options.attachTemplate ? template : '';
|
||||
|
||||
({ fileMetadata, fileContent } = await this.attachFile(fileMetadata, fileContent, options.attachFile));
|
||||
({ fileMetadata, fileContent } = await this.attachTemplate(fileMetadata, fileContent, template));
|
||||
|
||||
if (this.plugin.settings.enableTemplaterIntegration && hasTemplaterPlugin(this.plugin.app)) {
|
||||
fileContent = `---\n<%* const media = ${JSON.stringify(mediaTypeModel)} %>\n${stringifyYaml(fileMetadata)}---\n${fileContent}`;
|
||||
} else {
|
||||
fileContent = `---\n${stringifyYaml(fileMetadata)}---\n${fileContent}`;
|
||||
}
|
||||
|
||||
return fileContent;
|
||||
}
|
||||
|
||||
async attachFile(fileMetadata: Metadata, fileContent: string, fileToAttach?: TFile): Promise<{ fileMetadata: Metadata; fileContent: string }> {
|
||||
if (!fileToAttach) {
|
||||
return { fileMetadata, fileContent };
|
||||
}
|
||||
|
||||
const attachFileMetadata = this.getMetadataFromFileCache(fileToAttach);
|
||||
fileMetadata = { ...attachFileMetadata, ...fileMetadata };
|
||||
|
||||
let attachFileContent = await this.plugin.app.vault.read(fileToAttach);
|
||||
const regExp = new RegExp(this.frontMatterRegExpPattern);
|
||||
attachFileContent = attachFileContent.replace(regExp, '');
|
||||
attachFileContent = attachFileContent.startsWith('\n') ? attachFileContent.substring(1) : attachFileContent;
|
||||
fileContent += attachFileContent;
|
||||
|
||||
return { fileMetadata, fileContent };
|
||||
}
|
||||
|
||||
async attachTemplate(fileMetadata: Metadata, fileContent: string, template: string | undefined): Promise<{ fileMetadata: Metadata; fileContent: string }> {
|
||||
if (!template) {
|
||||
return { fileMetadata, fileContent };
|
||||
}
|
||||
|
||||
const templateMetadata = this.getMetaDataFromFileContent(template);
|
||||
fileMetadata = { ...templateMetadata, ...fileMetadata };
|
||||
|
||||
const regExp = new RegExp(this.frontMatterRegExpPattern);
|
||||
const attachFileContent = template.replace(regExp, '');
|
||||
fileContent += attachFileContent;
|
||||
|
||||
return { fileMetadata, fileContent };
|
||||
}
|
||||
|
||||
getMetaDataFromFileContent(fileContent: string): Metadata {
|
||||
const regExp = new RegExp(this.frontMatterRegExpPattern);
|
||||
const frontMatterRegExpResult = regExp.exec(fileContent);
|
||||
if (!frontMatterRegExpResult) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let frontMatter = frontMatterRegExpResult[0];
|
||||
if (!frontMatter) {
|
||||
return {};
|
||||
}
|
||||
|
||||
frontMatter = frontMatter.substring(4);
|
||||
frontMatter = frontMatter.substring(0, frontMatter.length - 3);
|
||||
|
||||
let metadata = parseYaml(frontMatter) as Metadata;
|
||||
if (!metadata) {
|
||||
metadata = {};
|
||||
}
|
||||
|
||||
Logger.debug(`MDB | metadata read from file content`, metadata);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
getMetadataFromFileCache(file: TFile): Metadata {
|
||||
const metadata: Metadata | undefined = this.plugin.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
return structuredClone(metadata ?? {});
|
||||
}
|
||||
|
||||
async createNote(fileName: string, fileContent: string, options: CreateNoteOptions): Promise<Result<TFile, AppError>> {
|
||||
const folder = options.folder ?? this.plugin.app.vault.getAbstractFileByPath('/');
|
||||
|
||||
if (!folder || !(folder instanceof TFolder)) {
|
||||
return err({ kind: AppErrorKind.Validation, message: 'MDB | invalid folder', userMessage: 'MDB | invalid folder' });
|
||||
}
|
||||
|
||||
fileName = replaceIllegalFileNameCharactersInString(fileName);
|
||||
const filePath = `${folder.path}/${fileName}.md`;
|
||||
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(filePath);
|
||||
if (file) {
|
||||
const shouldOverwrite = await new Promise<boolean>(resolve => {
|
||||
new ConfirmOverwriteModal(this.plugin.app, fileName, resolve).open();
|
||||
});
|
||||
|
||||
if (!shouldOverwrite) {
|
||||
return err({ kind: AppErrorKind.Cancelled, message: 'MDB | file creation cancelled by user', userMessage: 'MDB | file creation cancelled by user' });
|
||||
}
|
||||
|
||||
await this.plugin.app.fileManager.trashFile(file);
|
||||
}
|
||||
|
||||
const targetFile = await this.plugin.app.vault.create(filePath, fileContent);
|
||||
Logger.debug(`MDB | created new file at ${filePath}`);
|
||||
|
||||
if (options.openNote) {
|
||||
const activeLeaf = this.plugin.app.workspace.getLeaf(false);
|
||||
if (!activeLeaf) {
|
||||
Logger.warn('MDB | no active leaf, not opening newly created note');
|
||||
return ok(targetFile);
|
||||
}
|
||||
await activeLeaf.openFile(targetFile, { state: { mode: 'source' } });
|
||||
}
|
||||
|
||||
return ok(targetFile);
|
||||
}
|
||||
|
||||
private async downloadImageForMediaModel(mediaTypeModel: MediaTypeModel): Promise<Result<void, AppError>> {
|
||||
if (mediaTypeModel.image && typeof mediaTypeModel.image === 'string' && mediaTypeModel.image.startsWith('http')) {
|
||||
const imageUrl = mediaTypeModel.image;
|
||||
const imageResult = await this.attempt(
|
||||
async () => {
|
||||
const imageExt = imageUrl.split('.').pop()?.split(/#|\?/)[0] ?? 'jpg';
|
||||
const imageFileName = `${replaceIllegalFileNameCharactersInString(`${mediaTypeModel.type}_${mediaTypeModel.title} (${mediaTypeModel.year})`)}.${imageExt}`;
|
||||
const imagePath = normalizePath(`${this.plugin.settings.imageFolder}/${imageFileName}`);
|
||||
|
||||
if (!this.plugin.app.vault.getAbstractFileByPath(this.plugin.settings.imageFolder)) {
|
||||
await this.plugin.app.vault.createFolder(this.plugin.settings.imageFolder);
|
||||
}
|
||||
|
||||
if (!this.plugin.app.vault.getAbstractFileByPath(imagePath)) {
|
||||
const response = await requestUrl({ url: imageUrl, method: 'GET' });
|
||||
await this.plugin.app.vault.createBinary(imagePath, response.arrayBuffer);
|
||||
}
|
||||
|
||||
mediaTypeModel.image = `[[${imagePath}]]`;
|
||||
},
|
||||
{
|
||||
kind: AppErrorKind.Network,
|
||||
message: 'MDB | Failed to download image',
|
||||
userMessage: 'Failed to download image',
|
||||
},
|
||||
);
|
||||
|
||||
if (!imageResult.ok) {
|
||||
Logger.warn('MDB | Failed to download image:', imageResult.error);
|
||||
return imageResult;
|
||||
}
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
private async attempt<T>(operation: () => Promise<T> | T, fallback: Omit<AppError, 'cause'>): Promise<Result<T, AppError>> {
|
||||
return await Promise.resolve()
|
||||
.then(operation)
|
||||
.then(
|
||||
value => ok(value),
|
||||
cause => err(toAppError(cause, fallback)),
|
||||
);
|
||||
}
|
||||
}
|
||||
11
packages/obsidian/src/utils/MediaType.ts
Normal file
11
packages/obsidian/src/utils/MediaType.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export enum MediaType {
|
||||
Movie = 'movie',
|
||||
Series = 'series',
|
||||
Season = 'season',
|
||||
ComicManga = 'comicManga',
|
||||
Game = 'game',
|
||||
MusicRelease = 'musicRelease',
|
||||
Wiki = 'wiki',
|
||||
BoardGame = 'boardgame',
|
||||
Book = 'book',
|
||||
}
|
||||
166
packages/obsidian/src/utils/MediaTypeManager.ts
Normal file
166
packages/obsidian/src/utils/MediaTypeManager.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import type { App } from 'obsidian';
|
||||
import { TFile } from 'obsidian';
|
||||
import { TFolder } from 'obsidian';
|
||||
import { BoardGameModel } from 'packages/obsidian/src/models/BoardGameModel';
|
||||
import { BookModel } from 'packages/obsidian/src/models/BookModel';
|
||||
import { ComicMangaModel } from 'packages/obsidian/src/models/ComicMangaModel';
|
||||
import { GameModel } from 'packages/obsidian/src/models/GameModel';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import { MovieModel } from 'packages/obsidian/src/models/MovieModel';
|
||||
import { MusicReleaseModel } from 'packages/obsidian/src/models/MusicReleaseModel';
|
||||
import { SeasonModel } from 'packages/obsidian/src/models/SeasonModel';
|
||||
import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel';
|
||||
import { WikiModel } from 'packages/obsidian/src/models/WikiModel';
|
||||
import type { MediaDbPluginSettings } from 'packages/obsidian/src/settings/Settings';
|
||||
import { ILLEGAL_FILENAME_CHARACTERS } from 'packages/obsidian/src/utils/IllegalFilenameCharactersList';
|
||||
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import { replaceTags } from 'packages/obsidian/src/utils/Utils';
|
||||
|
||||
// All media types in alphabetical order
|
||||
export const MEDIA_TYPES: MediaType[] = [
|
||||
MediaType.BoardGame,
|
||||
MediaType.Book,
|
||||
MediaType.ComicManga,
|
||||
MediaType.Game,
|
||||
MediaType.Movie,
|
||||
MediaType.MusicRelease,
|
||||
MediaType.Series,
|
||||
MediaType.Season,
|
||||
MediaType.Wiki,
|
||||
];
|
||||
|
||||
export class MediaTypeManager {
|
||||
mediaFileNameTemplateMap: Map<MediaType, string>;
|
||||
mediaTemplateMap: Map<MediaType, string>;
|
||||
mediaFolderMap: Map<MediaType, string>;
|
||||
|
||||
constructor() {
|
||||
this.mediaFileNameTemplateMap = new Map<MediaType, string>();
|
||||
this.mediaTemplateMap = new Map<MediaType, string>();
|
||||
this.mediaFolderMap = new Map<MediaType, string>();
|
||||
}
|
||||
|
||||
updateTemplates(settings: MediaDbPluginSettings): void {
|
||||
this.mediaFileNameTemplateMap = new Map<MediaType, string>();
|
||||
this.mediaFileNameTemplateMap.set(MediaType.Movie, settings.movieFileNameTemplate);
|
||||
this.mediaFileNameTemplateMap.set(MediaType.Series, settings.seriesFileNameTemplate);
|
||||
this.mediaFileNameTemplateMap.set(MediaType.Season, settings.seasonFileNameTemplate);
|
||||
this.mediaFileNameTemplateMap.set(MediaType.ComicManga, settings.mangaFileNameTemplate);
|
||||
this.mediaFileNameTemplateMap.set(MediaType.Game, settings.gameFileNameTemplate);
|
||||
this.mediaFileNameTemplateMap.set(MediaType.Wiki, settings.wikiFileNameTemplate);
|
||||
this.mediaFileNameTemplateMap.set(MediaType.MusicRelease, settings.musicReleaseFileNameTemplate);
|
||||
this.mediaFileNameTemplateMap.set(MediaType.BoardGame, settings.boardgameFileNameTemplate);
|
||||
this.mediaFileNameTemplateMap.set(MediaType.Book, settings.bookFileNameTemplate);
|
||||
|
||||
this.mediaTemplateMap = new Map<MediaType, string>();
|
||||
this.mediaTemplateMap.set(MediaType.Movie, settings.movieTemplate);
|
||||
this.mediaTemplateMap.set(MediaType.Series, settings.seriesTemplate);
|
||||
this.mediaTemplateMap.set(MediaType.Season, settings.seasonTemplate);
|
||||
this.mediaTemplateMap.set(MediaType.ComicManga, settings.mangaTemplate);
|
||||
this.mediaTemplateMap.set(MediaType.Game, settings.gameTemplate);
|
||||
this.mediaTemplateMap.set(MediaType.Wiki, settings.wikiTemplate);
|
||||
this.mediaTemplateMap.set(MediaType.MusicRelease, settings.musicReleaseTemplate);
|
||||
this.mediaTemplateMap.set(MediaType.BoardGame, settings.boardgameTemplate);
|
||||
this.mediaTemplateMap.set(MediaType.Book, settings.bookTemplate);
|
||||
}
|
||||
|
||||
updateFolders(settings: MediaDbPluginSettings): void {
|
||||
this.mediaFolderMap = new Map<MediaType, string>();
|
||||
this.mediaFolderMap.set(MediaType.Movie, settings.movieFolder);
|
||||
this.mediaFolderMap.set(MediaType.Series, settings.seriesFolder);
|
||||
this.mediaFolderMap.set(MediaType.Season, settings.seasonFolder);
|
||||
this.mediaFolderMap.set(MediaType.ComicManga, settings.mangaFolder);
|
||||
this.mediaFolderMap.set(MediaType.Game, settings.gameFolder);
|
||||
this.mediaFolderMap.set(MediaType.Wiki, settings.wikiFolder);
|
||||
this.mediaFolderMap.set(MediaType.MusicRelease, settings.musicReleaseFolder);
|
||||
this.mediaFolderMap.set(MediaType.BoardGame, settings.boardgameFolder);
|
||||
this.mediaFolderMap.set(MediaType.Book, settings.bookFolder);
|
||||
}
|
||||
|
||||
getFileName(mediaTypeModel: MediaTypeModel): string {
|
||||
// Ignore undefined tags since some search APIs do not return all properties in the model and produce clean file names even if errors occur
|
||||
const fileName = replaceTags(this.mediaFileNameTemplateMap.get(mediaTypeModel.getMediaType())!, mediaTypeModel, true);
|
||||
return this.cleanFileName(fileName);
|
||||
}
|
||||
|
||||
cleanFileName(fileName: string): string {
|
||||
const cleanedFileName = ILLEGAL_FILENAME_CHARACTERS.reduce((str, char) => str.replaceAll(char[0], char[1]), fileName);
|
||||
// Remove all duplicate whitespace in the file name
|
||||
return cleanedFileName.replaceAll(/ +/g, ' ');
|
||||
}
|
||||
|
||||
async getTemplate(mediaTypeModel: MediaTypeModel, app: App): Promise<string> {
|
||||
const templateFilePath = this.mediaTemplateMap.get(mediaTypeModel.getMediaType());
|
||||
|
||||
if (!templateFilePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let templateFile = app.vault.getAbstractFileByPath(templateFilePath) ?? undefined;
|
||||
|
||||
// WARNING: This was previously selected by filename, but that could lead to collisions and unwanted effects.
|
||||
// This now falls back to the previous method if no file is found
|
||||
if (!templateFile || templateFile instanceof TFolder) {
|
||||
templateFile = app.vault
|
||||
.getFiles()
|
||||
.filter((f: TFile) => f.name === templateFilePath)
|
||||
.first();
|
||||
|
||||
if (!templateFile) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!(templateFile instanceof TFile)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const template = await app.vault.cachedRead(templateFile);
|
||||
return replaceTags(template, mediaTypeModel);
|
||||
}
|
||||
|
||||
async getFolder(mediaTypeModel: MediaTypeModel, app: App): Promise<TFolder> {
|
||||
let folderPath = this.mediaFolderMap.get(mediaTypeModel.getMediaType()) ?? '/';
|
||||
|
||||
if (!(await app.vault.adapter.exists(folderPath))) {
|
||||
await app.vault.createFolder(folderPath);
|
||||
}
|
||||
const folder = app.vault.getAbstractFileByPath(folderPath);
|
||||
|
||||
if (!(folder instanceof TFolder)) {
|
||||
throw Error(`Expected ${folder?.path} to be instance of TFolder`);
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an object and a MediaType and turns the object into an instance of a MediaTypeModel corresponding to the MediaType passed in.
|
||||
*
|
||||
* @param obj
|
||||
* @param mediaType
|
||||
*/
|
||||
createMediaTypeModelFromMediaType(obj: object, mediaType: MediaType): MediaTypeModel {
|
||||
if (mediaType === MediaType.Movie) {
|
||||
return new MovieModel(obj);
|
||||
} else if (mediaType === MediaType.Series) {
|
||||
return new SeriesModel(obj);
|
||||
} else if (mediaType === MediaType.Season) {
|
||||
return new SeasonModel(obj);
|
||||
} else if (mediaType === MediaType.ComicManga) {
|
||||
return new ComicMangaModel(obj);
|
||||
} else if (mediaType === MediaType.Game) {
|
||||
return new GameModel(obj);
|
||||
} else if (mediaType === MediaType.Wiki) {
|
||||
return new WikiModel(obj);
|
||||
} else if (mediaType === MediaType.MusicRelease) {
|
||||
return new MusicReleaseModel(obj);
|
||||
} else if (mediaType === MediaType.BoardGame) {
|
||||
return new BoardGameModel(obj);
|
||||
} else if (mediaType === MediaType.Book) {
|
||||
return new BookModel(obj);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown media type: ${mediaType}`);
|
||||
}
|
||||
}
|
||||
282
packages/obsidian/src/utils/ModalHelper.ts
Normal file
282
packages/obsidian/src/utils/ModalHelper.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import type MediaDbPlugin from 'packages/obsidian/src/main';
|
||||
import { MediaDbAdvancedSearchModal } from 'packages/obsidian/src/modals/MediaDbAdvancedSearchModal';
|
||||
import { MediaDbIdSearchModal } from 'packages/obsidian/src/modals/MediaDbIdSearchModal';
|
||||
import { MediaDbPreviewModal } from 'packages/obsidian/src/modals/MediaDbPreviewModal';
|
||||
import { MediaDbSearchModal } from 'packages/obsidian/src/modals/MediaDbSearchModal';
|
||||
import { MediaDbSearchResultModal } from 'packages/obsidian/src/modals/MediaDbSearchResultModal';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
import type { AppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
|
||||
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
|
||||
import type { Outcome } from 'packages/obsidian/src/utils/result';
|
||||
import { cancelled, failure, OutcomeStatus, skipped, success } from 'packages/obsidian/src/utils/result';
|
||||
|
||||
export interface SearchModalData {
|
||||
query: string;
|
||||
types: MediaType[];
|
||||
}
|
||||
|
||||
export interface AdvancedSearchModalData {
|
||||
query: string;
|
||||
apis: string[];
|
||||
}
|
||||
|
||||
export interface IdSearchModalData {
|
||||
query: string;
|
||||
api: string;
|
||||
}
|
||||
|
||||
export interface SelectModalData {
|
||||
selected: MediaTypeModel[];
|
||||
}
|
||||
|
||||
export interface PreviewModalData {
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
export interface SearchModalOptions {
|
||||
modalTitle?: string;
|
||||
preselectedTypes?: MediaType[];
|
||||
prefilledSearchString?: string;
|
||||
}
|
||||
|
||||
export interface AdvancedSearchModalOptions {
|
||||
modalTitle?: string;
|
||||
preselectedAPIs?: string[];
|
||||
prefilledSearchString?: string;
|
||||
}
|
||||
|
||||
export interface IdSearchModalOptions {
|
||||
modalTitle?: string;
|
||||
preselectedAPI?: string;
|
||||
prefilledSearchString?: string;
|
||||
}
|
||||
|
||||
export interface SelectModalOptions {
|
||||
elements?: MediaTypeModel[];
|
||||
multiSelect?: boolean;
|
||||
modalTitle?: string;
|
||||
skipButton?: boolean;
|
||||
description?: string;
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
export interface PreviewModalOptions {
|
||||
modalTitle?: string;
|
||||
elements?: MediaTypeModel[];
|
||||
}
|
||||
|
||||
export const SEARCH_MODAL_DEFAULT_OPTIONS: SearchModalOptions = {
|
||||
modalTitle: 'Media DB Search',
|
||||
preselectedTypes: [],
|
||||
prefilledSearchString: '',
|
||||
};
|
||||
|
||||
export const ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS: AdvancedSearchModalOptions = {
|
||||
modalTitle: 'Media DB Advanced Search',
|
||||
preselectedAPIs: [],
|
||||
prefilledSearchString: '',
|
||||
};
|
||||
|
||||
export const ID_SEARCH_MODAL_DEFAULT_OPTIONS: IdSearchModalOptions = {
|
||||
modalTitle: 'Media DB Id Search',
|
||||
preselectedAPI: undefined,
|
||||
prefilledSearchString: '',
|
||||
};
|
||||
|
||||
export const SELECT_MODAL_OPTIONS_DEFAULT: SelectModalOptions = {
|
||||
modalTitle: 'Media DB Search Results',
|
||||
elements: [],
|
||||
multiSelect: true,
|
||||
skipButton: false,
|
||||
};
|
||||
|
||||
export const PREVIEW_MODAL_DEFAULT_OPTIONS: PreviewModalOptions = {
|
||||
modalTitle: 'Media DB Preview',
|
||||
elements: [],
|
||||
};
|
||||
|
||||
export const SELECTMODALOPTIONSDEFAULT: SelectModalOptions = {
|
||||
elements: [],
|
||||
multiSelect: true,
|
||||
modalTitle: '',
|
||||
skipButton: false,
|
||||
description: 'Select one or multiple search results.',
|
||||
submitButtonText: 'Ok',
|
||||
};
|
||||
|
||||
interface ModalCoreResult<T, TModal> {
|
||||
modalResult: Outcome<T, AppError>;
|
||||
modal: TModal;
|
||||
}
|
||||
|
||||
export class ModalHelper {
|
||||
plugin: MediaDbPlugin;
|
||||
|
||||
constructor(plugin: MediaDbPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
private async openModalCore<TData, TModal extends { open(): void; close(): void }>(
|
||||
createModal: () => TModal,
|
||||
wireHandlers: (modal: TModal, resolve: (result: Outcome<TData, AppError>) => void) => void,
|
||||
): Promise<ModalCoreResult<TData, TModal>> {
|
||||
const modal = createModal();
|
||||
const modalResult = await new Promise<Outcome<TData, AppError>>(resolve => {
|
||||
wireHandlers(modal, resolve);
|
||||
modal.open();
|
||||
});
|
||||
|
||||
return { modalResult, modal };
|
||||
}
|
||||
|
||||
private async resolveOutcome<T>(outcomePromise: Promise<Outcome<T, AppError>>): Promise<T | undefined> {
|
||||
const outcome = await outcomePromise;
|
||||
|
||||
if (outcome.status === OutcomeStatus.Ok) {
|
||||
return outcome.data;
|
||||
}
|
||||
|
||||
if (outcome.status === OutcomeStatus.Error) {
|
||||
this.plugin.errorReporter.report(outcome.error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async createSearchModalOutcome(searchModalOptions: SearchModalOptions): Promise<Outcome<SearchModalData, AppError>> {
|
||||
const { modalResult, modal } = await this.openModalCore<SearchModalData, MediaDbSearchModal>(
|
||||
() => new MediaDbSearchModal(this.plugin, searchModalOptions),
|
||||
(modal, resolve) => {
|
||||
modal.setSubmitCb(res => resolve(success(res)));
|
||||
modal.setCloseCb(err => {
|
||||
if (err) {
|
||||
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Search modal closed with an error' })));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(cancelled());
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (modalResult.status === OutcomeStatus.Ok) {
|
||||
modal.close();
|
||||
}
|
||||
|
||||
return modalResult;
|
||||
}
|
||||
|
||||
async promptSearchModal(searchModalOptions: SearchModalOptions): Promise<SearchModalData | undefined> {
|
||||
return await this.resolveOutcome(this.createSearchModalOutcome(searchModalOptions));
|
||||
}
|
||||
|
||||
async createAdvancedSearchModalOutcome(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise<Outcome<AdvancedSearchModalData, AppError>> {
|
||||
const { modalResult, modal } = await this.openModalCore<AdvancedSearchModalData, MediaDbAdvancedSearchModal>(
|
||||
() => new MediaDbAdvancedSearchModal(this.plugin, advancedSearchModalOptions),
|
||||
(modal, resolve) => {
|
||||
modal.setSubmitCb(res => resolve(success(res)));
|
||||
modal.setCloseCb(err => {
|
||||
if (err) {
|
||||
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Advanced search modal closed with an error' })));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(cancelled());
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (modalResult.status === OutcomeStatus.Ok) {
|
||||
modal.close();
|
||||
}
|
||||
|
||||
return modalResult;
|
||||
}
|
||||
|
||||
async promptAdvancedSearchModal(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise<AdvancedSearchModalData | undefined> {
|
||||
return await this.resolveOutcome(this.createAdvancedSearchModalOutcome(advancedSearchModalOptions));
|
||||
}
|
||||
|
||||
async createIdSearchModalOutcome(idSearchModalOptions: IdSearchModalOptions): Promise<Outcome<IdSearchModalData, AppError>> {
|
||||
const { modalResult, modal } = await this.openModalCore<IdSearchModalData, MediaDbIdSearchModal>(
|
||||
() => new MediaDbIdSearchModal(this.plugin, idSearchModalOptions),
|
||||
(modal, resolve) => {
|
||||
modal.setSubmitCb(res => resolve(success(res)));
|
||||
modal.setCloseCb(err => {
|
||||
if (err) {
|
||||
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Id search modal closed with an error' })));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(cancelled());
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (modalResult.status === OutcomeStatus.Ok) {
|
||||
modal.close();
|
||||
}
|
||||
|
||||
return modalResult;
|
||||
}
|
||||
|
||||
async promptIdSearchModal(idSearchModalOptions: IdSearchModalOptions): Promise<IdSearchModalData | undefined> {
|
||||
return await this.resolveOutcome(this.createIdSearchModalOutcome(idSearchModalOptions));
|
||||
}
|
||||
|
||||
async createSelectModalOutcome(selectModalOptions: SelectModalOptions): Promise<Outcome<SelectModalData, AppError>> {
|
||||
const { modalResult, modal } = await this.openModalCore<SelectModalData, MediaDbSearchResultModal>(
|
||||
() => new MediaDbSearchResultModal(this.plugin, selectModalOptions),
|
||||
(modal, resolve) => {
|
||||
modal.setSubmitCb(res => resolve(success(res)));
|
||||
modal.setSkipCallback(() => resolve(skipped()));
|
||||
modal.setCloseCb(err => {
|
||||
if (err) {
|
||||
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Select modal closed with an error' })));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(cancelled());
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (modalResult.status === OutcomeStatus.Ok || modalResult.status === OutcomeStatus.Skipped) {
|
||||
modal.close();
|
||||
}
|
||||
|
||||
return modalResult;
|
||||
}
|
||||
|
||||
async promptSelectModal(selectModalOptions: SelectModalOptions): Promise<SelectModalData | undefined> {
|
||||
return await this.resolveOutcome(this.createSelectModalOutcome(selectModalOptions));
|
||||
}
|
||||
|
||||
async createPreviewModalOutcome(previewModalOptions: PreviewModalOptions): Promise<Outcome<PreviewModalData, AppError>> {
|
||||
const { modalResult, modal } = await this.openModalCore<PreviewModalData, MediaDbPreviewModal>(
|
||||
() => new MediaDbPreviewModal(this.plugin, previewModalOptions),
|
||||
(modal, resolve) => {
|
||||
modal.setSubmitCb(res => resolve(success(res)));
|
||||
modal.setCloseCb(err => {
|
||||
if (err) {
|
||||
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Preview modal closed with an error' })));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(cancelled());
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (modalResult.status === OutcomeStatus.Ok) {
|
||||
modal.close();
|
||||
}
|
||||
|
||||
return modalResult;
|
||||
}
|
||||
|
||||
async promptPreviewModal(previewModalOptions: PreviewModalOptions): Promise<PreviewModalData | undefined> {
|
||||
return await this.resolveOutcome(this.createPreviewModalOutcome(previewModalOptions));
|
||||
}
|
||||
}
|
||||
315
packages/obsidian/src/utils/Utils.ts
Normal file
315
packages/obsidian/src/utils/Utils.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { iso6392 } from 'iso-639-2';
|
||||
import type { TFile, TFolder, App } from 'obsidian';
|
||||
import { requestUrl } from 'obsidian';
|
||||
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
|
||||
|
||||
export const pluginName: string = 'obsidian-media-db-plugin';
|
||||
export const contactEmail: string = 'm.projects.code@gmail.com';
|
||||
export const mediaDbTag: string = 'mediaDB';
|
||||
export const mediaDbVersion: string = '0.8.0';
|
||||
|
||||
export function wrapAround(value: number, size: number): number {
|
||||
if (size <= 0) {
|
||||
throw Error('size may not be zero or negative');
|
||||
}
|
||||
return mod(value, size);
|
||||
}
|
||||
|
||||
export function containsOnlyLettersAndUnderscores(str: string): boolean {
|
||||
return /^[\p{Letter}\p{M}_]+$/u.test(str);
|
||||
}
|
||||
|
||||
export function replaceIllegalFileNameCharactersInString(string: string): string {
|
||||
return string.replace(/[\\,#%&{}/*<>$"@.?]*/g, '').replace(/:+/g, ' -');
|
||||
}
|
||||
|
||||
export function replaceTags(template: string, mediaTypeModel: MediaTypeModel, ignoreUndefined: boolean = false): string {
|
||||
return template.replace(new RegExp('{{.*?}}', 'g'), (match: string) => replaceTag(match, mediaTypeModel, ignoreUndefined));
|
||||
}
|
||||
|
||||
function replaceTag(match: string, mediaTypeModel: MediaTypeModel, ignoreUndefined: boolean): string {
|
||||
let tag = match;
|
||||
tag = tag.substring(2);
|
||||
tag = tag.substring(0, tag.length - 2);
|
||||
tag = tag.trim();
|
||||
|
||||
const parts = tag.split(':');
|
||||
if (parts.length === 1) {
|
||||
const path = parts[0].split('.');
|
||||
|
||||
const obj = traverseMetaData(path, mediaTypeModel);
|
||||
|
||||
if (obj === undefined) {
|
||||
return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
return obj?.toString() ?? 'null';
|
||||
} else if (parts.length === 2) {
|
||||
const operator = parts[0];
|
||||
|
||||
const path = parts[1].split('.');
|
||||
|
||||
const obj = traverseMetaData(path, mediaTypeModel);
|
||||
|
||||
if (obj === undefined) {
|
||||
return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}';
|
||||
}
|
||||
|
||||
if (operator === 'LIST') {
|
||||
if (!Array.isArray(obj)) {
|
||||
return '{{ INVALID TEMPLATE TAG - operator LIST is only applicable on an array }}';
|
||||
}
|
||||
|
||||
return obj.map((e: unknown) => `- ${e}`).join('\n');
|
||||
} else if (operator === 'ENUM') {
|
||||
if (!Array.isArray(obj)) {
|
||||
return '{{ INVALID TEMPLATE TAG - operator ENUM is only applicable on an array }}';
|
||||
}
|
||||
return obj.join(', ');
|
||||
} else if (operator === 'FIRST') {
|
||||
if (!Array.isArray(obj)) {
|
||||
return '{{ INVALID TEMPLATE TAG - operator FIRST is only applicable on an array }}';
|
||||
}
|
||||
|
||||
const first = obj[0] as unknown;
|
||||
return first?.toString() ?? 'null';
|
||||
} else if (operator === 'LAST') {
|
||||
if (!Array.isArray(obj)) {
|
||||
return '{{ INVALID TEMPLATE TAG - operator LAST is only applicable on an array }}';
|
||||
}
|
||||
|
||||
const last = obj[obj.length - 1] as unknown;
|
||||
return last?.toString() ?? 'null';
|
||||
}
|
||||
|
||||
return `{{ INVALID TEMPLATE TAG - unknown operator ${operator} }}`;
|
||||
}
|
||||
|
||||
return '{{ INVALID TEMPLATE TAG }}';
|
||||
}
|
||||
|
||||
function traverseMetaData(path: string[], mediaTypeModel: MediaTypeModel): unknown {
|
||||
let o: unknown = mediaTypeModel;
|
||||
|
||||
for (const part of path) {
|
||||
if (o !== undefined) {
|
||||
o = (o as Record<string, unknown>)[part];
|
||||
}
|
||||
}
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
export function markdownTable(content: string[][]): string {
|
||||
const rows = content.length;
|
||||
if (rows === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const columns = content[0].length;
|
||||
if (columns === 0) {
|
||||
return '';
|
||||
}
|
||||
for (const row of content) {
|
||||
if (row.length !== columns) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const longestStringInColumns: number[] = [];
|
||||
|
||||
for (let i = 0; i < columns; i++) {
|
||||
let longestStringInColumn = 0;
|
||||
for (const row of content) {
|
||||
if (row[i].length > longestStringInColumn) {
|
||||
longestStringInColumn = row[i].length;
|
||||
}
|
||||
}
|
||||
|
||||
longestStringInColumns.push(longestStringInColumn);
|
||||
}
|
||||
|
||||
let table = '';
|
||||
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '|';
|
||||
for (let j = 0; j < columns; j++) {
|
||||
let element = content[i][j];
|
||||
element += ' '.repeat(longestStringInColumns[j] - element.length);
|
||||
table += ' ' + element + ' |';
|
||||
}
|
||||
table += '\n';
|
||||
if (i === 0) {
|
||||
table += '|';
|
||||
for (let j = 0; j < columns; j++) {
|
||||
table += ' ' + '-'.repeat(longestStringInColumns[j]) + ' |';
|
||||
}
|
||||
table += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
export function dateToString(date: Date): string {
|
||||
return `${date.getMonth() + 1}-${date.getDate()}-${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function timeToString(time: Date): string {
|
||||
return `${time.getHours()}-${time.getMinutes()}-${time.getSeconds()}`;
|
||||
}
|
||||
|
||||
export function dateTimeToString(dateTime: Date): string {
|
||||
return `${dateToString(dateTime)} ${timeToString(dateTime)}`;
|
||||
}
|
||||
|
||||
// js can't even implement modulo correctly...
|
||||
export function mod(n: number, m: number): number {
|
||||
return ((n % m) + m) % m;
|
||||
}
|
||||
|
||||
export function capitalizeFirstLetter(string: string): string {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
export class PropertyMappingValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class PropertyMappingNameConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - attachTemplate: whether to attach the template (DEFAULT: false)
|
||||
* - attachFie: a file to attach (DEFAULT: undefined)
|
||||
* - openNote: whether to open the note after creation (DEFAULT: false)
|
||||
* - folder: folder to put the note in
|
||||
*/
|
||||
export interface CreateNoteOptions {
|
||||
attachTemplate?: boolean;
|
||||
attachFile?: TFile;
|
||||
openNote?: boolean;
|
||||
folder?: TFolder;
|
||||
}
|
||||
|
||||
export function migrateObject<T extends object>(object: T, oldData: Record<string, unknown>, defaultData: T): void {
|
||||
for (const key in object) {
|
||||
object[key] = Object.hasOwn(oldData, key) && oldData[key] !== undefined && oldData[key] !== null ? (oldData[key] as T[typeof key]) : defaultData[key];
|
||||
}
|
||||
}
|
||||
|
||||
export function unCamelCase(str: string): string {
|
||||
return (
|
||||
str
|
||||
// insert a space between lower & upper
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// space before last upper in a sequence followed by lower
|
||||
.replace(/\b([A-Z]+)([A-Z])([a-z])/, '$1 $2$3')
|
||||
// uppercase the first character
|
||||
.replace(/^./, function (str) {
|
||||
return str.toUpperCase();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
interface TemplaterPlugin {
|
||||
settings?: {
|
||||
trigger_on_file_creation?: boolean;
|
||||
};
|
||||
templater?: {
|
||||
overwrite_file_commands: (file: TFile) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
function getTemplaterPlugin(app: App): TemplaterPlugin | null {
|
||||
const pluginContainer = (app as App & { plugins?: { plugins?: Record<string, unknown> } }).plugins;
|
||||
const candidate = pluginContainer?.plugins?.['templater-obsidian'];
|
||||
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function hasTemplaterPlugin(app: App): boolean {
|
||||
const templater = getTemplaterPlugin(app);
|
||||
|
||||
return !!templater;
|
||||
}
|
||||
|
||||
// Copied from https://github.com/anpigon/obsidian-book-search-plugin
|
||||
// Licensed under the MIT license. Copyright (c) 2020 Jake Runzer
|
||||
export async function useTemplaterPluginInFile(app: App, file: TFile): Promise<void> {
|
||||
const templater = getTemplaterPlugin(app);
|
||||
if (templater && !templater.settings?.trigger_on_file_creation && templater.templater) {
|
||||
await templater.templater.overwrite_file_commands(file);
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelToData<T> = {
|
||||
[K in keyof T as T[K] extends (...args: never[]) => unknown ? never : K]?: T[K] | null;
|
||||
};
|
||||
|
||||
// Checks if a given URL points to an existing image (status 200), or returns false for 404/other errors.
|
||||
|
||||
export async function imageUrlExists(url: string): Promise<boolean> {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: 'HEAD',
|
||||
throw: false,
|
||||
});
|
||||
return response.status === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTruthy<T>(value: T): value is Exclude<T, false | 0 | '' | null | undefined> {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps Obsidians `requestUrl` in a fetch like API.
|
||||
*/
|
||||
export async function obsidianFetch(input: Request): Promise<Response> {
|
||||
const obs_headers: Record<string, string> = {};
|
||||
input.headers.forEach((value, key) => {
|
||||
obs_headers[key] = value;
|
||||
});
|
||||
|
||||
const res = await requestUrl({
|
||||
url: input.url,
|
||||
method: input.method,
|
||||
headers: obs_headers,
|
||||
throw: false, // Do not throw on error, handle it manually
|
||||
});
|
||||
|
||||
const responseHeaders: Headers = new Headers();
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
responseHeaders.append(key, value);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
headers: responseHeaders,
|
||||
// eslint-disable-next-line
|
||||
json: async () => res.json,
|
||||
text: async () => res.text,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
export function getLanguageName(code: string): string | null {
|
||||
const language = iso6392.find(lang => lang.iso6392B === code || lang.iso6392T === code);
|
||||
|
||||
return language?.name ?? null;
|
||||
}
|
||||
52
packages/obsidian/src/utils/result.ts
Normal file
52
packages/obsidian/src/utils/result.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export interface Ok<T> {
|
||||
ok: true;
|
||||
value: T;
|
||||
}
|
||||
export interface Err<E> {
|
||||
ok: false;
|
||||
error: E;
|
||||
}
|
||||
export type Result<T, E> = Ok<T> | Err<E>;
|
||||
|
||||
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
|
||||
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
|
||||
|
||||
export const isOk = <T, E>(result: Result<T, E>): result is Ok<T> => result.ok;
|
||||
export const isErr = <T, E>(result: Result<T, E>): result is Err<E> => !result.ok;
|
||||
|
||||
export const mapResult = <T, E, U>(result: Result<T, E>, mapper: (value: T) => U): Result<U, E> => (result.ok ? ok(mapper(result.value)) : result);
|
||||
|
||||
export const mapError = <T, E, F>(result: Result<T, E>, mapper: (error: E) => F): Result<T, F> => (result.ok ? result : err(mapper(result.error)));
|
||||
|
||||
export const andThen = <T, E, U>(result: Result<T, E>, binder: (value: T) => Result<U, E>): Result<U, E> => (result.ok ? binder(result.value) : result);
|
||||
|
||||
export const tapError = <T, E>(result: Result<T, E>, sideEffect: (error: E) => void): Result<T, E> => {
|
||||
if (!result.ok) sideEffect(result.error);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const fromPromise = async <T, E>(promise: Promise<T>, onError: (cause: unknown) => E): Promise<Result<T, E>> => {
|
||||
try {
|
||||
return ok(await promise);
|
||||
} catch (cause) {
|
||||
return err(onError(cause));
|
||||
}
|
||||
};
|
||||
|
||||
export enum OutcomeStatus {
|
||||
Ok = 'ok',
|
||||
Cancelled = 'cancelled',
|
||||
Skipped = 'skipped',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type Outcome<T, E> =
|
||||
| { status: OutcomeStatus.Ok; data: T }
|
||||
| { status: OutcomeStatus.Cancelled }
|
||||
| { status: OutcomeStatus.Skipped }
|
||||
| { status: OutcomeStatus.Error; error: E };
|
||||
|
||||
export const cancelled = (): Outcome<never, never> => ({ status: OutcomeStatus.Cancelled });
|
||||
export const skipped = (): Outcome<never, never> => ({ status: OutcomeStatus.Skipped });
|
||||
export const success = <T>(data: T): Outcome<T, never> => ({ status: OutcomeStatus.Ok, data });
|
||||
export const failure = <E>(error: E): Outcome<never, E> => ({ status: OutcomeStatus.Error, error });
|
||||
11226
packages/schemas/src/GiantBomb.json
Normal file
11226
packages/schemas/src/GiantBomb.json
Normal file
File diff suppressed because it is too large
Load diff
6454
packages/schemas/src/GiantBomb.ts
Normal file
6454
packages/schemas/src/GiantBomb.ts
Normal file
File diff suppressed because it is too large
Load diff
6761
packages/schemas/src/MALAPI.ts
Normal file
6761
packages/schemas/src/MALAPI.ts
Normal file
File diff suppressed because it is too large
Load diff
602
packages/schemas/src/OpenLibrary.json
Normal file
602
packages/schemas/src/OpenLibrary.json
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
},
|
||||
"title": "Detail",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"title": "HTTPValidationError",
|
||||
"type": "object"
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"loc": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Location",
|
||||
"type": "array"
|
||||
},
|
||||
"msg": {
|
||||
"title": "Message",
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"title": "Error Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["loc", "msg", "type"],
|
||||
"title": "ValidationError",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"description": "- These are still in development and may not be perfect\n- Contribute by proposing edits to [openapi.json](https://github.com/internetarchive/openlibrary/blob/master/static/openapi.json)\n- Please do not use our APIs for bulk downloads, see [dev center](https://openlibrary.org/developers/api)",
|
||||
"title": "Open Library API",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"openapi": "3.0.2",
|
||||
"paths": {
|
||||
"/api/books": {
|
||||
"get": {
|
||||
"operationId": "read_api_books_api_books_get",
|
||||
"parameters": [
|
||||
{
|
||||
"examples": {
|
||||
"isbn": {
|
||||
"value": "ISBN:0201558025"
|
||||
},
|
||||
"multiple": {
|
||||
"value": "ISBN:9781408113479,OCLC:420517"
|
||||
},
|
||||
"oclc": {
|
||||
"value": "OCLC:263296519"
|
||||
}
|
||||
},
|
||||
"in": "query",
|
||||
"name": "bibkeys",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Bibkeys",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Specifies the response format. Possible values are json and javascript. When not specified the format is javascript.",
|
||||
"in": "query",
|
||||
"name": "format",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "json",
|
||||
"title": "Format",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "The name of the JavaScript function to call with the result. This is considered only when the format is javascript.",
|
||||
"in": "query",
|
||||
"name": "callback",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"title": "Callback"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Decides what information to provide for each matched bib_key. Possible values are viewapi and data. The default value is viewapi.",
|
||||
"in": "query",
|
||||
"name": "jscmd",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "viewapi",
|
||||
"title": "Jscmd",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Api Books",
|
||||
"tags": ["books"]
|
||||
}
|
||||
},
|
||||
"/api/volumes/brief/{key_type}/{value}.json": {
|
||||
"get": {
|
||||
"operationId": "read_api_volumes_brief_api_volumes_brief__key_type___value__json_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "key_type",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Key Type"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "value",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "callback",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"title": "Callback"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Api Volumes Brief",
|
||||
"tags": ["books"]
|
||||
}
|
||||
},
|
||||
"/authors/{olid}.json": {
|
||||
"get": {
|
||||
"operationId": "read_authors_authors__olid__json_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "olid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Olid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Authors",
|
||||
"tags": ["authors"]
|
||||
}
|
||||
},
|
||||
"/authors/{olid}/works.json": {
|
||||
"get": {
|
||||
"operationId": "read_authors_works_authors__olid__works_json_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "olid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Olid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"title": "Limit",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Authors Works",
|
||||
"tags": ["authors"]
|
||||
}
|
||||
},
|
||||
"/books/{olid}": {
|
||||
"get": {
|
||||
"operationId": "read_books_books__olid__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "olid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"example": "OL53924W",
|
||||
"title": "Olid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Books",
|
||||
"tags": ["books"]
|
||||
}
|
||||
},
|
||||
"/covers/{key_type}/{value}-{size}.jpg": {
|
||||
"get": {
|
||||
"operationId": "read_covers_key_type_value_size_jpeg_covers__key_type___value___size__jpg_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "key_type",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Key Type"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "value",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "size",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Size"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Covers Key Type Value Size Jpeg",
|
||||
"tags": ["covers"]
|
||||
}
|
||||
},
|
||||
"/isbn/{isbn}": {
|
||||
"get": {
|
||||
"operationId": "read_isbn_isbn__isbn__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "isbn",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Isbn"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Isbn",
|
||||
"tags": ["books"]
|
||||
}
|
||||
},
|
||||
"/search.json": {
|
||||
"get": {
|
||||
"operationId": "read_search_json_search_json_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "q",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Q"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"title": "Page",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Search Json",
|
||||
"tags": ["search"]
|
||||
}
|
||||
},
|
||||
"/search/authors.json": {
|
||||
"get": {
|
||||
"operationId": "read_search_authors_json_search_authors_json_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "q",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Q"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Search Authors Json",
|
||||
"tags": ["search"]
|
||||
}
|
||||
},
|
||||
"/subjects/{subject}.json": {
|
||||
"get": {
|
||||
"operationId": "read_subjects_subjects__subject__json_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "subject",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Subject"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "details",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": false,
|
||||
"title": "Details",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Subjects",
|
||||
"tags": ["subjects"]
|
||||
}
|
||||
},
|
||||
"/works/{olid}": {
|
||||
"get": {
|
||||
"operationId": "read_works_works__olid__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "olid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Olid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Read Works",
|
||||
"tags": ["books"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"description": "Retrieve a specific work or edition by identifier",
|
||||
"externalDocs": {
|
||||
"description": "Find out more",
|
||||
"url": "https://openlibrary.org/dev/docs/api/books"
|
||||
},
|
||||
"name": "books"
|
||||
},
|
||||
{
|
||||
"description": "Retrieve an author and their works by author identifier",
|
||||
"externalDocs": {
|
||||
"description": "Find out more",
|
||||
"url": "https://openlibrary.org/dev/docs/api/authors"
|
||||
},
|
||||
"name": "authors"
|
||||
},
|
||||
{
|
||||
"description": "Search results for books, authors, and more",
|
||||
"externalDocs": {
|
||||
"description": "Find out more",
|
||||
"url": "https://openlibrary.org/dev/docs/api/search"
|
||||
},
|
||||
"name": "search"
|
||||
},
|
||||
{
|
||||
"description": "Fetch book covers by ISBN or Open Library identifier",
|
||||
"externalDocs": {
|
||||
"description": "Find out more",
|
||||
"url": "https://openlibrary.org/dev/docs/api/covers"
|
||||
},
|
||||
"name": "covers"
|
||||
},
|
||||
{
|
||||
"description": "Fetch books by subject name ",
|
||||
"externalDocs": {
|
||||
"description": "Find out more",
|
||||
"url": "https://openlibrary.org/dev/docs/api/subjects"
|
||||
},
|
||||
"name": "subjects"
|
||||
}
|
||||
]
|
||||
}
|
||||
578
packages/schemas/src/OpenLibrary.ts
Normal file
578
packages/schemas/src/OpenLibrary.ts
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
'/api/books': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Api Books */
|
||||
get: operations['read_api_books_api_books_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/volumes/brief/{key_type}/{value}.json': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Api Volumes Brief */
|
||||
get: operations['read_api_volumes_brief_api_volumes_brief__key_type___value__json_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/authors/{olid}.json': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Authors */
|
||||
get: operations['read_authors_authors__olid__json_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/authors/{olid}/works.json': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Authors Works */
|
||||
get: operations['read_authors_works_authors__olid__works_json_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/books/{olid}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Books */
|
||||
get: operations['read_books_books__olid__get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/covers/{key_type}/{value}-{size}.jpg': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Covers Key Type Value Size Jpeg */
|
||||
get: operations['read_covers_key_type_value_size_jpeg_covers__key_type___value___size__jpg_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/isbn/{isbn}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Isbn */
|
||||
get: operations['read_isbn_isbn__isbn__get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/search.json': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Search Json */
|
||||
get: operations['read_search_json_search_json_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/search/authors.json': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Search Authors Json */
|
||||
get: operations['read_search_authors_json_search_authors_json_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/subjects/{subject}.json': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Subjects */
|
||||
get: operations['read_subjects_subjects__subject__json_get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/works/{olid}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read Works */
|
||||
get: operations['read_works_works__olid__get'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
detail?: components['schemas']['ValidationError'][];
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
loc: string[];
|
||||
/** Message */
|
||||
msg: string;
|
||||
/** Error Type */
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
read_api_books_api_books_get: {
|
||||
parameters: {
|
||||
query: {
|
||||
bibkeys: string;
|
||||
/** @description Specifies the response format. Possible values are json and javascript. When not specified the format is javascript. */
|
||||
format?: string;
|
||||
/** @description The name of the JavaScript function to call with the result. This is considered only when the format is javascript. */
|
||||
callback?: unknown;
|
||||
/** @description Decides what information to provide for each matched bib_key. Possible values are viewapi and data. The default value is viewapi. */
|
||||
jscmd?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_api_volumes_brief_api_volumes_brief__key_type___value__json_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
callback?: unknown;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
key_type: unknown;
|
||||
value: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_authors_authors__olid__json_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
olid: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_authors_works_authors__olid__works_json_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
olid: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_books_books__olid__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
olid: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_covers_key_type_value_size_jpeg_covers__key_type___value___size__jpg_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
key_type: unknown;
|
||||
value: unknown;
|
||||
size: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_isbn_isbn__isbn__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
isbn: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_search_json_search_json_get: {
|
||||
parameters: {
|
||||
query: {
|
||||
q: unknown;
|
||||
page?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_search_authors_json_search_authors_json_get: {
|
||||
parameters: {
|
||||
query: {
|
||||
q: unknown;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_subjects_subjects__subject__json_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
details?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
subject: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_works_works__olid__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
olid: unknown;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['HTTPValidationError'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
22832
packages/schemas/src/TMDB.ts
Normal file
22832
packages/schemas/src/TMDB.ts
Normal file
File diff suppressed because it is too large
Load diff
15
packages/schemas/src/fetchSchemas.sh
Normal file
15
packages/schemas/src/fetchSchemas.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#! /bin/env bash
|
||||
|
||||
# IMPORTANT: needs to be ran from this directory, otherwise the output files will be generated in the wrong place
|
||||
|
||||
# https://docs.api.jikan.moe/
|
||||
bun openapi-typescript https://raw.githubusercontent.com/jikan-me/jikan-rest/master/storage/api-docs/api-docs.json -o ./MALAPI.ts
|
||||
|
||||
# https://www.giantbomb.com/forums/api-developers-3017/giant-bomb-openapi-specification-1901269/
|
||||
bun openapi-typescript ./GiantBomb.json -o ./GiantBomb.ts
|
||||
|
||||
# https://github.com/internetarchive/openlibrary-api/blob/main/swagger.yaml
|
||||
bun openapi-typescript ./OpenLibrary.json -o ./OpenLibrary.ts
|
||||
|
||||
# https://developer.themoviedb.org/openapi
|
||||
bun openapi-typescript https://developer.themoviedb.org/openapi/tmdb-api.json -o ./TMDB.ts
|
||||
Loading…
Add table
Add a link
Reference in a new issue