diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2594994..e7202e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,9 +13,14 @@ jobs: build: runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Determine prerelease status id: status @@ -27,23 +32,32 @@ jobs: fi - name: Install Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Build id: build run: | - bun install + bun install --frozen-lockfile bun run build mkdir ${{ env.PLUGIN_NAME }} cp dist/main.js dist/manifest.json dist/styles.css ${{ env.PLUGIN_NAME }} zip -r ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip ${{ env.PLUGIN_NAME }} ls + - name: Attest build provenance + uses: actions/attest@v4 + with: + subject-path: | + dist/main.js + dist/manifest.json + dist/styles.css + ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip + - name: Release id: release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: prerelease: ${{ steps.status.outputs.prerelease }} token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 7cd31f3..5a3e090 100644 --- a/README.md +++ b/README.md @@ -116,20 +116,18 @@ Now you select the result you want, and the plugin will cast its magic, creating ### Currently supported APIs: -| Name | Description | Supported formats | Authentification | Rate limiting | SFW filter support | -| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| [Jikan](https://jikan.moe/) | Jikan is an API that uses [My Anime List](https://myanimelist.net) and offers metadata for anime. | series, movies, specials, OVAs, manga, manwha, novels | No | 60 per minute and 3 per second | Yes | -| [OMDb](https://www.omdbapi.com/) | OMDb is an API that offers metadata for movies, series, and games. | series, movies, games | Yes, you can get a free key here [here](https://www.omdbapi.com/apikey.aspx) | 1000 per day | No | -| [TMDB](https://www.themoviedb.org/) | TMDB is a API that offers community editable metadata for movies and series. | series, movies | Yes, by making an account [here](https://www.themoviedb.org/signup) and getting your `API Read Access Token` (**not** `API Key`) [here](https://www.themoviedb.org/settings/api) | 50 per second | Yes | -| [MusicBrainz](https://musicbrainz.org/) | MusicBrainz is an API that offers information about music releases. | music releases | No | 50 per second | No | -| [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | The Wikipedia API allows access to all Wikipedia articles. | wiki articles | No | None | No | -| [Steam](https://store.steampowered.com/) | The Steam API offers information on all Steam games. | games | No | 10000 per day | No | -| [Open Library](https://openlibrary.org) | The OpenLibrary API offers metadata for books | books | No | Cover access is rate-limited when not using CoverID or OLID by max 100 requests/IP every 5 minutes. This plugin uses OLID, so there shouldn't be a rate limit. | No | -| [Moby Games](https://www.mobygames.com) | The Moby Games API offers metadata for games for all platforms | games | Yes, by making an account [here](https://www.mobygames.com/user/register/). NOTE: As of September 2024 the API key is no longer free so consider using Giant Bomb or steam instead | API requests are limited to 360 per hour (one every ten seconds). In addition, requests should be made no more frequently than one per second. | No | -| [Giant Bomb](https://www.giantbomb.com) | The Giant Bomb API offers metadata for games for all platforms | games | Yes, by making an account [here](https://www.giantbomb.com/login-signup/) | API requests are limited to 200 requests per resource, per hour. In addition, they implement velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No | -| Comic Vine | The Comic Vine API offers metadata for comic books | comicbooks | Yes, by making an account [here](https://comicvine.gamespot.com/login-signup/) and going to the [api section](https://comicvine.gamespot.com/api/) of the site | 200 requests per resource, per hour. There is also a velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No | -| [VNDB](https://vndb.org/) | The VNDB API offers metadata for visual novels | games | No | 200 requests per 5 minutes | Yes | -| [Boardgame Geek](https://boardgamegeek.com) | The Boardgame Geek API offers metadata for boardgames | boardgames | Yes, by making an account [here](https://boardgamegeek.com/join/) and then [requesting an application token](https://boardgamegeek.com/applications) | Exact usage limits are still undetermined | No | +| Name | Description | Supported formats | Authentification | Rate limiting | SFW filter support | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | +| [Jikan](https://jikan.moe/) | Jikan is an API that uses [My Anime List](https://myanimelist.net) and offers metadata for anime. | series, movies, specials, OVAs, manga, manwha, novels | No | 60 per minute and 3 per second | Yes | +| [OMDb](https://www.omdbapi.com/) | OMDb is an API that offers metadata for movies, series, and games. | series, movies, games | Yes, you can get a free key here [here](https://www.omdbapi.com/apikey.aspx) | 1000 per day | No | +| [TMDB](https://www.themoviedb.org/) | TMDB is a API that offers community editable metadata for movies and series. | series, movies | Yes, by making an account [here](https://www.themoviedb.org/signup) and getting your `API Read Access Token` (**not** `API Key`) [here](https://www.themoviedb.org/settings/api) | 50 per second | Yes | +| [MusicBrainz](https://musicbrainz.org/) | MusicBrainz is an API that offers information about music releases. | music releases | No | 50 per second | No | +| [Wikipedia](https://en.wikipedia.org/wiki/Main_Page) | The Wikipedia API allows access to all Wikipedia articles. | wiki articles | No | None | No | +| [Steam](https://store.steampowered.com/) | The Steam API offers information on all Steam games. | games | No | 10000 per day | No | +| [Open Library](https://openlibrary.org) | The OpenLibrary API offers metadata for books | books | No | Cover access is rate-limited when not using CoverID or OLID by max 100 requests/IP every 5 minutes. This plugin uses OLID, so there shouldn't be a rate limit. | No | +| Comic Vine | The Comic Vine API offers metadata for comic books | comicbooks | Yes, by making an account [here](https://comicvine.gamespot.com/login-signup/) and going to the [api section](https://comicvine.gamespot.com/api/) of the site | 200 requests per resource, per hour. There is also a velocity detection to prevent malicious use. If too many requests are made per second, you may receive temporary blocks to resources. | No | +| [VNDB](https://vndb.org/) | The VNDB API offers metadata for visual novels | games | No | 200 requests per 5 minutes | Yes | +| [Boardgame Geek](https://boardgamegeek.com) | The Boardgame Geek API offers metadata for boardgames | boardgames | Yes, by making an account [here](https://boardgamegeek.com/join/) and then [requesting an application token](https://boardgamegeek.com/applications) | Exact usage limits are still undetermined | No | #### Notes @@ -137,6 +135,8 @@ Now you select the result you want, and the plugin will cast its magic, creating - sometimes the api is very slow; this is normal - you need to use the title the anime has on [My Anime List](https://myanimelist.net), which is in most cases the Japanese title - e.g. instead of "Demon Slayer" you have to search "Kimetsu no Yaiba" +- Support for the [Moby Games](https://www.mobygames.com) API has been removed from the plugins as the API is no longer free to use. +- Support for the [Giant Bomb](https://www.giantbomb.com) API has been removed from the plugin temporarily as their API is [currently non functional](https://giantbomb.com/api). #### Search by ID @@ -174,12 +174,6 @@ Now you select the result you want, and the plugin will cast its magic, creating - This URL is located near the top of the page above the title, see `An edition of Fantastic Mr Fox (1970) ` - For a specific edition of "Fantastic Mr. Fox" the "/books/" URL looks like this `https://openlibrary.org/books/OL3567303M/` so the ID is `/books/OL3567303M` - This URL is located in the editions section` -- [Moby Games](https://www.mobygames.com) - - you can find this ID in the URL - - e.g. for "Bioshock 2" the URL looks like this `https://www.mobygames.com/game/45089/bioshock-2/` so the ID is `45089` -- [Giant Bomb](https://www.giantbomb.com) - - you can find this ID in the URL - - e.g. for "Dota 2" the URL looks like this `https://www.giantbomb.com/dota-2/3030-32887/` so the ID is `3030-32887` - [Comic Vine](https://www.comicvine.gamespot.com) - you can find this ID in the URL - e.g. for "Boule & Bill" the URL looks like this `https://comicvine.gamespot.com/boule-bill/4050-70187/` so the ID is `4050-70187` diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts deleted file mode 100644 index 5ae0205..0000000 --- a/__mocks__/obsidian.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { RequestUrlParam, RequestUrlResponse } from 'obsidian'; - -export function requestUrl(request: RequestUrlParam): Promise { - return fetch(request.url, { - method: request.method, - headers: request.headers, - body: request.body, - }).then(async response => { - if (response.status >= 400 && request.throw) { - throw new Error(`Request failed, ${response.status}`); - } - - // Turn response headers into Record object - const headers: Record = {}; - response.headers.forEach((value, key) => { - headers[key] = value; - }); - - const arraybuffer = await response.arrayBuffer(); - const text = arraybuffer ? new TextDecoder().decode(arraybuffer) : ''; - const json = text ? JSON.parse(text) : {}; - - let response_body: RequestUrlResponse = { - status: response.status, - headers: headers, - arrayBuffer: arraybuffer, - json: json, - text: text, - }; - return response_body; - }); -} diff --git a/automation/build/buildBanner.ts b/automation/build/buildBanner.ts deleted file mode 100644 index 849aec3..0000000 --- a/automation/build/buildBanner.ts +++ /dev/null @@ -1,24 +0,0 @@ -import manifest from '../../manifest.json' with { type: 'json' }; - -export function getBuildBanner(buildType: string, getVersion: (version: string) => string) { - return `/* -------------------------------------------- -${manifest.name} - ${buildType} -------------------------------------------- -By: ${manifest.author} (${manifest.authorUrl}) -Time: ${new Date().toUTCString()} -Version: ${getVersion(manifest.version)} -------------------------------------------- -THIS IS A GENERATED/BUNDLED FILE -if you want to view the source, please visit the github repository of this plugin -------------------------------------------- -Copyright (c) ${new Date().getFullYear()} ${manifest.author} -------------------------------------------- -This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - -You should have received a copy of the GNU General Public License along with this program. If not, see . -*/ -`; -} diff --git a/automation/config.json b/automation/config.json deleted file mode 100644 index c9a8947..0000000 --- a/automation/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "devBranch": "master", - "releaseBranch": "release", - "github": "https://github.com/mProjectsCode/obsidian-media-db-plugin" -} diff --git a/automation/fetchSchemas.ts b/automation/fetchSchemas.ts deleted file mode 100644 index c5c2102..0000000 --- a/automation/fetchSchemas.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { $ } from './utils/shellUtils'; - -async function fetchSchema() { - // https://docs.api.jikan.moe/ - await $('bun openapi-typescript https://raw.githubusercontent.com/jikan-me/jikan-rest/master/storage/api-docs/api-docs.json -o ./src/api/schemas/MALAPI.ts'); - - // https://www.giantbomb.com/forums/api-developers-3017/giant-bomb-openapi-specification-1901269/ - await $('bun openapi-typescript ./src/api/schemas/GiantBomb.json -o ./src/api/schemas/GiantBomb.ts'); - - // https://www.omdbapi.com/swagger.json - // await $('bun openapi-typescript ./src/api/schemas/OMDb.json -o ./src/api/schemas/OMDb.ts'); - - // https://github.com/internetarchive/openlibrary-api/blob/main/swagger.yaml - await $('bun openapi-typescript ./src/api/schemas/OpenLibrary.json -o ./src/api/schemas/OpenLibrary.ts'); - - // https://developer.themoviedb.org/openapi - await $('bun openapi-typescript https://developer.themoviedb.org/openapi/tmdb-api.json -o ./src/api/schemas/TMDB.ts'); -} - -await fetchSchema(); diff --git a/automation/release.ts b/automation/release.ts deleted file mode 100644 index 9c60136..0000000 --- a/automation/release.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { UserError } from './utils/utils'; -import { CanaryVersion, Version, getIncrementOptions, parseVersion, stringifyVersion } from './utils/versionUtils'; -import config from './config.json'; -import { $choice as $choice, $confirm, $seq, CMD_FMT, Verboseness } from './utils/shellUtils'; - -async function runPreconditions(): Promise { - // run preconditions - await $seq( - [`bun run format`, `bun run test`], - (cmd: string) => { - throw new UserError(`precondition "${cmd}" failed`); - }, - () => {}, - undefined, - Verboseness.VERBOSE, - ); - - // add changed files - await $seq( - [`git add .`], - () => { - throw new UserError('failed to add preconditions changes to git'); - }, - () => {}, - undefined, - Verboseness.NORMAL, - ); - - // check if there were any changes - let changesToCommit = false; - await $seq( - [`git diff --quiet`, `git diff --cached --quiet`], - () => { - changesToCommit = true; - }, - () => {}, - undefined, - Verboseness.QUITET, - ); - - // if there were any changes, commit them - if (changesToCommit) { - await $seq( - [`git commit -m "[auto] run release preconditions"`], - () => { - throw new UserError('failed to add preconditions changes to git'); - }, - () => {}, - undefined, - Verboseness.NORMAL, - ); - } -} - -async function run() { - console.log('looking for untracked changes ...'); - - // check for any uncommited files and exit if there are any - await $seq( - [`git add .`, `git diff --quiet`, `git diff --cached --quiet`, `git checkout ${config.devBranch}`], - () => { - throw new UserError('there are still untracked changes'); - }, - () => {}, - undefined, - Verboseness.QUITET, - ); - - console.log('\nrunning preconditions ...\n'); - - await runPreconditions(); - - console.log('\nbumping versions ...\n'); - - const manifestFile = Bun.file('./manifest.json'); - const manifest = await manifestFile.json(); - - const versionString: string = manifest.version; - const currentVersion: Version = parseVersion(versionString); - const currentVersionString = stringifyVersion(currentVersion); - - const versionIncrementOptions = getIncrementOptions(currentVersion); - - const selectedIndex = await $choice( - `Current version "${currentVersionString}". Select new version`, - versionIncrementOptions.map(x => stringifyVersion(x)), - ); - const newVersion = versionIncrementOptions[selectedIndex]; - const newVersionString = stringifyVersion(newVersion); - - console.log(''); - - await $confirm(`Version will be updated "${currentVersionString}" -> "${newVersionString}". Are you sure`, () => { - throw new UserError('user canceled script'); - }); - - if (!(newVersion instanceof CanaryVersion)) { - manifest.version = newVersionString; - } - - await Bun.write(manifestFile, JSON.stringify(manifest, null, '\t')); - - const betaManifest = structuredClone(manifest); - betaManifest.version = newVersionString; - - const betaManifestFile = Bun.file('./manifest-beta.json'); - await Bun.write(betaManifestFile, JSON.stringify(betaManifest, null, '\t')); - - if (!(newVersion instanceof CanaryVersion)) { - const versionsFile = Bun.file('./versions.json'); - const versionsJson = await versionsFile.json(); - - versionsJson[newVersionString] = manifest.minAppVersion; - - await Bun.write(versionsFile, JSON.stringify(versionsJson, null, '\t')); - - const packageFile = Bun.file('./package.json'); - const packageJson = await packageFile.json(); - - packageJson.version = newVersionString; - - await Bun.write(packageFile, JSON.stringify(packageJson, null, '\t')); - } - - await $seq( - [`bun run format`, `git add .`, `git commit -m "[auto] bump version to \`${newVersionString}\`"`], - () => { - throw new UserError('failed to add preconditions changes to git'); - }, - () => {}, - undefined, - Verboseness.NORMAL, - ); - - console.log('\ncreating release tag ...\n'); - - await $seq( - [ - `git checkout ${config.releaseBranch}`, - `git merge ${config.devBranch} --commit -m "[auto] merge \`${newVersionString}\` release commit"`, - `git push origin ${config.releaseBranch}`, - `git tag -a ${newVersionString} -m "release version ${newVersionString}"`, - `git push origin ${newVersionString}`, - `git checkout ${config.devBranch}`, - `git merge ${config.releaseBranch}`, - `git push origin ${config.devBranch}`, - ], - () => { - throw new UserError('failed to merge or create tag'); - }, - () => {}, - undefined, - Verboseness.NORMAL, - ); - - console.log(''); - - console.log(`${CMD_FMT.BgGreen}done${CMD_FMT.Reset}`); - console.log(`${config.github}`); - console.log(`${config.github}/releases/tag/${newVersionString}`); -} - -try { - await run(); -} catch (e) { - if (e instanceof UserError) { - console.error(e.message); - } else { - console.error(e); - } -} diff --git a/automation/stats.ts b/automation/stats.ts deleted file mode 100644 index 2a34c03..0000000 --- a/automation/stats.ts +++ /dev/null @@ -1,160 +0,0 @@ -import * as fs from 'fs'; - -interface Stat { - fileType: string; - count: number; - lines: number; -} - -abstract class StatsBase { - parent: StatsBase | undefined; - path: string; - name: string; - stats: Stat[]; - - constructor(parent: StatsBase | undefined, path: string, name: string, stats: Stat[]) { - this.parent = parent; - - this.path = path; - this.name = name; - this.stats = stats; - } - - abstract addChild(child: StatsBase): void; - - abstract mergeStats(stats: Stat[]): void; - - abstract print(depth: number, lastChildArr: boolean[]): void; - - abstract sort(): void; - - getPrefix(depth: number, lastChildArr: boolean[]): string { - let prefix = ''; - for (let i = 0; i < depth; i++) { - prefix += lastChildArr[i] ? ' ' : '│ '; - } - - if (lastChildArr.at(-1)) { - prefix += '└─ '; - } else { - prefix += '├─ '; - } - - return prefix; - } -} - -class FolderStats extends StatsBase { - children: StatsBase[]; - - constructor(parent: StatsBase | undefined, path: string, name: string) { - super(parent, path, name, []); - - this.children = []; - } - - addChild(child: StatsBase) { - this.children.push(child); - this.mergeStats(child.stats); - } - - mergeStats(stats: Stat[]): void { - // console.log(this, stats); - for (const stat of stats) { - const existingStat = this.stats.find(s => s.fileType === stat.fileType); - if (existingStat) { - existingStat.count += stat.count; - existingStat.lines += stat.lines; - } else { - this.stats.push(structuredClone(stat)); - } - } - - this.parent?.mergeStats(stats); - } - - print(depth: number, lastChildArr: boolean[]): void { - console.log( - `${this.getPrefix(depth, lastChildArr)}${this.name} | ${this.stats.reduce((acc, s) => acc + s.count, 0)} files | ${this.stats.reduce((acc, s) => acc + s.lines, 0)} lines`, - ); - for (let i = 0; i < this.children.length; i++) { - const child = this.children[i]; - child.print(depth + 1, [...lastChildArr, i === this.children.length - 1]); - } - } - - sort(): void { - this.children.sort((a, b) => { - if (a instanceof FolderStats && b instanceof FileStats) { - return 1; - } else if (a instanceof FileStats && b instanceof FolderStats) { - return -1; - } else { - return a.name.localeCompare(b.name); - } - }); - this.children.forEach(c => c.sort()); - } -} - -class FileStats extends StatsBase { - constructor(parent: StatsBase, path: string, name: string, stats: Stat[]) { - super(parent, path, name, stats); - } - - addChild(_child: StatsBase): void { - throw new Error('Cannot add child to file'); - } - - mergeStats(_stats: Stat[]): void { - throw new Error('Cannot merge stats to file'); - } - - print(depth: number, lastChildArr: boolean[]): void { - console.log(`${this.getPrefix(depth, lastChildArr)}${this.name} | ${this.stats[0].lines} lines`); - } - - sort(): void {} -} - -function collectStats() { - const root = new FolderStats(undefined, './src', 'src'); - const ignore = ['node_modules', 'extraTypes', 'bun.lockb']; - - const todo: FolderStats[] = [root]; - - while (todo.length > 0) { - const current = todo.pop()!; - const children = fs.readdirSync(current.path, { withFileTypes: true }); - - for (const child of children) { - if (ignore.includes(child.name)) { - continue; - } - - if (child.isDirectory()) { - const folder = new FolderStats(current, `${current.path}/${child.name}`, child.name); - current.addChild(folder); - - todo.push(folder); - } else { - const content = fs.readFileSync(`${current.path}/${child.name}`, 'utf-8'); - - const file = new FileStats(current, `${current.path}/${child.name}`, child.name, [ - { - fileType: child.name.split('.').splice(1).join('.'), - count: 1, - lines: content.split('\n').length, - }, - ]); - current.addChild(file); - } - } - } - - root.sort(); - - root.print(0, [true]); -} - -collectStats(); diff --git a/automation/tsconfig.json b/automation/tsconfig.json deleted file mode 100644 index 8e1333b..0000000 --- a/automation/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "compilerOptions": { - "module": "ESNext", - "target": "ESNext", - "allowJs": true, - "noImplicitAny": true, - "strict": true, - "strictNullChecks": true, - "noImplicitReturns": true, - "moduleResolution": "bundler", - "importHelpers": true, - "isolatedModules": true, - "lib": ["DOM", "ES5", "ES6", "ES7", "Es2022"], - "types": ["bun-types"], - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true - }, - "include": ["**/*.ts"] -} diff --git a/automation/utils/shellUtils.ts b/automation/utils/shellUtils.ts deleted file mode 100644 index d1c58e0..0000000 --- a/automation/utils/shellUtils.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Subprocess } from 'bun'; -import stringArgv from 'string-argv'; - -export enum Verboseness { - QUITET, - NORMAL, - VERBOSE, -} - -function exec(c: string, cwd?: string): Subprocess<'ignore', 'pipe', 'inherit'> { - return Bun.spawn(stringArgv(c), { cwd: cwd }); -} - -export async function $(cmd: string, cwd?: string | undefined, verboseness: Verboseness = Verboseness.NORMAL): Promise<{ stdout: string; stderr: string; exit: number }> { - if (verboseness === Verboseness.NORMAL || verboseness === Verboseness.VERBOSE) { - if (cwd !== undefined) { - console.log(`\n${CMD_FMT.Bright}running${CMD_FMT.Reset} in ${cwd} - ${cmd}\n`); - } else { - console.log(`\n${CMD_FMT.Bright}running${CMD_FMT.Reset} - ${cmd}\n`); - } - } - - const proc = exec(cmd, cwd); - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - - if (verboseness === Verboseness.VERBOSE) { - if (stdout !== '') { - console.log( - stdout - .split('\n') - .map(x => `${CMD_FMT.FgGray}>${CMD_FMT.Reset} ${x}\n`) - .join(''), - ); - } - - if (stderr !== '') { - console.log( - stderr - .split('\n') - .map(x => `${CMD_FMT.FgRed}>${CMD_FMT.Reset} ${x}\n`) - .join(''), - ); - } - } - - const exit = await proc.exited; - - if (verboseness === Verboseness.NORMAL || verboseness === Verboseness.VERBOSE) { - if (exit === 0) { - console.log(`${CMD_FMT.FgGreen}success${CMD_FMT.Reset} - ${cmd}\n`); - } else { - console.log(`${CMD_FMT.FgRed}fail${CMD_FMT.Reset} - ${cmd} - code ${exit}\n`); - } - } - - return { - stdout, - stderr, - exit, - }; -} - -export async function $seq( - cmds: string[], - onError: (cmd: string, index: number) => void, - onSuccess: () => void, - cwd?: string | undefined, - verboseness: Verboseness = Verboseness.NORMAL, -): Promise { - const results = []; - for (let i = 0; i < cmds.length; i += 1) { - const cmd = cmds[i]; - const result = await $(cmd, cwd, verboseness); - - if (result.exit !== 0) { - onError(cmd, i); - return; - } - - results.push(result); - } - onSuccess(); -} - -export async function $input(message: string): Promise { - console.write(`${message} `); - const stdin = Bun.stdin.stream(); - const reader = stdin.getReader(); - const chunk = await reader.read(); - reader.releaseLock(); - const text = Buffer.from(chunk.value ?? '').toString(); - return text.trim(); -} - -export async function $choice(message: string, options: string[]): Promise { - console.log(`${message} `); - - let optionNumbers = new Map(); - - for (let i = 0; i < options.length; i++) { - const option = options[i]; - console.log(`[${i}] ${option}`); - - optionNumbers.set(i.toString(), i); - } - - let ret: undefined | number = undefined; - - while (ret === undefined) { - const selectedStr = await $input(`Select [${[...optionNumbers.keys()].join('/')}]:`); - - ret = optionNumbers.get(selectedStr); - - if (ret === undefined) { - console.log(`${CMD_FMT.FgRed}invalid selection, please select a valid option${CMD_FMT.Reset}`); - } - } - - return ret; -} - -export async function $confirm(message: string, onReject: () => void): Promise { - while (true) { - const answer = await $input(`${message} [Y/N]?`); - - if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { - return; - } - - if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') { - onReject(); - return; - } - - console.log(`${CMD_FMT.FgRed}invalid selection, please select a valid option${CMD_FMT.Reset}`); - } -} - -export const CMD_FMT = { - Reset: '\x1b[0m', - Bright: '\x1b[1m', - Dim: '\x1b[2m', - Underscore: '\x1b[4m', - Blink: '\x1b[5m', - Reverse: '\x1b[7m', - Hidden: '\x1b[8m', - - FgBlack: '\x1b[30m', - FgRed: '\x1b[31m', - FgGreen: '\x1b[32m', - FgYellow: '\x1b[33m', - FgBlue: '\x1b[34m', - FgMagenta: '\x1b[35m', - FgCyan: '\x1b[36m', - FgWhite: '\x1b[37m', - FgGray: '\x1b[90m', - - BgBlack: '\x1b[40m', - BgRed: '\x1b[41m', - BgGreen: '\x1b[42m', - BgYellow: '\x1b[43m', - BgBlue: '\x1b[44m', - BgMagenta: '\x1b[45m', - BgCyan: '\x1b[46m', - BgWhite: '\x1b[47m', - BgGray: '\x1b[100m', -}; diff --git a/automation/utils/utils.ts b/automation/utils/utils.ts deleted file mode 100644 index f5d72ee..0000000 --- a/automation/utils/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class UserError extends Error {} - -export interface ProjectConfig { - corePackages: string[]; - packages: string[]; -} diff --git a/automation/utils/versionUtils.ts b/automation/utils/versionUtils.ts deleted file mode 100644 index 8ad3fa2..0000000 --- a/automation/utils/versionUtils.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Parser } from '@lemons_dev/parsinom/lib/Parser'; -import { P_UTILS } from '@lemons_dev/parsinom/lib/ParserUtils'; -import { P } from '@lemons_dev/parsinom/lib/ParsiNOM'; -import Moment from 'moment'; -import { UserError } from './utils'; - -export class Version { - major: number; - minor: number; - patch: number; - - constructor(major: number, minor: number, patch: number) { - this.major = major; - this.minor = minor; - this.patch = patch; - } - - toString(): string { - return `${this.major}.${this.minor}.${this.patch}`; - } -} - -export class CanaryVersion extends Version { - canary: string; - - constructor(major: number, minor: number, patch: number, canary: string) { - super(major, minor, patch); - this.canary = canary; - } - - toString(): string { - return `${super.toString()}-canary.${this.canary}`; - } -} - -const numberParser: Parser = P_UTILS.digits() - .map(x => Number.parseInt(x)) - .chain(x => { - if (Number.isNaN(x)) { - return P.fail('a number'); - } else { - return P.succeed(x); - } - }); - -const canaryParser: Parser = P.sequenceMap( - (_, c1, c2, c3) => { - return c1 + c2 + c3; - }, - P.string('-canary.'), - P_UTILS.digit() - .repeat(8, 8) - .map(x => x.join('')), - P.string('T'), - P_UTILS.digit() - .repeat(6, 6) - .map(x => x.join('')), -); - -export const versionParser: Parser = P.or( - P.sequenceMap( - (major, _1, minor, _2, patch) => { - return new Version(major, minor, patch); - }, - numberParser, - P.string('.'), - numberParser, - P.string('.'), - numberParser, - P_UTILS.eof(), - ), - P.sequenceMap( - (major, _1, minor, _2, patch, canary) => { - return new CanaryVersion(major, minor, patch, canary); - }, - numberParser, - P.string('.'), - numberParser, - P.string('.'), - numberParser, - canaryParser, - P_UTILS.eof(), - ), -); - -export function parseVersion(str: string): Version { - const parserRes = versionParser.tryParse(str); - if (parserRes.success) { - return parserRes.value; - } else { - throw new UserError(`failed to parse manifest version "${str}"`); - } -} - -export function stringifyVersion(version: Version): string { - return version.toString(); -} - -export function getIncrementOptions(version: Version): [Version, Version, Version, CanaryVersion] { - const moment = Moment(); - const canary = moment.utcOffset(0).format('YYYYMMDDTHHmmss'); - return [ - new Version(version.major + 1, 0, 0), - new Version(version.major, version.minor + 1, 0), - new Version(version.major, version.minor, version.patch + 1), - new CanaryVersion(version.major, version.minor, version.patch, canary), - ]; -} diff --git a/bun.lock b/bun.lock index 2460bba..9f91cfa 100644 --- a/bun.lock +++ b/bun.lock @@ -5,33 +5,34 @@ "": { "name": "obsidian-media-db-plugin", "devDependencies": { - "@happy-dom/global-registrator": "^20.8.9", - "@lemons_dev/parsinom": "^0.1.0", - "@types/bun": "^1.3.11", + "@happy-dom/global-registrator": "^20.9.0", + "@lemons_dev/lemons-obsidian-plugin-automation": "^0.1.3", + "@types/bun": "^1.3.13", "eslint": "^9.39.4", "eslint-plugin-import": "^2.32.0", + "eslint-plugin-no-relative-import-paths": "^1.6.1", + "eslint-plugin-obsidianmd": "^0.2.9", "eslint-plugin-only-warn": "^1.2.1", "iso-639-2": "^3.0.2", "obsidian": "latest", "openapi-fetch": "^0.17.0", "openapi-typescript": "^7.13.0", - "prettier": "^3.8.1", + "prettier": "^3.8.3", "solid-js": "^1.9.12", - "string-argv": "^0.3.2", "tslib": "^2.8.1", - "typescript": "^6.0.2", + "typescript": "^6.0.3", "typescript-eslint": "^8.58.0", - "vite": "^8.0.5", + "vite": "^8.0.10", "vite-plugin-banner": "^0.8.1", "vite-plugin-solid": "^2.11.12", - "vite-plugin-static-copy": "^4.0.1", + "vite-plugin-static-copy": "^4.1.0", }, }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], @@ -55,7 +56,7 @@ "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], @@ -69,9 +70,9 @@ "@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="], - "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], @@ -89,18 +90,24 @@ "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + "@eslint/json": ["@eslint/json@0.14.0", "", { "dependencies": { "@eslint/core": "^0.17.0", "@eslint/plugin-kit": "^0.4.1", "@humanwhocodes/momoa": "^3.3.10", "natural-compare": "^1.4.0" } }, "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ=="], + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.8.9" } }, "sha512-DtZeRRHY9A/bisTJziUBBPrdnPui7+R185G/hzi6/Boymhqh7/wi53AY+IvQHS1+7OPaqfO/1XNpngNwthLz+A=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + "@humanwhocodes/momoa": ["@humanwhocodes/momoa@3.3.10", "", {}, "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ=="], + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -113,55 +120,61 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@lemons_dev/parsinom": ["@lemons_dev/parsinom@0.1.0", "", {}, "sha512-Jek2Drc1n6lRbr3QCB9JzK4rId5/U3rMmywiE/Quqmvq+f4unVqLeHe6XVTm6DeKn/T4Udgx/pC4Okt8FHX2sw=="], + "@lemons_dev/lemons-obsidian-plugin-automation": ["@lemons_dev/lemons-obsidian-plugin-automation@0.1.3", "", { "dependencies": { "@lemons_dev/parsinom": "^0.2.1", "moment": "^2.30.1", "string-argv": "^0.3.2" }, "peerDependencies": { "vite": "^8.0.9" }, "bin": { "lemons-automation": "bin/lemons-automation.js" } }, "sha512-Nv1eONy1Q9citS+nsMMasgL9ZfhKb57uinJmHRF+F1H+9HSiRkB5wlx9J/S1ou9cJL8IAhFvv72KGzE9HTIWtg=="], + + "@lemons_dev/parsinom": ["@lemons_dev/parsinom@0.2.1", "", {}, "sha512-ew+G3gm5aWJBnIhrysxI7k09FCAtAoj48wbTzr16NoX9ndhB8UnhmqjTxYVv46DsG88HQo+DTWPIFQW5qTAwwg=="], "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], + "@microsoft/eslint-plugin-sdl": ["@microsoft/eslint-plugin-sdl@1.1.0", "", { "dependencies": { "eslint-plugin-n": "17.10.3", "eslint-plugin-react": "7.37.3", "eslint-plugin-security": "1.4.0" }, "peerDependencies": { "eslint": "^9" } }, "sha512-dxdNHOemLnBhfY3eByrujX9KyLigcNtW8sU+axzWv5nLGcsSBeKW2YYyTpfPo1hV8YPOmIGnfA4fZHyKVtWqBQ=="], - "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + + "@pkgr/core": ["@pkgr/core@0.1.2", "", {}, "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ=="], "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], - "@redocly/openapi-core": ["@redocly/openapi-core@1.34.11", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.1.1", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg=="], + "@redocly/openapi-core": ["@redocly/openapi-core@1.34.14", "", { "dependencies": { "@redocly/ajv": "8.11.2", "@redocly/config": "0.22.0", "colorette": "1.4.0", "https-proxy-agent": "7.0.6", "js-levenshtein": "1.1.6", "js-yaml": "4.1.1", "minimatch": "5.1.9", "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" } }, "sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -171,17 +184,19 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/codemirror": ["@types/codemirror@5.60.8", "", { "dependencies": { "@types/tern": "*" } }, "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="], @@ -189,25 +204,25 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/type-utils": "8.58.0", "@typescript-eslint/utils": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.0", "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0" } }, "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.0", "@typescript-eslint/tsconfig-utils": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -215,7 +230,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], @@ -229,12 +244,16 @@ "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -247,19 +266,19 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], - "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -267,7 +286,7 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001786", "", {}, "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -311,16 +330,22 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.351", "", {}, "sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-iterator-helpers": ["es-iterator-helpers@1.3.2", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], @@ -335,14 +360,34 @@ "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + "eslint-compat-utils": ["eslint-compat-utils@0.5.1", "", { "dependencies": { "semver": "^7.5.4" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q=="], + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + "eslint-plugin-depend": ["eslint-plugin-depend@1.3.1", "", { "dependencies": { "empathic": "^2.0.0", "module-replacements": "^2.8.0", "semver": "^7.6.3" } }, "sha512-1uo2rFAr9vzNrCYdp7IBZRB54LiyVxfaIso0R6/QV3t6Dax6DTbW/EV2Hktf0f4UtmGHK8UyzJWI382pwW04jw=="], + + "eslint-plugin-es-x": ["eslint-plugin-es-x@7.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.1.2", "@eslint-community/regexpp": "^4.11.0", "eslint-compat-utils": "^0.5.1" }, "peerDependencies": { "eslint": ">=8" } }, "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ=="], + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + "eslint-plugin-json-schema-validator": ["eslint-plugin-json-schema-validator@5.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.3.0", "ajv": "^8.0.0", "debug": "^4.3.1", "eslint-compat-utils": "^0.5.0", "json-schema-migrate": "^2.0.0", "jsonc-eslint-parser": "^2.0.0", "minimatch": "^8.0.0", "synckit": "^0.9.0", "toml-eslint-parser": "^0.9.0", "tunnel-agent": "^0.6.0", "yaml-eslint-parser": "^1.0.0" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-ZmVyxRIjm58oqe2kTuy90PpmZPrrKvOjRPXKzq8WCgRgAkidCgm5X8domL2KSfadZ3QFAmifMgGTcVNhZ5ez2g=="], + + "eslint-plugin-n": ["eslint-plugin-n@17.10.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "enhanced-resolve": "^5.17.0", "eslint-plugin-es-x": "^7.5.0", "get-tsconfig": "^4.7.0", "globals": "^15.8.0", "ignore": "^5.2.4", "minimatch": "^9.0.5", "semver": "^7.5.3" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw=="], + + "eslint-plugin-no-relative-import-paths": ["eslint-plugin-no-relative-import-paths@1.6.1", "", {}, "sha512-YZNeOnsOrJcwhFw0X29MXjIzu2P/f5X2BZDPWw1R3VUYBRFxNIh77lyoL/XrMU9ewZNQPcEvAgL/cBOT1P330A=="], + + "eslint-plugin-no-unsanitized": ["eslint-plugin-no-unsanitized@4.1.5", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg=="], + + "eslint-plugin-obsidianmd": ["eslint-plugin-obsidianmd@0.2.9", "", { "dependencies": { "@eslint/config-helpers": "^0.4.2", "@eslint/js": "^9.30.1", "@eslint/json": "0.14.0", "@microsoft/eslint-plugin-sdl": "^1.1.0", "@types/eslint": "9.6.1", "@types/node": "20.12.12", "@typescript-eslint/types": "^8.33.1", "@typescript-eslint/utils": "^8.33.1", "eslint": ">=9.0.0", "eslint-plugin-depend": "1.3.1", "eslint-plugin-import": "^2.31.0", "eslint-plugin-json-schema-validator": "5.1.0", "eslint-plugin-no-unsanitized": "^4.1.5", "eslint-plugin-security": "2.1.1", "globals": "14.0.0", "obsidian": "1.12.3", "semver": "^7.7.4", "typescript": "5.4.5", "typescript-eslint": "^8.35.1" }, "bin": { "eslint-plugin-obsidian": "dist/lib/index.js" } }, "sha512-+dF5Zz5T6/j0QYGu+wHbY3UZb45Kh+QFkFdfvkVk05o4YIIVqHMlrTFrlRVhuBd6Htu8QxcFOwzeMTN4aysVTA=="], + "eslint-plugin-only-warn": ["eslint-plugin-only-warn@1.2.1", "", {}, "sha512-j37hwfaQDEOfkZ1Dpvu/HnWLavlzQxQxfbrU/9Jb4R9qzrE1eTYuRJyrxq7LzLRI8miG5FOV6veoUVhx7AI84w=="], + "eslint-plugin-react": ["eslint-plugin-react@7.37.3", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.8", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA=="], + + "eslint-plugin-security": ["eslint-plugin-security@2.1.1", "", { "dependencies": { "safe-regex": "^2.1.1" } }, "sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w=="], + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], @@ -363,6 +408,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -395,6 +442,8 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -403,7 +452,9 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "happy-dom": ["happy-dom@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -417,7 +468,7 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], @@ -445,7 +496,7 @@ "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], @@ -493,6 +544,8 @@ "iso-639-2": ["iso-639-2@3.0.2", "", {}, "sha512-tna50aWwcGTIn81S9MzD1NSovHYTpFgmPVszHiLF5Vg/xmXAJ9XAkMOB9a8TH9Vi7qwf/x/8NJy2F+lM5OEwAw=="], + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -503,12 +556,18 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema-migrate": ["json-schema-migrate@2.0.0", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "jsonc-eslint-parser": ["jsonc-eslint-parser@2.4.2", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", "espree": "^9.0.0", "semver": "^7.3.5" } }, "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -541,6 +600,8 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -551,20 +612,24 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="], + "module-replacements": ["module-replacements@2.11.0", "", {}, "sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA=="], + + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], - "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], @@ -617,18 +682,24 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -637,19 +708,27 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - "rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + "ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], + + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], + + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + "safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="], + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="], + "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], - "seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="], + "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -663,7 +742,7 @@ "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], @@ -679,6 +758,10 @@ "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], @@ -695,16 +778,24 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "synckit": ["synckit@0.9.3", "", { "dependencies": { "@pkgr/core": "^0.1.0", "tslib": "^2.6.2" } }, "sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toml-eslint-parser": ["toml-eslint-parser@0.9.3", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0" } }, "sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -717,13 +808,13 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.58.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.0", "@typescript-eslint/parser": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/utils": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA=="], + "typescript-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -731,13 +822,13 @@ "uri-js-replace": ["uri-js-replace@1.0.1", "", {}, "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g=="], - "vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="], + "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="], "vite-plugin-banner": ["vite-plugin-banner@0.8.1", "", {}, "sha512-0+gGguHk3MH0HvzMSOCJC6fGgH4+jtY9KlKVZh+hwwE+PBkGVzY8xe657JL74vEgbeUJD37XjVqTrmve8XvZBQ=="], "vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="], - "vite-plugin-static-copy": ["vite-plugin-static-copy@4.0.1", "", { "dependencies": { "chokidar": "^3.6.0", "p-map": "^7.0.4", "picocolors": "^1.1.1", "tinyglobby": "^0.2.15" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-r3kQUrrimduikhyRm58ayemoxsgB8lZdn/JULLL4wpXHAZlYejtyZx7E/id7dwRtIOSYWu/tWvFjdEOTzso2MA=="], + "vite-plugin-static-copy": ["vite-plugin-static-copy@4.1.0", "", { "dependencies": { "chokidar": "^3.6.0", "p-map": "^7.0.4", "picocolors": "^1.1.1", "tinyglobby": "^0.2.15" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-9XOarNV7LgP0KBB7AApxdgFikLXx3daZdqjC3AevYsL6MrUH62zphonLUs2a6LZc1HN1GY+vQdheZ8VVJb6dQQ=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], @@ -761,8 +852,12 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yaml-ast-parser": ["yaml-ast-parser@0.0.43", "", {}, "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A=="], + "yaml-eslint-parser": ["yaml-eslint-parser@1.3.2", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0", "yaml": "^2.0.0" } }, "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg=="], + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -771,6 +866,8 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@microsoft/eslint-plugin-sdl/eslint-plugin-security": ["eslint-plugin-security@1.4.0", "", { "dependencies": { "safe-regex": "^1.1.0" } }, "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA=="], + "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], @@ -791,20 +888,66 @@ "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "eslint-compat-utils/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-plugin-depend/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-plugin-json-schema-validator/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "eslint-plugin-json-schema-validator/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "eslint-plugin-n/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "eslint-plugin-n/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "eslint-plugin-n/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "eslint-plugin-obsidianmd/@types/node": ["@types/node@20.12.12", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw=="], + + "eslint-plugin-obsidianmd/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "eslint-plugin-obsidianmd/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], + + "json-schema-migrate/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "jsonc-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "jsonc-eslint-parser/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + + "jsonc-eslint-parser/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "obsidian/moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + "toml-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "yaml-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@microsoft/eslint-plugin-sdl/eslint-plugin-security/safe-regex": ["safe-regex@1.1.0", "", { "dependencies": { "ret": "~0.1.10" } }, "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg=="], + + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "eslint-plugin-json-schema-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "eslint-plugin-json-schema-validator/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "eslint-plugin-n/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "eslint-plugin-obsidianmd/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "json-schema-migrate/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/eslint.config.mjs b/eslint.config.mjs index c634b74..5f85572 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,17 +1,25 @@ // @ts-check import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; import only_warn from 'eslint-plugin-only-warn'; -import * as plugin_import from 'eslint-plugin-import'; +import no_relative_import_paths from 'eslint-plugin-no-relative-import-paths'; +import obsidianmd from 'eslint-plugin-obsidianmd'; -export default tseslint.config( +export default defineConfig( { - ignores: ['npm/', 'node_modules/', 'exampleVault/', 'automation/', 'main.js', '*.css', 'src/api/schemas/'], + ignores: ['npm/', 'node_modules/', 'exampleVault/', 'automation/', 'main.js', '*.css'], }, { - files: ['src/**/*.ts'], - extends: [eslint.configs.recommended, ...tseslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.stylisticTypeChecked], + files: ['packages/obsidian/src/**/*.ts'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + ...obsidianmd.configs.recommended, + ], languageOptions: { parser: tseslint.parser, parserOptions: { @@ -21,7 +29,8 @@ export default tseslint.config( plugins: { // @ts-ignore 'only-warn': only_warn, - import: plugin_import, + 'no-relative-import-paths': no_relative_import_paths, + obsidianmd: obsidianmd, }, rules: { '@typescript-eslint/no-explicit-any': ['warn'], @@ -44,11 +53,15 @@ export default tseslint.config( '@typescript-eslint/no-confusing-void-expression': ['error', { ignoreArrowShorthand: true }], '@typescript-eslint/restrict-template-expressions': 'off', + 'no-relative-import-paths/no-relative-import-paths': ['warn', { allowSameFolder: false }], + '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-inferrable-types': 'off', '@typescript-eslint/explicit-function-return-type': ['warn'], - '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-deprecated': 'off', + + 'obsidianmd/ui/sentence-case': 'off', }, }, ); diff --git a/manifest.json b/manifest.json index a8d101f..5e91e7f 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "id": "obsidian-media-db-plugin", "name": "Media DB", "version": "0.8.0", - "minAppVersion": "1.5.0", + "minAppVersion": "1.12.0", "description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", "author": "Moritz Jung", "authorUrl": "https://www.moritzjung.dev", diff --git a/package.json b/package.json index 815c67a..5661e62 100644 --- a/package.json +++ b/package.json @@ -6,41 +6,41 @@ "scripts": { "dev": "vite build --watch --mode development", "build": "bun run tsc && vite build --mode production", - "tsc": "tsc -noEmit -skipLibCheck", + "typecheck": "tsc -noEmit -skipLibCheck", "test": "bun test", "test:log": "LOG_TESTS=true bun test", "format": "prettier --write .", "format:check": "prettier --check .", - "lint": "eslint --max-warnings=0 --no-warn-ignored src/**", - "lint:fix": "eslint --max-warnings=0 --fix --no-warn-ignored src/**", - "check": "bun run format:check && bun run tsc && bun run lint", - "check:fix": "bun run format && bun run tsc && bun run lint:fix", - "release": "bun run automation/release.ts", - "stats": "bun run automation/stats.ts" + "lint": "eslint --max-warnings=0 --no-warn-ignored packages/obsidian/src/**", + "lint:fix": "eslint --max-warnings=0 --fix --no-warn-ignored packages/obsidian/src/**", + "check": "bun run format:check && bun run typecheck && bun run lint", + "check:fix": "bun run format && bun run typecheck && bun run lint:fix", + "release": "lemons-automation release" }, "keywords": [], "author": "Moritz Jung", "license": "GPL-3.0", "devDependencies": { - "@happy-dom/global-registrator": "^20.8.9", - "@lemons_dev/parsinom": "^0.1.0", - "@types/bun": "^1.3.11", + "@happy-dom/global-registrator": "^20.9.0", + "@lemons_dev/lemons-obsidian-plugin-automation": "^0.1.3", + "@types/bun": "^1.3.13", "eslint": "^9.39.4", "eslint-plugin-import": "^2.32.0", + "eslint-plugin-no-relative-import-paths": "^1.6.1", + "eslint-plugin-obsidianmd": "^0.2.9", "eslint-plugin-only-warn": "^1.2.1", "iso-639-2": "^3.0.2", "obsidian": "latest", "openapi-fetch": "^0.17.0", "openapi-typescript": "^7.13.0", - "prettier": "^3.8.1", + "prettier": "^3.8.3", "solid-js": "^1.9.12", - "string-argv": "^0.3.2", "tslib": "^2.8.1", - "typescript": "^6.0.2", + "typescript": "^6.0.3", "typescript-eslint": "^8.58.0", - "vite": "^8.0.5", + "vite": "^8.0.10", "vite-plugin-banner": "^0.8.1", "vite-plugin-solid": "^2.11.12", - "vite-plugin-static-copy": "^4.0.1" + "vite-plugin-static-copy": "^4.1.0" } } diff --git a/packages/obsidian/src/api/APIManager.ts b/packages/obsidian/src/api/APIManager.ts new file mode 100644 index 0000000..de9ba41 --- /dev/null +++ b/packages/obsidian/src/api/APIManager.ts @@ -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> { + 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> { + 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> { + 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); + } +} diff --git a/src/api/APIModel.ts b/packages/obsidian/src/api/APIModel.ts similarity index 55% rename from src/api/APIModel.ts rename to packages/obsidian/src/api/APIModel.ts index 0919db9..7527e99 100644 --- a/src/api/APIModel.ts +++ b/packages/obsidian/src/api/APIModel.ts @@ -1,6 +1,8 @@ -import type MediaDbPlugin from '../main'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; -import type { MediaType } from '../utils/MediaType'; +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; @@ -14,9 +16,9 @@ export abstract class APIModel { * * @param title the title to query for */ - abstract searchByTitle(title: string): Promise; + abstract searchByTitle(title: string): Promise>; - abstract getById(id: string): Promise; + abstract getById(id: string): Promise>; abstract getDisabledMediaTypes(): MediaType[]; diff --git a/packages/obsidian/src/api/apis/BoardGameGeekAPI.ts b/packages/obsidian/src/api/apis/BoardGameGeekAPI.ts new file mode 100644 index 0000000..889b779 --- /dev/null +++ b/packages/obsidian/src/api/apis/BoardGameGeekAPI.ts @@ -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> { + 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> { + 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; + } +} diff --git a/packages/obsidian/src/api/apis/ComicVineAPI.ts b/packages/obsidian/src/api/apis/ComicVineAPI.ts new file mode 100644 index 0000000..0babc39 --- /dev/null +++ b/packages/obsidian/src/api/apis/ComicVineAPI.ts @@ -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> { + 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> { + 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; + } +} diff --git a/packages/obsidian/src/api/apis/GiantBombAPI.ts b/packages/obsidian/src/api/apis/GiantBombAPI.ts new file mode 100644 index 0000000..2346fbe --- /dev/null +++ b/packages/obsidian/src/api/apis/GiantBombAPI.ts @@ -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 { +// 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({ 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 { +// 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({ 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; +// } +// } diff --git a/packages/obsidian/src/api/apis/IGDBAPI.ts b/packages/obsidian/src/api/apis/IGDBAPI.ts new file mode 100644 index 0000000..acfbf61 --- /dev/null +++ b/packages/obsidian/src/api/apis/IGDBAPI.ts @@ -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> { + 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> { + 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> { + 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 ?? []; + } +} diff --git a/packages/obsidian/src/api/apis/MALAPI.ts b/packages/obsidian/src/api/apis/MALAPI.ts new file mode 100644 index 0000000..11d1c02 --- /dev/null +++ b/packages/obsidian/src/api/apis/MALAPI.ts @@ -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; + 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(); + 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> { + Logger.log(`MDB | api "${this.apiName}" queried by Title`); + + const client = createClient({ 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> { + Logger.log(`MDB | api "${this.apiName}" queried by ID`); + + const client = createClient({ 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; + } +} diff --git a/src/api/apis/MALAPIManga.ts b/packages/obsidian/src/api/apis/MALAPIManga.ts similarity index 52% rename from src/api/apis/MALAPIManga.ts rename to packages/obsidian/src/api/apis/MALAPIManga.ts index 2d39a4f..424449b 100644 --- a/src/api/apis/MALAPIManga.ts +++ b/packages/obsidian/src/api/apis/MALAPIManga.ts @@ -1,11 +1,16 @@ import createClient from 'openapi-fetch'; -import type MediaDbPlugin from '../../main'; -import { ComicMangaModel } from '../../models/ComicMangaModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MediaType } from '../../utils/MediaType'; -import { isTruthy, obsidianFetch } from '../../utils/Utils'; -import { APIModel } from '../APIModel'; -import type { paths } from '../schemas/MALAPI'; +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; @@ -30,8 +35,8 @@ export class MALAPIManga extends APIModel { this.typeMappings.set('novel', 'novel'); } - async searchByTitle(title: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by Title`); + async searchByTitle(title: string): Promise> { + Logger.log(`MDB | api "${this.apiName}" queried by Title`); const client = createClient({ baseUrl: 'https://api.jikan.moe/v4/' }); @@ -47,7 +52,12 @@ export class MALAPIManga extends APIModel { }); if (response.error !== undefined) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); + 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; @@ -93,11 +103,11 @@ export class MALAPIManga extends APIModel { ); } - return ret; + return ok(ret); } - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); + async getById(id: string): Promise> { + Logger.log(`MDB | api "${this.apiName}" queried by ID`); const client = createClient({ baseUrl: 'https://api.jikan.moe/v4/' }); @@ -111,13 +121,23 @@ export class MALAPIManga extends APIModel { }); if (response.error !== undefined) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); + 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) { - throw Error(`MDB | No data found for ID ${id} in ${this.apiName}.`); + 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(); @@ -125,35 +145,37 @@ export class MALAPIManga extends APIModel { const year = result.published?.prop?.from?.year?.toString() ?? ''; const new_id = result.mal_id?.toString(); - return 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, + 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, + 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, + 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, - }, - }); + userData: { + read: false, + lastRead: '', + personalRating: 0, + }, + }), + ); } getDisabledMediaTypes(): MediaType[] { return this.plugin.settings.MALAPIManga_disabledMediaTypes; diff --git a/packages/obsidian/src/api/apis/MusicBrainzAPI.ts b/packages/obsidian/src/api/apis/MusicBrainzAPI.ts new file mode 100644 index 0000000..a61d7d0 --- /dev/null +++ b/packages/obsidian/src/api/apis/MusicBrainzAPI.ts @@ -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> { + 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> { + 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')}`; +} diff --git a/packages/obsidian/src/api/apis/OMDbAPI.ts b/packages/obsidian/src/api/apis/OMDbAPI.ts new file mode 100644 index 0000000..2439378 --- /dev/null +++ b/packages/obsidian/src/api/apis/OMDbAPI.ts @@ -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; + 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(); + this.typeMappings.set('movie', 'movie'); + this.typeMappings.set('series', 'series'); + this.typeMappings.set('game', 'game'); + } + + async searchByTitle(title: string): Promise> { + 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> { + 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; + } +} diff --git a/packages/obsidian/src/api/apis/OpenLibraryAPI.ts b/packages/obsidian/src/api/apis/OpenLibraryAPI.ts new file mode 100644 index 0000000..6a52bdb --- /dev/null +++ b/packages/obsidian/src/api/apis/OpenLibraryAPI.ts @@ -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> { + Logger.log(`MDB | api "${this.apiName}" queried by Title`); + + const client = createClient({ 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> { + Logger.log(`MDB | api "${this.apiName}" queried by ID`); + + const client = createClient({ 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; + } +} diff --git a/packages/obsidian/src/api/apis/RAWGAPI.ts b/packages/obsidian/src/api/apis/RAWGAPI.ts new file mode 100644 index 0000000..21d603b --- /dev/null +++ b/packages/obsidian/src/api/apis/RAWGAPI.ts @@ -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> { + 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> { + 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 ?? []; + } +} diff --git a/src/api/apis/SteamAPI.ts b/packages/obsidian/src/api/apis/SteamAPI.ts similarity index 50% rename from src/api/apis/SteamAPI.ts rename to packages/obsidian/src/api/apis/SteamAPI.ts index b8e72b2..8a47efa 100644 --- a/src/api/apis/SteamAPI.ts +++ b/packages/obsidian/src/api/apis/SteamAPI.ts @@ -1,10 +1,15 @@ import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import { GameModel } from '../../models/GameModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MediaType } from '../../utils/MediaType'; -import { imageUrlExists } from '../../utils/Utils'; -import { APIModel } from '../APIModel'; +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; @@ -143,16 +148,34 @@ export class SteamAPI extends APIModel { this.typeMappings.set('game', 'game'); } - async searchByTitle(title: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by Title`); + async searchByTitle(title: string): Promise> { + Logger.log(`MDB | api "${this.apiName}" queried by Title`); const searchUrl = `https://steamcommunity.com/actions/SearchApps/${encodeURIComponent(title)}`; - const fetchData = await requestUrl({ - url: searchUrl, - }); + 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) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); + 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[]; @@ -174,19 +197,37 @@ export class SteamAPI extends APIModel { ); } - return ret; + return ok(ret); } - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); + async getById(id: string): Promise> { + Logger.log(`MDB | api "${this.apiName}" queried by ID`); const searchUrl = `https://store.steampowered.com/api/appdetails?appids=${encodeURIComponent(id)}&l=en`; - const fetchData = await requestUrl({ - url: searchUrl, - }); + 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) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); + 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); @@ -200,14 +241,27 @@ export class SteamAPI extends APIModel { } } if (!result) { - throw Error(`MDB | API returned invalid data.`); + 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 exists = await imageUrlExists(imageUrl); + 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; @@ -215,29 +269,31 @@ export class SteamAPI extends APIModel { finalimageurl = result.header_image ?? ''; } - return 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(), + 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, + 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), + released: !result.release_date?.coming_soon, + releaseDate: this.plugin.dateFormatter.format(result.release_date?.date, this.apiDateFormat), - userData: { - played: false, - personalRating: 0, - }, - }); + userData: { + played: false, + personalRating: 0, + }, + }), + ); } getDisabledMediaTypes(): MediaType[] { return this.plugin.settings.SteamAPI_disabledMediaTypes; diff --git a/packages/obsidian/src/api/apis/TMDBMovieAPI.ts b/packages/obsidian/src/api/apis/TMDBMovieAPI.ts new file mode 100644 index 0000000..9f2dbf1 --- /dev/null +++ b/packages/obsidian/src/api/apis/TMDBMovieAPI.ts @@ -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; + 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(); + this.typeMappings.set('movie', 'movie'); + } + + async searchByTitle(title: string): Promise> { + 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({ 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> { + 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({ 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; + } +} diff --git a/packages/obsidian/src/api/apis/TMDBSeasonAPI.ts b/packages/obsidian/src/api/apis/TMDBSeasonAPI.ts new file mode 100644 index 0000000..867879e --- /dev/null +++ b/packages/obsidian/src/api/apis/TMDBSeasonAPI.ts @@ -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; + 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(); + this.typeMappings.set('tv', 'season'); + } + + async searchByTitle(title: string): Promise> { + 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({ 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> { + 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({ 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> { + 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 "/season/".`, + 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({ 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 []; + } +} diff --git a/packages/obsidian/src/api/apis/TMDBSeriesAPI.ts b/packages/obsidian/src/api/apis/TMDBSeriesAPI.ts new file mode 100644 index 0000000..40f68d7 --- /dev/null +++ b/packages/obsidian/src/api/apis/TMDBSeriesAPI.ts @@ -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; + 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(); + this.typeMappings.set('tv', 'series'); + } + + async searchByTitle(title: string): Promise> { + 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({ 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> { + 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({ 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; + } +} diff --git a/packages/obsidian/src/api/apis/VNDBAPI.ts b/packages/obsidian/src/api/apis/VNDBAPI.ts new file mode 100644 index 0000000..eae27de --- /dev/null +++ b/packages/obsidian/src/api/apis/VNDBAPI.ts @@ -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> { + 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> { + 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> { + const result = await this.postQuery('/release', body); + return result.ok ? ok(result.value as ReleaseJSONResponse) : err(result.error); + } + + async searchByTitle(title: string): Promise> { + 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> { + 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; + } +} diff --git a/packages/obsidian/src/api/apis/WikipediaAPI.ts b/packages/obsidian/src/api/apis/WikipediaAPI.ts new file mode 100644 index 0000000..2765786 --- /dev/null +++ b/packages/obsidian/src/api/apis/WikipediaAPI.ts @@ -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; + }; +} + +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> { + 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 }; + 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> { + 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 }; + 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; + } +} diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts new file mode 100644 index 0000000..3bddc83 --- /dev/null +++ b/packages/obsidian/src/main.ts @@ -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 { + 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 { + 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 { + this.mediaTypeManager.updateTemplates(this.settings); + this.mediaTypeManager.updateFolders(this.settings); + this.dateFormatter.setFormat(this.settings.customDateFormat); + + await this.saveData(this.settings); + } +} diff --git a/src/modals/ConfirmOverwriteModal.ts b/packages/obsidian/src/modals/ConfirmOverwriteModal.ts similarity index 100% rename from src/modals/ConfirmOverwriteModal.ts rename to packages/obsidian/src/modals/ConfirmOverwriteModal.ts diff --git a/src/modals/MediaDbAdvancedSearchModal.ts b/packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts similarity index 86% rename from src/modals/MediaDbAdvancedSearchModal.ts rename to packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts index e0e8020..04f0598 100644 --- a/src/modals/MediaDbAdvancedSearchModal.ts +++ b/packages/obsidian/src/modals/MediaDbAdvancedSearchModal.ts @@ -1,8 +1,8 @@ import type { ButtonComponent } from 'obsidian'; import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian'; -import type MediaDbPlugin from '../main'; -import type { AdvancedSearchModalData, AdvancedSearchModalOptions } from '../utils/ModalHelper'; -import { ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; +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; @@ -71,7 +71,7 @@ export class MediaDbAdvancedSearchModal extends Modal { const placeholder = 'Search by title'; const searchComponent = new TextComponent(contentEl); - searchComponent.inputEl.style.width = '100%'; + searchComponent.inputEl.addClass('media-db-plugin-search-input'); searchComponent.setPlaceholder(placeholder); searchComponent.setValue(this.query); searchComponent.onChange(value => (this.query = value)); @@ -85,13 +85,13 @@ export class MediaDbAdvancedSearchModal extends Modal { // const apiToggleComponents: Component[] = []; for (const api of this.plugin.apiManager.apis) { - const apiToggleListElementWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); + const apiToggleListElementWrapper = contentEl.createDiv({ cls: 'media-db-plugin-list-wrapper' }); - const apiToggleTextWrapper = apiToggleListElementWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); - apiToggleTextWrapper.createEl('span', { text: api.apiName, cls: 'media-db-plugin-list-text' }); + 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.createEl('div', { cls: 'media-db-plugin-list-toggle' }); + const apiToggleComponentWrapper = apiToggleListElementWrapper.createDiv({ cls: 'media-db-plugin-list-toggle' }); const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper); apiToggleComponent.setTooltip(api.apiName); diff --git a/src/modals/MediaDbBulkImportModal.ts b/packages/obsidian/src/modals/MediaDbBulkImportModal.ts similarity index 79% rename from src/modals/MediaDbBulkImportModal.ts rename to packages/obsidian/src/modals/MediaDbBulkImportModal.ts index 0763d32..0b5c3d4 100644 --- a/src/modals/MediaDbBulkImportModal.ts +++ b/packages/obsidian/src/modals/MediaDbBulkImportModal.ts @@ -1,8 +1,8 @@ import type { ButtonComponent } from 'obsidian'; import { DropdownComponent, Modal, Setting, TextComponent, ToggleComponent } from 'obsidian'; -import type { APIModel } from '../api/APIModel'; -import type MediaDbPlugin from '../main'; -import { BulkImportLookupMethod } from '../utils/BulkImportHelper'; +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; @@ -47,14 +47,14 @@ export class MediaDbBulkImportModal extends Modal { contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); contentEl.createEl('h3', { text: 'Append note content to Media DB entry?' }); - const appendContentToggleElementWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); - const appendContentToggleTextWrapper = appendContentToggleElementWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); - appendContentToggleTextWrapper.createEl('span', { + 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.createEl('div', { cls: 'media-db-plugin-list-toggle' }); + const appendContentToggleComponentWrapper = appendContentToggleElementWrapper.createDiv({ cls: 'media-db-plugin-list-toggle' }); const appendContentToggle = new ToggleComponent(appendContentToggleElementWrapper); appendContentToggle.setValue(false); @@ -81,9 +81,9 @@ export class MediaDbBulkImportModal extends Modal { contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); - const fieldNameWrapperEl = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); - const fieldNameLabelWrapperEl = fieldNameWrapperEl.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); - fieldNameLabelWrapperEl.createEl('span', { text: 'Using the property named', cls: 'media-db-plugin-list-text' }); + 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'); @@ -115,9 +115,9 @@ export class MediaDbBulkImportModal extends Modal { } createDropdownEl(parentEl: HTMLElement, label: string, onChange: (value: string) => void, options: { value: string; display: string }[]): void { - const wrapperEl = parentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); - const labelWrapperEl = wrapperEl.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); - labelWrapperEl.createEl('span', { text: label, cls: 'media-db-plugin-list-text' }); + 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); diff --git a/src/modals/MediaDbIdSearchModal.ts b/packages/obsidian/src/modals/MediaDbIdSearchModal.ts similarity index 82% rename from src/modals/MediaDbIdSearchModal.ts rename to packages/obsidian/src/modals/MediaDbIdSearchModal.ts index 7027bb5..1f65d30 100644 --- a/src/modals/MediaDbIdSearchModal.ts +++ b/packages/obsidian/src/modals/MediaDbIdSearchModal.ts @@ -1,8 +1,8 @@ import type { ButtonComponent } from 'obsidian'; import { DropdownComponent, Modal, Notice, Setting, TextComponent } from 'obsidian'; -import type MediaDbPlugin from '../main'; -import type { IdSearchModalData, IdSearchModalOptions } from '../utils/ModalHelper'; -import { ID_SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; +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; @@ -69,7 +69,7 @@ export class MediaDbIdSearchModal extends Modal { const placeholder = 'Search by id'; const searchComponent = new TextComponent(contentEl); - searchComponent.inputEl.style.width = '100%'; + 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)); @@ -79,9 +79,9 @@ export class MediaDbIdSearchModal extends Modal { contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); - const apiSelectorWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); - const apiSelectorTExtWrapper = apiSelectorWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); - apiSelectorTExtWrapper.createEl('span', { text: 'API to search', cls: 'media-db-plugin-list-text' }); + 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) => { diff --git a/src/modals/MediaDbPreviewModal.ts b/packages/obsidian/src/modals/MediaDbPreviewModal.ts similarity index 80% rename from src/modals/MediaDbPreviewModal.ts rename to packages/obsidian/src/modals/MediaDbPreviewModal.ts index 1efd66a..6bf3848 100644 --- a/src/modals/MediaDbPreviewModal.ts +++ b/packages/obsidian/src/modals/MediaDbPreviewModal.ts @@ -1,8 +1,9 @@ import { Component, MarkdownRenderer, Modal, Setting } from 'obsidian'; -import type MediaDbPlugin from '../main'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; -import type { PreviewModalData, PreviewModalOptions } from '../utils/ModalHelper'; -import { PREVIEW_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; +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; @@ -48,14 +49,14 @@ export class MediaDbPreviewModal extends Modal { previewWrapper.createEl('h3', { text: result.englishTitle }); const fileDiv = previewWrapper.createDiv({ cls: 'media-db-plugin-preview' }); - let fileContent = this.plugin.generateMediaDbNoteFrontmatterPreview(result); + 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) { - console.warn(`mdb | error during rendering of preview`, e); + Logger.warn(`mdb | error during rendering of preview`, e); } } diff --git a/src/modals/MediaDbSearchModal.ts b/packages/obsidian/src/modals/MediaDbSearchModal.ts similarity index 82% rename from src/modals/MediaDbSearchModal.ts rename to packages/obsidian/src/modals/MediaDbSearchModal.ts index 800eace..e62828d 100644 --- a/src/modals/MediaDbSearchModal.ts +++ b/packages/obsidian/src/modals/MediaDbSearchModal.ts @@ -1,11 +1,11 @@ import type { ButtonComponent } from 'obsidian'; import { Modal, Notice, Setting, TextComponent, ToggleComponent } from 'obsidian'; -import type MediaDbPlugin from '../main'; -import type { MediaType } from '../utils/MediaType'; -import { MEDIA_TYPES } from '../utils/MediaTypeManager'; -import type { SearchModalData, SearchModalOptions } from '../utils/ModalHelper'; -import { SEARCH_MODAL_DEFAULT_OPTIONS } from '../utils/ModalHelper'; -import { unCamelCase } from '../utils/Utils'; +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; @@ -76,7 +76,7 @@ export class MediaDbSearchModal extends Modal { const searchComponent = new TextComponent(contentEl); let currentToggle: ToggleComponent | undefined = undefined; - searchComponent.inputEl.style.width = '100%'; + searchComponent.inputEl.addClass('media-db-plugin-search-input'); searchComponent.setPlaceholder(placeholder); searchComponent.setValue(this.query); searchComponent.onChange(value => (this.query = value)); @@ -89,12 +89,12 @@ export class MediaDbSearchModal extends Modal { contentEl.createEl('h3', { text: 'APIs to search' }); for (const mediaType of MEDIA_TYPES) { - const apiToggleListElementWrapper = contentEl.createEl('div', { cls: 'media-db-plugin-list-wrapper' }); + const apiToggleListElementWrapper = contentEl.createDiv({ cls: 'media-db-plugin-list-wrapper' }); - const apiToggleTextWrapper = apiToggleListElementWrapper.createEl('div', { cls: 'media-db-plugin-list-text-wrapper' }); - apiToggleTextWrapper.createEl('span', { text: unCamelCase(mediaType), cls: 'media-db-plugin-list-text' }); + 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.createEl('div', { cls: 'media-db-plugin-list-toggle' }); + const apiToggleComponentWrapper = apiToggleListElementWrapper.createDiv({ cls: 'media-db-plugin-list-toggle' }); const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper); apiToggleComponent.setTooltip(unCamelCase(mediaType)); diff --git a/src/modals/MediaDbSearchResultModal.ts b/packages/obsidian/src/modals/MediaDbSearchResultModal.ts similarity index 80% rename from src/modals/MediaDbSearchResultModal.ts rename to packages/obsidian/src/modals/MediaDbSearchResultModal.ts index 786df40..e513663 100644 --- a/src/modals/MediaDbSearchResultModal.ts +++ b/packages/obsidian/src/modals/MediaDbSearchResultModal.ts @@ -1,8 +1,8 @@ -import type MediaDbPlugin from '../main'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; -import type { SelectModalData, SelectModalOptions } from '../utils/ModalHelper'; -import { SELECTMODALOPTIONSDEFAULT } from '../utils/ModalHelper'; -import { SelectModal } from './SelectModal'; +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 { plugin: MediaDbPlugin; @@ -41,7 +41,7 @@ export class MediaDbSearchResultModal extends SelectModal { // Renders each suggestion item. renderElement(item: MediaTypeModel, el: HTMLElement): void { - el.createEl('div', { text: this.plugin.mediaTypeManager.getFileName(item) }); + 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}` }); } diff --git a/src/modals/MediaDbSeasonSelectModal.ts b/packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts similarity index 88% rename from src/modals/MediaDbSeasonSelectModal.ts rename to packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts index 0066c31..6508c63 100644 --- a/src/modals/MediaDbSeasonSelectModal.ts +++ b/packages/obsidian/src/modals/MediaDbSeasonSelectModal.ts @@ -1,5 +1,5 @@ -import type MediaDbPlugin from '../main'; -import { SelectModal } from './SelectModal'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { SelectModal } from 'packages/obsidian/src/modals/SelectModal'; export interface SeasonSelectModalElement { season_number: number; @@ -24,7 +24,7 @@ export class MediaDbSeasonSelectModal extends SelectModal extends Modal { allowMultiSelect: boolean; @@ -48,7 +48,7 @@ export abstract class SelectModal extends Modal { this.activateHighlighted(); }); this.scope.register([], ' ', evt => { - if (this.elementWrapper && this.elementWrapper === document.activeElement) { + if (this.elementWrapper && this.elementWrapper === activeDocument.activeElement) { this.activateHighlighted(); evt.preventDefault(); } diff --git a/src/modals/SelectModalElement.ts b/packages/obsidian/src/modals/SelectModalElement.ts similarity index 96% rename from src/modals/SelectModalElement.ts rename to packages/obsidian/src/modals/SelectModalElement.ts index 70c09f1..7b78d24 100644 --- a/src/modals/SelectModalElement.ts +++ b/packages/obsidian/src/modals/SelectModalElement.ts @@ -1,4 +1,4 @@ -import type { SelectModal } from './SelectModal'; +import type { SelectModal } from 'packages/obsidian/src/modals/SelectModal'; export class SelectModalElement { selectModal: SelectModal; diff --git a/src/models/BoardGameModel.ts b/packages/obsidian/src/models/BoardGameModel.ts similarity index 78% rename from src/models/BoardGameModel.ts rename to packages/obsidian/src/models/BoardGameModel.ts index e782137..1297b97 100644 --- a/src/models/BoardGameModel.ts +++ b/packages/obsidian/src/models/BoardGameModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/models/BookModel.ts b/packages/obsidian/src/models/BookModel.ts similarity index 77% rename from src/models/BookModel.ts rename to packages/obsidian/src/models/BookModel.ts index e75da93..ff5ba65 100644 --- a/src/models/BookModel.ts +++ b/packages/obsidian/src/models/BookModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/models/ComicMangaModel.ts b/packages/obsidian/src/models/ComicMangaModel.ts similarity index 82% rename from src/models/ComicMangaModel.ts rename to packages/obsidian/src/models/ComicMangaModel.ts index ec47a95..57fc791 100644 --- a/src/models/ComicMangaModel.ts +++ b/packages/obsidian/src/models/ComicMangaModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/models/GameModel.ts b/packages/obsidian/src/models/GameModel.ts similarity index 76% rename from src/models/GameModel.ts rename to packages/obsidian/src/models/GameModel.ts index 5661f6e..536ee13 100644 --- a/src/models/GameModel.ts +++ b/packages/obsidian/src/models/GameModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/models/MediaTypeModel.ts b/packages/obsidian/src/models/MediaTypeModel.ts similarity index 92% rename from src/models/MediaTypeModel.ts rename to packages/obsidian/src/models/MediaTypeModel.ts index e4d03a0..cdadbb7 100644 --- a/src/models/MediaTypeModel.ts +++ b/packages/obsidian/src/models/MediaTypeModel.ts @@ -1,4 +1,4 @@ -import type { MediaType } from '../utils/MediaType'; +import type { MediaType } from 'packages/obsidian/src/utils/MediaType'; export abstract class MediaTypeModel { type: string; diff --git a/src/models/MovieModel.ts b/packages/obsidian/src/models/MovieModel.ts similarity index 82% rename from src/models/MovieModel.ts rename to packages/obsidian/src/models/MovieModel.ts index 563a0f9..a386615 100644 --- a/src/models/MovieModel.ts +++ b/packages/obsidian/src/models/MovieModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/models/MusicReleaseModel.ts b/packages/obsidian/src/models/MusicReleaseModel.ts similarity index 81% rename from src/models/MusicReleaseModel.ts rename to packages/obsidian/src/models/MusicReleaseModel.ts index 38427a9..decb5d5 100644 --- a/src/models/MusicReleaseModel.ts +++ b/packages/obsidian/src/models/MusicReleaseModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/models/SeasonModel.ts b/packages/obsidian/src/models/SeasonModel.ts similarity index 82% rename from src/models/SeasonModel.ts rename to packages/obsidian/src/models/SeasonModel.ts index fc4e0a3..b295d36 100644 --- a/src/models/SeasonModel.ts +++ b/packages/obsidian/src/models/SeasonModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/models/SeriesModel.ts b/packages/obsidian/src/models/SeriesModel.ts similarity index 82% rename from src/models/SeriesModel.ts rename to packages/obsidian/src/models/SeriesModel.ts index b87a32c..974d019 100644 --- a/src/models/SeriesModel.ts +++ b/packages/obsidian/src/models/SeriesModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/models/WikiModel.ts b/packages/obsidian/src/models/WikiModel.ts similarity index 75% rename from src/models/WikiModel.ts rename to packages/obsidian/src/models/WikiModel.ts index ee49c2d..8216d15 100644 --- a/src/models/WikiModel.ts +++ b/packages/obsidian/src/models/WikiModel.ts @@ -1,7 +1,7 @@ -import { MediaType } from '../utils/MediaType'; -import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; -import { MediaTypeModel } from './MediaTypeModel'; +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; diff --git a/src/settings/Icon.tsx b/packages/obsidian/src/settings/Icon.tsx similarity index 100% rename from src/settings/Icon.tsx rename to packages/obsidian/src/settings/Icon.tsx diff --git a/src/settings/PropertyMapper.ts b/packages/obsidian/src/settings/PropertyMapper.ts similarity index 87% rename from src/settings/PropertyMapper.ts rename to packages/obsidian/src/settings/PropertyMapper.ts index b34f245..831bb7d 100644 --- a/src/settings/PropertyMapper.ts +++ b/packages/obsidian/src/settings/PropertyMapper.ts @@ -1,7 +1,8 @@ -import type MediaDbPlugin from '../main'; -import type { MediaType } from '../utils/MediaType'; -import { MEDIA_TYPES } from '../utils/MediaTypeManager'; -import { PropertyMappingOption } from './PropertyMapping'; +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; @@ -75,7 +76,7 @@ export class PropertyMapper { if (obj.type === 'manga') { obj.type = 'comicManga'; - console.debug(`MDB | updated metadata type`, obj.type); + Logger.debug(`MDB | updated metadata type`, obj.type); } if (!MEDIA_TYPES.includes(obj.type as MediaType)) { return obj; diff --git a/src/settings/PropertyMapping.ts b/packages/obsidian/src/settings/PropertyMapping.ts similarity index 96% rename from src/settings/PropertyMapping.ts rename to packages/obsidian/src/settings/PropertyMapping.ts index 82d044e..62e86c8 100644 --- a/src/settings/PropertyMapping.ts +++ b/packages/obsidian/src/settings/PropertyMapping.ts @@ -1,5 +1,6 @@ -import type { MediaType } from '../utils/MediaType'; -import { containsOnlyLettersAndUnderscores, PropertyMappingNameConflictError, PropertyMappingValidationError } from '../utils/Utils'; +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 { @@ -33,7 +34,7 @@ export class PropertyMappingModel { } validate(): { res: boolean; err?: Error } { - console.debug(`MDB | validated property mappings for ${this.type}`); + Logger.debug(`MDB | validated property mappings for ${this.type}`); // check properties for (const property of this.properties) { diff --git a/src/settings/PropertyMappingModelComponent.tsx b/packages/obsidian/src/settings/PropertyMappingModelComponent.tsx similarity index 100% rename from src/settings/PropertyMappingModelComponent.tsx rename to packages/obsidian/src/settings/PropertyMappingModelComponent.tsx diff --git a/src/settings/PropertyMappingModelsComponent.tsx b/packages/obsidian/src/settings/PropertyMappingModelsComponent.tsx similarity index 100% rename from src/settings/PropertyMappingModelsComponent.tsx rename to packages/obsidian/src/settings/PropertyMappingModelsComponent.tsx diff --git a/src/settings/Settings.ts b/packages/obsidian/src/settings/Settings.ts similarity index 91% rename from src/settings/Settings.ts rename to packages/obsidian/src/settings/Settings.ts index c7e694a..40ceb8d 100644 --- a/src/settings/Settings.ts +++ b/packages/obsidian/src/settings/Settings.ts @@ -1,16 +1,51 @@ 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'; -import type MediaDbPlugin from '../main'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; -import { MediaType } from '../utils/MediaType'; -import { MEDIA_TYPES } from '../utils/MediaTypeManager'; -import { fragWithHTML, unCamelCase } from '../utils/Utils'; -import type { PropertyMappingModelData } from './PropertyMapping'; -import { PropertyMapping, PropertyMappingModel, PropertyMappingOption } from './PropertyMapping'; -import PropertyMappingModelsComponent from './PropertyMappingModelsComponent'; -import { FileSuggest } from './suggesters/FileSuggest'; -import { FolderSuggest } from './suggesters/FolderSuggest'; + +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 { @@ -435,22 +470,14 @@ export class MediaDbSettingTab extends PluginSettingTab { setting => void setting .setName('Date format') - .setDesc( - fragWithHTML( - "Your custom date format. Use 'YYYY-MM-DD' for example.
" + - "For more syntax, refer to format reference.
" + - "Your current syntax looks like this: " + - this.plugin.dateFormatter.getPreview() + - '', - ), - ) + .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 = document.getElementById('media-db-dateformat-preview'); + const previewEl = activeDocument.getElementById('media-db-dateformat-preview'); if (previewEl) { previewEl.textContent = this.plugin.dateFormatter.getPreview(newDateFormat); // update preview } @@ -809,15 +836,7 @@ export class MediaDbSettingTab extends PluginSettingTab { const mappingGroup = new SettingGroup(containerEl); mappingGroup.setHeading('Property mappings'); mappingGroup.addSetting(setting => { - setting - .setName('Property mappings explanation') - .setDesc( - fragWithHTML( - '

