From 7d6722c38e94d86a3e2179c7ba4f6141a0246d32 Mon Sep 17 00:00:00 2001 From: Moritz Jung Date: Mon, 6 Apr 2026 17:05:45 +0200 Subject: [PATCH] migrate to secret storage and other improvements --- bun.lockb | Bin 211573 -> 234471 bytes package.json | 28 ++-- src/api/APIManager.ts | 4 +- src/api/apis/BoardGameGeekAPI.ts | 14 +- src/api/apis/ComicVineAPI.ts | 14 +- src/api/apis/GiantBombAPI.ts | 12 +- src/api/apis/MALAPI.ts | 2 +- src/api/apis/MALAPIManga.ts | 2 +- src/api/apis/MobyGamesAPI.ts | 10 +- src/api/apis/OMDbAPI.ts | 10 +- src/api/apis/OpenLibraryAPI.ts | 4 +- src/api/apis/TMDBMovieAPI.ts | 57 ++++++-- src/api/apis/TMDBSeasonAPI.ts | 137 +++++++++++------- src/api/apis/TMDBSeriesAPI.ts | 44 ++++-- src/main.ts | 60 ++++---- src/modals/MediaDbBulkImportModal.ts | 4 +- src/modals/MediaDbPreviewModal.ts | 4 +- src/modals/MediaDbSearchModal.ts | 2 +- src/settings/PropertyMapper.ts | 79 +++++----- .../PropertyMappingModelComponent.tsx | 2 +- src/settings/Settings.ts | 134 ++++++++--------- src/settings/suggesters/FileSuggest.ts | 2 +- src/settings/suggesters/FolderSuggest.ts | 2 +- src/utils/BulkImportHelper.ts | 6 +- src/utils/DateFormatter.ts | 34 +++-- src/utils/ModalHelper.ts | 2 +- src/utils/Utils.ts | 35 +++-- tsconfig.json | 3 +- 28 files changed, 415 insertions(+), 292 deletions(-) diff --git a/bun.lockb b/bun.lockb index 419cad865cc97c68424cbbc91486c024ac6b6702..9f23f83d24f67c7519062e000a6a4b9ccb3531a1 100755 GIT binary patch delta 61357 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