Here you can customize how metadata fields are mapped to property names in the front matter of the created notes.

' + - '

You can choose to keep the original name, rename the property, or remove it entirely.

' + - '

Remember to save your changes using the save button for each individual category.

', - ), - ); + setting.setName('Property mappings explanation').setDesc(createPropertyMappingsDescription()); render( () => diff --git a/src/settings/suggesters/FileSuggest.ts b/packages/obsidian/src/settings/suggesters/FileSuggest.ts similarity index 100% rename from src/settings/suggesters/FileSuggest.ts rename to packages/obsidian/src/settings/suggesters/FileSuggest.ts diff --git a/src/settings/suggesters/FolderSuggest.ts b/packages/obsidian/src/settings/suggesters/FolderSuggest.ts similarity index 100% rename from src/settings/suggesters/FolderSuggest.ts rename to packages/obsidian/src/settings/suggesters/FolderSuggest.ts diff --git a/src/styles.css b/packages/obsidian/src/styles.css similarity index 99% rename from src/styles.css rename to packages/obsidian/src/styles.css index b204d1d..18a0f55 100644 --- a/src/styles.css +++ b/packages/obsidian/src/styles.css @@ -64,6 +64,10 @@ small.media-db-plugin-list-text { margin-bottom: 10px; } +.media-db-plugin-search-input { + width: 100%; +} + .media-db-plugin-button:focus { /*outline: 1px solid white;*/ } diff --git a/packages/obsidian/src/utils/AppError.ts b/packages/obsidian/src/utils/AppError.ts new file mode 100644 index 0000000..50db684 --- /dev/null +++ b/packages/obsidian/src/utils/AppError.ts @@ -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; +} + +export const appError = (error: AppError): AppError => error; + +export const toAppError = (cause: unknown, fallback: Omit): AppError => { + const message = cause instanceof Error ? cause.message : String(cause); + return { ...fallback, message: fallback.message || message, cause }; +}; diff --git a/src/utils/BulkImportHelper.ts b/packages/obsidian/src/utils/BulkImportHelper.ts similarity index 67% rename from src/utils/BulkImportHelper.ts rename to packages/obsidian/src/utils/BulkImportHelper.ts index da4b1a4..f91ae37 100644 --- a/src/utils/BulkImportHelper.ts +++ b/packages/obsidian/src/utils/BulkImportHelper.ts @@ -1,10 +1,10 @@ import type { TFolder } from 'obsidian'; import { TFile } from 'obsidian'; -import type MediaDbPlugin from '../main'; -import { MediaDbBulkImportModal as MediaDbBulkImportModal } from '../modals/MediaDbBulkImportModal'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; -import { ModalResultCode } from './ModalHelper'; -import { dateTimeToString, markdownTable } from './Utils'; +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', @@ -50,7 +50,7 @@ export class BulkImportHelper { continue; } - const metadata = this.plugin.getMetadataFromFileCache(file); + const metadata = this.plugin.fileHelper.getMetadataFromFileCache(file); const lookupValue = metadata[fieldName]; if (!lookupValue || typeof lookupValue !== 'string') { @@ -81,60 +81,54 @@ export class BulkImportHelper { } private async importById(file: TFile, lookupValue: string, selectedAPI: string, appendContent: boolean): Promise { - try { - const model = await this.plugin.apiManager.queryDetailedInfoById(lookupValue, selectedAPI); - if (model) { - await this.plugin.createMediaDbNotes([model], appendContent ? file : undefined); - return undefined; - } else { - return { filePath: file.path, error: `Failed to query API with id: ${lookupValue}` }; - } - } catch (e) { - return { filePath: file.path, error: `${e}` }; + 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 { - let results: MediaTypeModel[] = []; - try { - results = await this.plugin.apiManager.query(lookupValue, [selectedAPI]); - } catch (e) { - return { filePath: file.path, error: `${e}` }; + 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, selectModal } = await this.plugin.modalHelper.createSelectModal({ + const selectModalResult = await this.plugin.modalHelper.createSelectModalOutcome({ elements: results, skipButton: true, modalTitle: `Results for '${lookupValue}'`, }); - if (selectModalResult.code === ModalResultCode.ERROR) { - selectModal.close(); - return { filePath: file.path, error: selectModalResult.error.message }; + if (selectModalResult.status === OutcomeStatus.Error) { + return { filePath: file.path, error: selectModalResult.error.userMessage ?? selectModalResult.error.message }; } - if (selectModalResult.code === ModalResultCode.CLOSE) { - selectModal.close(); + if (selectModalResult.status === OutcomeStatus.Cancelled) { return { filePath: file.path, error: 'user canceled', canceled: true }; } - if (selectModalResult.code === ModalResultCode.SKIP) { - selectModal.close(); + if (selectModalResult.status === OutcomeStatus.Skipped) { return { filePath: file.path, error: 'user skipped' }; } if (selectModalResult.data.selected.length === 0) { - selectModal.close(); return { filePath: file.path, error: `no search results selected` }; } - const detailedResults = await this.plugin.queryDetails(selectModalResult.data.selected); - await this.plugin.createMediaDbNotes(detailedResults, appendContent ? file : undefined); - - selectModal.close(); + const detailedResults = await this.plugin.entryHelper.queryDetails(selectModalResult.data.selected); + await this.plugin.fileHelper.createMediaDbNotes(detailedResults, appendContent ? file : undefined); return undefined; } diff --git a/src/utils/DateFormatter.ts b/packages/obsidian/src/utils/DateFormatter.ts similarity index 80% rename from src/utils/DateFormatter.ts rename to packages/obsidian/src/utils/DateFormatter.ts index 31169e5..bc12064 100644 --- a/src/utils/DateFormatter.ts +++ b/packages/obsidian/src/utils/DateFormatter.ts @@ -1,8 +1,6 @@ -import type momentType from 'moment'; -import type { Moment } from 'moment'; -import { moment as obsidianMoment } from 'obsidian'; +import { moment } from 'obsidian'; -const moment: typeof momentType = obsidianMoment as unknown as typeof momentType; +const momentFn = 'default' in moment ? moment.default : moment; export class DateFormatter { private static readonly RFC2822_FORMAT = 'ddd, DD MMM YYYY HH:mm:ss ZZ'; @@ -21,7 +19,7 @@ export class DateFormatter { } getPreview(format?: string): string { - const today = moment(); + const today = momentFn(); format ??= this.toFormat; @@ -44,32 +42,32 @@ export class DateFormatter { return null; } - let date: Moment; + let date: moment.Moment; if (!dateFormat) { date = this.parseWithoutFormat(dateString); } else { - date = moment(dateString, dateFormat, locale); + 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 { + 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 moment(dateString); + return momentFn(dateString); } // fall back to native Date parsing for unknown formats - return moment(new Date(dateString)); + return momentFn(new Date(dateString)); } private hasMomentFormat(dateString: string): boolean { - const date = moment(dateString, [moment.ISO_8601, DateFormatter.RFC2822_FORMAT], true); // strict mode + const date = momentFn(dateString, [moment.ISO_8601, DateFormatter.RFC2822_FORMAT], true); // strict mode return date.isValid(); } } diff --git a/packages/obsidian/src/utils/ErrorReporter.ts b/packages/obsidian/src/utils/ErrorReporter.ts new file mode 100644 index 0000000..4155ae1 --- /dev/null +++ b/packages/obsidian/src/utils/ErrorReporter.ts @@ -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); + } + } +} diff --git a/src/utils/IconList.ts b/packages/obsidian/src/utils/IconList.ts similarity index 100% rename from src/utils/IconList.ts rename to packages/obsidian/src/utils/IconList.ts diff --git a/src/utils/IllegalFilenameCharactersList.ts b/packages/obsidian/src/utils/IllegalFilenameCharactersList.ts similarity index 100% rename from src/utils/IllegalFilenameCharactersList.ts rename to packages/obsidian/src/utils/IllegalFilenameCharactersList.ts diff --git a/packages/obsidian/src/utils/Logger.ts b/packages/obsidian/src/utils/Logger.ts new file mode 100644 index 0000000..ab88308 --- /dev/null +++ b/packages/obsidian/src/utils/Logger.ts @@ -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); + } + } +} diff --git a/packages/obsidian/src/utils/MediaDbEntryHelper.ts b/packages/obsidian/src/utils/MediaDbEntryHelper.ts new file mode 100644 index 0000000..222a11c --- /dev/null +++ b/packages/obsidian/src/utils/MediaDbEntryHelper.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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]); + } + }), + ); + } +} diff --git a/packages/obsidian/src/utils/MediaDbFileHelper.ts b/packages/obsidian/src/utils/MediaDbFileHelper.ts new file mode 100644 index 0000000..42f1bdd --- /dev/null +++ b/packages/obsidian/src/utils/MediaDbFileHelper.ts @@ -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; + +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> { + 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> { + 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 { + 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 { + let template = await this.plugin.mediaTypeManager.getTemplate(mediaTypeModel, this.plugin.app); + let fileMetadata: Record; + + 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> { + 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(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> { + 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(operation: () => Promise | T, fallback: Omit): Promise> { + return await Promise.resolve() + .then(operation) + .then( + value => ok(value), + cause => err(toAppError(cause, fallback)), + ); + } +} diff --git a/src/utils/MediaType.ts b/packages/obsidian/src/utils/MediaType.ts similarity index 100% rename from src/utils/MediaType.ts rename to packages/obsidian/src/utils/MediaType.ts diff --git a/src/utils/MediaTypeManager.ts b/packages/obsidian/src/utils/MediaTypeManager.ts similarity index 82% rename from src/utils/MediaTypeManager.ts rename to packages/obsidian/src/utils/MediaTypeManager.ts index d968573..f3ef631 100644 --- a/src/utils/MediaTypeManager.ts +++ b/packages/obsidian/src/utils/MediaTypeManager.ts @@ -1,19 +1,20 @@ -import type { App, TFile } from 'obsidian'; +import type { App } from 'obsidian'; +import { TFile } from 'obsidian'; import { TFolder } from 'obsidian'; -import { BoardGameModel } from '../models/BoardGameModel'; -import { BookModel } from '../models/BookModel'; -import { ComicMangaModel } from '../models/ComicMangaModel'; -import { GameModel } from '../models/GameModel'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; -import { MovieModel } from '../models/MovieModel'; -import { MusicReleaseModel } from '../models/MusicReleaseModel'; -import { SeasonModel } from '../models/SeasonModel'; -import { SeriesModel } from '../models/SeriesModel'; -import { WikiModel } from '../models/WikiModel'; -import type { MediaDbPluginSettings } from '../settings/Settings'; -import { ILLEGAL_FILENAME_CHARACTERS } from './IllegalFilenameCharactersList'; -import { MediaType } from './MediaType'; -import { replaceTags } from './Utils'; +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[] = [ @@ -110,16 +111,16 @@ export class MediaTypeManager { } } - const template = await app.vault.cachedRead(templateFile as TFile); - // console.log(template); + if (!(templateFile instanceof TFile)) { + return ''; + } + + const template = await app.vault.cachedRead(templateFile); return replaceTags(template, mediaTypeModel); } async getFolder(mediaTypeModel: MediaTypeModel, app: App): Promise { - let folderPath = this.mediaFolderMap.get(mediaTypeModel.getMediaType()); - - folderPath ??= `/`; - // console.log(folderPath); + let folderPath = this.mediaFolderMap.get(mediaTypeModel.getMediaType()) ?? '/'; if (!(await app.vault.adapter.exists(folderPath))) { await app.vault.createFolder(folderPath); diff --git a/packages/obsidian/src/utils/ModalHelper.ts b/packages/obsidian/src/utils/ModalHelper.ts new file mode 100644 index 0000000..89a1b24 --- /dev/null +++ b/packages/obsidian/src/utils/ModalHelper.ts @@ -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 { + modalResult: Outcome; + modal: TModal; +} + +export class ModalHelper { + plugin: MediaDbPlugin; + + constructor(plugin: MediaDbPlugin) { + this.plugin = plugin; + } + + private async openModalCore( + createModal: () => TModal, + wireHandlers: (modal: TModal, resolve: (result: Outcome) => void) => void, + ): Promise> { + const modal = createModal(); + const modalResult = await new Promise>(resolve => { + wireHandlers(modal, resolve); + modal.open(); + }); + + return { modalResult, modal }; + } + + private async resolveOutcome(outcomePromise: Promise>): Promise { + 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> { + const { modalResult, modal } = await this.openModalCore( + () => 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 { + return await this.resolveOutcome(this.createSearchModalOutcome(searchModalOptions)); + } + + async createAdvancedSearchModalOutcome(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise> { + const { modalResult, modal } = await this.openModalCore( + () => 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 { + return await this.resolveOutcome(this.createAdvancedSearchModalOutcome(advancedSearchModalOptions)); + } + + async createIdSearchModalOutcome(idSearchModalOptions: IdSearchModalOptions): Promise> { + const { modalResult, modal } = await this.openModalCore( + () => 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 { + return await this.resolveOutcome(this.createIdSearchModalOutcome(idSearchModalOptions)); + } + + async createSelectModalOutcome(selectModalOptions: SelectModalOptions): Promise> { + const { modalResult, modal } = await this.openModalCore( + () => 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 { + return await this.resolveOutcome(this.createSelectModalOutcome(selectModalOptions)); + } + + async createPreviewModalOutcome(previewModalOptions: PreviewModalOptions): Promise> { + const { modalResult, modal } = await this.openModalCore( + () => 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 { + return await this.resolveOutcome(this.createPreviewModalOutcome(previewModalOptions)); + } +} diff --git a/src/utils/Utils.ts b/packages/obsidian/src/utils/Utils.ts similarity index 96% rename from src/utils/Utils.ts rename to packages/obsidian/src/utils/Utils.ts index 9e4f286..cc5b2bd 100644 --- a/src/utils/Utils.ts +++ b/packages/obsidian/src/utils/Utils.ts @@ -1,13 +1,12 @@ import { iso6392 } from 'iso-639-2'; import type { TFile, TFolder, App } from 'obsidian'; import { requestUrl } from 'obsidian'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; +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.5.2'; -export const debug: boolean = true; +export const mediaDbVersion: string = '0.8.0'; export function wrapAround(value: number, size: number): number { if (size <= 0) { @@ -153,10 +152,6 @@ export function markdownTable(content: string[][]): string { return table; } -export function fragWithHTML(html: string): DocumentFragment { - return createFragment(frag => (frag.createDiv().innerHTML = html)); -} - export function dateToString(date: Date): string { return `${date.getMonth() + 1}-${date.getDate()}-${date.getFullYear()}`; } @@ -240,7 +235,7 @@ function getTemplaterPlugin(app: App): TemplaterPlugin | null { return null; } - return candidate as TemplaterPlugin; + return candidate; } export function hasTemplaterPlugin(app: App): boolean { diff --git a/packages/obsidian/src/utils/result.ts b/packages/obsidian/src/utils/result.ts new file mode 100644 index 0000000..64c71c3 --- /dev/null +++ b/packages/obsidian/src/utils/result.ts @@ -0,0 +1,52 @@ +export interface Ok { + ok: true; + value: T; +} +export interface Err { + ok: false; + error: E; +} +export type Result = Ok | Err; + +export const ok = (value: T): Ok => ({ ok: true, value }); +export const err = (error: E): Err => ({ ok: false, error }); + +export const isOk = (result: Result): result is Ok => result.ok; +export const isErr = (result: Result): result is Err => !result.ok; + +export const mapResult = (result: Result, mapper: (value: T) => U): Result => (result.ok ? ok(mapper(result.value)) : result); + +export const mapError = (result: Result, mapper: (error: E) => F): Result => (result.ok ? result : err(mapper(result.error))); + +export const andThen = (result: Result, binder: (value: T) => Result): Result => (result.ok ? binder(result.value) : result); + +export const tapError = (result: Result, sideEffect: (error: E) => void): Result => { + if (!result.ok) sideEffect(result.error); + return result; +}; + +export const fromPromise = async (promise: Promise, onError: (cause: unknown) => E): Promise> => { + 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 = + | { status: OutcomeStatus.Ok; data: T } + | { status: OutcomeStatus.Cancelled } + | { status: OutcomeStatus.Skipped } + | { status: OutcomeStatus.Error; error: E }; + +export const cancelled = (): Outcome => ({ status: OutcomeStatus.Cancelled }); +export const skipped = (): Outcome => ({ status: OutcomeStatus.Skipped }); +export const success = (data: T): Outcome => ({ status: OutcomeStatus.Ok, data }); +export const failure = (error: E): Outcome => ({ status: OutcomeStatus.Error, error }); diff --git a/src/api/schemas/GiantBomb.json b/packages/schemas/src/GiantBomb.json similarity index 100% rename from src/api/schemas/GiantBomb.json rename to packages/schemas/src/GiantBomb.json diff --git a/src/api/schemas/GiantBomb.ts b/packages/schemas/src/GiantBomb.ts similarity index 100% rename from src/api/schemas/GiantBomb.ts rename to packages/schemas/src/GiantBomb.ts diff --git a/src/api/schemas/MALAPI.ts b/packages/schemas/src/MALAPI.ts similarity index 100% rename from src/api/schemas/MALAPI.ts rename to packages/schemas/src/MALAPI.ts diff --git a/src/api/schemas/OpenLibrary.json b/packages/schemas/src/OpenLibrary.json similarity index 100% rename from src/api/schemas/OpenLibrary.json rename to packages/schemas/src/OpenLibrary.json diff --git a/src/api/schemas/OpenLibrary.ts b/packages/schemas/src/OpenLibrary.ts similarity index 100% rename from src/api/schemas/OpenLibrary.ts rename to packages/schemas/src/OpenLibrary.ts diff --git a/src/api/schemas/TMDB.ts b/packages/schemas/src/TMDB.ts similarity index 100% rename from src/api/schemas/TMDB.ts rename to packages/schemas/src/TMDB.ts diff --git a/packages/schemas/src/fetchSchemas.sh b/packages/schemas/src/fetchSchemas.sh new file mode 100644 index 0000000..e5793ee --- /dev/null +++ b/packages/schemas/src/fetchSchemas.sh @@ -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 \ No newline at end of file diff --git a/src/api/APIManager.ts b/src/api/APIManager.ts deleted file mode 100644 index 00dca1a..0000000 --- a/src/api/APIManager.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Notice } from 'obsidian'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; -import type { APIModel } from './APIModel'; - -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 { - console.debug(`MDB | api manager queried with "${query}"`); - - const promises = this.apis - .filter(api => apisToQuery.includes(api.apiName)) - .map(async api => { - try { - return await api.searchByTitle(query); - } catch (e) { - new Notice(`Error querying ${api.apiName}: ${e}`); - console.warn(e); - - return []; - } - }); - - return (await Promise.all(promises)).flat(); - } - - /** - * Queries detailed information for a MediaTypeModel. - * - * @param item - */ - async queryDetailedInfo(item: MediaTypeModel): Promise { - 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 { - for (const api of this.apis) { - if (api.apiName === apiName) { - try { - return await api.getById(id); - } catch (e) { - new Notice(`Error querying ${api.apiName}: ${e}`); - console.warn(e); - - return undefined; - } - } - } - - return undefined; - } - - 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); - } -} diff --git a/src/api/apis/BoardGameGeekAPI.ts b/src/api/apis/BoardGameGeekAPI.ts deleted file mode 100644 index bddddb7..0000000 --- a/src/api/apis/BoardGameGeekAPI.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import { BoardGameModel } from '../../models/BoardGameModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; - -// 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 { - console.log(`MDB | api "${this.apiName}" queried by Title`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.BoardgameGeekKeyId); - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const searchUrl = `${this.apiUrl}/search?search=${encodeURIComponent(title)}`; - const fetchData = await requestUrl({ - url: searchUrl, - headers: { - Authorization: `Bearer ${key}`, - }, - }); - - if (fetchData.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.BoardgameGeekKeyId); - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const searchUrl = `${this.apiUrl}/boardgame/${encodeURIComponent(id)}?stats=1`; - const fetchData = await requestUrl({ - url: searchUrl, - headers: { - Authorization: `Bearer ${key}`, - }, - }); - - if (fetchData.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - const data = fetchData.text; - const response = new window.DOMParser().parseFromString(data, 'text/xml'); - // console.debug(response); - - const boardgame = response.querySelector('boardgame'); - if (!boardgame) { - throw Error(`MDB | Received invalid data from ${this.apiName}.`); - } - - 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 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; - } -} diff --git a/src/api/apis/ComicVineAPI.ts b/src/api/apis/ComicVineAPI.ts deleted file mode 100644 index da7e7ad..0000000 --- a/src/api/apis/ComicVineAPI.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ - -import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import { ComicMangaModel } from '../../models/ComicMangaModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; - -// 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 { - console.log(`MDB | api "${this.apiName}" queried by Title`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.ComicVineKeyId); - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const searchUrl = `${this.apiUrl}/search/?api_key=${key}&format=json&resources=volume&query=${encodeURIComponent(title)}`; - const fetchData = await requestUrl({ - url: searchUrl, - }); - // console.debug(fetchData); - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.ComicVineKeyId); - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const searchUrl = `${this.apiUrl}/volume/${encodeURIComponent(id)}/?api_key=${key}&format=json`; - const fetchData = await requestUrl({ - url: searchUrl, - }); - - console.debug(fetchData); - - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - const data = await fetchData.json; - const result = data.results; - - const authors = result.people as - | { - name: string; - }[] - | undefined; - - return 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; - } -} diff --git a/src/api/apis/GiantBombAPI.ts b/src/api/apis/GiantBombAPI.ts deleted file mode 100644 index 6d6d1ea..0000000 --- a/src/api/apis/GiantBombAPI.ts +++ /dev/null @@ -1,167 +0,0 @@ -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 { - 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({ 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 { - 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({ 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; - } -} diff --git a/src/api/apis/IGDBAPI.ts b/src/api/apis/IGDBAPI.ts deleted file mode 100644 index 14094d0..0000000 --- a/src/api/apis/IGDBAPI.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import { GameModel } from '../../models/GameModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; - -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 { - const currentTime = Date.now(); - if (this.accessToken && currentTime < this.tokenExpiry) return 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) { - throw Error(`MDB | Client ID or Client Secret for ${this.apiName} missing.`); - } - console.log(`MDB | Refreshing Twitch Auth Token for ${this.apiName}`); - const response = await requestUrl({ - url: `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`, - method: 'POST', - }); - if (response.status !== 200) throw Error(`MDB | Auth failed for ${this.apiName}. Check Credentials.`); - const data = response.json as TwitchAuthResponse; - this.accessToken = data.access_token; - this.tokenExpiry = currentTime + data.expires_in * 1000 - 60000; - return this.accessToken; - } - - async searchByTitle(title: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by Title`); - const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId); - if (!clientId) throw Error(`MDB | Client ID for ${this.apiName} missing.`); - const token = await this.getAuthToken(); - const queryBody = `search "${title}"; fields name, cover.url, first_release_date, summary, total_rating; limit 20;`; - const response = await requestUrl({ - url: `${this.apiUrl}/games`, - method: 'POST', - headers: { 'Client-ID': clientId, Authorization: `Bearer ${token}`, Accept: 'application/json' }, - body: queryBody, - }); - if (response.status !== 200) throw Error(`MDB | Received status code ${response.status} from ${this.apiName}.`); - - const data = response.json as IGDBGame[]; - return 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 { - console.log(`MDB | api "${this.apiName}" queried by ID`); - const clientId = this.plugin.app.secretStorage.getSecret(this.plugin.settings.IGDBClientId); - if (!clientId) throw Error(`MDB | Client ID for ${this.apiName} missing.`); - const token = await this.getAuthToken(); - 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 response = await requestUrl({ - url: `${this.apiUrl}/games`, - method: 'POST', - headers: { 'Client-ID': clientId, Authorization: `Bearer ${token}`, Accept: 'application/json' }, - body: queryBody, - }); - if (response.status !== 200) throw Error(`MDB | Received status code ${response.status} from ${this.apiName}.`); - - const data = response.json as IGDBGame[]; - if (!data || data.length === 0) throw Error(`MDB | No result found for ID ${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 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 ?? []; - } -} diff --git a/src/api/apis/MALAPI.ts b/src/api/apis/MALAPI.ts deleted file mode 100644 index 3083e23..0000000 --- a/src/api/apis/MALAPI.ts +++ /dev/null @@ -1,229 +0,0 @@ -import createClient from 'openapi-fetch'; -import type MediaDbPlugin from '../../main'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MovieModel } from '../../models/MovieModel'; -import { SeriesModel } from '../../models/SeriesModel'; -import { MediaType } from '../../utils/MediaType'; -import { isTruthy, obsidianFetch } from '../../utils/Utils'; -import { APIModel } from '../APIModel'; -import type { paths } from '../schemas/MALAPI'; - -export class MALAPI extends APIModel { - plugin: MediaDbPlugin; - typeMappings: Map; - 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(); - 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 { - console.log(`MDB | api "${this.apiName}" queried by Title`); - - const client = createClient({ 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) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); - } - - 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - - const client = createClient({ 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) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); - } - - const result = response.data?.data; - - if (result === undefined) { - throw Error(`MDB | No data found for ID ${id} in ${this.apiName}.`); - } - - 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 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 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 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, - }, - }); - } - - throw new Error(`MDB | Unknown media type for id ${id}`); - } - getDisabledMediaTypes(): MediaType[] { - return this.plugin.settings.MALAPI_disabledMediaTypes; - } -} diff --git a/src/api/apis/MobyGamesAPI.ts b/src/api/apis/MobyGamesAPI.ts deleted file mode 100644 index a8972b3..0000000 --- a/src/api/apis/MobyGamesAPI.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */ - -import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import { GameModel } from '../../models/GameModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; - -// sadly no open api schema available - -// TODO: maybe we should remove this API, as it can no longer be tested without paying for an API key - -export class MobyGamesAPI extends APIModel { - plugin: MediaDbPlugin; - apiDateFormat: string = 'YYYY-DD-MM'; - - constructor(plugin: MediaDbPlugin) { - super(); - - this.plugin = plugin; - this.apiName = 'MobyGamesAPI'; - this.apiDescription = 'A free API for games.'; - this.apiUrl = 'https://api.mobygames.com/v1'; - this.types = [MediaType.Game]; - } - - async searchByTitle(title: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by Title`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.MobyGamesKeyId); - - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const searchUrl = `${this.apiUrl}/games?title=${encodeURIComponent(title)}&api_key=${key}`; - const fetchData = await requestUrl({ - url: searchUrl, - }); - - // console.debug(fetchData); - - if (fetchData.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - if (fetchData.status === 429) { - throw Error(`MDB | Too many requests for ${this.apiName}, you've exceeded your API quota.`); - } - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - const data = await fetchData.json; - // console.debug(data); - const ret: MediaTypeModel[] = []; - for (const result of data.games) { - ret.push( - new GameModel({ - type: MediaType.Game, - title: result.title, - englishTitle: result.title, - year: new Date(result.platforms[0].first_release_date).getFullYear().toString(), - dataSource: this.apiName, - id: result.game_id, - }), - ); - } - - return ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.MobyGamesKeyId); - - if (!key) { - throw Error(`MDB | API key for ${this.apiName} missing.`); - } - - const searchUrl = `${this.apiUrl}/games?id=${encodeURIComponent(id)}&api_key=${key}`; - const fetchData = await requestUrl({ - url: searchUrl, - }); - console.debug(fetchData); - - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - const data = await fetchData.json; - // console.debug(data); - const result = data.games[0]; - - return new GameModel({ - type: MediaType.Game, - title: result.title, - englishTitle: result.title, - year: new Date(result.platforms[0].first_release_date).getFullYear().toString(), - dataSource: this.apiName, - url: `https://www.mobygames.com/game/${result.game_id}`, - id: result.game_id, - developers: [], - publishers: [], - genres: result.genres?.map((x: any) => x.genre_name) ?? [], - onlineRating: result.moby_score, - image: result.sample_cover?.image ?? '', - - released: true, - releaseDate: result.platforms[0].first_release_date ?? 'unknown', - - userData: { - played: false, - - personalRating: 0, - }, - }); - } - getDisabledMediaTypes(): MediaType[] { - return this.plugin.settings.MobyGamesAPI_disabledMediaTypes; - } -} diff --git a/src/api/apis/MusicBrainzAPI.ts b/src/api/apis/MusicBrainzAPI.ts deleted file mode 100644 index e1d0d7a..0000000 --- a/src/api/apis/MusicBrainzAPI.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MusicReleaseModel } from '../../models/MusicReleaseModel'; -import { MediaType } from '../../utils/MediaType'; -import { contactEmail, getLanguageName, mediaDbVersion, pluginName } from '../../utils/Utils'; -import { APIModel } from '../APIModel'; - -// 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 { - console.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 fetchData = await requestUrl({ - url: searchUrl, - headers: { - 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, - }, - }); - - // console.debug(fetchData); - - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - 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 ret; - } - - async getById(id: string): Promise { - console.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 groupResponse = await requestUrl({ - url: groupUrl, - headers: { - 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, - }, - }); - - if (groupResponse.status !== 200) { - throw Error(`MDB | Received status code ${groupResponse.status} from ${this.apiName}.`); - } - - const result = (await groupResponse.json) as IdResponse; - - // Get ID of the first release - const firstRelease = result.releases?.[0]; - if (!firstRelease) { - throw Error('MDB | No releases found in release group.'); - } - - // Fetch recordings for the first release - const releaseUrl = `https://musicbrainz.org/ws/2/release/${firstRelease.id}?inc=recordings+artists&fmt=json`; - console.log(`MDB | Fetching release recordings from: ${releaseUrl}`); - - const releaseResponse = await requestUrl({ - url: releaseUrl, - headers: { - 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, - }, - }); - - if (releaseResponse.status !== 200) { - throw Error(`MDB | Received status code ${releaseResponse.status} from ${this.apiName}.`); - } - - 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); - - console.log(releaseData); - - return 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')}`; -} diff --git a/src/api/apis/OMDbAPI.ts b/src/api/apis/OMDbAPI.ts deleted file mode 100644 index 3862080..0000000 --- a/src/api/apis/OMDbAPI.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import { GameModel } from '../../models/GameModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MovieModel } from '../../models/MovieModel'; -import { SeriesModel } from '../../models/SeriesModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; - -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; - 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(); - this.typeMappings.set('movie', 'movie'); - this.typeMappings.set('series', 'series'); - this.typeMappings.set('game', 'game'); - } - - async searchByTitle(title: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by Title`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.OMDbKeyId); - - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const response = await requestUrl({ - url: `https://www.omdbapi.com/?s=${encodeURIComponent(title)}&apikey=${key}`, - method: 'GET', - }); - - if (response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - if (response.status !== 200) { - throw Error(`MDB | Received status code ${response.status} from ${this.apiName}.`); - } - - const data = response.json as SearchResponse | undefined; - - if (!data) { - throw Error(`MDB | No data received from ${this.apiName}.`); - } - - if (data.Response === 'False') { - if (data.Error === 'Movie not found!') { - return []; - } - - throw Error(`MDB | Received error from ${this.apiName}: ${data.Error}`); - } - if (!data.Search) { - return []; - } - - // 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.OMDbKeyId); - - if (!key) { - throw Error(`MDB | API key for ${this.apiName} missing.`); - } - - const response = await requestUrl({ - url: `https://www.omdbapi.com/?i=${encodeURIComponent(id)}&apikey=${key}`, - method: 'GET', - }); - - if (response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - if (response.status !== 200) { - throw Error(`MDB | Received status code ${response.status} from ${this.apiName}.`); - } - - const result = response.json as IdResponse | undefined; - - if (!result) { - throw Error(`MDB | No data received from ${this.apiName}.`); - } - - if (result.Response === 'False') { - throw Error(`MDB | Received error from ${this.apiName}: ${result.Error}`); - } - - const type = this.typeMappings.get(result.Type.toLowerCase()); - if (type === undefined) { - throw Error(`${result.Type.toLowerCase()} is an unsupported type.`); - } - - if (type === 'movie') { - return 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 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 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, - }, - }); - } - - throw new Error(`MDB | Unknown media type for id ${id}`); - } - - getDisabledMediaTypes(): MediaType[] { - return this.plugin.settings.OMDbAPI_disabledMediaTypes; - } -} diff --git a/src/api/apis/OpenLibraryAPI.ts b/src/api/apis/OpenLibraryAPI.ts deleted file mode 100644 index f42f791..0000000 --- a/src/api/apis/OpenLibraryAPI.ts +++ /dev/null @@ -1,161 +0,0 @@ -import createClient from 'openapi-fetch'; -import type MediaDbPlugin from '../../main'; -import { BookModel } from '../../models/BookModel'; -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/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 { - console.log(`MDB | api "${this.apiName}" queried by Title`); - - const client = createClient({ baseUrl: 'https://openlibrary.org/' }); - - const response = await client.GET('/search.json', { - params: { - query: { - q: title, - }, - }, - fetch: obsidianFetch, - }); - - if (response.error !== undefined) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); - } - - 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - - const client = createClient({ baseUrl: 'https://openlibrary.org/' }); - - const response = await 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, - }); - - if (response.error !== undefined) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); - } - - const data = response.data as { - docs: SearchResponse[]; - q?: string; - }; - - const result = data.docs[0]; - - 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 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; - } -} diff --git a/src/api/apis/RAWGAPI.ts b/src/api/apis/RAWGAPI.ts deleted file mode 100644 index ec0a53e..0000000 --- a/src/api/apis/RAWGAPI.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import { GameModel } from '../../models/GameModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; - -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 { - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.RAWGAPIKeyId); - if (!key) { - throw Error(`MDB | API key for ${this.apiName} missing.`); - } - - const response = await requestUrl({ - url: `${this.apiUrl}/games?key=${key}&search=${encodeURIComponent(title)}&page_size=20`, - method: 'GET', - }); - if (response.status !== 200) { - throw Error(`MDB | Error ${response.status} from ${this.apiName}.`); - } - - const data = response.json as RAWGSearchResponse; - return 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 { - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.RAWGAPIKeyId); - if (!key) { - throw Error(`MDB | API key for ${this.apiName} missing.`); - } - - const response = await requestUrl({ - url: `${this.apiUrl}/games/${id}?key=${key}`, - method: 'GET', - }); - if (response.status !== 200) { - throw Error(`MDB | Error ${response.status} from ${this.apiName}.`); - } - - const result = response.json as RAWGGame; - return 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 ?? []; - } -} diff --git a/src/api/apis/TMDBMovieAPI.ts b/src/api/apis/TMDBMovieAPI.ts deleted file mode 100644 index 4f4e174..0000000 --- a/src/api/apis/TMDBMovieAPI.ts +++ /dev/null @@ -1,187 +0,0 @@ -import createClient from 'openapi-fetch'; -import type MediaDbPlugin from '../../main'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MovieModel } from '../../models/MovieModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; -import type { paths } from '../schemas/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; - 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(); - this.typeMappings.set('movie', 'movie'); - } - - async searchByTitle(title: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by Title`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId); - - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const client = createClient({ baseUrl: 'https://api.themoviedb.org' }); - const response = await client.GET('/3/search/movie', { - headers: { - Authorization: `Bearer ${key}`, - }, - params: { - query: { - query: encodeURIComponent(title), - include_adult: this.plugin.settings.sfwFilter ? false : true, - }, - }, - fetch: fetch, - }); - - if (response.response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - if (response.response.status !== 200) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); - } - - const data = response.data; - - if (!data) { - throw Error(`MDB | No data received from ${this.apiName}.`); - } - - if (data.total_results === 0 || !data.results) { - return []; - } - - // 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId); - - if (!key) { - throw Error(`MDB | API key for ${this.apiName} missing.`); - } - - const client = createClient({ baseUrl: 'https://api.themoviedb.org' }); - const response = await client.GET('/3/movie/{movie_id}', { - headers: { - Authorization: `Bearer ${key}`, - }, - params: { - path: { movie_id: parseInt(id) }, - query: { - append_to_response: 'credits', - }, - }, - fetch: fetch, - }); - - if (response.response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - if (response.response.status !== 200) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); - } - - const result = response.data; - - if (!result) { - throw Error(`MDB | No data received from ${this.apiName}.`); - } - // console.debug(result); - const credits = (result as TMDBCreditsResponse).credits; - - return 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; - } -} diff --git a/src/api/apis/TMDBSeasonAPI.ts b/src/api/apis/TMDBSeasonAPI.ts deleted file mode 100644 index d4f9da6..0000000 --- a/src/api/apis/TMDBSeasonAPI.ts +++ /dev/null @@ -1,302 +0,0 @@ -import createClient from 'openapi-fetch'; -import type MediaDbPlugin from '../../main'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { SeasonModel } from '../../models/SeasonModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; -import type { paths } from '../schemas/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; - 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(); - this.typeMappings.set('tv', 'season'); - } - - async searchByTitle(title: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by Title`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId); - - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const client = createClient({ baseUrl: 'https://api.themoviedb.org' }); - const searchResponse = await client.GET('/3/search/tv', { - headers: { - Authorization: `Bearer ${key}`, - }, - params: { - query: { - query: encodeURIComponent(title), - include_adult: this.plugin.settings.sfwFilter ? false : true, - }, - }, - fetch: fetch, - }); - - if (searchResponse.response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - - if (searchResponse.response.status !== 200) { - throw Error(`MDB | Received status code ${searchResponse.response.status} from ${this.apiName}.`); - } - - const searchData = searchResponse.data; - - if (!searchData?.results || searchData.total_results === 0) { - return []; - } - - const topResults = searchData.results.slice(0, 20); - - return 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: fetch, - }); - - 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, - }); - }), - ); - } - - // Fetch all seasons for a given series - async getSeasonsForSeries(tvId: string): Promise { - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId); - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const client = createClient({ baseUrl: 'https://api.themoviedb.org' }); - const seriesResponse = await client.GET('/3/tv/{series_id}', { - headers: { - Authorization: `Bearer ${key}`, - }, - params: { - path: { series_id: Number.parseInt(tvId, 10) }, - }, - fetch: fetch, - }); - - if (seriesResponse.response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - - if (seriesResponse.response.status !== 200) { - throw Error(`MDB | Received status code ${seriesResponse.response.status} from ${this.apiName}.`); - } - - 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId); - - if (!key) { - throw Error(`MDB | API key for ${this.apiName} missing.`); - } - - // Expect season ids like "12345/season/2" - const m = /^(\d+)\/season\/(\d+)$/.exec(id); - if (!m) { - throw Error(`MDB | Invalid season id "${id}". Expected format "/season/".`); - } - - const tvId = Number.parseInt(m[1], 10); - const seasonNumber = Number.parseInt(m[2], 10); - - const client = createClient({ baseUrl: 'https://api.themoviedb.org' }); - - // Fetch season details - const seasonResponse = await client.GET('/3/tv/{series_id}/season/{season_number}', { - headers: { - Authorization: `Bearer ${key}`, - }, - params: { - path: { - series_id: tvId, - season_number: seasonNumber, - }, - }, - fetch: fetch, - }); - - if (seasonResponse.response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - - if (seasonResponse.response.status !== 200) { - throw Error(`MDB | Received status code ${seasonResponse.response.status} from ${this.apiName}.`); - } - - const seasonData = seasonResponse.data; - if (!seasonData) { - throw Error(`MDB | No data received from ${this.apiName}.`); - } - // Fetch parent series to build consistent titles and inherit fields - const seriesResponse = await client.GET('/3/tv/{series_id}', { - headers: { - Authorization: `Bearer ${key}`, - }, - params: { - path: { series_id: tvId }, - query: { - append_to_response: 'credits', - }, - }, - fetch: fetch, - }); - - if (seriesResponse.response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - - if (seriesResponse.response.status !== 200) { - throw Error(`MDB | Received status code ${seriesResponse.response.status} from ${this.apiName}.`); - } - - const seriesData = seriesResponse.data; - - if (!seriesData) { - throw Error(`MDB | No data received from ${this.apiName}.`); - } - - 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 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 []; - } -} diff --git a/src/api/apis/TMDBSeriesAPI.ts b/src/api/apis/TMDBSeriesAPI.ts deleted file mode 100644 index cb6ce28..0000000 --- a/src/api/apis/TMDBSeriesAPI.ts +++ /dev/null @@ -1,179 +0,0 @@ -import createClient from 'openapi-fetch'; -import type MediaDbPlugin from '../../main'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { SeriesModel } from '../../models/SeriesModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; -import type { paths } from '../schemas/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; - 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(); - this.typeMappings.set('tv', 'series'); - } - - async searchByTitle(title: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by Title`); - - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId); - if (!key) { - throw new Error(`MDB | API key for ${this.apiName} missing.`); - } - - const client = createClient({ baseUrl: 'https://api.themoviedb.org' }); - const response = await client.GET('/3/search/tv', { - headers: { - Authorization: `Bearer ${key}`, - }, - params: { - query: { - query: encodeURIComponent(title), - include_adult: this.plugin.settings.sfwFilter ? false : true, - }, - }, - fetch: fetch, - }); - - if (response.response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - if (response.response.status !== 200) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); - } - - const data = response.data; - - if (!data) { - throw Error(`MDB | No data received from ${this.apiName}.`); - } - - if (data.total_results === 0 || !data.results) { - return []; - } - - // 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - const key = this.plugin.app.secretStorage.getSecret(this.plugin.settings.TMDBKeyId); - - if (!key) { - throw Error(`MDB | API key for ${this.apiName} missing.`); - } - - const client = createClient({ baseUrl: 'https://api.themoviedb.org' }); - const response = await client.GET('/3/tv/{series_id}', { - headers: { - Authorization: `Bearer ${key}`, - }, - params: { - path: { series_id: parseInt(id) }, - query: { - append_to_response: 'credits', - }, - }, - fetch: fetch, - }); - - if (response.response.status === 401) { - throw Error(`MDB | Authentication for ${this.apiName} failed. Check the API key.`); - } - if (response.response.status !== 200) { - throw Error(`MDB | Received status code ${response.response.status} from ${this.apiName}.`); - } - - const result = response.data; - - if (!result) { - throw Error(`MDB | No data received from ${this.apiName}.`); - } - // console.debug(result); - const credits = (result as TMDBCreditsResponse).credits; - - return 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; - } -} diff --git a/src/api/apis/VNDBAPI.ts b/src/api/apis/VNDBAPI.ts deleted file mode 100644 index 0139d4e..0000000 --- a/src/api/apis/VNDBAPI.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { requestUrl } from 'obsidian'; -import type MediaDbPlugin from '../../main'; -import { GameModel } from '../../models/GameModel'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; - -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. - * @throws Error The request returned an unsuccessful or unexpected HTTP status code. - * @see {@link https://api.vndb.org/kana#api-structure} - */ - private async postQuery(endpoint: string, body: string): Promise { - const fetchData = await requestUrl({ - url: `${this.apiUrl}${endpoint}`, - method: 'POST', - contentType: 'application/json', - body: body, - throw: false, - }); - - if (fetchData.status !== 200) { - switch (fetchData.status) { - case 400: - throw Error(`MDB | Invalid request body or query [${fetchData.text}].`); - case 404: - throw Error(`MDB | Invalid API path or HTTP method.`); - case 429: - throw Error(`MDB | Throttled.`); - case 500: - throw Error(`MDB | VNDB server error.`); - case 502: - throw Error(`MDB | VNDB server is down.`); - default: - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - } - - return fetchData.json; - } - - /** - * Make a `POST` request to the `/vn` endpoint. - * Queries visual novel entries. - * @see {@link https://api.vndb.org/kana#post-vn} - */ - private postVNQuery(body: string): Promise { - return this.postQuery('/vn', body) as Promise; - } - - /** - * Make a `POST` request to the `/release` endpoint. - * Queries release entries. - * @see {@link https://api.vndb.org/kana#post-release} - */ - private postReleaseQuery(body: string): Promise { - return this.postQuery('/release', body) as Promise; - } - - async searchByTitle(title: string): Promise { - console.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 vnData = 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 - }`); - - 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 ret; - } - - async getById(id: string): Promise { - console.log(`MDB | api "${this.apiName}" queried by ID`); - - const vnData = 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 (vnData.results.length !== 1) throw Error(`MDB | Expected 1 result from query, got ${vnData.results.length}.`); - const vn = vnData.results[0]; - const releasedIsDate = vn.released !== null && vn.released !== 'TBA'; - vn.released ??= 'Unknown'; - - const releaseData = await this.postReleaseQuery(`{ - "filters": ["and" - , ["vn", "=" - , ["id", "=", "${id}"] - ] - , ["official", "=", 1] - ], - "fields": "producers.name, producers.publisher, producers.developer", - "results": 100 - }`); - - return 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; - } -} diff --git a/src/api/apis/WikipediaAPI.ts b/src/api/apis/WikipediaAPI.ts deleted file mode 100644 index 9b3b289..0000000 --- a/src/api/apis/WikipediaAPI.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type MediaDbPlugin from '../../main'; -import type { MediaTypeModel } from '../../models/MediaTypeModel'; -import { WikiModel } from '../../models/WikiModel'; -import { MediaType } from '../../utils/MediaType'; -import { APIModel } from '../APIModel'; - -interface SearchResponse { - query: { - search: { - title: string; - pageid: number; - }[]; - }; -} - -interface IdResponse { - query: { - pages: Record; - }; -} - -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 { - console.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 fetch(searchUrl); - // console.debug(fetchData); - - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - const data = (await fetchData.json()) as SearchResponse; - console.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 ret; - } - - async getById(id: string): Promise { - console.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 fetch(searchUrl); - - if (fetchData.status !== 200) { - throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); - } - - const data = (await fetchData.json()) as IdResponse; - // console.debug(data); - const result = Object.values(data?.query?.pages)[0]; - - return 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; - } -} diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 8a83e92..0000000 --- a/src/main.ts +++ /dev/null @@ -1,743 +0,0 @@ -import type { TFile } from 'obsidian'; -import { MarkdownView, Notice, parseYaml, Plugin, stringifyYaml, TFolder } from 'obsidian'; -import { requestUrl, normalizePath } from 'obsidian'; -import { APIManager } from './api/APIManager'; -import { BoardGameGeekAPI } from './api/apis/BoardGameGeekAPI'; -import { ComicVineAPI } from './api/apis/ComicVineAPI'; -import { GiantBombAPI } from './api/apis/GiantBombAPI'; -import { IGDBAPI } from './api/apis/IGDBAPI'; -import { MALAPI } from './api/apis/MALAPI'; -import { MALAPIManga } from './api/apis/MALAPIManga'; -import { MobyGamesAPI } from './api/apis/MobyGamesAPI'; -import { MusicBrainzAPI } from './api/apis/MusicBrainzAPI'; -import { OMDbAPI } from './api/apis/OMDbAPI'; -import { OpenLibraryAPI } from './api/apis/OpenLibraryAPI'; -import { RAWGAPI } from './api/apis/RAWGAPI'; -import { SteamAPI } from './api/apis/SteamAPI'; -import { TMDBMovieAPI } from './api/apis/TMDBMovieAPI'; -import { TMDBSeasonAPI } from './api/apis/TMDBSeasonAPI'; -import { TMDBSeriesAPI } from './api/apis/TMDBSeriesAPI'; -import { VNDBAPI } from './api/apis/VNDBAPI'; -import { WikipediaAPI } from './api/apis/WikipediaAPI'; -import { ConfirmOverwriteModal } from './modals/ConfirmOverwriteModal'; -import type { SeasonSelectModalElement } from './modals/MediaDbSeasonSelectModal'; -import { MediaDbSeasonSelectModal } from './modals/MediaDbSeasonSelectModal'; -import type { MediaTypeModel } from './models/MediaTypeModel'; -import type { SeasonModel } from './models/SeasonModel'; -import { PropertyMapper } from './settings/PropertyMapper'; -import { PropertyMappingModel } from './settings/PropertyMapping'; -import type { MediaDbPluginSettings } from './settings/Settings'; -import { getDefaultSettings, MediaDbSettingTab } from './settings/Settings'; -import { BulkImportHelper } from './utils/BulkImportHelper'; -import { DateFormatter } from './utils/DateFormatter'; -import type { MediaType } from './utils/MediaType'; -import { MEDIA_TYPES, MediaTypeManager } from './utils/MediaTypeManager'; -import type { SearchModalOptions } from './utils/ModalHelper'; -import { ModalHelper } from './utils/ModalHelper'; -import type { CreateNoteOptions } from './utils/Utils'; -import { replaceIllegalFileNameCharactersInString, unCamelCase, hasTemplaterPlugin, useTemplaterPluginInFile } from './utils/Utils'; -import 'src/styles.css'; - -export type Metadata = Record; - -export interface MediaTypeModelObj { - id: string; - type: MediaType; - dataSource: string; -} - -export default class MediaDbPlugin extends Plugin { - settings!: MediaDbPluginSettings; - apiManager!: APIManager; - mediaTypeManager!: MediaTypeManager; - modelPropertyMapper!: PropertyMapper; - modalHelper!: ModalHelper; - bulkImportHelper!: BulkImportHelper; - dateFormatter!: DateFormatter; - - frontMatterRexExpPattern: string = '^(---)\\n[\\s\\S]*?\\n---'; - - async onload(): Promise { - this.apiManager = new APIManager(); - // register APIs - 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 MobyGamesAPI(this)); - this.apiManager.registerAPI(new GiantBombAPI(this)); - this.apiManager.registerAPI(new IGDBAPI(this)); - this.apiManager.registerAPI(new RAWGAPI(this)); - this.apiManager.registerAPI(new VNDBAPI(this)); - - this.mediaTypeManager = new MediaTypeManager(); - this.modelPropertyMapper = new PropertyMapper(this); - this.modalHelper = new ModalHelper(this); - this.bulkImportHelper = new BulkImportHelper(this); - this.dateFormatter = new DateFormatter(); - - await this.loadSettings(); - // register the settings tab - this.addSettingTab(new MediaDbSettingTab(this.app, this)); - - this.mediaTypeManager.updateTemplates(this.settings); - this.mediaTypeManager.updateFolders(this.settings); - this.dateFormatter.setFormat(this.settings.customDateFormat); - - // add icon to the left ribbon - const ribbonIconEl = this.addRibbonIcon('database', 'Add new Media DB entry', () => this.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)); - }); - } - }), - ); - - // register command to open search modal - this.addCommand({ - id: 'open-media-db-search-modal', - name: 'Create Media DB entry', - callback: () => this.createEntryWithSearchModal(), - }); - for (const mediaType of MEDIA_TYPES) { - this.addCommand({ - id: `open-media-db-search-modal-with-${mediaType}`, - name: `Create Media DB entry (${unCamelCase(mediaType)})`, - callback: () => this.createEntryWithSearchModal({ preselectedTypes: [mediaType] }), - }); - } - this.addCommand({ - id: 'open-media-db-advanced-search-modal', - name: 'Create Media DB entry (advanced search)', - callback: () => this.createEntryWithAdvancedSearchModal(), - }); - // register command to open id search modal - this.addCommand({ - id: 'open-media-db-id-search-modal', - name: 'Create Media DB entry by id', - callback: () => this.createEntryWithIdSearchModal(), - }); - // register command to update the open note - 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.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.updateActiveNote(true); - } - return true; - }, - }); - // register link insert command - this.addCommand({ - id: 'add-media-db-link', - name: 'Insert link', - checkCallback: (checking: boolean) => { - if (!this.app.workspace.getActiveFile()) { - return false; - } - if (!checking) { - void this.createLinkWithSearchModal(); - } - return true; - }, - }); - } - - async createLinkWithSearchModal(): Promise { - const apiSearchResults = await this.modalHelper.openAdvancedSearchModal({}, async advancedSearchModalData => { - return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis); - }); - - if (!apiSearchResults) { - return; - } - - const selectResults = await this.modalHelper.openSelectModal({ elements: apiSearchResults, multiSelect: false }, async selectModalData => { - return await this.queryDetails(selectModalData.selected); - }); - - if (!selectResults || selectResults.length < 1) { - return; - } - - const link = `[${selectResults[0].title}](${selectResults[0].url})`; - - const view = this.app.workspace.getActiveViewOfType(MarkdownView); - - // Make sure the user is editing a Markdown file. - if (view) { - view.editor.replaceRange(link, view.editor.getCursor()); - } - } - - async createEntryWithSearchModal(searchModalOptions?: SearchModalOptions): Promise { - let types: string[] = []; - let apiSearchResults = await this.modalHelper.openSearchModal(searchModalOptions ?? {}, async searchModalData => { - types = searchModalData.types; - const apis = this.apiManager.apis.filter(x => x.hasTypeOverlap(searchModalData.types)).map(x => x.apiName); - try { - return await this.apiManager.query(searchModalData.query, apis); - } catch (e) { - console.warn('MDB | Query failed:', e); - new Notice(`Search failed: ${e}`); - return []; - } - }); - - if (!apiSearchResults || apiSearchResults.length === 0) { - new Notice('No results found.'); - return; - } - - // filter the results - apiSearchResults = apiSearchResults.filter(x => types.includes(x.type)); - - if (apiSearchResults.length === 0) { - new Notice('No results found for the selected types.'); - return; - } - - // Show selection modal - for seasons, skip detail query - const selectResults = - types.length === 1 && types[0] === 'season' - ? await this.modalHelper.openSelectModal( - { - elements: apiSearchResults, - description: 'Select one search result to proceed.', - submitButtonText: 'Ok', - }, - async selectModalData => selectModalData.selected, - ) - : await this.modalHelper.openSelectModal({ elements: apiSearchResults }, async selectModalData => this.queryDetails(selectModalData.selected)); - - if (!selectResults || selectResults.length === 0) { - return; - } - - // Handle season selection for both direct season searches and series-to-season conversion - const seasonHandlingResult = await this.handleSeasonWorkflow(types, selectResults); - if (seasonHandlingResult.handled) { - return; - } - - // Show preview and confirm - const confirmed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => previewModalData.confirmed); - if (!confirmed) { - return; - } - - // User confirmed, create notes and exit - await this.createMediaDbNotes(selectResults); - } - - /** - * Handles the season workflow for both direct season searches and series-to-season conversion. - * Returns an object indicating what happened and how to proceed. - */ - private async handleSeasonWorkflow(types: string[], selectResults: MediaTypeModel[]): Promise<{ handled: boolean; seasonsCreated?: boolean }> { - // Case 1: User searched specifically for seasons and selected a series from TMDB - 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 }; - } - - // Case 2: User searched for series but it's actually from TMDBSeasonAPI - // (This happens when searching for seasons returns series results) - if (types.includes('series') && selectResults.some(r => r.dataSource === 'TMDBSeriesAPI')) { - const seriesResults = selectResults.filter(r => r.dataSource === 'TMDBSeriesAPI'); - // If only one series result and user searched for seasons, show season selection - 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 }; - } - - /** - * Shows the season selection modal for a given series and creates notes for selected seasons. - * Returns true if seasons were successfully created, false if cancelled. - */ - private async showSeasonSelectAndCreate(seriesId: string, seriesTitle: string): Promise { - const tmdbSeasonAPI = this.apiManager.getApiByName('TMDBSeasonAPI') as TMDBSeasonAPI; - if (!tmdbSeasonAPI) { - new Notice('TMDBSeasonAPI not available.'); - return false; - } - - try { - // Fetch all seasons for the selected series - const allSeasons = await tmdbSeasonAPI.getSeasonsForSeries(seriesId); - if (!allSeasons || allSeasons.length === 0) { - new Notice('No seasons found for this series.'); - return false; - } - - // Show season selection modal - const selectedSeasons = await this.showSeasonSelectModal(allSeasons, seriesTitle); - if (!selectedSeasons || selectedSeasons.length === 0) { - return false; - } - - // Create notes for all selected seasons in parallel - await this.createNotesForSelectedSeasons(selectedSeasons, allSeasons, tmdbSeasonAPI); - new Notice(`Successfully created ${selectedSeasons.length} season ${selectedSeasons.length === 1 ? 'entry' : 'entries'}.`); - return true; - } catch (e) { - console.warn('MDB | Error in season selection workflow:', e); - new Notice(`Error loading seasons: ${e}`); - return false; - } - } - - /** - * Shows the season selection modal and returns the selected seasons. - */ - private async showSeasonSelectModal(allSeasons: SeasonModel[], seriesTitle: string): Promise { - const modal = new MediaDbSeasonSelectModal( - this, - allSeasons.map(s => ({ - season_number: s.seasonNumber, - name: s.seasonTitle || s.title, - episode_count: s.episodes || 0, - air_date: s.year, - poster_path: s.image, - })), - true, - seriesTitle, - ); - - return new Promise(resolve => { - modal.setSubmitCb(resolve); - modal.open(); - }); - } - - /** - * Creates notes for all selected seasons by fetching full metadata and creating entries. - */ - private async createNotesForSelectedSeasons(selectedSeasons: SeasonSelectModalElement[], allSeasons: SeasonModel[], tmdbSeasonAPI: TMDBSeasonAPI): Promise { - await Promise.all( - selectedSeasons.map(async selectedSeason => { - const seasonModel = allSeasons.find(s => s.seasonNumber === selectedSeason.season_number); - if (seasonModel) { - try { - // Fetch full metadata using getById - const fullMetadata = await tmdbSeasonAPI.getById(seasonModel.id); - await this.createMediaDbNotes([fullMetadata]); - } catch (e) { - console.warn(`MDB | Failed to create season ${selectedSeason.season_number}:`, e); - new Notice(`Failed to create season ${selectedSeason.season_number}: ${e}`); - } - } - }), - ); - } - - async createEntryWithAdvancedSearchModal(): Promise { - const apiSearchResults = await this.modalHelper.openAdvancedSearchModal({}, async advancedSearchModalData => { - return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis); - }); - - if (!apiSearchResults || apiSearchResults.length === 0) { - new Notice('No results found.'); - return; - } - - const selectResults = - (await this.modalHelper.openSelectModal({ elements: apiSearchResults }, async selectModalData => { - return await this.queryDetails(selectModalData.selected); - })) ?? []; - if (selectResults.length < 1) { - return; - } - - const confirmed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => { - return previewModalData.confirmed; - }); - if (!confirmed) { - return; - } - - await this.createMediaDbNotes(selectResults); - } - - async createEntryWithIdSearchModal(): Promise { - let idSearchResult: MediaTypeModel | undefined = undefined; - let proceed: boolean = false; - - while (!proceed) { - idSearchResult = await this.modalHelper.openIdSearchModal({}, async idSearchModalData => { - return await this.apiManager.queryDetailedInfoById(idSearchModalData.query, idSearchModalData.api); - }); - if (!idSearchResult) { - return; - } - - proceed = await this.modalHelper.openPreviewModal({ elements: [idSearchResult] }, async previewModalData => { - return previewModalData.confirmed; - }); - } - - if (!idSearchResult) { - return; - } - await this.createMediaDbNoteFromModel(idSearchResult, { attachTemplate: true, openNote: true }); - } - - async createMediaDbNotes(models: MediaTypeModel[], attachFile?: TFile): Promise { - // Create notes in parallel for better performance - const results = await Promise.allSettled(models.map(model => this.createMediaDbNoteFromModel(model, { attachTemplate: true, attachFile: attachFile }))); - - // Report any failures - const failures = results.filter(r => r.status === 'rejected'); - if (failures.length > 0) { - console.warn('MDB | Some notes failed to create:', failures); - new Notice(`${models.length - failures.length} of ${models.length} notes created successfully.`); - } - } - - async queryDetails(models: MediaTypeModel[]): Promise { - // Query details in parallel for better performance - const results = await Promise.allSettled(models.map(model => this.apiManager.queryDetailedInfo(model))); - - // Filter out failures and return successful results - const detailModels: MediaTypeModel[] = results - .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled' && r.value !== undefined) - .map(r => r.value!); - - // Log failures for debugging - const failures = results.filter(r => r.status === 'rejected'); - if (failures.length > 0) { - console.warn('MDB | Some detail queries failed:', failures); - } - - return detailModels; - } - - async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise { - try { - console.debug('MDB | creating new note'); - - options.openNote = this.settings.openNoteInNewTab; - - if (this.settings.imageDownload) { - await this.downloadImageForMediaModel(mediaTypeModel); - } - - const fileContent = await this.generateMediaDbNoteContents(mediaTypeModel, options); - - options.folder ??= await this.mediaTypeManager.getFolder(mediaTypeModel, this.app); - - const targetFile = await this.createNote(this.mediaTypeManager.getFileName(mediaTypeModel), fileContent, options); - - if (this.settings.enableTemplaterIntegration) { - await useTemplaterPluginInFile(this.app, targetFile); - } - } catch (e) { - console.warn(e); - new Notice(`${e}`); - } - } - - /** - * Tries to download the image for a media model. - * - * @param mediaTypeModel - * @returns true if the image was downloaded, false otherwise - */ - private async downloadImageForMediaModel(mediaTypeModel: MediaTypeModel): Promise { - if (mediaTypeModel.image && typeof mediaTypeModel.image === 'string' && mediaTypeModel.image.startsWith('http')) { - try { - const imageUrl = mediaTypeModel.image; - const imageExt = imageUrl.split('.').pop()?.split(/#|\?/)[0] ?? 'jpg'; - const imageFileName = `${replaceIllegalFileNameCharactersInString(`${mediaTypeModel.type}_${mediaTypeModel.title} (${mediaTypeModel.year})`)}.${imageExt}`; - const imagePath = normalizePath(`${this.settings.imageFolder}/${imageFileName}`); - - if (!this.app.vault.getAbstractFileByPath(this.settings.imageFolder)) { - await this.app.vault.createFolder(this.settings.imageFolder); - } - - if (!this.app.vault.getAbstractFileByPath(imagePath)) { - const response = await requestUrl({ url: imageUrl, method: 'GET' }); - await this.app.vault.createBinary(imagePath, response.arrayBuffer); - } - - // Update model to use local image path - mediaTypeModel.image = `[[${imagePath}]]`; - return true; - } catch (e) { - console.warn('MDB | Failed to download image:', e); - } - } - - return false; - } - - generateMediaDbNoteFrontmatterPreview(mediaTypeModel: MediaTypeModel): string { - const fileMetadata = this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject()); - return stringifyYaml(fileMetadata); - } - - /** - * Generates the content of a note from a media model and some options. - * - * @param mediaTypeModel - * @param options - */ - async generateMediaDbNoteContents(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise { - let template = await this.mediaTypeManager.getTemplate(mediaTypeModel, this.app); - let fileMetadata: Record; - - if (this.settings.useDefaultFrontMatter) { - fileMetadata = this.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.settings.enableTemplaterIntegration && hasTemplaterPlugin(this.app)) { - // Include the media variable in all templater commands by using a top level JavaScript execution command. - 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: fileMetadata, fileContent: fileContent }; - } - - const attachFileMetadata = this.getMetadataFromFileCache(fileToAttach); - fileMetadata = { ...attachFileMetadata, ...fileMetadata }; - - let attachFileContent: string = await this.app.vault.read(fileToAttach); - const regExp = new RegExp(this.frontMatterRexExpPattern); - attachFileContent = attachFileContent.replace(regExp, ''); - attachFileContent = attachFileContent.startsWith('\n') ? attachFileContent.substring(1) : attachFileContent; - fileContent += attachFileContent; - - return { fileMetadata: fileMetadata, fileContent: fileContent }; - } - - async attachTemplate(fileMetadata: Metadata, fileContent: string, template: string | undefined): Promise<{ fileMetadata: Metadata; fileContent: string }> { - if (!template) { - return { fileMetadata: fileMetadata, fileContent: fileContent }; - } - - const templateMetadata = this.getMetaDataFromFileContent(template); - fileMetadata = { ...templateMetadata, ...fileMetadata }; - - const regExp = new RegExp(this.frontMatterRexExpPattern); - const attachFileContent = template.replace(regExp, ''); - fileContent += attachFileContent; - - return { fileMetadata: fileMetadata, fileContent: fileContent }; - } - - getMetaDataFromFileContent(fileContent: string): Metadata { - let metadata: Metadata; - - const regExp = new RegExp(this.frontMatterRexExpPattern); - 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); - - metadata = parseYaml(frontMatter) as Metadata; - - if (!metadata) { - metadata = {}; - } - - console.debug(`MDB | metadata read from file content`, metadata); - - return metadata; - } - - getMetadataFromFileCache(file: TFile): Metadata { - const metadata: Metadata | undefined = this.app.metadataCache.getFileCache(file)?.frontmatter; - return structuredClone(metadata ?? {}); - } - - /** - * Creates a note in the vault. - * - * @param fileName - * @param fileContent - * @param options - */ - async createNote(fileName: string, fileContent: string, options: CreateNoteOptions): Promise { - // find and possibly create the folder set in settings or passed in folder - const folder = options.folder ?? this.app.vault.getAbstractFileByPath('/'); - - if (!folder || !(folder instanceof TFolder)) { - throw new Error('MDB | invalid folder'); - } - - fileName = replaceIllegalFileNameCharactersInString(fileName); - const filePath = `${folder.path}/${fileName}.md`; - - // look if file already exists and ask if it should be overwritten - const file = this.app.vault.getAbstractFileByPath(filePath); - if (file) { - const shouldOverwrite = await new Promise(resolve => { - new ConfirmOverwriteModal(this.app, fileName, resolve).open(); - }); - - if (!shouldOverwrite) { - throw new Error('MDB | file creation cancelled by user'); - } - - await this.app.vault.delete(file); - } - - // create the file - const targetFile = await this.app.vault.create(filePath, fileContent); - console.debug(`MDB | created new file at ${filePath}`); - - // open newly created file - if (options.openNote) { - const activeLeaf = this.app.workspace.getUnpinnedLeaf(); - if (!activeLeaf) { - console.warn('MDB | no active leaf, not opening newly created note'); - return targetFile; - } - await activeLeaf.openFile(targetFile, { state: { mode: 'source' } }); - } - - return targetFile; - } - - /** - * Update the active note by querying the API again. - * Tries to read the type, id and dataSource of the active note. If successful it will query the api, delete the old note and create a new one. - */ - async updateActiveNote(onlyMetadata: boolean = false): Promise { - const activeFile = this.app.workspace.getActiveFile() ?? undefined; - if (!activeFile) { - throw new Error('MDB | there is no active note'); - } - - let metadata = this.getMetadataFromFileCache(activeFile); - metadata = this.modelPropertyMapper.convertObjectBack(metadata); - - console.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; - console.debug(`MDB | validOldMetadata`, validOldMetadata); - - const oldMediaTypeModel = this.mediaTypeManager.createMediaTypeModelFromMediaType(validOldMetadata, validOldMetadata.type); - console.debug(`MDB | oldMediaTypeModel created`, oldMediaTypeModel); - - let newMediaTypeModel = await this.apiManager.queryDetailedInfoById(validOldMetadata.id, validOldMetadata.dataSource); - if (!newMediaTypeModel) { - return; - } - - newMediaTypeModel = Object.assign(oldMediaTypeModel, newMediaTypeModel.getWithOutUserData()); - console.debug(`MDB | newMediaTypeModel after merge`, newMediaTypeModel); - - if (onlyMetadata) { - await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachFile: activeFile, folder: activeFile.parent ?? undefined, openNote: true }); - } else { - await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachTemplate: true, folder: activeFile.parent ?? undefined, openNote: true }); - } - } - - async loadSettings(): Promise { - 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; - - // Migrate property mappings using the dedicated migration method - const migratedModels = PropertyMappingModel.migrateModels( - loadedSettings.propertyMappingModels || [], - defaultSettings.propertyMappingModels.map(m => PropertyMappingModel.fromJSON(m)), - ); - - // Store as plain data for serialization - loadedSettings.propertyMappingModels = migratedModels.map(m => m.toJSON()); - - this.settings = loadedSettings; - - await this.saveSettings(); - } - - async saveSettings(): Promise { - this.mediaTypeManager.updateTemplates(this.settings); - this.mediaTypeManager.updateFolders(this.settings); - this.dateFormatter.setFormat(this.settings.customDateFormat); - - await this.saveData(this.settings); - } -} diff --git a/src/utils/ModalHelper.ts b/src/utils/ModalHelper.ts deleted file mode 100644 index a8d1503..0000000 --- a/src/utils/ModalHelper.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { Notice } from 'obsidian'; -import type MediaDbPlugin from '../main'; -import { MediaDbAdvancedSearchModal } from '../modals/MediaDbAdvancedSearchModal'; -import { MediaDbIdSearchModal } from '../modals/MediaDbIdSearchModal'; -import { MediaDbPreviewModal } from '../modals/MediaDbPreviewModal'; -import { MediaDbSearchModal } from '../modals/MediaDbSearchModal'; -import { MediaDbSearchResultModal } from '../modals/MediaDbSearchResultModal'; -import type { MediaTypeModel } from '../models/MediaTypeModel'; -import type { MediaType } from './MediaType'; - -export enum ModalResultCode { - SUCCESS = 'SUCCESS', - SKIP = 'SKIP', - CLOSE = 'CLOSE', - ERROR = 'ERROR', -} - -type ModalResult = - | { - code: ModalResultCode.CLOSE; - } - | { - code: ModalResultCode.ERROR; - error: Error; - } - | { - code: ModalResultCode.SUCCESS; - data: T; - }; - -type SkippableModalResult = - | ModalResult - | { - code: ModalResultCode.SKIP; - }; - -/** - * Object containing the data {@link ModalHelper.createSearchModal} returns. - * On {@link ModalResultCode.SUCCESS} this contains {@link SearchModalData}. - * On {@link ModalResultCode.ERROR} this contains a reference to that error. - */ -export type SearchModalResult = ModalResult; - -/** - * Object containing the data {@link ModalHelper.createAdvancedSearchModal} returns. - * On {@link ModalResultCode.SUCCESS} this contains {@link AdvancedSearchModalData}. - * On {@link ModalResultCode.ERROR} this contains a reference to that error. - */ -export type AdvancedSearchModalResult = ModalResult; - -/** - * Object containing the data {@link ModalHelper.createIdSearchModal} returns. - * On {@link ModalResultCode.SUCCESS} this contains {@link IdSearchModalData}. - * On {@link ModalResultCode.ERROR} this contains a reference to that error. - */ -export type IdSearchModalResult = ModalResult; - -/** - * Object containing the data {@link ModalHelper.createSelectModal} returns. - * On {@link ModalResultCode.SUCCESS} this contains {@link SelectModalData}. - * On {@link ModalResultCode.ERROR} this contains a reference to that error. - */ -export type SelectModalResult = SkippableModalResult; - -/** - * Object containing the data {@link ModalHelper.createPreviewModal} returns. - * On {@link ModalResultCode.SUCCESS} this contains {@link PreviewModalData}. - * On {@link ModalResultCode.ERROR} this contains a reference to that error. - */ -export type PreviewModalResult = ModalResult; - -/** - * The data the search modal returns. - * - query: the query string - * - types: the selected APIs - */ -export interface SearchModalData { - query: string; - types: MediaType[]; -} - -/** - * The data the advanced search modal returns. - * - query: the query string - * - apis: the selected APIs - */ -export interface AdvancedSearchModalData { - query: string; - apis: string[]; -} - -/** - * The data the id search modal returns. - * - query: the query string - * - apis: the selected APIs - */ -export interface IdSearchModalData { - query: string; - api: string; -} - -/** - * The data the select modal returns. - * - selected: the selected items - */ -export interface SelectModalData { - selected: MediaTypeModel[]; -} - -/** - * The data the preview modal returns. - * - confirmed: whether the selected element has been confirmed - */ -export interface PreviewModalData { - confirmed: boolean; -} - -/** - * Options for the search modal. - * - modalTitle: the title of the modal - * - preselectedTypes: a list of preselected Types - * - prefilledSearchString: prefilled query - */ -export interface SearchModalOptions { - modalTitle?: string; - preselectedTypes?: MediaType[]; - prefilledSearchString?: string; -} - -/** - * Options for the advanced search modal. - * - modalTitle: the title of the modal - * - preselectedAPIs: a list of preselected APIs - * - prefilledSearchString: prefilled query - */ -export interface AdvancedSearchModalOptions { - modalTitle?: string; - preselectedAPIs?: string[]; - prefilledSearchString?: string; -} - -/** - * Options for the id search modal. - * - modalTitle: the title of the modal - * - preselectedAPIs: a list of preselected APIs - * - prefilledSearchString: prefilled query - */ -export interface IdSearchModalOptions { - modalTitle?: string; - preselectedAPI?: string; - prefilledSearchString?: string; -} - -/** - * Options for the select modal. - * - modalTitle: the title of the modal - * - elements: the elements the user can select from - * - multiSelect: whether to allow multiselect - * - skipButton: whether to add a skip button to the modal - */ -export interface SelectModalOptions { - elements?: MediaTypeModel[]; - multiSelect?: boolean; - modalTitle?: string; - skipButton?: boolean; - description?: string; // Add this - submitButtonText?: string; // Add this too -} -/** - * Options for the preview modal. - * - modalTitle: the title of the modal - * - elements: the elements to preview - */ -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', -}; - -/** - * A class providing multiple usefull functions for dealing with the plugins modals. - */ -export class ModalHelper { - plugin: MediaDbPlugin; - - constructor(plugin: MediaDbPlugin) { - this.plugin = plugin; - } - - /** - * Creates an {@link MediaDbSearchModal}, then sets callbacks and awaits them, - * returning either the user input once submitted or nothing once closed. - * The modal needs ot be manually closed by calling `close()` on the modal reference. - * - * @param searchModalOptions the options for the modal, see {@link SEARCH_MODAL_DEFAULT_OPTIONS} - * @returns the user input or nothing and a reference to the modal. - */ - async createSearchModal(searchModalOptions: SearchModalOptions): Promise<{ searchModalResult: SearchModalResult; searchModal: MediaDbSearchModal }> { - const modal = new MediaDbSearchModal(this.plugin, searchModalOptions); - const res: SearchModalResult = await new Promise(resolve => { - modal.setSubmitCb(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); - modal.setCloseCb(err => { - if (err) { - resolve({ code: ModalResultCode.ERROR, error: err }); - } - resolve({ code: ModalResultCode.CLOSE }); - }); - - modal.open(); - }); - return { searchModalResult: res, searchModal: modal }; - } - - /** - * Opens an {@link MediaDbSearchModal} and awaits its result, - * then executes the `submitCallback` returning the callbacks result and closing the modal. - * - * @param searchModalOptions the options for the modal, see {@link SEARCH_MODAL_DEFAULT_OPTIONS} - * @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed - * @returns the user input or nothing and a reference to the modal. - */ - async openSearchModal( - searchModalOptions: SearchModalOptions, - submitCallback: (searchModalData: SearchModalData) => Promise, - ): Promise { - const { searchModalResult, searchModal } = await this.createSearchModal(searchModalOptions); - console.debug(`MDB | searchModal closed with code ${searchModalResult.code}`); - - if (searchModalResult.code === ModalResultCode.ERROR) { - // there was an error in the modal itself - console.warn(searchModalResult.error); - new Notice(searchModalResult.error.toString()); - searchModal.close(); - return undefined; - } - - if (searchModalResult.code === ModalResultCode.CLOSE) { - // modal is already being closed - return undefined; - } - - try { - const callbackRes: MediaTypeModel[] = await submitCallback(searchModalResult.data); - searchModal.close(); - return callbackRes; - } catch (e) { - console.warn(e); - new Notice(`${e}`); - searchModal.close(); - return undefined; - } - } - - /** - * Creates an {@link MediaDbAdvancedSearchModal}, then sets callbacks and awaits them, - * returning either the user input once submitted or nothing once closed. - * The modal needs ot be manually closed by calling `close()` on the modal reference. - * - * @param advancedSearchModalOptions the options for the modal, see {@link ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS} - * @returns the user input or nothing and a reference to the modal. - */ - async createAdvancedSearchModal( - advancedSearchModalOptions: AdvancedSearchModalOptions, - ): Promise<{ advancedSearchModalResult: AdvancedSearchModalResult; advancedSearchModal: MediaDbAdvancedSearchModal }> { - const modal = new MediaDbAdvancedSearchModal(this.plugin, advancedSearchModalOptions); - const res: AdvancedSearchModalResult = await new Promise(resolve => { - modal.setSubmitCb(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); - modal.setCloseCb(err => { - if (err) { - resolve({ code: ModalResultCode.ERROR, error: err }); - } - resolve({ code: ModalResultCode.CLOSE }); - }); - - modal.open(); - }); - return { advancedSearchModalResult: res, advancedSearchModal: modal }; - } - - /** - * Opens an {@link MediaDbAdvancedSearchModal} and awaits its result, - * then executes the `submitCallback` returning the callbacks result and closing the modal. - * - * @param advancedSearchModalOptions the options for the modal, see {@link ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS} - * @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed - * @returns the user input or nothing and a reference to the modal. - */ - async openAdvancedSearchModal( - advancedSearchModalOptions: AdvancedSearchModalOptions, - submitCallback: (advancedSearchModalData: AdvancedSearchModalData) => Promise, - ): Promise { - const { advancedSearchModalResult, advancedSearchModal } = await this.createAdvancedSearchModal(advancedSearchModalOptions); - console.debug(`MDB | advencedSearchModal closed with code ${advancedSearchModalResult.code}`); - - if (advancedSearchModalResult.code === ModalResultCode.ERROR) { - // there was an error in the modal itself - console.warn(advancedSearchModalResult.error); - new Notice(advancedSearchModalResult.error.toString()); - advancedSearchModal.close(); - return undefined; - } - - if (advancedSearchModalResult.code === ModalResultCode.CLOSE) { - // modal is already being closed - return undefined; - } - - try { - const callbackRes: MediaTypeModel[] = await submitCallback(advancedSearchModalResult.data); - advancedSearchModal.close(); - return callbackRes; - } catch (e) { - console.warn(e); - new Notice(`${e}`); - advancedSearchModal.close(); - return undefined; - } - } - - /** - * Creates an {@link MediaDbIdSearchModal}, then sets callbacks and awaits them, - * returning either the user input once submitted or nothing once closed. - * The modal needs ot be manually closed by calling `close()` on the modal reference. - * - * @param idSearchModalOptions the options for the modal, see {@link ID_SEARCH_MODAL_DEFAULT_OPTIONS} - * @returns the user input or nothing and a reference to the modal. - */ - async createIdSearchModal(idSearchModalOptions: IdSearchModalOptions): Promise<{ idSearchModalResult: IdSearchModalResult; idSearchModal: MediaDbIdSearchModal }> { - const modal = new MediaDbIdSearchModal(this.plugin, idSearchModalOptions); - const res: IdSearchModalResult = await new Promise(resolve => { - modal.setSubmitCb(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); - modal.setCloseCb(err => { - if (err) { - resolve({ code: ModalResultCode.ERROR, error: err }); - } - resolve({ code: ModalResultCode.CLOSE }); - }); - - modal.open(); - }); - return { idSearchModalResult: res, idSearchModal: modal }; - } - - /** - * Opens an {@link MediaDbIdSearchModal} and awaits its result, - * then executes the `submitCallback` returning the callbacks result and closing the modal. - * - * @param idSearchModalOptions the options for the modal, see {@link ID_SEARCH_MODAL_DEFAULT_OPTIONS} - * @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed - * @returns the user input or nothing and a reference to the modal. - */ - async openIdSearchModal( - idSearchModalOptions: IdSearchModalOptions, - submitCallback: (idSearchModalData: IdSearchModalData) => Promise, - ): Promise { - const { idSearchModalResult, idSearchModal } = await this.createIdSearchModal(idSearchModalOptions); - console.debug(`MDB | idSearchModal closed with code ${idSearchModalResult.code}`); - - if (idSearchModalResult.code === ModalResultCode.ERROR) { - // there was an error in the modal itself - console.warn(idSearchModalResult.error); - new Notice(idSearchModalResult.error.toString()); - idSearchModal.close(); - return undefined; - } - - if (idSearchModalResult.code === ModalResultCode.CLOSE) { - // modal is already being closed - return undefined; - } - - try { - const callbackRes = await submitCallback(idSearchModalResult.data); - idSearchModal.close(); - return callbackRes; - } catch (e) { - console.warn(e); - new Notice(`${e}`); - idSearchModal.close(); - return undefined; - } - } - - /** - * Creates an {@link MediaDbSearchResultModal}, then sets callbacks and awaits them, - * returning either the user input once submitted or nothing once closed. - * The modal needs ot be manually closed by calling `close()` on the modal reference. - * - * @param selectModalOptions the options for the modal, see {@link SELECT_MODAL_OPTIONS_DEFAULT} - * @returns the user input or nothing and a reference to the modal. - */ - async createSelectModal(selectModalOptions: SelectModalOptions): Promise<{ selectModalResult: SelectModalResult; selectModal: MediaDbSearchResultModal }> { - const modal = new MediaDbSearchResultModal(this.plugin, selectModalOptions); - const res: SelectModalResult = await new Promise(resolve => { - modal.setSubmitCb(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); - modal.setSkipCallback(() => resolve({ code: ModalResultCode.SKIP })); - modal.setCloseCb(err => { - if (err) { - resolve({ code: ModalResultCode.ERROR, error: err }); - } - resolve({ code: ModalResultCode.CLOSE }); - }); - - modal.open(); - }); - return { selectModalResult: res, selectModal: modal }; - } - - /** - * Opens an {@link MediaDbSearchResultModal} and awaits its result, - * then executes the `submitCallback` returning the callbacks result and closing the modal. - * - * @param selectModalOptions the options for the modal, see {@link SELECT_MODAL_OPTIONS_DEFAULT} - * @param submitCallback the callback that gets executed after the modal has been submitted, but before it has been closed - * @returns the user input or nothing and a reference to the modal. - */ - async openSelectModal( - selectModalOptions: SelectModalOptions, - submitCallback: (selectModalData: SelectModalData) => Promise, - ): Promise { - const { selectModalResult, selectModal } = await this.createSelectModal(selectModalOptions); - console.debug(`MDB | selectModal closed with code ${selectModalResult.code}`); - - if (selectModalResult.code === ModalResultCode.ERROR) { - // there was an error in the modal itself - console.warn(selectModalResult.error); - new Notice(selectModalResult.error.toString()); - selectModal.close(); - return undefined; - } - - if (selectModalResult.code === ModalResultCode.CLOSE) { - // modal is already being closed - return undefined; - } - - if (selectModalResult.code === ModalResultCode.SKIP) { - // selection was skipped - return undefined; - } - - try { - const callbackRes: MediaTypeModel[] = await submitCallback(selectModalResult.data); - selectModal.close(); - return callbackRes; - } catch (e) { - console.warn(e); - new Notice(`${e}`); - selectModal.close(); - return; - } - } - - async createPreviewModal(previewModalOptions: PreviewModalOptions): Promise<{ previewModalResult: PreviewModalResult; previewModal: MediaDbPreviewModal }> { - //todo: handle attachFile for existing files - const modal = new MediaDbPreviewModal(this.plugin, previewModalOptions); - const res: PreviewModalResult = await new Promise(resolve => { - modal.setSubmitCb(res => resolve({ code: ModalResultCode.SUCCESS, data: res })); - modal.setCloseCb(err => { - if (err) { - resolve({ code: ModalResultCode.ERROR, error: err }); - } - resolve({ code: ModalResultCode.CLOSE }); - }); - - modal.open(); - }); - return { previewModalResult: res, previewModal: modal }; - } - - async openPreviewModal(previewModalOptions: PreviewModalOptions, submitCallback: (previewModalData: PreviewModalData) => Promise): Promise { - const { previewModalResult, previewModal } = await this.createPreviewModal(previewModalOptions); - console.debug(`MDB | previewModal closed with code ${previewModalResult.code}`); - - if (previewModalResult.code === ModalResultCode.ERROR) { - // there was an error in the modal itself - console.warn(previewModalResult.error); - new Notice(previewModalResult.error.toString()); - previewModal.close(); - return false; - } - - if (previewModalResult.code === ModalResultCode.CLOSE) { - // modal is already being closed - return false; - } - - try { - const callbackRes: boolean = await submitCallback(previewModalResult.data); - previewModal.close(); - return callbackRes; - } catch (e) { - console.warn(e); - new Notice(`${e}`); - previewModal.close(); - return true; - } - } -} diff --git a/tsconfig.json b/tsconfig.json index 49b6fcb..7ce937b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,12 @@ { "compilerOptions": { + "paths": { + "packages/*": ["./packages/*"], + "tests/*": ["./tests/*"] + }, "module": "ESNext", - "target": "ESNext", + "target": "ES2022", + "noEmit": true, "allowJs": true, "checkJs": true, "noImplicitAny": true, @@ -22,5 +27,5 @@ "jsxImportSource": "solid-js", "types": ["vite/client"] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"] + "include": ["packages/obsidian/**/*.ts", "packages/obsidian/**/*.tsx"] } diff --git a/vite.config.ts b/vite.config.mts similarity index 70% rename from vite.config.ts rename to vite.config.mts index 3a8f24b..e42ec6d 100644 --- a/vite.config.ts +++ b/vite.config.mts @@ -1,20 +1,20 @@ -import { defineConfig } from 'vite'; -import solidPlugin from 'vite-plugin-solid'; -import { builtinModules } from 'node:module'; -import { getBuildBanner } from './automation/build/buildBanner'; +import { UserConfig, defineConfig } from 'vite'; +import solid from 'vite-plugin-solid'; import { viteStaticCopy } from 'vite-plugin-static-copy'; import banner from 'vite-plugin-banner'; -import manifest from './manifest.json' with { type: 'json' }; import path from 'path'; +import { builtinModules } from 'node:module'; +import { dtsBundlePlugin, getBuildBanner } from '@lemons_dev/lemons-obsidian-plugin-automation'; +import manifest from './manifest.json' with { type: 'json' }; -const entryFile = 'src/main.ts'; +const entryFile = 'packages/obsidian/src/main.ts'; -export default defineConfig(({ mode }) => { +export default defineConfig(async ({ mode }) => { const prod = mode === 'production'; - const outDir = prod ? 'dist/' : `exampleVault/.obsidian/plugins/${manifest.id}`; + const outDir = prod ? 'dist/' : `exampleVault/.obsidian/plugins/${manifest.id}/`; - const plugins = [ - solidPlugin(), + let plugins = [ + solid(), banner({ outDir: outDir, content: getBuildBanner(prod ? 'Release Build' : 'Dev Build', version => version), @@ -30,13 +30,16 @@ export default defineConfig(({ mode }) => { ]; return { - plugins, - + plugins: plugins, resolve: { alias: { - src: path.resolve(__dirname, './src'), + packages: path.resolve(__dirname, './packages'), }, }, + define: { + // Disable all logs and debug statements in production, but keep warnings and errors + __LOG_LEVEL__: prod ? 2 : 4, + }, build: { lib: { entry: path.resolve(__dirname, entryFile), @@ -45,11 +48,12 @@ export default defineConfig(({ mode }) => { formats: ['cjs'], }, minify: prod, + target: 'es2022', sourcemap: prod ? false : 'inline', cssCodeSplit: false, emptyOutDir: false, outDir: '', - rollupOptions: { + rolldownOptions: { input: { main: path.resolve(__dirname, entryFile), }, @@ -76,5 +80,5 @@ export default defineConfig(({ mode }) => { ], }, }, - }; + } as UserConfig; });