5Z?&G8ww`T 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 apisToQuery.contains(api.apiName)) + .filter(api => apisToQuery.includes(api.apiName)) .map(async api => { try { return await api.searchByTitle(query); @@ -53,7 +53,7 @@ export class APIManager { for (const api of this.apis) { if (api.apiName === apiName) { try { - return api.getById(id); + return await api.getById(id); } catch (e) { new Notice(`Error querying ${api.apiName}: ${e}`); console.warn(e); diff --git a/src/api/apis/BoardGameGeekAPI.ts b/src/api/apis/BoardGameGeekAPI.ts index 07c2098..bddddb7 100644 --- a/src/api/apis/BoardGameGeekAPI.ts +++ b/src/api/apis/BoardGameGeekAPI.ts @@ -1,6 +1,6 @@ import { requestUrl } from 'obsidian'; -import { BoardGameModel } from 'src/models/BoardGameModel'; 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'; @@ -22,12 +22,16 @@ export class BoardGameGeekAPI extends APIModel { 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 ${this.plugin.settings.BoardgameGeekKey}`, + Authorization: `Bearer ${key}`, }, }); @@ -67,12 +71,16 @@ export class BoardGameGeekAPI extends APIModel { 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 ${this.plugin.settings.BoardgameGeekKey}`, + Authorization: `Bearer ${key}`, }, }); diff --git a/src/api/apis/ComicVineAPI.ts b/src/api/apis/ComicVineAPI.ts index 53d9820..da7e7ad 100644 --- a/src/api/apis/ComicVineAPI.ts +++ b/src/api/apis/ComicVineAPI.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ import { requestUrl } from 'obsidian'; -import { ComicMangaModel } from 'src/models/ComicMangaModel'; 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'; @@ -24,8 +24,12 @@ export class ComicVineAPI extends APIModel { 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=${this.plugin.settings.ComicVineKey}&format=json&resources=volume&query=${encodeURIComponent(title)}`; + const searchUrl = `${this.apiUrl}/search/?api_key=${key}&format=json&resources=volume&query=${encodeURIComponent(title)}`; const fetchData = await requestUrl({ url: searchUrl, }); @@ -55,8 +59,12 @@ export class ComicVineAPI extends APIModel { 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=${this.plugin.settings.ComicVineKey}&format=json`; + const searchUrl = `${this.apiUrl}/volume/${encodeURIComponent(id)}/?api_key=${key}&format=json`; const fetchData = await requestUrl({ url: searchUrl, }); diff --git a/src/api/apis/GiantBombAPI.ts b/src/api/apis/GiantBombAPI.ts index 76e451b..6d6d1ea 100644 --- a/src/api/apis/GiantBombAPI.ts +++ b/src/api/apis/GiantBombAPI.ts @@ -1,9 +1,9 @@ import createClient from 'openapi-fetch'; -import { obsidianFetch } from 'src/utils/Utils'; 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'; @@ -23,8 +23,9 @@ export class GiantBombAPI extends APIModel { 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 (!this.plugin.settings.GiantBombKey) { + if (!key) { throw Error(`MDB | API key for ${this.apiName} missing.`); } @@ -32,7 +33,7 @@ export class GiantBombAPI extends APIModel { const response = await client.GET('/games', { params: { query: { - api_key: this.plugin.settings.GiantBombKey, + api_key: key, filter: `name:${title}`, format: 'json', limit: 20, @@ -73,8 +74,9 @@ export class GiantBombAPI extends APIModel { 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 (!this.plugin.settings.GiantBombKey) { + if (!key) { throw Error(`MDB | API key for ${this.apiName} missing.`); } @@ -85,7 +87,7 @@ export class GiantBombAPI extends APIModel { guid: id, }, query: { - api_key: this.plugin.settings.GiantBombKey, + api_key: key, format: 'json', }, }, diff --git a/src/api/apis/MALAPI.ts b/src/api/apis/MALAPI.ts index 5ac2cf9..5d27a61 100644 --- a/src/api/apis/MALAPI.ts +++ b/src/api/apis/MALAPI.ts @@ -1,10 +1,10 @@ import createClient from 'openapi-fetch'; -import { isTruthy, obsidianFetch } from 'src/utils/Utils'; 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'; diff --git a/src/api/apis/MALAPIManga.ts b/src/api/apis/MALAPIManga.ts index 83d4732..2d39a4f 100644 --- a/src/api/apis/MALAPIManga.ts +++ b/src/api/apis/MALAPIManga.ts @@ -1,9 +1,9 @@ import createClient from 'openapi-fetch'; -import { isTruthy, obsidianFetch } from 'src/utils/Utils'; 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'; diff --git a/src/api/apis/MobyGamesAPI.ts b/src/api/apis/MobyGamesAPI.ts index a88eec9..a8972b3 100644 --- a/src/api/apis/MobyGamesAPI.ts +++ b/src/api/apis/MobyGamesAPI.ts @@ -27,12 +27,13 @@ export class MobyGamesAPI extends APIModel { 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 (!this.plugin.settings.MobyGamesKey) { + if (!key) { throw new Error(`MDB | API key for ${this.apiName} missing.`); } - const searchUrl = `${this.apiUrl}/games?title=${encodeURIComponent(title)}&api_key=${this.plugin.settings.MobyGamesKey}`; + const searchUrl = `${this.apiUrl}/games?title=${encodeURIComponent(title)}&api_key=${key}`; const fetchData = await requestUrl({ url: searchUrl, }); @@ -70,12 +71,13 @@ export class MobyGamesAPI extends APIModel { 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 (!this.plugin.settings.MobyGamesKey) { + if (!key) { throw Error(`MDB | API key for ${this.apiName} missing.`); } - const searchUrl = `${this.apiUrl}/games?id=${encodeURIComponent(id)}&api_key=${this.plugin.settings.MobyGamesKey}`; + const searchUrl = `${this.apiUrl}/games?id=${encodeURIComponent(id)}&api_key=${key}`; const fetchData = await requestUrl({ url: searchUrl, }); diff --git a/src/api/apis/OMDbAPI.ts b/src/api/apis/OMDbAPI.ts index 51b4f17..3862080 100644 --- a/src/api/apis/OMDbAPI.ts +++ b/src/api/apis/OMDbAPI.ts @@ -76,13 +76,14 @@ export class OMDbAPI extends APIModel { 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 (!this.plugin.settings.OMDbKey) { + 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=${this.plugin.settings.OMDbKey}`, + url: `https://www.omdbapi.com/?s=${encodeURIComponent(title)}&apikey=${key}`, method: 'GET', }); @@ -160,13 +161,14 @@ export class OMDbAPI extends APIModel { 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 (!this.plugin.settings.OMDbKey) { + if (!key) { throw Error(`MDB | API key for ${this.apiName} missing.`); } const response = await requestUrl({ - url: `https://www.omdbapi.com/?i=${encodeURIComponent(id)}&apikey=${this.plugin.settings.OMDbKey}`, + url: `https://www.omdbapi.com/?i=${encodeURIComponent(id)}&apikey=${key}`, method: 'GET', }); diff --git a/src/api/apis/OpenLibraryAPI.ts b/src/api/apis/OpenLibraryAPI.ts index 645d15b..f42f791 100644 --- a/src/api/apis/OpenLibraryAPI.ts +++ b/src/api/apis/OpenLibraryAPI.ts @@ -1,9 +1,9 @@ import createClient from 'openapi-fetch'; -import { BookModel } from 'src/models/BookModel'; -import { obsidianFetch } from 'src/utils/Utils'; 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'; diff --git a/src/api/apis/TMDBMovieAPI.ts b/src/api/apis/TMDBMovieAPI.ts index 936ab57..4f4e174 100644 --- a/src/api/apis/TMDBMovieAPI.ts +++ b/src/api/apis/TMDBMovieAPI.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ - import createClient from 'openapi-fetch'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; @@ -8,6 +6,36 @@ 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; @@ -27,15 +55,16 @@ export class TMDBMovieAPI extends APIModel { 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 (!this.plugin.settings.TMDBKey) { + 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 ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${key}`, }, params: { query: { @@ -85,15 +114,16 @@ export class TMDBMovieAPI extends APIModel { 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 (!this.plugin.settings.TMDBKey) { + 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 ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${key}`, }, params: { path: { movie_id: parseInt(id) }, @@ -117,6 +147,7 @@ export class TMDBMovieAPI extends APIModel { throw Error(`MDB | No data received from ${this.apiName}.`); } // console.debug(result); + const credits = (result as TMDBCreditsResponse).credits; return new MovieModel({ type: 'movie', @@ -129,18 +160,14 @@ export class TMDBMovieAPI extends APIModel { id: result.id.toString(), plot: result.overview ?? '', - genres: result.genres?.map((g: any) => g.name) ?? [], - // TMDB's spec allows for 'append_to_response' but doesn't seem to account for it in the type - // @ts-ignore - writer: result.credits.crew?.filter((c: any) => c.job === 'Screenplay').map((c: any) => c.name) ?? [], - // @ts-ignore - director: result.credits.crew?.filter((c: any) => c.job === 'Director').map((c: any) => c.name) ?? [], - studio: result.production_companies?.map((s: any) => s.name) ?? [], + 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, - // @ts-ignore - actors: result.credits.cast.map((c: any) => c.name).slice(0, 5) ?? [], + actors: getTopCastNames(credits, 5), image: `https://image.tmdb.org/t/p/w780${result.poster_path}`, released: ['Released'].includes(result.status!), diff --git a/src/api/apis/TMDBSeasonAPI.ts b/src/api/apis/TMDBSeasonAPI.ts index 426f6db..d4f9da6 100644 --- a/src/api/apis/TMDBSeasonAPI.ts +++ b/src/api/apis/TMDBSeasonAPI.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ - import createClient from 'openapi-fetch'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; @@ -8,6 +6,40 @@ 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; @@ -27,15 +59,16 @@ export class TMDBSeasonAPI extends APIModel { 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 (!this.plugin.settings.TMDBKey) { + 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 ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${key}`, }, params: { query: { @@ -60,36 +93,32 @@ export class TMDBSeasonAPI extends APIModel { return []; } - const ret: MediaTypeModel[] = []; + const topResults = searchData.results.slice(0, 20); - for (const result of searchData.results) { - if (ret.length >= 20) break; + 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, + }); - // Fetch series details to get the total number of seasons - let totalSeasons = 0; - try { - const detailsResponse = await client.GET('/3/tv/{series_id}', { - headers: { - Authorization: `Bearer ${this.plugin.settings.TMDBKey}`, - }, - params: { - path: { series_id: result.id ?? 0 }, - }, - fetch: fetch, - }); - - if (detailsResponse.response.status === 200 && detailsResponse.data) { - const detailsData = detailsResponse.data; - if (Array.isArray(detailsData.seasons)) { - totalSeasons = detailsData.seasons.length; + if (detailsResponse.response.status === 200 && Array.isArray(detailsResponse.data?.seasons)) { + totalSeasons = detailsResponse.data.seasons.length; + } + } catch { + // Ignore detail errors and use 0 as fallback. } } - } catch { - // Ignore errors and assume 0 seasons - } - ret.push( - new SeasonModel({ + 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', @@ -97,26 +126,25 @@ export class TMDBSeasonAPI extends APIModel { id: result.id?.toString() ?? '', seasonTitle: result.name ?? result.original_name ?? '', seasonNumber: totalSeasons, - }), - ); - } - - return ret; + }); + }), + ); } // Fetch all seasons for a given series async getSeasonsForSeries(tvId: string): Promise { - if (!this.plugin.settings.TMDBKey) { + 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 ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${key}`, }, params: { - path: { series_id: parseInt(tvId) }, + path: { series_id: Number.parseInt(tvId, 10) }, }, fetch: fetch, }); @@ -158,8 +186,9 @@ export class TMDBSeasonAPI extends APIModel { 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 (!this.plugin.settings.TMDBKey) { + if (!key) { throw Error(`MDB | API key for ${this.apiName} missing.`); } @@ -169,20 +198,20 @@ export class TMDBSeasonAPI extends APIModel { throw Error(`MDB | Invalid season id "${id}". Expected format "/season/".`); } - const tvId = m[1]; - const seasonNumber = m[2]; + 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 ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${key}`, }, params: { path: { - series_id: parseInt(tvId), - season_number: parseInt(seasonNumber), + series_id: tvId, + season_number: seasonNumber, }, }, fetch: fetch, @@ -203,10 +232,10 @@ export class TMDBSeasonAPI extends APIModel { // Fetch parent series to build consistent titles and inherit fields const seriesResponse = await client.GET('/3/tv/{series_id}', { headers: { - Authorization: `Bearer ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${key}`, }, params: { - path: { series_id: parseInt(tvId) }, + path: { series_id: tvId }, query: { append_to_response: 'credits', }, @@ -238,28 +267,28 @@ export class TMDBSeasonAPI extends APIModel { 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}/season/${seasonData.season_number}`, - id: `${tvId}/season/${seasonData.season_number}`, + 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 ?? Number(seasonNumber), + seasonNumber: seasonData.season_number ?? seasonNumber, episodes: Array.isArray(seasonData.episodes) ? seasonData.episodes.length : 0, airedFrom: this.plugin.dateFormatter.format(airDate, this.apiDateFormat) ?? 'unknown', - airedTo: airedTo, + airedTo: formattedAiredTo, plot: seasonData.overview ?? '', image: seasonData.poster_path ? `https://image.tmdb.org/t/p/w780${seasonData.poster_path}` : '', - genres: seriesData.genres?.map(g => g.name ?? '').filter(name => name !== '') ?? [], - writer: seriesData.created_by?.map(c => c.name ?? '').filter(name => name !== '') ?? [], - studio: seriesData.production_companies?.map(s => s.name ?? '').filter(name => name !== '') ?? [], + 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, - // @ts-ignore - append_to_response credits not reflected in base schema - actors: seriesData.credits?.cast?.map((c: any) => c.name).slice(0, 5) ?? [], + actors: getTopActorNames((seriesData as { credits?: CreditsLike }).credits), released: ['Returning Series', 'Cancelled', 'Ended'].includes(seriesData.status ?? ''), streamingServices: [], airing: ['Returning Series'].includes(seriesData.status ?? ''), diff --git a/src/api/apis/TMDBSeriesAPI.ts b/src/api/apis/TMDBSeriesAPI.ts index 1c8a21f..cb6ce28 100644 --- a/src/api/apis/TMDBSeriesAPI.ts +++ b/src/api/apis/TMDBSeriesAPI.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ - import createClient from 'openapi-fetch'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; @@ -8,6 +6,27 @@ 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; @@ -28,14 +47,15 @@ export class TMDBSeriesAPI extends APIModel { async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); - if (!this.plugin.settings.TMDBKey) { + 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 ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${key}`, }, params: { query: { @@ -85,15 +105,16 @@ export class TMDBSeriesAPI extends APIModel { 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 (!this.plugin.settings.TMDBKey) { + 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 ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${key}`, }, params: { path: { series_id: parseInt(id) }, @@ -117,6 +138,7 @@ export class TMDBSeriesAPI extends APIModel { throw Error(`MDB | No data received from ${this.apiName}.`); } // console.debug(result); + const credits = (result as TMDBCreditsResponse).credits; return new SeriesModel({ type: 'series', @@ -128,15 +150,13 @@ export class TMDBSeriesAPI extends APIModel { id: result.id.toString(), plot: result.overview ?? '', - genres: result.genres?.map((g: any) => g.name) ?? [], - writer: result.created_by?.map((c: any) => c.name) ?? [], - studio: result.production_companies?.map((s: any) => s.name) ?? [], + 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, - // TMDB's spec allows for 'append_to_response' but doesn't seem to account for it in the type - // @ts-ignore - actors: result.credits?.cast.map((c: any) => c.name).slice(0, 5) ?? [], + 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!), diff --git a/src/main.ts b/src/main.ts index df97525..56d415b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,6 @@ import type { TFile } from 'obsidian'; import { MarkdownView, Notice, parseYaml, Plugin, stringifyYaml, TFolder } from 'obsidian'; import { requestUrl, normalizePath } from 'obsidian'; -import type { MediaType } from 'src/utils/MediaType'; import { APIManager } from './api/APIManager'; import { BoardGameGeekAPI } from './api/apis/BoardGameGeekAPI'; import { ComicVineAPI } from './api/apis/ComicVineAPI'; @@ -29,6 +28,7 @@ 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'; @@ -218,7 +218,7 @@ export default class MediaDbPlugin extends Plugin { } // filter the results - apiSearchResults = apiSearchResults.filter(x => types.contains(x.type)); + apiSearchResults = apiSearchResults.filter(x => types.includes(x.type)); if (apiSearchResults.length === 0) { new Notice('No results found for the selected types.'); @@ -373,28 +373,22 @@ export default class MediaDbPlugin extends Plugin { return; } - let selectResults: MediaTypeModel[]; - const proceed: boolean = false; - - while (!proceed) { - selectResults = - (await this.modalHelper.openSelectModal({ elements: apiSearchResults }, async selectModalData => { - return await this.queryDetails(selectModalData.selected); - })) ?? []; - if (!selectResults || selectResults.length < 1) { - return; - } - - const confirmed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => { - return previewModalData.confirmed; - }); - if (!confirmed) { - return; - } - break; + const selectResults = + (await this.modalHelper.openSelectModal({ elements: apiSearchResults }, async selectModalData => { + return await this.queryDetails(selectModalData.selected); + })) ?? []; + if (selectResults.length < 1) { + return; } - await this.createMediaDbNotes(selectResults!); + const confirmed = await this.modalHelper.openPreviewModal({ elements: selectResults }, async previewModalData => { + return previewModalData.confirmed; + }); + if (!confirmed) { + return; + } + + await this.createMediaDbNotes(selectResults); } async createEntryWithIdSearchModal(): Promise { @@ -556,8 +550,7 @@ export default class MediaDbPlugin extends Plugin { } const attachFileMetadata = this.getMetadataFromFileCache(fileToAttach); - // TODO: better object merging - fileMetadata = Object.assign(attachFileMetadata, fileMetadata); + fileMetadata = { ...attachFileMetadata, ...fileMetadata }; let attachFileContent: string = await this.app.vault.read(fileToAttach); const regExp = new RegExp(this.frontMatterRexExpPattern); @@ -574,8 +567,7 @@ export default class MediaDbPlugin extends Plugin { } const templateMetadata = this.getMetaDataFromFileContent(template); - // TODO: better object merging - fileMetadata = Object.assign(templateMetadata, fileMetadata); + fileMetadata = { ...templateMetadata, ...fileMetadata }; const regExp = new RegExp(this.frontMatterRexExpPattern); const attachFileContent = template.replace(regExp, ''); @@ -709,6 +701,20 @@ export default class MediaDbPlugin extends Plugin { 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 || [], @@ -719,6 +725,8 @@ export default class MediaDbPlugin extends Plugin { loadedSettings.propertyMappingModels = migratedModels.map(m => m.toJSON()); this.settings = loadedSettings; + + await this.saveSettings(); } async saveSettings(): Promise { diff --git a/src/modals/MediaDbBulkImportModal.ts b/src/modals/MediaDbBulkImportModal.ts index 1aee8ee..0763d32 100644 --- a/src/modals/MediaDbBulkImportModal.ts +++ b/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 'src/api/APIModel'; -import { BulkImportLookupMethod } from 'src/utils/BulkImportHelper'; +import type { APIModel } from '../api/APIModel'; import type MediaDbPlugin from '../main'; +import { BulkImportLookupMethod } from '../utils/BulkImportHelper'; export class MediaDbBulkImportModal extends Modal { plugin: MediaDbPlugin; diff --git a/src/modals/MediaDbPreviewModal.ts b/src/modals/MediaDbPreviewModal.ts index 931556c..1efd66a 100644 --- a/src/modals/MediaDbPreviewModal.ts +++ b/src/modals/MediaDbPreviewModal.ts @@ -1,6 +1,6 @@ import { Component, MarkdownRenderer, Modal, Setting } from 'obsidian'; -import type MediaDbPlugin from 'src/main'; -import type { MediaTypeModel } from 'src/models/MediaTypeModel'; +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'; diff --git a/src/modals/MediaDbSearchModal.ts b/src/modals/MediaDbSearchModal.ts index 986cb8a..800eace 100644 --- a/src/modals/MediaDbSearchModal.ts +++ b/src/modals/MediaDbSearchModal.ts @@ -98,7 +98,7 @@ export class MediaDbSearchModal extends Modal { const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper); apiToggleComponent.setTooltip(unCamelCase(mediaType)); - apiToggleComponent.setValue(this.selectedTypes.contains(mediaType)); + apiToggleComponent.setValue(this.selectedTypes.includes(mediaType)); if (apiToggleComponent.getValue()) { currentToggle = apiToggleComponent; } diff --git a/src/settings/PropertyMapper.ts b/src/settings/PropertyMapper.ts index bf08d32..b34f245 100644 --- a/src/settings/PropertyMapper.ts +++ b/src/settings/PropertyMapper.ts @@ -1,5 +1,5 @@ -import type { MediaType } from 'src/utils/MediaType'; import type MediaDbPlugin from '../main'; +import type { MediaType } from '../utils/MediaType'; import { MEDIA_TYPES } from '../utils/MediaTypeManager'; import { PropertyMappingOption } from './PropertyMapping'; @@ -21,9 +21,7 @@ export class PropertyMapper { return obj; } - // console.log(obj.type); - - if (MEDIA_TYPES.filter(x => x.toString() == obj.type).length < 1) { + if (!MEDIA_TYPES.includes(obj.type as MediaType)) { return obj; } @@ -33,33 +31,32 @@ export class PropertyMapper { } const propertyMappings = propertyMappingModel.properties; + const propertyMappingByProperty = new Map(propertyMappings.map(mapping => [mapping.property, mapping])); const newObj: Record = {}; for (const [key, value] of Object.entries(obj)) { - for (const propertyMapping of propertyMappings) { - if (propertyMapping.property === key) { - let finalValue = value; - if (propertyMapping.wikilink) { - if (typeof value === 'string') { - finalValue = `[[${value}]]`; - } else if (Array.isArray(value)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - finalValue = value.map(v => (typeof v === 'string' ? `[[${v}]]` : v)); - } - } - if (propertyMapping.mapping === PropertyMappingOption.Map) { - // @ts-ignore - newObj[propertyMapping.newProperty] = finalValue; - } else if (propertyMapping.mapping === PropertyMappingOption.Remove) { - // do nothing - } else if (propertyMapping.mapping === PropertyMappingOption.Default) { - // @ts-ignore - newObj[key] = finalValue; - } - break; + const propertyMapping = propertyMappingByProperty.get(key); + + if (!propertyMapping) { + newObj[key] = value; + continue; + } + + let finalValue = value; + if (propertyMapping.wikilink) { + if (typeof value === 'string') { + finalValue = `[[${value}]]`; + } else if (Array.isArray(value)) { + finalValue = (value as unknown[]).map((v: unknown) => (typeof v === 'string' ? `[[${v}]]` : v)); } } + + if (propertyMapping.mapping === PropertyMappingOption.Map) { + newObj[propertyMapping.newProperty] = finalValue; + } else if (propertyMapping.mapping === PropertyMappingOption.Default) { + newObj[key] = finalValue; + } } return newObj; @@ -80,34 +77,28 @@ export class PropertyMapper { obj.type = 'comicManga'; console.debug(`MDB | updated metadata type`, obj.type); } - if (MEDIA_TYPES.contains(obj.type as MediaType)) { + if (!MEDIA_TYPES.includes(obj.type as MediaType)) { return obj; } const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type); const propertyMappings = propertyMappingModel?.properties ?? []; + const propertyMappingByOriginal = new Map(propertyMappings.map(mapping => [mapping.property, mapping])); + const propertyMappingByMapped = new Map(propertyMappings.map(mapping => [mapping.newProperty, mapping])); - const originalObj: Record = {}; + const originalObj: Record = { ...obj }; - objLoop: for (const [key, value] of Object.entries(obj)) { - // first try if it is a normal property - for (const propertyMapping of propertyMappings) { - if (propertyMapping.property === key) { - // @ts-ignore - originalObj[key] = value; - - continue objLoop; - } + for (const [key, value] of Object.entries(obj)) { + const normalProperty = propertyMappingByOriginal.get(key); + if (normalProperty) { + originalObj[key] = value; + continue; } - // otherwise see if it is a mapped property - for (const propertyMapping of propertyMappings) { - if (propertyMapping.newProperty === key) { - // @ts-ignore - originalObj[propertyMapping.property] = value; - - continue objLoop; - } + const mappedProperty = propertyMappingByMapped.get(key); + if (mappedProperty) { + originalObj[mappedProperty.property] = value; + delete originalObj[key]; } } diff --git a/src/settings/PropertyMappingModelComponent.tsx b/src/settings/PropertyMappingModelComponent.tsx index d9a9017..c31bdbf 100644 --- a/src/settings/PropertyMappingModelComponent.tsx +++ b/src/settings/PropertyMappingModelComponent.tsx @@ -96,7 +96,7 @@ export default function PropertyMappingModelComponent(props: PropertyMappingMode —} + fallback={N/A} >

diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 4c24635..1b29fb3 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -1,9 +1,9 @@ import type { App } from 'obsidian'; -import { Notice, PluginSettingTab, SettingGroup } from 'obsidian'; +import { Notice, PluginSettingTab, SecretComponent, SettingGroup } from 'obsidian'; import { render } from 'solid-js/web'; -import { MediaType } from 'src/utils/MediaType'; 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'; @@ -14,12 +14,13 @@ import { FolderSuggest } from './suggesters/FolderSuggest'; // MARK: Settings export interface MediaDbPluginSettings { - OMDbKey: string; - TMDBKey: string; - MobyGamesKey: string; - GiantBombKey: string; - ComicVineKey: string; - BoardgameGeekKey: string; + OMDbKeyId: string; + TMDBKeyId: string; + MobyGamesKeyId: string; + GiantBombKeyId: string; + ComicVineKeyId: string; + BoardgameGeekKeyId: string; + sfwFilter: boolean; templates: boolean; customDateFormat: string; @@ -267,12 +268,13 @@ class MediaTypeMappedSettings { // MARK: Defaults const DEFAULT_SETTINGS: MediaDbPluginSettings = { - OMDbKey: '', - TMDBKey: '', - MobyGamesKey: '', - GiantBombKey: '', - ComicVineKey: '', - BoardgameGeekKey: '', + OMDbKeyId: '', + TMDBKeyId: '', + MobyGamesKeyId: '', + GiantBombKeyId: '', + ComicVineKeyId: '', + BoardgameGeekKeyId: '', + sfwFilter: true, templates: true, customDateFormat: 'L', @@ -361,7 +363,7 @@ export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings key, '', PropertyMappingOption.Default, - lockedPropertyMappings.contains(key), + lockedPropertyMappings.includes(key), false, // wikilink default ), ); @@ -534,23 +536,15 @@ export class MediaDbSettingTab extends PluginSettingTab { void setting .setName('OMDb API key') .setDesc('API key for "www.omdbapi.com".') - // .addComponent((el) => { - // let component = new SecretComponent(this.app, el); + .addComponent(el => { + const component = new SecretComponent(this.app, el); - // component.setValue(this.plugin.settings.OMDbKey).onChange(data => { - // this.plugin.settings.OMDbKey = data; - // void this.plugin.saveSettings(); - // }); + component.setValue(this.plugin.settings.OMDbKeyId).onChange(data => { + this.plugin.settings.OMDbKeyId = data; + void this.plugin.saveSettings(); + }); - // return component; - // }) - .addText(cb => { - cb.setPlaceholder('API key') - .setValue(this.plugin.settings.OMDbKey) - .onChange(data => { - this.plugin.settings.OMDbKey = data; - void this.plugin.saveSettings(); - }); + return component; }), ); apiKeyGroup.addSetting( @@ -558,13 +552,15 @@ export class MediaDbSettingTab extends PluginSettingTab { void setting .setName('TMDB API key') .setDesc('API key for "https://www.themoviedb.org".') - .addText(cb => { - cb.setPlaceholder('API key') - .setValue(this.plugin.settings.TMDBKey) - .onChange(data => { - this.plugin.settings.TMDBKey = data; - void this.plugin.saveSettings(); - }); + .addComponent(el => { + const component = new SecretComponent(this.app, el); + + component.setValue(this.plugin.settings.TMDBKeyId).onChange(data => { + this.plugin.settings.TMDBKeyId = data; + void this.plugin.saveSettings(); + }); + + return component; }), ); apiKeyGroup.addSetting( @@ -572,13 +568,15 @@ export class MediaDbSettingTab extends PluginSettingTab { void setting .setName('Moby Games key') .setDesc('API key for "www.mobygames.com".') - .addText(cb => { - cb.setPlaceholder('API key') - .setValue(this.plugin.settings.MobyGamesKey) - .onChange(data => { - this.plugin.settings.MobyGamesKey = data; - void this.plugin.saveSettings(); - }); + .addComponent(el => { + const component = new SecretComponent(this.app, el); + + component.setValue(this.plugin.settings.MobyGamesKeyId).onChange(data => { + this.plugin.settings.MobyGamesKeyId = data; + void this.plugin.saveSettings(); + }); + + return component; }), ); apiKeyGroup.addSetting( @@ -586,13 +584,15 @@ export class MediaDbSettingTab extends PluginSettingTab { void setting .setName('Giant Bomb Key') .setDesc('API key for "www.giantbomb.com".') - .addText(cb => { - cb.setPlaceholder('API key') - .setValue(this.plugin.settings.GiantBombKey) - .onChange(data => { - this.plugin.settings.GiantBombKey = data; - void this.plugin.saveSettings(); - }); + .addComponent(el => { + const component = new SecretComponent(this.app, el); + + component.setValue(this.plugin.settings.GiantBombKeyId).onChange(data => { + this.plugin.settings.GiantBombKeyId = data; + void this.plugin.saveSettings(); + }); + + return component; }), ); apiKeyGroup.addSetting( @@ -600,13 +600,15 @@ export class MediaDbSettingTab extends PluginSettingTab { void setting .setName('Comic Vine Key') .setDesc('API key for "www.comicvine.gamespot.com".') - .addText(cb => { - cb.setPlaceholder('API key') - .setValue(this.plugin.settings.ComicVineKey) - .onChange(data => { - this.plugin.settings.ComicVineKey = data; - void this.plugin.saveSettings(); - }); + .addComponent(el => { + const component = new SecretComponent(this.app, el); + + component.setValue(this.plugin.settings.ComicVineKeyId).onChange(data => { + this.plugin.settings.ComicVineKeyId = data; + void this.plugin.saveSettings(); + }); + + return component; }), ); apiKeyGroup.addSetting( @@ -614,13 +616,15 @@ export class MediaDbSettingTab extends PluginSettingTab { void setting .setName('Boardgame Geek Key') .setDesc('API key for "www.boardgamegeek.com".') - .addText(cb => { - cb.setPlaceholder('API key') - .setValue(this.plugin.settings.BoardgameGeekKey) - .onChange(data => { - this.plugin.settings.BoardgameGeekKey = data; - void this.plugin.saveSettings(); - }); + .addComponent(el => { + const component = new SecretComponent(this.app, el); + + component.setValue(this.plugin.settings.BoardgameGeekKeyId).onChange(data => { + this.plugin.settings.BoardgameGeekKeyId = data; + void this.plugin.saveSettings(); + }); + + return component; }), ); diff --git a/src/settings/suggesters/FileSuggest.ts b/src/settings/suggesters/FileSuggest.ts index 6bbdb96..6e91100 100644 --- a/src/settings/suggesters/FileSuggest.ts +++ b/src/settings/suggesters/FileSuggest.ts @@ -8,7 +8,7 @@ export class FileSuggest extends AbstractInputSuggest { return this.app.vault .getAllLoadedFiles() .filter(file => file instanceof TFile) - .filter(file => file.path.toLowerCase().contains(lowerCaseInputStr)); + .filter(file => file.path.toLowerCase().includes(lowerCaseInputStr)); } renderSuggestion(value: TFile, el: HTMLElement): void { diff --git a/src/settings/suggesters/FolderSuggest.ts b/src/settings/suggesters/FolderSuggest.ts index eaee715..2ce3574 100644 --- a/src/settings/suggesters/FolderSuggest.ts +++ b/src/settings/suggesters/FolderSuggest.ts @@ -8,7 +8,7 @@ export class FolderSuggest extends AbstractInputSuggest { return this.app.vault .getAllLoadedFiles() .filter(file => file instanceof TFolder) - .filter(file => file.path.toLowerCase().contains(lowerCaseInputStr)); + .filter(file => file.path.toLowerCase().includes(lowerCaseInputStr)); } renderSuggestion(value: TFolder, el: HTMLElement): void { diff --git a/src/utils/BulkImportHelper.ts b/src/utils/BulkImportHelper.ts index dcbaabb..da4b1a4 100644 --- a/src/utils/BulkImportHelper.ts +++ b/src/utils/BulkImportHelper.ts @@ -1,8 +1,8 @@ import type { TFolder } from 'obsidian'; import { TFile } from 'obsidian'; -import type MediaDbPlugin from 'src/main'; -import { MediaDbBulkImportModal as MediaDbBulkImportModal } from 'src/modals/MediaDbBulkImportModal'; -import type { MediaTypeModel } from 'src/models/MediaTypeModel'; +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'; diff --git a/src/utils/DateFormatter.ts b/src/utils/DateFormatter.ts index 0088af0..31169e5 100644 --- a/src/utils/DateFormatter.ts +++ b/src/utils/DateFormatter.ts @@ -1,6 +1,12 @@ -import { moment } from 'obsidian'; +import type momentType from 'moment'; +import type { Moment } from 'moment'; +import { moment as obsidianMoment } from 'obsidian'; + +const moment: typeof momentType = obsidianMoment as unknown as typeof momentType; export class DateFormatter { + private static readonly RFC2822_FORMAT = 'ddd, DD MMM YYYY HH:mm:ss ZZ'; + toFormat: string; locale: string; @@ -38,18 +44,10 @@ export class DateFormatter { return null; } - let date: moment.Moment; + let date: Moment; if (!dateFormat) { - // reading date formats other then C2822 or ISO with moment is deprecated - // see https://momentjs.com/docs/#/parsing/string/ - if (this.hasMomentFormat(dateString)) { - // expect C2822 or ISO format - date = moment(dateString); - } else { - // try to read date string with native Date - date = moment(new Date(dateString)); - } + date = this.parseWithoutFormat(dateString); } else { date = moment(dateString, dateFormat, locale); } @@ -58,8 +56,20 @@ export class DateFormatter { return date.isValid() ? date.locale(this.locale).format(this.toFormat) : null; } + private parseWithoutFormat(dateString: string): 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); + } + + // fall back to native Date parsing for unknown formats + return moment(new Date(dateString)); + } + private hasMomentFormat(dateString: string): boolean { - const date = moment(dateString, true); // strict mode + const date = moment(dateString, [moment.ISO_8601, DateFormatter.RFC2822_FORMAT], true); // strict mode return date.isValid(); } } diff --git a/src/utils/ModalHelper.ts b/src/utils/ModalHelper.ts index 7efba29..a8d1503 100644 --- a/src/utils/ModalHelper.ts +++ b/src/utils/ModalHelper.ts @@ -1,8 +1,8 @@ import { Notice } from 'obsidian'; -import { MediaDbPreviewModal } from 'src/modals/MediaDbPreviewModal'; 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'; diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 9b25b34..9e4f286 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -223,10 +223,28 @@ export function unCamelCase(str: string): string { ); } -/* eslint-disable */ +interface TemplaterPlugin { + settings?: { + trigger_on_file_creation?: boolean; + }; + templater?: { + overwrite_file_commands: (file: TFile) => Promise; + }; +} + +function getTemplaterPlugin(app: App): TemplaterPlugin | null { + const pluginContainer = (app as App & { plugins?: { plugins?: Record } }).plugins; + const candidate = pluginContainer?.plugins?.['templater-obsidian']; + + if (!candidate || typeof candidate !== 'object') { + return null; + } + + return candidate as TemplaterPlugin; +} export function hasTemplaterPlugin(app: App): boolean { - const templater = (app as any).plugins.plugins['templater-obsidian']; + const templater = getTemplaterPlugin(app); return !!templater; } @@ -234,17 +252,14 @@ export function hasTemplaterPlugin(app: App): boolean { // Copied from https://github.com/anpigon/obsidian-book-search-plugin // Licensed under the MIT license. Copyright (c) 2020 Jake Runzer export async function useTemplaterPluginInFile(app: App, file: TFile): Promise { - const templater = (app as any).plugins.plugins['templater-obsidian']; - if (templater && !templater?.settings.trigger_on_file_creation) { + const templater = getTemplaterPlugin(app); + if (templater && !templater.settings?.trigger_on_file_creation && templater.templater) { await templater.templater.overwrite_file_commands(file); } } -/* eslint-enable */ - export type ModelToData = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - [K in keyof T as T[K] extends Function ? never : K]?: T[K] | null; + [K in keyof T as T[K] extends (...args: never[]) => unknown ? never : K]?: T[K] | null; }; // Checks if a given URL points to an existing image (status 200), or returns false for 404/other errors. @@ -272,8 +287,8 @@ export function isTruthy(value: T): value is Exclude { const obs_headers: Record = {}; - input.headers.forEach((header, value) => { - obs_headers[header] = value; + input.headers.forEach((value, key) => { + obs_headers[key] = value; }); const res = await requestUrl({ diff --git a/tsconfig.json b/tsconfig.json index c6eb672..49b6fcb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "baseUrl": ".", "module": "ESNext", "target": "ESNext", "allowJs": true, @@ -9,7 +8,7 @@ "strict": true, "strictNullChecks": true, "noImplicitReturns": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "importHelpers": true, "isolatedModules": true, "skipLibCheck": true,