diff --git a/README.md b/README.md index 7cd31f37..66ff12df 100644 --- a/README.md +++ b/README.md @@ -44,35 +44,35 @@ I also published my own templates [here](https://github.com/mProjectsCode/obsidi The plugin offers a setting to automatically download the poster images for a new media, ensuring offline access. The images are saved as `type_title (year)` e.g. `movie_The Perfect Storm (2000)`, in a user-chosen folder. -#### Metadata field customization +#### Property Mapping & Customization -Allows you to rename the metadata fields this plugin generates through mappings. The mappings can be set in the plugin's settings. -The three options for mapping are: +The plugin allows you to completely reorganize and customize how metadata fields are generated into your Obsidian notes. In the plugin settings, you can edit mappings with the following granular controls: -- `default`: Keep the original name -- `remap`: Rename the property -- `remove`: Removes the property entirely +- **Mapping Types**: Choose to keep original names (`default`), rename properties (`remap`), or skip them entirely (`remove`). +- **Drag & Drop Ordering**: Easily drag and rearrange properties to dictate their exact output order in your frontmatter. +- **Wikilink**: Convert specific properties into safe `[[Wiki-links]]` directly within the frontmatter. +- **Pin to Bottom**: Pin specific properties to the absolute bottom of the frontmatter. If multiple properties are pinned, they strictly follow your drag-and-drop order. +- **Auto-Tagging**: Dynamically convert property values into Obsidian tags, complete with a custom nested prefix input box (e.g., configuring `genres` to generate `#genre/action`). -#### Bulk Import +#### Bulk Operations -The plugin allows you to import your preexisting media collection and upgrade it to Media DB entries. +The plugin offers powerful bulk actions accessible via right-clicking a folder or the left-side Ribbon: -##### Prerequisites +- **Bulk Download Images**: Automatically downloads and stores remote poster images locally for all notes in a folder. +- **Bulk Update Metadata**: Refresh API data for multiple existing notes at once. +- **Bulk Recreate Metadata**: Features two unique modes: **Reset** (completely wipes and reconstructs the metadata) and **Safe** (preserves your manual text/content while safely reorganizing properties to match your latest mapping layout). +- **Import Folder as Media**: Convert a folder of basic notes (e.g. from a CSV import) into rich Media DB entries by searching their titles. -The preexisting media notes must be inside a folder in your vault. -For the plugin to be able to query them, they need one metadata field that is used as the title the piece of media is searched by. -This can be achieved by, for example, using a `csv` import plugin to import an existing list from outside of Obsidian. +#### Auto-Tracker Engine -##### Importing +An automated tracking system that periodically checks for `airing` and `released` state changes of your ongoing media (Series, Games, Movies, etc.). -To start the import process, right-click on the folder and select the `Import folder as Media DB entries` option. -Then specify the API to search, if the current note content and metadata should be appended to the Media DB entry, and the name of the metadata field that contains the title of the piece of media. +- The tracker scans on Obsidian startup or can be triggered manually via the Ribbon icon or bulk menus. +- **Custom Statuses**: You can customize the exact keywords the plugin looks for (e.g., matching your personalized vocabulary for "Airing", "Released", or "Upcoming") in the Settings panel. -Then the plugin will go through every file in the folder and prompt you to select from the search results. +#### Intelligent Ghost Tag Purging -##### Post import - -After all files have been imported or the import was canceled, you will find the new entries as well as an error report that contains any errors or skipped/canceled files in the folder specified in the setting of the plugin. +The plugin's granular Auto-Tag generator is strictly non-destructive. Whenever a piece of media updates (e.g., a game's genre changes on the API), the plugin intelligently calculates which old tags it previously auto-generated and safely removes only those, leaving any manual tags you typed yourself perfectly preserved! ### How to install @@ -130,6 +130,8 @@ Now you select the result you want, and the plugin will cast its magic, creating | 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 | +| [IGDB](https://www.igdb.com/) | IGDB is a community-driven game database offering rich metadata for video games. | games | Yes, requires a free Twitch Developer account. Create an app at [dev.twitch.tv](https://dev.twitch.tv/console/apps) to get a Client ID and Client Secret. | 4 requests per second | No | +| [RAWG](https://rawg.io/) | RAWG is one of the largest open video game databases with Metacritic scores and platform data. | games | Yes, get a free API key at [rawg.io/apidocs](https://rawg.io/apidocs) | None stated | No | #### Notes diff --git a/automation/build/esbuild.config.ts b/automation/build/esbuild.config.ts index 20d35b8f..44bddce6 100644 --- a/automation/build/esbuild.config.ts +++ b/automation/build/esbuild.config.ts @@ -1,4 +1,4 @@ -import builtins from 'builtin-modules'; +import { builtinModules } from 'node:module'; import esbuild from 'esbuild'; import esbuildSvelte from 'esbuild-svelte'; import { sveltePreprocess } from 'svelte-preprocess'; @@ -26,7 +26,7 @@ const build = await esbuild.build({ '@lezer/common', '@lezer/highlight', '@lezer/lr', - ...builtins, + ...builtinModules, ], format: 'cjs', target: 'es2018', diff --git a/manifest-beta.json b/manifest-beta.json index e93a0224..fc51c90d 100644 --- a/manifest-beta.json +++ b/manifest-beta.json @@ -2,7 +2,7 @@ "id": "obsidian-media-db-plugin", "name": "Media DB", "version": "0.8.0-canary.20260129T112027", - "minAppVersion": "1.5.0", + "minAppVersion": "1.11.4", "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/manifest.json b/manifest.json index a8d101ff..56b232b4 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.11.4", "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 92d08d1c..d5eaf3d8 100644 --- a/package.json +++ b/package.json @@ -7,31 +7,29 @@ "dev": "vite build --watch --mode development", "build": "bun run tsc && vite build --mode production", "tsc": "tsc -noEmit -skipLibCheck", - "test": "bun test", - "test:log": "LOG_TESTS=true bun test", + "test": "npm test", + "test:log": "LOG_TESTS=true npm 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" + "check": "npm run format:check && npm run tsc && npm run lint", + "check:fix": "npm run format && npm run tsc && npm run lint:fix", + "release": "npm run automation/release.ts", + "stats": "npm run automation/stats.ts" }, "keywords": [], "author": "Moritz Jung", "license": "GPL-3.0", "devDependencies": { - "@happy-dom/global-registrator": "^18.0.1", "@lemons_dev/parsinom": "^0.0.12", - "@popperjs/core": "^2.11.8", "@types/bun": "^1.3.7", "builtin-modules": "^5.0.0", "eslint": "^9.39.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-only-warn": "^1.1.0", "iso-639-2": "^3.0.2", - "obsidian": "latest", + "obsidian": "^1.12.3", "openapi-fetch": "^0.14.1", "openapi-typescript": "^7.10.1", "prettier": "^3.8.1", diff --git a/src/api/APIManager.ts b/src/api/APIManager.ts index 741c50e4..d0f9a59d 100644 --- a/src/api/APIManager.ts +++ b/src/api/APIManager.ts @@ -1,5 +1,6 @@ import { Notice } from 'obsidian'; import type { MediaTypeModel } from '../models/MediaTypeModel'; +import type { MediaType } from '../utils/MediaType'; import type { APIModel } from './APIModel'; export class APIManager { @@ -40,24 +41,28 @@ export class APIManager { * @param item */ async queryDetailedInfo(item: MediaTypeModel): Promise { - return await this.queryDetailedInfoById(item.id, item.dataSource); + return await this.queryDetailedInfoById(item.id, item.dataSource, item.getMediaType()); } /** * Queries detailed info for an id from an API. + * MusicBrainz-backed notes use on-disk dataSource `MusicBrainz`; `mediaType` picks Artist vs release/song API. * * @param id - * @param apiName + * @param apiName Stored dataSource on the note, or an exact {@link APIModel.apiName} (e.g. bulk import / ID search). + * @param mediaType When set with a MusicBrainz family dataSource, selects which MusicBrainz API handles {@link getById}. */ - async queryDetailedInfoById(id: string, apiName: string): Promise { + async queryDetailedInfoById(id: string, apiName: string, mediaType?: MediaType): Promise { + const effectiveApiName = apiName.trim() || apiName; + + // Delegate to each registered API — APIs override canHandleDataSource() for special logic for (const api of this.apis) { - if (api.apiName === apiName) { + if (api.canHandleDataSource(effectiveApiName, mediaType)) { try { - return api.getById(id); + return await api.getById(id); } catch (e) { new Notice(`Error querying ${api.apiName}: ${e}`); console.warn(e); - return undefined; } } diff --git a/src/api/APIModel.ts b/src/api/APIModel.ts index 0919db90..450b5435 100644 --- a/src/api/APIModel.ts +++ b/src/api/APIModel.ts @@ -28,4 +28,22 @@ export abstract class APIModel { hasTypeOverlap(types: MediaType[]): boolean { return types.some(type => this.hasType(type)); } + + canHandleDataSource(dataSource: string, _mediaType?: MediaType): boolean { + return this.apiName === dataSource; + } + + /** + * Returns the wiki-link string for a given property value. + * + * @param value the raw string value to wrap + * @param folderPrefix the wiki-link folder prefix (e.g. 'Media DB/wiki/') + */ + wikilinkValueFor(value: string, folderPrefix: string): string { + const clean = value + .replace(/^\[\[(.*?)\]\]$/, '$1') + .split('|') + .pop()!; + return `[[${folderPrefix}${clean}|${clean}]]`; + } } diff --git a/src/api/GeniusClient.ts b/src/api/GeniusClient.ts new file mode 100644 index 00000000..50b93634 --- /dev/null +++ b/src/api/GeniusClient.ts @@ -0,0 +1,82 @@ +import { requestUrl } from 'obsidian'; +import { contactEmail, mediaDbVersion, pluginName } from '../utils/Utils'; +import { extractLyricsFromGeniusHtml } from './helpers/geniusLyricsExtract'; + +interface GeniusSearchHit { + result: { + id: number; + title: string; + url: string; + primary_artist: { name: string }; + }; +} + +interface GeniusSearchResponse { + response: { + hits: GeniusSearchHit[]; + }; +} + +export class GeniusClient { + private readonly accessToken: string | undefined; + private readonly userAgent: string; + + constructor(accessToken: string | undefined) { + this.accessToken = accessToken; + this.userAgent = `${pluginName}/${mediaDbVersion} (${contactEmail})`; + } + + isConfigured(): boolean { + return Boolean(this.accessToken?.trim()); + } + + async searchFirstSongHit(query: string): Promise<{ url: string; title: string } | null> { + if (!this.accessToken?.trim()) { + return null; + } + + const url = `https://api.genius.com/search?q=${encodeURIComponent(query)}`; + const res = await requestUrl({ + url, + throw: false, + headers: { + 'User-Agent': this.userAgent, + Authorization: `Bearer ${this.accessToken.trim()}`, + }, + }); + + if (res.status !== 200) { + if (res.status === 401) { + console.warn('MDB | Genius search returned 401 — access token missing, invalid, or expired. Update it in Media DB settings or clear it to skip lyrics.'); + } else { + console.warn(`MDB | Genius search returned ${res.status}`); + } + return null; + } + + const data = res.json as GeniusSearchResponse; + const hit = data.response?.hits?.[0]?.result; + if (!hit?.url) { + return null; + } + + return { url: hit.url, title: hit.title }; + } + + async fetchLyricsFromSongPage(songPageUrl: string): Promise { + const res = await requestUrl({ + url: songPageUrl, + throw: false, + headers: { + 'User-Agent': this.userAgent, + }, + }); + + if (res.status !== 200) { + console.warn(`MDB | Genius song page returned ${res.status}`); + return ''; + } + + return extractLyricsFromGeniusHtml(res.text); + } +} diff --git a/src/api/SpotifyClient.ts b/src/api/SpotifyClient.ts new file mode 100644 index 00000000..a2a468e2 --- /dev/null +++ b/src/api/SpotifyClient.ts @@ -0,0 +1,144 @@ +import { requestUrl } from 'obsidian'; +import { contactEmail, mediaDbVersion, pluginName } from '../utils/Utils'; + +interface SpotifyTokenResponse { + access_token: string; + expires_in: number; + token_type: string; +} + +interface SpotifySearchResponse { + tracks?: { + items: { external_urls?: { spotify?: string } }[]; + }; +} + +function spotifyTrackArtistQuery(trackTitle: string, artistName: string): string { + const clean = (s: string) => s.trim().replace(/"/g, ' ').replace(/\s+/g, ' '); + const t = clean(trackTitle); + const a = clean(artistName); + if (!t) { + return ''; + } + if (!a) { + return `track:"${t}"`; + } + return `track:"${t}" artist:"${a}"`; +} + +export class SpotifyClient { + private readonly clientId: string; + private readonly clientSecret: string; + private readonly userAgent: string; + private accessToken: string | null = null; + private tokenExpiresAtMs = 0; + + constructor(clientId: string | undefined, clientSecret: string | undefined) { + this.clientId = (clientId ?? '').trim(); + this.clientSecret = (clientSecret ?? '').trim(); + this.userAgent = `${pluginName}/${mediaDbVersion} (${contactEmail})`; + } + + isConfigured(): boolean { + return Boolean(this.clientId && this.clientSecret); + } + + private async refreshAccessToken(): Promise { + if (!this.isConfigured()) { + return null; + } + const basic = btoa(`${this.clientId}:${this.clientSecret}`); + const res = await requestUrl({ + url: 'https://accounts.spotify.com/api/token', + method: 'POST', + throw: false, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${basic}`, + 'User-Agent': this.userAgent, + }, + body: 'grant_type=client_credentials', + }); + if (res.status !== 200) { + console.warn(`MDB | Spotify token request returned ${res.status}`); + this.accessToken = null; + this.tokenExpiresAtMs = 0; + return null; + } + const data = res.json as SpotifyTokenResponse; + if (!data.access_token) { + return null; + } + this.accessToken = data.access_token; + const ttlMs = (data.expires_in ?? 3600) * 1000; + this.tokenExpiresAtMs = Date.now() + ttlMs - 60_000; + return this.accessToken; + } + + private async getAccessToken(): Promise { + if (!this.isConfigured()) { + return null; + } + const now = Date.now(); + if (this.accessToken && now < this.tokenExpiresAtMs) { + return this.accessToken; + } + return this.refreshAccessToken(); + } + + /** + * Search for a track and return the first result's open.spotify.com URL, or ''. + */ + async searchFirstTrackUrl(trackTitle: string, artistName: string): Promise { + const q = spotifyTrackArtistQuery(trackTitle, artistName); + if (!q) { + return ''; + } + let token = await this.getAccessToken(); + if (!token) { + console.warn('MDB | Spotify search fetch skipped: could not obtain access token'); + return ''; + } + + const params = new URLSearchParams({ q, type: 'track', limit: '1' }); + const url = `https://api.spotify.com/v1/search?${params.toString()}`; + console.log(`MDB | Spotify search fetch: ${url}`); + let res = await requestUrl({ + url, + method: 'GET', + throw: false, + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': this.userAgent, + }, + }); + + if (res.status === 401) { + this.accessToken = null; + this.tokenExpiresAtMs = 0; + token = await this.refreshAccessToken(); + if (!token) { + return ''; + } + console.log(`MDB | Spotify search fetch (retry after 401): ${url}`); + res = await requestUrl({ + url, + method: 'GET', + throw: false, + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': this.userAgent, + }, + }); + } + + if (res.status !== 200) { + console.warn(`MDB | Spotify search returned ${res.status}`); + return ''; + } + + const data = res.json as SpotifySearchResponse; + const link = data.tracks?.items?.[0]?.external_urls?.spotify; + return typeof link === 'string' ? link : ''; + } +} diff --git a/src/api/apis/BoardGameGeekAPI.ts b/src/api/apis/BoardGameGeekAPI.ts index 07c20982..704d54fa 100644 --- a/src/api/apis/BoardGameGeekAPI.ts +++ b/src/api/apis/BoardGameGeekAPI.ts @@ -2,7 +2,9 @@ import { requestUrl } from 'obsidian'; import { BoardGameModel } from 'src/models/BoardGameModel'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; +import { coerceYear } from '../../utils/Utils'; import { APIModel } from '../APIModel'; // sadly no open api schema available @@ -23,11 +25,16 @@ export class BoardGameGeekAPI extends APIModel { async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); + const bggKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.boardgameGeek); + if (!bggKey) { + throw 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 ${bggKey}`, }, }); @@ -57,7 +64,7 @@ export class BoardGameGeekAPI extends APIModel { id, title, englishTitle: title, - year, + year: coerceYear(year), }), ); } @@ -68,11 +75,16 @@ export class BoardGameGeekAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); + const bggKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.boardgameGeek); + if (!bggKey) { + throw 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 ${bggKey}`, }, }); @@ -111,7 +123,7 @@ export class BoardGameGeekAPI extends APIModel { return new BoardGameModel({ title: title ?? undefined, englishTitle: title ?? undefined, - year: year === '0' ? '' : year, + year: year === '0' ? 0 : coerceYear(year), dataSource: this.apiName, url: `https://boardgamegeek.com/boardgame/${id}`, id: id, diff --git a/src/api/apis/ComicVineAPI.ts b/src/api/apis/ComicVineAPI.ts index 53d98207..1bfa3daa 100644 --- a/src/api/apis/ComicVineAPI.ts +++ b/src/api/apis/ComicVineAPI.ts @@ -4,7 +4,9 @@ import { requestUrl } from 'obsidian'; import { ComicMangaModel } from 'src/models/ComicMangaModel'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; +import { coerceYear } from '../../utils/Utils'; import { APIModel } from '../APIModel'; // sadly no open api schema available @@ -25,7 +27,12 @@ export class ComicVineAPI extends APIModel { async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); - const searchUrl = `${this.apiUrl}/search/?api_key=${this.plugin.settings.ComicVineKey}&format=json&resources=volume&query=${encodeURIComponent(title)}`; + const apiKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.comicVine); + if (!apiKey) { + throw Error(`MDB | API key for ${this.apiName} missing.`); + } + + const searchUrl = `${this.apiUrl}/search/?api_key=${apiKey}&format=json&resources=volume&query=${encodeURIComponent(title)}`; const fetchData = await requestUrl({ url: searchUrl, }); @@ -42,7 +49,7 @@ export class ComicVineAPI extends APIModel { new ComicMangaModel({ title: result.name, englishTitle: result.name, - year: result.start_year, + year: coerceYear(result.start_year), dataSource: this.apiName, id: `4050-${result.id}`, publishers: result.publisher?.name, @@ -56,7 +63,12 @@ export class ComicVineAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); - const searchUrl = `${this.apiUrl}/volume/${encodeURIComponent(id)}/?api_key=${this.plugin.settings.ComicVineKey}&format=json`; + const apiKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.comicVine); + if (!apiKey) { + throw Error(`MDB | API key for ${this.apiName} missing.`); + } + + const searchUrl = `${this.apiUrl}/volume/${encodeURIComponent(id)}/?api_key=${apiKey}&format=json`; const fetchData = await requestUrl({ url: searchUrl, }); @@ -82,7 +94,7 @@ export class ComicVineAPI extends APIModel { englishTitle: result.name, alternateTitles: result.aliases, plot: result.deck, - year: result.start_year, + year: coerceYear(result.start_year), dataSource: this.apiName, url: result.site_detail_url, id: `4050-${result.id}`, diff --git a/src/api/apis/GiantBombAPI.ts b/src/api/apis/GiantBombAPI.ts index 76e451b2..99db26e5 100644 --- a/src/api/apis/GiantBombAPI.ts +++ b/src/api/apis/GiantBombAPI.ts @@ -1,8 +1,9 @@ import createClient from 'openapi-fetch'; -import { obsidianFetch } from 'src/utils/Utils'; +import { coerceYear, obsidianFetch } from 'src/utils/Utils'; import type MediaDbPlugin from '../../main'; import { GameModel } from '../../models/GameModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; import { APIModel } from '../APIModel'; import type { paths } from '../schemas/GiantBomb'; @@ -24,7 +25,8 @@ export class GiantBombAPI extends APIModel { async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); - if (!this.plugin.settings.GiantBombKey) { + const apiKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.giantBomb); + if (!apiKey) { throw Error(`MDB | API key for ${this.apiName} missing.`); } @@ -32,7 +34,7 @@ export class GiantBombAPI extends APIModel { const response = await client.GET('/games', { params: { query: { - api_key: this.plugin.settings.GiantBombKey, + api_key: apiKey, filter: `name:${title}`, format: 'json', limit: 20, @@ -55,13 +57,13 @@ export class GiantBombAPI extends APIModel { const ret: MediaTypeModel[] = []; for (const result of data ?? []) { - const year = result.original_release_date ? new Date(result.original_release_date).getFullYear().toString() : undefined; + const year = result.original_release_date ? new Date(result.original_release_date).getFullYear() : undefined; ret.push( new GameModel({ title: result.name, englishTitle: result.name, - year: year, + year: coerceYear(year), dataSource: this.apiName, id: result.guid?.toString(), }), @@ -74,7 +76,8 @@ export class GiantBombAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); - if (!this.plugin.settings.GiantBombKey) { + const apiKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.giantBomb); + if (!apiKey) { throw Error(`MDB | API key for ${this.apiName} missing.`); } @@ -85,7 +88,7 @@ export class GiantBombAPI extends APIModel { guid: id, }, query: { - api_key: this.plugin.settings.GiantBombKey, + api_key: apiKey, format: 'json', }, }, @@ -111,7 +114,7 @@ export class GiantBombAPI extends APIModel { 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 year = result.original_release_date ? new Date(result.original_release_date).getFullYear() : undefined; const developers = result.developers as | { name: string; @@ -139,7 +142,7 @@ export class GiantBombAPI extends APIModel { type: MediaType.Game, title: result.name, englishTitle: result.name, - year: year, + year: coerceYear(year), dataSource: this.apiName, url: result.site_detail_url, id: result.guid?.toString(), diff --git a/src/api/apis/IGDBAPI.ts b/src/api/apis/IGDBAPI.ts index 57a42f1e..89355bc4 100644 --- a/src/api/apis/IGDBAPI.ts +++ b/src/api/apis/IGDBAPI.ts @@ -2,19 +2,54 @@ import { requestUrl } from 'obsidian'; import type MediaDbPlugin from '../../main'; import { GameModel } from '../../models/GameModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; +import { coerceYear } from '../../utils/Utils'; 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 IGDBCover { + url: string; +} +interface IGDBGenre { + name: string; +} +interface IGDBCompany { + name: string; +} +interface IGDBInvolvedCompany { + company: IGDBCompany; + developer: boolean; + publisher: boolean; +} +interface IGDBPlatform { + name: string; +} +interface IGDBGameMode { + name: string; +} +interface IGDBCollection { + name: string; +} interface IGDBGame { - id: number; name: string; cover?: IGDBCover; first_release_date?: number; - summary?: string; total_rating?: number; url?: string; - genres?: IGDBGenre[]; involved_companies?: IGDBInvolvedCompany[]; + id: number; + name: string; + cover?: IGDBCover; + first_release_date?: number; + summary?: string; + total_rating?: number; + url?: string; + genres?: IGDBGenre[]; + involved_companies?: IGDBInvolvedCompany[]; + platforms?: IGDBPlatform[]; + game_modes?: IGDBGameMode[]; + collection?: IGDBCollection; + collections?: IGDBCollection[]; + franchises?: IGDBCollection[]; +} +interface TwitchAuthResponse { + access_token: string; + expires_in: number; } -interface TwitchAuthResponse { access_token: string; expires_in: number; } export class IGDBAPI extends APIModel { plugin: MediaDbPlugin; @@ -35,39 +70,48 @@ export class IGDBAPI extends APIModel { const currentTime = Date.now(); if (this.accessToken && currentTime < this.tokenExpiry) return this.accessToken; - if (!this.plugin.settings.IGDBClientId || !this.plugin.settings.IGDBClientSecret) { + const clientId = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.igdbClientId); + const clientSecret = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.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=${this.plugin.settings.IGDBClientId}&client_secret=${this.plugin.settings.IGDBClientSecret}&grant_type=client_credentials`, + 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; + 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 token = await this.getAuthToken(); + const clientId = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.igdbClientId); 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': this.plugin.settings.IGDBClientId, 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' }, + 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') : ''; + const year = result.first_release_date ? new Date(result.first_release_date * 1000).getFullYear() : 0; + const image = result.cover?.url ? 'https:' + result.cover.url.replace('t_thumb', 't_1080p').replace(/\.jpg$/, '.webp') : ''; return new GameModel({ - type: MediaType.Game, title: result.name, englishTitle: result.name, year: year, - dataSource: this.apiName, id: result.id.toString(), image: image + type: MediaType.Game, + title: result.name, + englishTitle: result.name, + year: coerceYear(year), + dataSource: this.apiName, + id: result.id.toString(), + image: image, }); }); } @@ -75,18 +119,20 @@ export class IGDBAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); 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 clientId = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.igdbClientId); + 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, platforms.name, game_modes.name, collection.name, collections.name, franchises.name; where id = ${id};`; const response = await requestUrl({ - url: `${this.apiUrl}/games`, method: 'POST', - headers: { 'Client-ID': this.plugin.settings.IGDBClientId, 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' }, + 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 => { @@ -94,18 +140,46 @@ export class IGDBAPI extends APIModel { 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') : ''; + const image = result.cover?.url ? 'https:' + result.cover.url.replace('t_thumb', 't_1080p').replace(/\.jpg$/, '.webp') : ''; + + const combinedSeries: string[] = []; + // Öncelik 1: Franchise (Ana marka) + result.franchises?.forEach(f => { + if (f.name && !combinedSeries.includes(f.name)) combinedSeries.push(f.name); + }); + + // Öncelik 2: Franchise yoksa Collection (Seri) fallback'i + if (combinedSeries.length === 0) { + if (result.collection?.name) combinedSeries.push(result.collection.name); + result.collections?.forEach(c => { + if (c.name && !combinedSeries.includes(c.name)) combinedSeries.push(c.name); + }); + } 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, + type: MediaType.Game, + title: result.name, + englishTitle: result.name, + year: coerceYear(result.first_release_date ? new Date(result.first_release_date * 1000).getFullYear() : 0), + dataSource: this.apiName, + url: result.url, + id: result.id.toString(), + summary: result.summary ?? '', + series: combinedSeries, + gameModes: result.game_modes?.map(g => g.name) || [], + platforms: result.platforms?.map(p => p.name) || [], + developers: developers, + publishers: publishers, + genres: result.genres?.map(g => g.name) || [], + onlineRating: result.total_rating ? Math.round(result.total_rating * 10) / 10 : 0, + image: image, + released: result.first_release_date ? result.first_release_date * 1000 <= Date.now() : false, releaseDate: dateStr ? this.plugin.dateFormatter.format(dateStr, this.apiDateFormat) : '', userData: { played: false, personalRating: 0 }, }); } - getDisabledMediaTypes(): MediaType[] { return this.plugin.settings.IGDBAPI_disabledMediaTypes || []; } -} \ No newline at end of file + getDisabledMediaTypes(): MediaType[] { + return this.plugin.settings.IGDBAPI_disabledMediaTypes || []; + } +} diff --git a/src/api/apis/MALAPI.ts b/src/api/apis/MALAPI.ts index 8d0e34b6..fc0c6986 100644 --- a/src/api/apis/MALAPI.ts +++ b/src/api/apis/MALAPI.ts @@ -1,5 +1,5 @@ import createClient from 'openapi-fetch'; -import { isTruthy, obsidianFetch } from 'src/utils/Utils'; +import { coerceMovieDurationMinutes, coerceYear, isTruthy, obsidianFetch } from 'src/utils/Utils'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; import { MovieModel } from '../../models/MovieModel'; @@ -55,7 +55,7 @@ export class MALAPI extends APIModel { 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 year = coerceYear(result.year ?? result.aired?.prop?.from?.year); const id = result.mal_id?.toString(); if (type === undefined) { @@ -124,7 +124,7 @@ export class MALAPI extends APIModel { 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 year = coerceYear(result.year ?? result.aired?.prop?.from?.year); const new_id = result.mal_id?.toString(); if (type === undefined) { @@ -141,7 +141,7 @@ export class MALAPI extends APIModel { plot: result.synopsis, genres: result.genres?.map(x => x.name).filter(isTruthy), studio: result.studios?.map(x => x.name).filter(isTruthy), - duration: result.duration, + duration: coerceMovieDurationMinutes(result.duration), onlineRating: result.score, image: result.images?.jpg?.image_url, @@ -172,7 +172,7 @@ export class MALAPI extends APIModel { plot: result.synopsis, genres: result.genres?.map(x => x.name).filter(isTruthy), studio: result.studios?.map(x => x.name).filter(isTruthy), - duration: result.duration, + duration: coerceMovieDurationMinutes(result.duration), onlineRating: result.score, image: result.images?.jpg?.image_url, diff --git a/src/api/apis/MALAPIManga.ts b/src/api/apis/MALAPIManga.ts index 83d47322..4293ca1a 100644 --- a/src/api/apis/MALAPIManga.ts +++ b/src/api/apis/MALAPIManga.ts @@ -1,5 +1,5 @@ import createClient from 'openapi-fetch'; -import { isTruthy, obsidianFetch } from 'src/utils/Utils'; +import { coerceYear, isTruthy, obsidianFetch } from 'src/utils/Utils'; import type MediaDbPlugin from '../../main'; import { ComicMangaModel } from '../../models/ComicMangaModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; @@ -57,7 +57,7 @@ export class MALAPIManga extends APIModel { for (const result of data ?? []) { const resType = result.type?.toLowerCase(); const type = resType ? this.typeMappings.get(resType) : undefined; - const year = result.published?.prop?.from?.year?.toString() ?? ''; + const year = coerceYear(result.published?.prop?.from?.year); const id = result.mal_id?.toString(); ret.push( @@ -122,7 +122,7 @@ export class MALAPIManga extends APIModel { const resType = result.type?.toLowerCase(); const type = resType ? this.typeMappings.get(resType) : undefined; - const year = result.published?.prop?.from?.year?.toString() ?? ''; + const year = coerceYear(result.published?.prop?.from?.year); const new_id = result.mal_id?.toString(); return new ComicMangaModel({ diff --git a/src/api/apis/MobyGamesAPI.ts b/src/api/apis/MobyGamesAPI.ts index a88eec94..5fa9853a 100644 --- a/src/api/apis/MobyGamesAPI.ts +++ b/src/api/apis/MobyGamesAPI.ts @@ -4,6 +4,7 @@ import { requestUrl } from 'obsidian'; import type MediaDbPlugin from '../../main'; import { GameModel } from '../../models/GameModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; import { APIModel } from '../APIModel'; @@ -28,11 +29,12 @@ export class MobyGamesAPI extends APIModel { async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); - if (!this.plugin.settings.MobyGamesKey) { + const apiKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.mobyGames); + if (!apiKey) { 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=${apiKey}`; const fetchData = await requestUrl({ url: searchUrl, }); @@ -58,7 +60,7 @@ export class MobyGamesAPI extends APIModel { type: MediaType.Game, title: result.title, englishTitle: result.title, - year: new Date(result.platforms[0].first_release_date).getFullYear().toString(), + year: new Date(result.platforms[0].first_release_date).getFullYear(), dataSource: this.apiName, id: result.game_id, }), @@ -71,11 +73,12 @@ export class MobyGamesAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); - if (!this.plugin.settings.MobyGamesKey) { + const apiKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.mobyGames); + if (!apiKey) { 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=${apiKey}`; const fetchData = await requestUrl({ url: searchUrl, }); @@ -93,7 +96,7 @@ export class MobyGamesAPI extends APIModel { type: MediaType.Game, title: result.title, englishTitle: result.title, - year: new Date(result.platforms[0].first_release_date).getFullYear().toString(), + year: new Date(result.platforms[0].first_release_date).getFullYear(), dataSource: this.apiName, url: `https://www.mobygames.com/game/${result.game_id}`, id: result.game_id, diff --git a/src/api/apis/MusicBrainzAPI.ts b/src/api/apis/MusicBrainzAPI.ts index e1d0d7a8..058c120c 100644 --- a/src/api/apis/MusicBrainzAPI.ts +++ b/src/api/apis/MusicBrainzAPI.ts @@ -3,7 +3,8 @@ 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 { contactEmail, coerceYear, getLanguageName, mediaDbVersion, pluginName } from '../../utils/Utils'; +import { MUSICBRAINZ_NOTE_DATA_SOURCE } from '../musicBrainzConstants'; import { APIModel } from '../APIModel'; // sadly no open api schema available @@ -25,6 +26,10 @@ interface Release { status: string; } +function pickNonBootlegRelease(releases: Release[] | undefined): Release | undefined { + return releases?.find(r => r.status !== 'Bootlet'); +} + interface ArtistCredit { name: string; artist: { @@ -78,6 +83,7 @@ interface MediaResponse { position: number; title: string; recording: { + id?: string; length: number; title: string; }; @@ -98,11 +104,18 @@ export class MusicBrainzAPI extends APIModel { this.plugin = plugin; this.apiName = 'MusicBrainz API'; - this.apiDescription = 'Free API for music albums.'; + this.apiDescription = 'Free API for music releases.'; this.apiUrl = 'https://musicbrainz.org/'; this.types = [MediaType.MusicRelease]; } + canHandleDataSource(dataSource: string, mediaType?: import('../../utils/MediaType').MediaType): boolean { + if (dataSource.contains('MusicBrainz')) { + return mediaType === MediaType.MusicRelease || mediaType === MediaType.Song; + } + return dataSource === this.apiName; + } + async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); @@ -133,9 +146,11 @@ export class MusicBrainzAPI extends APIModel { type: 'musicRelease', title: result.title, englishTitle: result.title, - year: new Date(result['first-release-date']).getFullYear().toString(), + year: coerceYear( + result['first-release-date'] ? new Date(result['first-release-date']).getFullYear() : 0, + ), releaseDate: this.plugin.dateFormatter.format(result['first-release-date'], this.apiDateFormat) ?? 'unknown', - dataSource: this.apiName, + dataSource: MUSICBRAINZ_NOTE_DATA_SOURCE, url: 'https://musicbrainz.org/release-group/' + result.id, id: result.id, image: 'https://coverartarchive.org/release-group/' + result.id + '/front-500.jpg', @@ -167,13 +182,12 @@ export class MusicBrainzAPI extends APIModel { const result = (await groupResponse.json) as IdResponse; - // Get ID of the first release - const firstRelease = result.releases?.[0]; + const firstRelease = pickNonBootlegRelease(result.releases); if (!firstRelease) { - throw Error('MDB | No releases found in release group.'); + throw Error('MDB | No non-bootleg release found in release group.'); } - // Fetch recordings for the first release + // Fetch recordings for the chosen release (skip MusicBrainz status=Bootleg when another edition exists) const releaseUrl = `https://musicbrainz.org/ws/2/release/${firstRelease.id}?inc=recordings+artists&fmt=json`; console.log(`MDB | Fetching release recordings from: ${releaseUrl}`); @@ -191,7 +205,7 @@ export class MusicBrainzAPI extends APIModel { const releaseData = (await releaseResponse.json) as MediaResponse; const tracks = extractTracksFromMedia(releaseData.media); - // Calculate total album length for the first release + // Calculate total length for the first release const totalrawLength = releaseData.media[0]?.tracks.reduce((sum, track) => { const len = track.length ?? track.recording?.length; @@ -205,9 +219,11 @@ export class MusicBrainzAPI extends APIModel { type: 'musicRelease', title: result.title, englishTitle: result.title, - year: new Date(result['first-release-date']).getFullYear().toString(), + year: coerceYear( + result['first-release-date'] ? new Date(result['first-release-date']).getFullYear() : 0, + ), releaseDate: this.plugin.dateFormatter.format(result['first-release-date'], this.apiDateFormat) ?? 'unknown', - dataSource: this.apiName, + dataSource: MUSICBRAINZ_NOTE_DATA_SOURCE, url: 'https://musicbrainz.org/release-group/' + result.id, id: result.id, image: 'https://coverartarchive.org/release-group/' + result.id + '/front-500.jpg', @@ -229,6 +245,39 @@ export class MusicBrainzAPI extends APIModel { getDisabledMediaTypes(): MediaType[] { return this.plugin.settings.MusicBrainzAPI_disabledMediaTypes; } + + /** + * Loads MusicBrainz recording URL relations and returns the open.spotify.com track URL if present. + * Callers should throttle requests (~1/s) per MusicBrainz etiquette. + */ + async fetchSpotifyUrlForRecording(recordingId: string): Promise { + if (!recordingId) { + return ''; + } + const recordingUrl = `https://musicbrainz.org/ws/2/recording/${encodeURIComponent(recordingId)}?inc=url-rels&fmt=json`; + const fetchData = await requestUrl({ + url: recordingUrl, + headers: { + 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, + }, + }); + if (fetchData.status !== 200) { + console.warn(`MDB | Recording ${recordingId} url-rels returned ${fetchData.status}`); + return ''; + } + const data = (await fetchData.json) as RecordingUrlRelsResponse; + for (const rel of data.relations ?? []) { + const resource = rel.url?.resource; + if (typeof resource === 'string' && resource.includes('open.spotify.com')) { + return resource; + } + } + return ''; + } +} + +interface RecordingUrlRelsResponse { + relations?: { type: string; url?: { resource: string } }[]; } function extractTracksFromMedia(media: MediaResponse['media']): { @@ -239,17 +288,19 @@ function extractTracksFromMedia(media: MediaResponse['media']): { }[] { if (!media || media.length === 0 || !media[0].tracks) return []; - return media[0].tracks.map((track, index) => { + 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) ?? []; + const recordingId = track.recording?.id; return { number: index + 1, title, duration, featuredArtists, + ...(recordingId ? { recordingId } : {}), }; }); } diff --git a/src/api/apis/MusicBrainzArtistAPI.ts b/src/api/apis/MusicBrainzArtistAPI.ts new file mode 100644 index 00000000..09579c0b --- /dev/null +++ b/src/api/apis/MusicBrainzArtistAPI.ts @@ -0,0 +1,256 @@ +import { requestUrl } from 'obsidian'; +import type MediaDbPlugin from '../../main'; +import { ArtistModel } from '../../models/ArtistModel'; +import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { MediaType } from '../../utils/MediaType'; +import { coerceYear, contactEmail, mediaDbVersion, pluginName } from '../../utils/Utils'; +import { MUSICBRAINZ_NOTE_DATA_SOURCE } from '../musicBrainzConstants'; +import { APIModel } from '../APIModel'; + +interface ArtistTag { + name: string; + count: number; +} + +interface ArtistGenre { + name: string; +} + +interface ArtistSearchArtist { + id: string; + name: string; + 'life-span'?: { begin?: string; end?: string }; + country?: string; + disambiguation?: string; + isnis?: string[]; +} + +interface ArtistSearchResponse { + artists: ArtistSearchArtist[]; +} + +interface ArtistDetailResponse { + id: string; + name: string; + type?: string; + 'life-span'?: { begin?: string; end?: string }; + country?: string; + disambiguation?: string; + isnis?: string[]; + tags?: ArtistTag[]; + genres?: ArtistGenre[]; + relations?: { url?: { resource: string } | null; type: string }[]; +} + +interface ReleaseGroupListItem { + id: string; + title: string; + 'primary-type': string; + 'secondary-types'?: string[]; + 'first-release-date'?: string; +} + +interface ReleaseGroupBrowseResponse { + 'release-groups': ReleaseGroupListItem[]; +} + +function isniFromMusicBrainz(isnis: string[] | undefined): string { + if (!isnis?.length) { + return ''; + } + return isnis.join(', '); +} + +const EXCLUDED_SECONDARY_TYPES = new Set([ + 'Compilation', + 'Live', + 'Remix', + 'Soundtrack', + 'Spokenword', + 'Interview', + 'Audio drama', + 'DJ-mix', + 'Mixtape/Street', + 'Demo', + 'Field recording', +]); + +export class MusicBrainzArtistAPI extends APIModel { + plugin: MediaDbPlugin; + apiDateFormat: string = 'YYYY-MM-DD'; + + constructor(plugin: MediaDbPlugin) { + super(); + + this.plugin = plugin; + this.apiName = 'MusicBrainz Artist API'; + this.apiDescription = 'MusicBrainz artist search and studio release discography.'; + this.apiUrl = 'https://musicbrainz.org/'; + this.types = [MediaType.Artist]; + } + + canHandleDataSource(dataSource: string, mediaType?: import('../../utils/MediaType').MediaType): boolean { + if (dataSource.contains('MusicBrainz')) { + return mediaType === MediaType.Artist; + } + return dataSource === this.apiName; + } + + private mbHeaders(): Record { + return { + 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, + }; + } + + private async throttleMs(ms: number): Promise { + await new Promise(resolve => setTimeout(resolve, ms)); + } + + async searchByTitle(title: string): Promise { + console.log(`MDB | api "${this.apiName}" queried by Title`); + + const searchUrl = `https://musicbrainz.org/ws/2/artist?query=${encodeURIComponent(title)}&limit=20&fmt=json`; + const fetchData = await requestUrl({ + url: searchUrl, + headers: this.mbHeaders(), + }); + + if (fetchData.status !== 200) { + throw Error(`MDB | Received status code ${fetchData.status} from ${this.apiName}.`); + } + + const data = (await fetchData.json) as ArtistSearchResponse; + const ret: MediaTypeModel[] = []; + + for (const artist of data.artists ?? []) { + const begin = artist['life-span']?.begin; + ret.push( + new ArtistModel({ + type: 'artist', + title: artist.name, + englishTitle: artist.name, + year: coerceYear(begin ? (begin.split('-')[0] ?? '') : ''), + beginYear: begin ? (begin.split('-')[0] ?? '') : '', + releaseDate: '', + dataSource: MUSICBRAINZ_NOTE_DATA_SOURCE, + url: 'https://musicbrainz.org/artist/' + artist.id, + id: artist.id, + country: artist.country ?? '', + disambiguation: artist.disambiguation ?? '', + isni: isniFromMusicBrainz(artist.isnis), + genres: [], + image: '', + officialWebsite: '', + subType: 'artist', + }), + ); + } + + return ret; + } + + async getById(id: string): Promise { + console.log(`MDB | api "${this.apiName}" queried by ID`); + + const artistUrl = `https://musicbrainz.org/ws/2/artist/${encodeURIComponent(id)}?inc=tags+genres+url-rels&fmt=json`; + const res = await requestUrl({ + url: artistUrl, + headers: this.mbHeaders(), + }); + + if (res.status !== 200) { + throw Error(`MDB | Received status code ${res.status} from ${this.apiName}.`); + } + + const artist = (await res.json) as ArtistDetailResponse; + const begin = artist['life-span']?.begin; + const beginYear = begin ? (begin.split('-')[0] ?? '') : ''; + + let officialWebsite = ''; + for (const rel of artist.relations ?? []) { + if (rel.type === 'official homepage' && rel.url?.resource) { + officialWebsite = rel.url.resource; + break; + } + } + + return new ArtistModel({ + type: 'artist', + title: artist.name, + englishTitle: artist.name, + year: coerceYear(beginYear), + beginYear, + releaseDate: begin ? (this.plugin.dateFormatter.format(begin, this.apiDateFormat) ?? 'unknown') : '', + dataSource: MUSICBRAINZ_NOTE_DATA_SOURCE, + url: 'https://musicbrainz.org/artist/' + artist.id, + id: artist.id, + country: artist.country ?? '', + disambiguation: artist.disambiguation ?? '', + isni: isniFromMusicBrainz(artist.isnis), + genres: [...new Set([...(artist.genres?.map(g => g.name) ?? []), ...(artist.tags?.map(t => t.name) ?? [])])], + image: '', + officialWebsite, + subType: 'artist', + userData: { + personalRating: 0, + }, + }); + } + + /** + * Lists release group MBIDs for studio releases (MusicBrainz primary type album, excluding live/compilations/etc.). + * Passes release-group-status=website-default so MusicBrainz omits groups that only have bootleg, promotional, or pseudo-releases + * (see MusicBrainz API “Release (Group) Type and Status”). + */ + async listStudioAlbumReleaseGroupIds(artistId: string): Promise { + const collected: { id: string; date: string }[] = []; + let offset = 0; + const limit = 100; + + while (true) { + await this.throttleMs(1100); + const url = `https://musicbrainz.org/ws/2/release-group?artist=${encodeURIComponent(artistId)}&type=album&fmt=json&limit=${limit}&offset=${offset}&release-group-status=website-default`; + + const res = await requestUrl({ + url, + headers: this.mbHeaders(), + }); + + if (res.status !== 200) { + throw Error(`MDB | Received status code ${res.status} browsing release groups.`); + } + + const data = (await res.json) as ReleaseGroupBrowseResponse; + const groups = data['release-groups'] ?? []; + if (groups.length === 0) { + break; + } + + for (const rg of groups) { + if (rg['primary-type'] !== 'Album') { + continue; + } + const secondary = rg['secondary-types'] ?? []; + if (secondary.some(t => EXCLUDED_SECONDARY_TYPES.has(t))) { + continue; + } + collected.push({ + id: rg.id, + date: rg['first-release-date'] ?? '', + }); + } + + offset += limit; + if (groups.length < limit) { + break; + } + } + + collected.sort((a, b) => a.date.localeCompare(b.date)); + return [...new Set(collected.map(c => c.id))]; + } + + getDisabledMediaTypes(): MediaType[] { + return this.plugin.settings.MusicBrainzArtistAPI_disabledMediaTypes; + } +} diff --git a/src/api/apis/OMDbAPI.ts b/src/api/apis/OMDbAPI.ts index 51b4f17a..28d69242 100644 --- a/src/api/apis/OMDbAPI.ts +++ b/src/api/apis/OMDbAPI.ts @@ -4,7 +4,9 @@ import { GameModel } from '../../models/GameModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; import { MovieModel } from '../../models/MovieModel'; import { SeriesModel } from '../../models/SeriesModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; +import { coerceMovieDurationMinutes, coerceYear } from '../../utils/Utils'; import { APIModel } from '../APIModel'; interface ErrorResponse { @@ -77,12 +79,13 @@ export class OMDbAPI extends APIModel { async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); - if (!this.plugin.settings.OMDbKey) { + const omdbKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.omdb); + if (!omdbKey) { 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=${omdbKey}`, method: 'GET', }); @@ -125,7 +128,7 @@ export class OMDbAPI extends APIModel { type: type, title: result.Title, englishTitle: result.Title, - year: result.Year, + year: coerceYear(result.Year), dataSource: this.apiName, id: result.imdbID, }), @@ -136,7 +139,7 @@ export class OMDbAPI extends APIModel { type: type, title: result.Title, englishTitle: result.Title, - year: result.Year, + year: coerceYear(result.Year), dataSource: this.apiName, id: result.imdbID, }), @@ -147,7 +150,7 @@ export class OMDbAPI extends APIModel { type: type, title: result.Title, englishTitle: result.Title, - year: result.Year, + year: coerceYear(result.Year), dataSource: this.apiName, id: result.imdbID, }), @@ -161,12 +164,13 @@ export class OMDbAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); - if (!this.plugin.settings.OMDbKey) { + const omdbKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.omdb); + if (!omdbKey) { 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=${omdbKey}`, method: 'GET', }); @@ -197,7 +201,7 @@ export class OMDbAPI extends APIModel { type: type, title: result.Title, englishTitle: result.Title, - year: result.Year, + year: coerceYear(result.Year), dataSource: this.apiName, url: `https://www.imdb.com/title/${result.imdbID}/`, id: result.imdbID, @@ -206,14 +210,14 @@ export class OMDbAPI extends APIModel { genres: result.Genre?.split(', '), director: result.Director?.split(', '), writer: result.Writer?.split(', '), - duration: result.Runtime, + duration: coerceMovieDurationMinutes(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, + revenue: result.BoxOffice && result.BoxOffice !== 'N/A' ? result.BoxOffice : '', ageRating: result.Rated, premiere: this.plugin.dateFormatter.format(result.Released, this.apiDateFormat), @@ -228,7 +232,7 @@ export class OMDbAPI extends APIModel { type: type, title: result.Title, englishTitle: result.Title, - year: result.Year, + year: coerceYear(result.Year), dataSource: this.apiName, url: `https://www.imdb.com/title/${result.imdbID}/`, id: result.imdbID, @@ -259,7 +263,7 @@ export class OMDbAPI extends APIModel { type: type, title: result.Title, englishTitle: result.Title, - year: result.Year, + year: coerceYear(result.Year), dataSource: this.apiName, url: `https://www.imdb.com/title/${result.imdbID}/`, id: result.imdbID, diff --git a/src/api/apis/OpenLibraryAPI.ts b/src/api/apis/OpenLibraryAPI.ts index 645d15ba..ae9b74ed 100644 --- a/src/api/apis/OpenLibraryAPI.ts +++ b/src/api/apis/OpenLibraryAPI.ts @@ -74,7 +74,7 @@ export class OpenLibraryAPI extends APIModel { new BookModel({ title: result.title, englishTitle: result.title, - year: result.first_publish_year?.toString() ?? 'unknown', + year: result.first_publish_year ?? 0, dataSource: this.apiName, id: result.key, author: result.author_name?.join(', '), @@ -132,7 +132,7 @@ export class OpenLibraryAPI extends APIModel { return new BookModel({ title: title, - year: result.first_publish_year?.toString() ?? 'unknown', + year: result.first_publish_year ?? 0, dataSource: this.apiName, url: `https://openlibrary.org` + key, id: key, diff --git a/src/api/apis/RAWGAPI.ts b/src/api/apis/RAWGAPI.ts index 1c79e5ae..2ab2dd97 100644 --- a/src/api/apis/RAWGAPI.ts +++ b/src/api/apis/RAWGAPI.ts @@ -2,15 +2,26 @@ import { requestUrl } from 'obsidian'; import type MediaDbPlugin from '../../main'; import { GameModel } from '../../models/GameModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; 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 }[]; + 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[]; } -interface RAWGSearchResponse { results: RAWGGame[]; } export class RAWGAPI extends APIModel { plugin: MediaDbPlugin; @@ -26,40 +37,58 @@ export class RAWGAPI extends APIModel { } async searchByTitle(title: string): Promise { - if (!this.plugin.settings.RAWGAPIKey) throw Error(`MDB | API key for ${this.apiName} missing.`); + const apiKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.rawg); + if (!apiKey) throw Error(`MDB | API key for ${this.apiName} missing.`); const response = await requestUrl({ - url: `${this.apiUrl}/games?key=${this.plugin.settings.RAWGAPIKey}&search=${encodeURIComponent(title)}&page_size=20`, + url: `${this.apiUrl}/games?key=${apiKey}&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 - })); + return data.results.map( + result => + new GameModel({ + type: MediaType.Game, + title: result.name, + englishTitle: result.name, + year: result.released ? new Date(result.released).getFullYear() : 0, + dataSource: this.apiName, + id: result.id.toString(), + image: result.background_image, + }), + ); } async getById(id: string): Promise { - if (!this.plugin.settings.RAWGAPIKey) throw Error(`MDB | API key for ${this.apiName} missing.`); + const apiKey = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.rawg); + if (!apiKey) throw Error(`MDB | API key for ${this.apiName} missing.`); const response = await requestUrl({ - url: `${this.apiUrl}/games/${id}?key=${this.plugin.settings.RAWGAPIKey}`, + url: `${this.apiUrl}/games/${id}?key=${apiKey}`, 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, + type: MediaType.Game, + title: result.name, + englishTitle: result.name_original || result.name, + year: result.released ? new Date(result.released).getFullYear() : 0, + 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 || []; } -} \ No newline at end of file + getDisabledMediaTypes(): MediaType[] { + return this.plugin.settings.RAWGAPI_disabledMediaTypes || []; + } +} diff --git a/src/api/apis/SteamAPI.ts b/src/api/apis/SteamAPI.ts index b8e72b25..8d8f78a0 100644 --- a/src/api/apis/SteamAPI.ts +++ b/src/api/apis/SteamAPI.ts @@ -3,7 +3,7 @@ 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 { coerceYear, imageUrlExists } from '../../utils/Utils'; import { APIModel } from '../APIModel'; interface SearchResponse { @@ -167,7 +167,7 @@ export class SteamAPI extends APIModel { type: MediaType.Game, title: result.name, englishTitle: result.name, - year: '', + year: 0, dataSource: this.apiName, id: result.appid, }), @@ -219,7 +219,7 @@ export class SteamAPI extends APIModel { type: MediaType.Game, title: result.name, englishTitle: result.name, - year: new Date(result.release_date.date).getFullYear().toString(), + year: coerceYear(new Date(result.release_date.date).getFullYear()), dataSource: this.apiName, url: `https://store.steampowered.com/app/${result.steam_appid}`, id: result.steam_appid.toString(), diff --git a/src/api/apis/TMDBMovieAPI.ts b/src/api/apis/TMDBMovieAPI.ts index 936ab57e..133b0145 100644 --- a/src/api/apis/TMDBMovieAPI.ts +++ b/src/api/apis/TMDBMovieAPI.ts @@ -4,10 +4,35 @@ import createClient from 'openapi-fetch'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; import { MovieModel } from '../../models/MovieModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; +import { formatUsdWholeDollars } from '../../utils/Utils'; import { APIModel } from '../APIModel'; import type { paths } from '../schemas/TMDB'; +/** TMDB `credits.crew` jobs that count as writing credits for movies. */ +const TMDB_WRITING_CREW_JOBS = new Set(['Writer', 'Screenplay', 'Story', 'Teleplay', 'Original Story', 'Characters', 'Novel', 'Screenstory']); + +function tmdbWritingCreditsFromCrew(crew: any[] | undefined): string[] { + if (!crew?.length) { + return []; + } + const seen = new Set(); + const names: string[] = []; + for (const c of crew) { + const job = c?.job as string | undefined; + const name = c?.name as string | undefined; + if (!job || !name || !TMDB_WRITING_CREW_JOBS.has(job)) { + continue; + } + if (!seen.has(name)) { + seen.add(name); + names.push(name); + } + } + return names; +} + export class TMDBMovieAPI extends APIModel { plugin: MediaDbPlugin; typeMappings: Map; @@ -28,14 +53,15 @@ export class TMDBMovieAPI extends APIModel { async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); - if (!this.plugin.settings.TMDBKey) { + const bearer = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.tmdb); + if (!bearer) { 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 ${bearer}`, }, params: { query: { @@ -73,7 +99,7 @@ export class TMDBMovieAPI extends APIModel { type: 'movie', title: result.original_title, englishTitle: result.title, - year: result.release_date ? new Date(result.release_date).getFullYear().toString() : 'unknown', + year: result.release_date ? new Date(result.release_date).getFullYear() : 0, dataSource: this.apiName, id: result.id.toString(), }), @@ -86,19 +112,20 @@ export class TMDBMovieAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); - if (!this.plugin.settings.TMDBKey) { + const bearer = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.tmdb); + if (!bearer) { 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 ${bearer}`, }, params: { path: { movie_id: parseInt(id) }, query: { - append_to_response: 'credits', + append_to_response: 'credits,release_dates,watch/providers', }, }, fetch: fetch, @@ -122,7 +149,7 @@ export class TMDBMovieAPI extends APIModel { type: 'movie', title: result.title, englishTitle: result.title, - year: result.release_date ? new Date(result.release_date).getFullYear().toString() : 'unknown', + year: result.release_date ? new Date(result.release_date).getFullYear() : 0, premiere: this.plugin.dateFormatter.format(result.release_date, this.apiDateFormat) ?? 'unknown', dataSource: this.apiName, url: `https://www.themoviedb.org/movie/${result.id}`, @@ -131,20 +158,26 @@ export class TMDBMovieAPI extends APIModel { 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) ?? [], + writer: tmdbWritingCreditsFromCrew((result as { credits?: { crew?: any[] } }).credits?.crew), // @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) ?? [], - duration: result.runtime?.toString() ?? 'unknown', - onlineRating: result.vote_average, + duration: result.runtime != null && Number.isFinite(result.runtime) ? Math.trunc(result.runtime) : 0, + onlineRating: result.vote_average ? Math.round(result.vote_average * 10) / 10 : 0, // @ts-ignore actors: result.credits.cast.map((c: any) => c.name).slice(0, 5) ?? [], image: `https://image.tmdb.org/t/p/w780${result.poster_path}`, released: ['Released'].includes(result.status!), - streamingServices: [], + country: result.production_countries?.map((c: any) => c.name) ?? [], + language: result.spoken_languages?.map((l: any) => l.english_name) ?? [], + budget: formatUsdWholeDollars(result.budget ?? 0), + revenue: formatUsdWholeDollars(result.revenue ?? 0), + // @ts-ignore + ageRating: result.release_dates?.results?.find((r: any) => r.iso_3166_1 === this.plugin.settings.tmdbRegion)?.release_dates?.[0]?.certification ?? '', + // @ts-ignore + streamingServices: result['watch/providers']?.results?.[this.plugin.settings.tmdbRegion]?.flatrate?.map((p: any) => p.provider_name) ?? [], userData: { watched: false, diff --git a/src/api/apis/TMDBSeasonAPI.ts b/src/api/apis/TMDBSeasonAPI.ts index 426f6db5..5c0eecd4 100644 --- a/src/api/apis/TMDBSeasonAPI.ts +++ b/src/api/apis/TMDBSeasonAPI.ts @@ -4,6 +4,7 @@ import createClient from 'openapi-fetch'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; import { SeasonModel } from '../../models/SeasonModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; import { APIModel } from '../APIModel'; import type { paths } from '../schemas/TMDB'; @@ -28,14 +29,15 @@ export class TMDBSeasonAPI extends APIModel { async searchByTitle(title: string): Promise { console.log(`MDB | api "${this.apiName}" queried by Title`); - if (!this.plugin.settings.TMDBKey) { + const bearer = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.tmdb); + if (!bearer) { 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 ${bearer}`, }, params: { query: { @@ -70,7 +72,7 @@ export class TMDBSeasonAPI extends APIModel { try { const detailsResponse = await client.GET('/3/tv/{series_id}', { headers: { - Authorization: `Bearer ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${bearer}`, }, params: { path: { series_id: result.id ?? 0 }, @@ -92,11 +94,12 @@ export class TMDBSeasonAPI extends APIModel { 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', + year: result.first_air_date ? new Date(result.first_air_date).getFullYear() : 0, dataSource: this.apiName, id: result.id?.toString() ?? '', seasonTitle: result.name ?? result.original_name ?? '', seasonNumber: totalSeasons, + image: result.poster_path ? `https://image.tmdb.org/t/p/w780${result.poster_path}` : '', }), ); } @@ -106,14 +109,15 @@ export class TMDBSeasonAPI extends APIModel { // Fetch all seasons for a given series async getSeasonsForSeries(tvId: string): Promise { - if (!this.plugin.settings.TMDBKey) { + const bearer = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.tmdb); + if (!bearer) { 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 ${bearer}`, }, params: { path: { series_id: parseInt(tvId) }, @@ -143,11 +147,12 @@ export class TMDBSeasonAPI extends APIModel { new SeasonModel({ title: titleText, englishTitle: titleText, - year: season.air_date ? new Date(season.air_date).getFullYear().toString() : 'unknown', + year: season.air_date ? new Date(season.air_date).getFullYear() : 0, dataSource: this.apiName, id: `${tvId}/season/${seasonNumber}`, seasonTitle: season.name ?? titleText, seasonNumber: seasonNumber, + image: season.poster_path ?? '', }), ); } @@ -159,7 +164,8 @@ export class TMDBSeasonAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); - if (!this.plugin.settings.TMDBKey) { + const bearer = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.tmdb); + if (!bearer) { throw Error(`MDB | API key for ${this.apiName} missing.`); } @@ -177,7 +183,7 @@ export class TMDBSeasonAPI extends APIModel { // Fetch season details const seasonResponse = await client.GET('/3/tv/{series_id}/season/{season_number}', { headers: { - Authorization: `Bearer ${this.plugin.settings.TMDBKey}`, + Authorization: `Bearer ${bearer}`, }, params: { path: { @@ -203,7 +209,7 @@ 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 ${bearer}`, }, params: { path: { series_id: parseInt(tvId) }, @@ -242,7 +248,7 @@ export class TMDBSeasonAPI extends APIModel { return new SeasonModel({ title: titleText, englishTitle: titleText, - year: airDate ? new Date(airDate).getFullYear().toString() : 'unknown', + year: airDate ? new Date(airDate).getFullYear() : 0, dataSource: this.apiName, url: `https://www.themoviedb.org/tv/${tvId}/season/${seasonData.season_number}`, id: `${tvId}/season/${seasonData.season_number}`, diff --git a/src/api/apis/TMDBSeriesAPI.ts b/src/api/apis/TMDBSeriesAPI.ts index 1c8a21fa..b8fb8bcf 100644 --- a/src/api/apis/TMDBSeriesAPI.ts +++ b/src/api/apis/TMDBSeriesAPI.ts @@ -4,6 +4,7 @@ import createClient from 'openapi-fetch'; import type MediaDbPlugin from '../../main'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; import { SeriesModel } from '../../models/SeriesModel'; +import { ApiSecretID, getApiSecretValue } from '../../settings/apiSecretsHelper'; import { MediaType } from '../../utils/MediaType'; import { APIModel } from '../APIModel'; import type { paths } from '../schemas/TMDB'; @@ -28,14 +29,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 bearer = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.tmdb); + if (!bearer) { 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 ${bearer}`, }, params: { query: { @@ -73,7 +75,7 @@ export class TMDBSeriesAPI extends APIModel { type: 'series', title: result.original_name, englishTitle: result.name, - year: result.first_air_date ? new Date(result.first_air_date).getFullYear().toString() : 'unknown', + year: result.first_air_date ? new Date(result.first_air_date).getFullYear() : 0, dataSource: this.apiName, id: result.id.toString(), }), @@ -86,19 +88,20 @@ export class TMDBSeriesAPI extends APIModel { async getById(id: string): Promise { console.log(`MDB | api "${this.apiName}" queried by ID`); - if (!this.plugin.settings.TMDBKey) { + const bearer = getApiSecretValue(this.plugin.app, this.plugin.settings.linkedApiSecretIds, ApiSecretID.tmdb); + if (!bearer) { 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 ${bearer}`, }, params: { path: { series_id: parseInt(id) }, query: { - append_to_response: 'credits', + append_to_response: 'credits,content_ratings,watch/providers', }, }, fetch: fetch, @@ -122,7 +125,7 @@ export class TMDBSeriesAPI extends APIModel { type: 'series', title: result.original_name, englishTitle: result.name, - year: result.first_air_date ? new Date(result.first_air_date).getFullYear().toString() : 'unknown', + year: result.first_air_date ? new Date(result.first_air_date).getFullYear() : 0, dataSource: this.apiName, url: `https://www.themoviedb.org/tv/${result.id}`, id: result.id.toString(), @@ -133,14 +136,20 @@ export class TMDBSeriesAPI extends APIModel { studio: result.production_companies?.map((s: any) => s.name) ?? [], episodes: result.number_of_episodes, duration: result.episode_run_time?.[0]?.toString() ?? 'unknown', - onlineRating: result.vote_average, + onlineRating: result.vote_average ? Math.round(result.vote_average * 10) / 10 : 0, // 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) ?? [], image: result.poster_path ? `https://image.tmdb.org/t/p/w780${result.poster_path}` : null, - released: ['Returning Series', 'Cancelled', 'Ended'].includes(result.status!), - streamingServices: [], + released: ['Returning Series', 'Cancelled', 'Canceled', 'Pilot', 'Ended'].includes(result.status!), + country: result.production_countries?.map((c: any) => c.name) ?? [], + language: result.spoken_languages?.map((l: any) => l.english_name) ?? [], + network: result.networks?.map((n: any) => n.name) ?? [], + // @ts-ignore + ageRating: result.content_ratings?.results?.find((r: any) => r.iso_3166_1 === this.plugin.settings.tmdbRegion)?.rating ?? '', + // @ts-ignore + streamingServices: result['watch/providers']?.results?.[this.plugin.settings.tmdbRegion]?.flatrate?.map((p: any) => p.provider_name) ?? [], 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'), diff --git a/src/api/apis/VNDBAPI.ts b/src/api/apis/VNDBAPI.ts index 0139d4ed..e1272f69 100644 --- a/src/api/apis/VNDBAPI.ts +++ b/src/api/apis/VNDBAPI.ts @@ -3,6 +3,7 @@ import type MediaDbPlugin from '../../main'; import { GameModel } from '../../models/GameModel'; import type { MediaTypeModel } from '../../models/MediaTypeModel'; import { MediaType } from '../../utils/MediaType'; +import { coerceYear } from '../../utils/Utils'; import { APIModel } from '../APIModel'; enum VNDevStatus { @@ -190,7 +191,7 @@ export class VNDBAPI extends APIModel { 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', + year: coerceYear(vn.released && vn.released !== 'TBA' ? new Date(vn.released).getFullYear() : 0), dataSource: this.apiName, id: vn.id, }), @@ -228,7 +229,7 @@ export class VNDBAPI extends APIModel { 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, + year: coerceYear(releasedIsDate ? new Date(vn.released).getFullYear() : vn.released), dataSource: this.apiName, url: `https://vndb.org/${vn.id}`, id: vn.id, diff --git a/src/api/apis/WikipediaAPI.ts b/src/api/apis/WikipediaAPI.ts index 9b3b2896..d4a62bcc 100644 --- a/src/api/apis/WikipediaAPI.ts +++ b/src/api/apis/WikipediaAPI.ts @@ -68,7 +68,7 @@ export class WikipediaAPI extends APIModel { type: 'wiki', title: result.title, englishTitle: result.title, - year: '', + year: 0, dataSource: this.apiName, id: result.pageid.toString(), }), diff --git a/src/api/helpers/geniusLyricsExtract.ts b/src/api/helpers/geniusLyricsExtract.ts new file mode 100644 index 00000000..ecdc178a --- /dev/null +++ b/src/api/helpers/geniusLyricsExtract.ts @@ -0,0 +1,89 @@ +const LYRICS_CONTAINER_OPEN_RE = /]*\bdata-lyrics-container\s*=\s*(?:"true"|'true'|true)[^>]*>/gi; + +function stripHtmlToPlainLyrics(fragment: string): string { + return fragment + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/\n{3,}/g, '\n\n') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); +} + +/** Parses nested
blocks; the naive `*?
` regex stops at the first inner close tag. */ +function extractBalancedDivInnerHtml(html: string, contentStart: number): string { + let depth = 1; + let i = contentStart; + const openRe = //gi; + while (depth > 0) { + openRe.lastIndex = i; + closeRe.lastIndex = i; + const om = openRe.exec(html); + const cm = closeRe.exec(html); + if (!cm) { + break; + } + const oIdx = om ? om.index : Number.POSITIVE_INFINITY; + const cIdx = cm.index; + if (om && oIdx < cIdx) { + depth++; + i = om.index + om[0].length; + } else { + depth--; + if (depth === 0) { + return html.slice(contentStart, cIdx); + } + i = cm.index + cm[0].length; + } + } + return ''; +} + +function collectLyricsContainersRegex(html: string): string[] { + const chunks: string[] = []; + let m: RegExpExecArray | null; + LYRICS_CONTAINER_OPEN_RE.lastIndex = 0; + while ((m = LYRICS_CONTAINER_OPEN_RE.exec(html)) !== null) { + const inner = extractBalancedDivInnerHtml(html, m.index + m[0].length); + if (inner) { + chunks.push(inner); + } + } + return chunks; +} + +function extractOneContainerPlain(el: Element): string { + const clone = el.cloneNode(true) as Element; + clone.querySelectorAll('[data-exclude-from-selection="true"]').forEach(node => node.remove()); + return stripHtmlToPlainLyrics(clone.innerHTML); +} + +export function extractLyricsFromGeniusHtml(html: string): string { + let chunks: string[] = []; + try { + const doc = new DOMParser().parseFromString(html, 'text/html'); + doc.querySelectorAll('[data-lyrics-container="true"]').forEach(c => { + const plain = extractOneContainerPlain(c); + if (plain) { + chunks.push(plain); + } + }); + } catch { + chunks = []; + } + + if (chunks.length === 0) { + return ''; + } + + return chunks + .join('\n\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/src/api/musicBrainzConstants.ts b/src/api/musicBrainzConstants.ts new file mode 100644 index 00000000..bb218884 --- /dev/null +++ b/src/api/musicBrainzConstants.ts @@ -0,0 +1,19 @@ +import { MediaType } from '../utils/MediaType'; + +/** Stored on notes for any row backed by MusicBrainz (release, artist, or song). */ +export const MUSICBRAINZ_NOTE_DATA_SOURCE = 'MusicBrainz'; + +export function isMusicBrainzFamilyDataSource(dataSource: string): boolean { + return dataSource.contains('MusicBrainz'); +} + +/** Which registered API implements getById for this media type. */ +export function musicBrainzRegisteredApiName(mediaType: MediaType): 'MusicBrainz API' | 'MusicBrainz Artist API' | undefined { + if (mediaType === MediaType.Artist) { + return 'MusicBrainz Artist API'; + } + if (mediaType === MediaType.MusicRelease || mediaType === MediaType.Song) { + return 'MusicBrainz API'; + } + return undefined; +} diff --git a/src/main.ts b/src/main.ts index 7f7019ed..bde78701 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,41 +1,54 @@ -import type { TFile } from 'obsidian'; -import { MarkdownView, Notice, parseYaml, Plugin, stringifyYaml, TFolder } from 'obsidian'; +import { MarkdownView, Notice, parseYaml, Plugin, stringifyYaml, TFolder, TFile } from 'obsidian'; import { requestUrl, normalizePath } from 'obsidian'; -import type { MediaType } from 'src/utils/MediaType'; +import { MediaType } from 'src/utils/MediaType'; 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 { RAWGAPI } from './api/apis/RAWGAPI'; 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 { MusicBrainzArtistAPI } from './api/apis/MusicBrainzArtistAPI'; 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 { GeniusClient } from './api/GeniusClient'; +import { MUSICBRAINZ_NOTE_DATA_SOURCE, musicBrainzRegisteredApiName } from './api/musicBrainzConstants'; +import { SpotifyClient } from './api/SpotifyClient'; +import { BulkUpdateConfirmModal } from './modals/BulkUpdateConfirmModal'; +import { CompletionModal } from './modals/CompletionModal'; +import { ConfirmOverwriteChoice, ConfirmOverwriteModal } from './modals/ConfirmOverwriteModal'; import type { SeasonSelectModalElement } from './modals/MediaDbSeasonSelectModal'; import { MediaDbSeasonSelectModal } from './modals/MediaDbSeasonSelectModal'; +import type { ArtistModel } from './models/ArtistModel'; import type { MediaTypeModel } from './models/MediaTypeModel'; +import type { MusicReleaseModel } from './models/MusicReleaseModel'; import type { SeasonModel } from './models/SeasonModel'; +import { SongModel } from './models/SongModel'; +import { ApiSecretID, getApiSecretValue } from './settings/apiSecretsHelper'; import { PropertyMapper } from './settings/PropertyMapper'; import { PropertyMappingModel } from './settings/PropertyMapping'; import type { MediaDbPluginSettings } from './settings/Settings'; -import { getDefaultSettings, MediaDbSettingTab } from './settings/Settings'; +import { getDefaultSettings, MediaDbSettingTab, propertyMappingModelsInDisplayOrder } from './settings/Settings'; +import { AutoTrackerHelper } from './utils/AutoTrackerHelper'; import { BulkImportHelper } from './utils/BulkImportHelper'; +import { BulkUpdateHelper } from './utils/BulkUpdateHelper'; +import { BulkRecreateHelper } from './utils/BulkRecreateHelper'; import { DateFormatter } from './utils/DateFormatter'; import { MEDIA_TYPES, MediaTypeManager } from './utils/MediaTypeManager'; import type { SearchModalOptions } from './utils/ModalHelper'; import { ModalHelper } from './utils/ModalHelper'; +import { noteTypeValueForMedia, resolveMetadataTypeToMediaType } from './utils/noteTypeSettings'; import type { CreateNoteOptions } from './utils/Utils'; -import { replaceIllegalFileNameCharactersInString, unCamelCase, hasTemplaterPlugin, useTemplaterPluginInFile } from './utils/Utils'; +import { replaceIllegalFileNameCharactersInString, unCamelCase, hasTemplaterPlugin, useTemplaterPluginInFile, dateTimeToString, markdownTable, parseUsdWholeDollarsFromDisplayString, normalizeTitleForAsciiAlias } from './utils/Utils'; import 'src/styles.css'; export type Metadata = Record; @@ -53,6 +66,9 @@ export default class MediaDbPlugin extends Plugin { modelPropertyMapper!: PropertyMapper; modalHelper!: ModalHelper; bulkImportHelper!: BulkImportHelper; + bulkUpdateHelper!: BulkUpdateHelper; + bulkRecreateHelper!: BulkRecreateHelper; + autoTrackerHelper!: AutoTrackerHelper; dateFormatter!: DateFormatter; frontMatterRexExpPattern: string = '^(---)\\n[\\s\\S]*?\\n---'; @@ -65,6 +81,7 @@ export default class MediaDbPlugin extends Plugin { this.apiManager.registerAPI(new MALAPIManga(this)); this.apiManager.registerAPI(new WikipediaAPI(this)); this.apiManager.registerAPI(new MusicBrainzAPI(this)); + this.apiManager.registerAPI(new MusicBrainzArtistAPI(this)); this.apiManager.registerAPI(new SteamAPI(this)); this.apiManager.registerAPI(new TMDBSeriesAPI(this)); this.apiManager.registerAPI(new TMDBSeasonAPI(this)); @@ -82,6 +99,9 @@ export default class MediaDbPlugin extends Plugin { this.modelPropertyMapper = new PropertyMapper(this); this.modalHelper = new ModalHelper(this); this.bulkImportHelper = new BulkImportHelper(this); + this.bulkUpdateHelper = new BulkUpdateHelper(this); + this.bulkRecreateHelper = new BulkRecreateHelper(this); + this.autoTrackerHelper = new AutoTrackerHelper(this); this.dateFormatter = new DateFormatter(); await this.loadSettings(); @@ -92,22 +112,137 @@ export default class MediaDbPlugin extends Plugin { 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'); + // add icon to the left ribbon and auto-tracker logic + this.refreshAutoTrackerRibbon(); + + this.app.workspace.onLayoutReady(() => { + if (this.settings.autoUpdateAiringMode) { + setTimeout(() => { + this.autoTrackerHelper.startBackgroundScan(true); + }, 5000); + } + }); this.registerEvent( this.app.workspace.on('file-menu', (menu, file) => { if (file instanceof TFolder) { + // Add our customized context menu options under a "Media DB" group menu.addItem(item => { - item.setTitle('Import folder as Media DB entries') - .setIcon('database') - .onClick(() => this.bulkImportHelper.import(file)); + item.setTitle('Media DB...'); + item.setIcon('database'); + // @ts-ignore + if (typeof item.setSubmenu === 'function') { + // @ts-ignore + const sub = item.setSubmenu(); + sub.addItem((subItem: any) => + subItem + .setTitle('Bulk Import Folder') + .setIcon('database') + .onClick(() => this.bulkImportHelper.import(file)), + ); + sub.addItem((subItem: any) => + subItem + .setTitle('Bulk Update Metadata') + .setIcon('refresh-cw') + .onClick(() => this.bulkUpdateHelper.updateFolder(file)), + ); + sub.addItem((subItem: any) => + subItem + .setTitle('Bulk Recreate Notes') + .setIcon('file-stack') + .onClick(() => this.bulkRecreateHelper.recreateFolder(file)), + ); + sub.addItem((subItem: any) => + subItem + .setTitle('Start Auto-Tracker in Folder') + .setIcon('sync') + .onClick(() => { + new BulkUpdateConfirmModal( + this.app, + (silentUpdate: boolean) => { + this.autoTrackerHelper.startBackgroundScan(silentUpdate, file); + }, + 'Auto Tracker Sync', + 'You are about to scan and automatically update Airing/Released status for tracked media in this folder.' + ).open(); + }), + ); + sub.addItem((subItem: any) => + subItem + .setTitle('Download images in folder') + .setIcon('image') + .onClick(() => this.downloadImagesInFolder(file)), + ); + } else { + // Fallback if setSubmenu isn't in older Obsidian versions + item.onClick(() => this.bulkUpdateHelper.updateFolder(file)); + } }); } }), ); + this.addCommand({ + id: 'media-db-bulk-import-active-file-folder', + name: 'Bulk Import Folder (Active Context)', + checkCallback: (checking: boolean) => { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile?.parent) return false; + if (!checking) void this.bulkImportHelper.import(activeFile.parent); + return true; + }, + }); + + this.addCommand({ + id: 'media-db-bulk-recreate-active-file-folder', + name: 'Bulk Recreate Notes (Active Context)', + checkCallback: (checking: boolean) => { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile?.parent) return false; + if (!checking) void this.bulkRecreateHelper.recreateFolder(activeFile.parent); + return true; + }, + }); + + this.addCommand({ + id: 'media-db-bulk-update-active-file-folder', + name: 'Bulk Update Metadata (Active Context)', + checkCallback: (checking: boolean) => { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile?.parent) return false; + if (!checking) void this.bulkUpdateHelper.updateFolder(activeFile.parent); + return true; + }, + }); + + this.addCommand({ + id: 'media-db-download-images-active-file-folder', + name: 'Download images in folder (Active Context)', + checkCallback: (checking: boolean) => { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile?.parent) return false; + if (!checking) void this.downloadImagesInFolder(activeFile.parent); + return true; + }, + }); + + this.addCommand({ + id: 'media-db-download-images-active-note', + name: 'Download images in active note', + checkCallback: (checking: boolean) => { + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile?.extension !== 'md') return false; + if (!checking) void this.downloadImagesInFile(activeFile); + return true; + }, + }); + + this.addCommand({ + id: 'media-db-manual-sync-auto-tracker', + name: 'Force Auto-Tracker Background Scan', + callback: () => this.autoTrackerHelper.startBackgroundScan(false), + }); + // register command to open search modal this.addCommand({ id: 'open-media-db-search-modal', @@ -117,7 +252,7 @@ export default class MediaDbPlugin extends Plugin { for (const mediaType of MEDIA_TYPES) { this.addCommand({ id: `open-media-db-search-modal-with-${mediaType}`, - name: `Create Media DB entry (${unCamelCase(mediaType)})`, + name: `Create Media DB entry: ${unCamelCase(mediaType)}`, callback: () => this.createEntryWithSearchModal({ preselectedTypes: [mediaType] }), }); } @@ -135,7 +270,7 @@ export default class MediaDbPlugin extends Plugin { // register command to update the open note this.addCommand({ id: 'update-media-db-note', - name: 'Update open note (this will recreate the note)', + name: 'Recreate open note (Reset mode)', checkCallback: (checking: boolean) => { if (!this.app.workspace.getActiveFile()) { return false; @@ -148,13 +283,27 @@ export default class MediaDbPlugin extends Plugin { }); this.addCommand({ id: 'update-media-db-note-metadata', - name: 'Update metadata', + name: 'Recreate open note (Safe mode)', checkCallback: (checking: boolean) => { if (!this.app.workspace.getActiveFile()) { return false; } if (!checking) { - void this.updateActiveNote(true); + void this.updateActiveNote(true, false); + } + return true; + }, + }); + + this.addCommand({ + id: 'update-media-db-note-legacy', + name: 'Update metadata (Keep current property order)', + checkCallback: (checking: boolean) => { + if (!this.app.workspace.getActiveFile()) { + return false; + } + if (!checking) { + void this.updateActiveNote(true, true); } return true; }, @@ -333,7 +482,7 @@ export default class MediaDbPlugin extends Plugin { season_number: s.seasonNumber, name: s.seasonTitle || s.title, episode_count: s.episodes || 0, - air_date: s.year, + air_date: s.year > 0 ? String(s.year) : 'unknown', poster_path: s.image, })), true, @@ -425,10 +574,17 @@ export default class MediaDbPlugin extends Plugin { } async createMediaDbNotes(models: MediaTypeModel[], attachFile?: TFile): Promise { - // Create notes in parallel for better performance + const hasArtist = models.some(m => m.getMediaType() === MediaType.Artist); + + if (hasArtist) { + for (const model of models) { + await this.createMediaDbNoteFromModel(model, { attachTemplate: true, attachFile: attachFile }); + } + return; + } + 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); @@ -455,10 +611,20 @@ export default class MediaDbPlugin extends Plugin { } async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise { + if (mediaTypeModel.getMediaType() === MediaType.Artist) { + await this.importArtistDiscography(mediaTypeModel as ArtistModel, options); + return; + } + + await this.createStandardMediaDbNoteFromModel(mediaTypeModel, options); + } + + /** @returns whether the note file was created (false if the user cancelled overwrite or an error occurred before the file was written). */ + private async createStandardMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise { try { console.debug('MDB | creating new note'); - options.openNote = this.settings.openNoteInNewTab; + options.openNote ??= this.settings.openNoteInNewTab; if (this.settings.imageDownload) { await this.downloadImageForMediaModel(mediaTypeModel); @@ -471,8 +637,284 @@ export default class MediaDbPlugin extends Plugin { const targetFile = await this.createNote(this.mediaTypeManager.getFileName(mediaTypeModel), fileContent, options); if (this.settings.enableTemplaterIntegration) { - await useTemplaterPluginInFile(this.app, targetFile); + try { + await useTemplaterPluginInFile(this.app, targetFile); + } catch (e) { + console.warn(e); + new Notice(`${e}`); + } + } + return true; + } catch (e) { + console.warn(e); + new Notice(`${e}`); + return false; + } + } + + private safeFileTreeSegment(title: string): string { + return replaceIllegalFileNameCharactersInString(title).replaceAll(/ +/g, ' ').trim(); + } + + private async ensureVaultFolder(folderPath: string): Promise { + const normalized = normalizePath(folderPath); + if (!(await this.app.vault.adapter.exists(normalized))) { + await this.app.vault.createFolder(normalized); + } + const folder = this.app.vault.getAbstractFileByPath(normalized); + if (!(folder instanceof TFolder)) { + throw new Error(`MDB | Expected folder at ${normalized}`); + } + return folder; + } + + private async importSongNotesForMusicReleaseTracks( + release: MusicReleaseModel, + geniusSearchArtist: string, + musicBrainzApi: MusicBrainzAPI, + genius: GeniusClient, + spotify: SpotifyClient, + childOptions: CreateNoteOptions, + useTree: boolean, + songNotesFolder: TFolder | undefined, + ): Promise { + for (const track of release.tracks) { + let lyrics = ''; + let geniusUrl = ''; + if (genius.isConfigured()) { + await new Promise(r => setTimeout(r, 500)); + const hit = await genius.searchFirstSongHit(`${geniusSearchArtist} ${track.title}`); + if (hit) { + geniusUrl = hit.url; + await new Promise(r => setTimeout(r, 600)); + lyrics = await genius.fetchLyricsFromSongPage(hit.url); + } + } + + let spotifyUrl = ''; + if (track.recordingId) { + await new Promise(r => setTimeout(r, 1100)); + try { + spotifyUrl = await musicBrainzApi.fetchSpotifyUrlForRecording(track.recordingId); + } catch (e) { + console.warn(`MDB | Spotify URL for recording ${track.recordingId}:`, e); + } + } + if (!spotifyUrl && spotify.isConfigured()) { + const primaryArtist = release.artists[0] ?? geniusSearchArtist; + console.log(`MDB | Spotify API fallback for track "${track.title}" (artist: ${primaryArtist})`); + try { + spotifyUrl = await spotify.searchFirstTrackUrl(track.title, primaryArtist); + } catch (e) { + console.warn(`MDB | Spotify search for "${track.title}":`, e); + } + } + + const song = new SongModel({ + type: 'song', + title: track.title, + englishTitle: track.title, + year: release.year, + releaseDate: release.releaseDate, + dataSource: MUSICBRAINZ_NOTE_DATA_SOURCE, + url: geniusUrl || release.url, + id: `${release.id}-t${track.number}`, + image: release.image, + subType: 'song', + genres: release.genres ?? [], + artists: release.artists.length > 0 ? release.artists : [geniusSearchArtist], + albumTitle: release.title, + albumReleaseGroupId: release.id, + trackNumber: track.number, + duration: track.duration, + featuredArtists: track.featuredArtists, + geniusUrl, + spotifyUrl, + lyrics, + userData: { personalRating: 0 }, + }); + + const songOpts: CreateNoteOptions = useTree && songNotesFolder ? { ...childOptions, folder: songNotesFolder } : { ...childOptions }; + + await this.createStandardMediaDbNoteFromModel(song, songOpts); + } + } + + private async importMusicReleaseWithOptionalSongs(release: MusicReleaseModel, options: CreateNoteOptions): Promise { + try { + const albumNotesFolder = options.folder ?? (await this.mediaTypeManager.getFolder(release, this.app)); + const useTree = this.settings.artistUseFileTreeForSongs; + const importSongs = this.settings.musicReleaseAutomaticallyImportSongs; + + let songNotesFolder: TFolder | undefined; + if (useTree && importSongs) { + const albumSeg = this.safeFileTreeSegment(release.title); + songNotesFolder = await this.ensureVaultFolder(normalizePath(`${albumNotesFolder.path}/${albumSeg}`)); + } + + const albumCreated = await this.createStandardMediaDbNoteFromModel(release, { ...options, folder: albumNotesFolder }); + if (!albumCreated) { + return; + } + + if (!importSongs || release.tracks.length === 0) { + return; + } + + const musicBrainzApi = this.apiManager.getApiByName('MusicBrainz API') as MusicBrainzAPI | undefined; + if (!musicBrainzApi) { + new Notice('MusicBrainz API not available; song notes were skipped.'); + console.warn('MusicBrainz API not available; song notes were skipped.'); + return; + } + + const geniusToken = getApiSecretValue(this.app, this.settings.linkedApiSecretIds, ApiSecretID.genius) || undefined; + const genius = new GeniusClient(geniusToken); + if (!genius.isConfigured()) { + new Notice('Album import: Genius token not found! Add a Genius API access token in settings to fetch lyrics.'); + console.warn('Album import: Genius token not found! Add a Genius API access token in settings to fetch lyrics.'); + } + + const spotifyClientId = getApiSecretValue(this.app, this.settings.linkedApiSecretIds, ApiSecretID.spotifyClientId) || undefined; + const spotifyClientSecret = getApiSecretValue(this.app, this.settings.linkedApiSecretIds, ApiSecretID.spotifyClientSecret) || undefined; + const spotify = new SpotifyClient(spotifyClientId, spotifyClientSecret); + + const geniusSearchArtist = release.artists[0] ?? release.title; + const childOptions: CreateNoteOptions = { + attachTemplate: true, + openNote: false, + attachFile: undefined, + folder: undefined, + }; + + new Notice(`Importing ${release.tracks.length} tracks for ${release.title}…`); + console.log(`Importing ${release.tracks.length} tracks for ${release.title}…`); + + await this.importSongNotesForMusicReleaseTracks( + release, + geniusSearchArtist, + musicBrainzApi, + genius, + spotify, + childOptions, + useTree, + songNotesFolder, + ); + } catch (e) { + console.warn(e); + new Notice(`${e}`); + } + } + + private async importArtistDiscography(artist: ArtistModel, options: CreateNoteOptions): Promise { + try { + const useTree = this.settings.artistUseFileTreeForSongs; + const childOptions: CreateNoteOptions = { + attachTemplate: true, + openNote: false, + attachFile: undefined, + folder: undefined, + }; + + const artistBaseFolder = await this.mediaTypeManager.getFolder(artist, this.app); + let artistNoteFolder = artistBaseFolder; + let albumNotesFolder = artistBaseFolder; + + if (useTree) { + const artistSeg = this.safeFileTreeSegment(artist.title); + const treeRootPath = normalizePath(`${artistBaseFolder.path}/${artistSeg}`); + albumNotesFolder = await this.ensureVaultFolder(treeRootPath); + } + + const artistNoteCreated = await this.createStandardMediaDbNoteFromModel(artist, { ...options, folder: artistNoteFolder }); + if (!artistNoteCreated) { + return; + } + + if (!this.settings.artistAutomaticallyImportReleases) { + new Notice(`✅ Finished artist import for ${artist.title}.`); + console.log(`✅ Finished artist import for ${artist.title}.`); + return; + } + + const geniusToken = getApiSecretValue(this.app, this.settings.linkedApiSecretIds, ApiSecretID.genius) || undefined; + const genius = new GeniusClient(geniusToken); + if (!genius.isConfigured()) { + new Notice('Artist import: Genius token not found! Add a Genius API access token in settings to fetch lyrics.'); + console.warn('Artist import: Genius token not found! Add a Genius API access token in settings to fetch lyrics.'); + } + + const spotifyClientId = getApiSecretValue(this.app, this.settings.linkedApiSecretIds, ApiSecretID.spotifyClientId) || undefined; + const spotifyClientSecret = getApiSecretValue(this.app, this.settings.linkedApiSecretIds, ApiSecretID.spotifyClientSecret) || undefined; + const spotify = new SpotifyClient(spotifyClientId, spotifyClientSecret); + + const artistApi = this.apiManager.getApiByName('MusicBrainz Artist API') as MusicBrainzArtistAPI | undefined; + const musicBrainzApi = this.apiManager.getApiByName('MusicBrainz API') as MusicBrainzAPI | undefined; + if (!artistApi || !musicBrainzApi) { + new Notice('MusicBrainz APIs not available.'); + console.warn('MusicBrainz APIs not available.'); + return; + } + + let releaseGroupIds: string[]; + try { + releaseGroupIds = await artistApi.listStudioAlbumReleaseGroupIds(artist.id); + } catch (e) { + new Notice(`Could not load albums: ${e}`); + console.log(`Could not load albums: ${e}`); + return; + } + + const importSongs = this.settings.musicReleaseAutomaticallyImportSongs; + new Notice( + `Importing ${releaseGroupIds.length} studio albums${importSongs ? ' and tracks' : ''} for ${artist.title}…`, + ); + console.log( + `Importing ${releaseGroupIds.length} studio albums${importSongs ? ' and tracks' : ''} for ${artist.title}…`, + ); + + for (const rgId of releaseGroupIds) { + await new Promise(r => setTimeout(r, 1100)); + let release: MusicReleaseModel; + try { + const model = await musicBrainzApi.getById(rgId); + release = model as MusicReleaseModel; + } catch (e) { + console.warn(`MDB | Skipping release group ${rgId}:`, e); + continue; + } + + let songNotesFolder: TFolder | undefined; + if (useTree && importSongs) { + const albumSeg = this.safeFileTreeSegment(release.title); + songNotesFolder = await this.ensureVaultFolder(normalizePath(`${albumNotesFolder.path}/${albumSeg}`)); + } + + const releaseOpts: CreateNoteOptions = useTree ? { ...childOptions, folder: albumNotesFolder } : { ...childOptions }; + + const albumNoteCreated = await this.createStandardMediaDbNoteFromModel(release, releaseOpts); + if (!albumNoteCreated) { + continue; + } + + if (!importSongs) { + continue; + } + + await this.importSongNotesForMusicReleaseTracks( + release, + artist.title, + musicBrainzApi, + genius, + spotify, + childOptions, + useTree, + songNotesFolder, + ); } + + new Notice(`✅ Finished artist import for ${artist.title}.`); + console.log(`✅ Finished artist import for ${artist.title}.`); } catch (e) { console.warn(e); new Notice(`${e}`); @@ -513,40 +955,260 @@ export default class MediaDbPlugin extends Plugin { return false; } - generateMediaDbNoteFrontmatterPreview(mediaTypeModel: MediaTypeModel): string { - const fileMetadata = this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject()); - return stringifyYaml(fileMetadata); + async downloadImagesInFolder(folder: TFolder): Promise { + new Notice(`MDB | Scanning for images to download in ${folder.name}...`); + const files = folder.children.filter((c): c is TFile => c instanceof TFile && c.extension === 'md'); + const startTime = Date.now(); + let downloaded = 0; + let failed = 0; + const erroredFiles: { filePath: string; error: string }[] = []; + + for (const file of files) { + const result = await this.downloadImagesInFile(file, true); + if (result.success) { + downloaded++; + } else if (!result.skipped) { + failed++; + if (result.error) erroredFiles.push({ filePath: file.path, error: result.error }); + } + // wait slightly as anti-rate limit + if (!result.skipped) { + await new Promise(r => setTimeout(r, 600)); + } + } + + if (failed > 0 && erroredFiles.length > 0) { + const title = `MDB - image download error report ${dateTimeToString(new Date())}`; + const filePath = `${title}.md`; + const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error])); + const fileContent = markdownTable(table); + await this.app.vault.create(filePath, fileContent); + } + + new CompletionModal(this.app, { + title: 'Image Download Complete', + icon: '🖼️', + total: downloaded + failed, + success: downloaded, + errors: failed, + elapsedMs: Date.now() - startTime, + notes: failed > 0 ? ['Some images could not be downloaded. A detailed report file has been created in your vault folder.'] : [], + }).open(); } /** - * Generates the content of a note from a media model and some options. - * - * @param mediaTypeModel - * @param options + * Downloads images for a single file. + * @returns object detailing success, possible errors, or whether it was skipped */ - async generateMediaDbNoteContents(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise { - let template = await this.mediaTypeManager.getTemplate(mediaTypeModel, this.app); - let fileMetadata: Record; + async downloadImagesInFile(file: TFile, silent: boolean = false): Promise<{ success: boolean; skipped?: boolean; error?: string }> { + const metadata = this.getMetadataFromFileCache(file); + if (typeof metadata.image === 'string' && metadata.image.startsWith('http')) { + try { + const imageUrl = metadata.image; + const extMatch = /\.([a-zA-Z0-9]+)$/.exec(imageUrl.split('?')[0]); + const ext = extMatch ? extMatch[1] : 'jpg'; + const imgName = replaceIllegalFileNameCharactersInString(file.basename) + '.' + ext; + const imgFolder = await this.ensureVaultFolder(this.settings.imageFolder); + const imagePath = `${imgFolder.path}/${imgName}`; + if (!this.app.vault.getAbstractFileByPath(imagePath)) { + const response = await requestUrl({ url: imageUrl, method: 'GET' }); + await this.app.vault.createBinary(imagePath, response.arrayBuffer); + } + + await this.app.fileManager.processFrontMatter(file, (frontmatter: any) => { + frontmatter.image = `[[${imagePath}]]`; + }); + if (!silent) new Notice(`MDB | Image downloaded for ${file.basename}`); + return { success: true }; + } catch (e) { + console.error('MDB | Image download failed for', file.path, e); + if (!silent) new Notice(`MDB | Image download failed for ${file.basename}`); + return { success: false, error: `${e}` }; + } + } + if (!silent) new Notice(`MDB | No external image found in ${file.basename}`); + return { success: false, skipped: true }; + } + + private metadataRecordForNewNote(mediaTypeModel: MediaTypeModel): Record { + let meta: Record; if (this.settings.useDefaultFrontMatter) { - fileMetadata = this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject()); + meta = mediaTypeModel.toMetaDataObject(); } else { - fileMetadata = { + meta = { id: mediaTypeModel.id, type: mediaTypeModel.type, dataSource: mediaTypeModel.dataSource, }; } + meta = this.withMovieCurrencyObjectFormat(meta, mediaTypeModel); + meta = this.withSanitizedColonStrings(meta); + return this.withNormalizedTitleAliasMetadata(meta, mediaTypeModel.title); + } + + /** Sanitize missing spaces after colons to avoid Obsidian treating them as URIs */ + private withSanitizedColonStrings(meta: Record): Record { + const next = { ...meta }; + for (const key of Object.keys(next)) { + const val = next[key]; + if (typeof val === 'string') { + // Don't format URLs or similar links + if (val.startsWith('http://') || val.startsWith('https://')) continue; + next[key] = val.replace(/:(?=[^\s\/\\])/g, ': '); + } + } + return next; + } + + /** When enabled, movie budget/revenue become `{ value, currency }` for YAML front matter. */ + private withMovieCurrencyObjectFormat(meta: Record, mediaTypeModel: MediaTypeModel): Record { + if (!this.settings.useObjectFormatForCurrencyValues || mediaTypeModel.getMediaType() !== MediaType.Movie) { + return meta; + } + const next = { ...meta }; + for (const key of ['budget', 'revenue'] as const) { + const raw = next[key]; + if (typeof raw !== 'string') { + continue; + } + const amount = parseUsdWholeDollarsFromDisplayString(raw); + next[key] = amount !== null ? { value: amount, currency: 'USD' } : null; + } + return next; + } + + private withNormalizedTitleAliasMetadata(meta: Record, title: string): Record { + if (!this.settings.addNormalizeTitlesAsAlias) { + return meta; + } + const alias = normalizeTitleForAsciiAlias(title); + if (alias === null) { + return meta; + } + const prev = meta['aliases']; + if (Array.isArray(prev)) { + if (!prev.includes(alias)) { + meta['aliases'] = [...prev, alias]; + } + } else if (typeof prev === 'string') { + meta['aliases'] = prev === alias ? [prev] : [prev, alias]; + } else { + meta['aliases'] = [alias]; + } + return meta; + } + + generateMediaDbNoteFrontmatterPreview(mediaTypeModel: MediaTypeModel): string { + mediaTypeModel.type = noteTypeValueForMedia(this.settings, mediaTypeModel.getMediaType()); + const fileMetadata = this.modelPropertyMapper.convertObject(this.metadataRecordForNewNote(mediaTypeModel)); + 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 { + mediaTypeModel.type = noteTypeValueForMedia(this.settings, mediaTypeModel.getMediaType()); + + let template = await this.mediaTypeManager.getTemplate(mediaTypeModel, this.app); + const originalTemplateText = template; + let fileMetadata: Record = this.modelPropertyMapper.convertObject(this.metadataRecordForNewNote(mediaTypeModel)); let fileContent = ''; template = options.attachTemplate ? template : ''; - ({ fileMetadata, fileContent } = await this.attachFile(fileMetadata, fileContent, options.attachFile)); + ({ fileMetadata, fileContent } = await this.attachFile(fileMetadata, fileContent, options.attachFile, options.preservePropertyOrder, originalTemplateText)); ({ fileMetadata, fileContent } = await this.attachTemplate(fileMetadata, fileContent, template)); + // --- Global Wiki-Link Post-Processing (for Custom/Manual Properties) --- + const entityWikiProps = this.settings.autoTagEntities + .split(',') + .map(s => s.trim().toLowerCase()) + .filter(s => s !== ''); + if (entityWikiProps.length > 0) { + const folderPrefix = this.settings.wikiFolder ? `${this.settings.wikiFolder}/` : ''; + const isEnabled = this.settings.enableWikiLinkParsing; + const formatWiki = (v: unknown) => { + if (typeof v !== 'string') return v; + let clean = v.replace(/^\[\[(.*?)\]\]$/, '$1'); + if (clean.includes('|')) clean = clean.split('|')[1]; + return isEnabled ? `[[${folderPrefix}${clean}|${clean}]]` : clean.trim(); + }; + + for (const [key, value] of Object.entries(fileMetadata)) { + if (key === 'aliases') continue; + if (entityWikiProps.includes(key.toLowerCase())) { + if (typeof value === 'string') { + fileMetadata[key] = formatWiki(value); + } else if (Array.isArray(value)) { + fileMetadata[key] = value.map(formatWiki); + } + } + } + } + + // --- Per-Property Auto-Tag Logic --- + const autoTagEntries = this.modelPropertyMapper.getAutoTagKeys(mediaTypeModel.type); + if (autoTagEntries.length > 0) { + const existingTags: string[] = Array.isArray(fileMetadata.tags) ? (fileMetadata.tags as string[]) : []; + const newTags = new Set(existingTags.filter(t => typeof t === 'string' && t.trim() !== '')); + + for (const [key, value] of Object.entries(fileMetadata)) { + const entry = autoTagEntries.find(e => e.key.toLowerCase() === key.toLowerCase()); + if (entry && value) { + const prefix = entry.prefix.trim().replace(/\/$/, ''); // strip trailing slash + const valuesToTag = Array.isArray(value) ? value : [value]; + for (let v of valuesToTag) { + if (typeof v === 'string') { + v = String(v).replace(/^\[\[(.*?)\]\]$/, '$1'); + if (v.includes('|')) { + v = v.split('|')[1]; + } + const sanitized = v + .trim() + .replace(/\s+/g, '-') + .replace(/[^\wığüşöçIĞÜŞÖÇ/-]/g, '') + .toLowerCase(); + + if (sanitized) newTags.add(prefix ? `${prefix}/${sanitized}` : sanitized); + } + } + } + } + + if (newTags.size > 0) { + fileMetadata.tags = Array.from(newTags); + } + } + + if (mediaTypeModel.getMediaType() === MediaType.Song) { + const song = mediaTypeModel as SongModel; + if (song.lyrics.length > 0) { + fileContent += `# Lyrics\n\`\`\`\n${song.lyrics}\n\`\`\`\n`; + } + } + + // Ensure 'pinBottom' properties (including 'tags' if pinned) appear at the absolute bottom + // This guarantees they are listed chronologically below template properties. + const pinnedKeys = this.modelPropertyMapper.getPinnedBottomKeys(mediaTypeModel.type); + for (const key of pinnedKeys) { + if (key in fileMetadata) { + const val = fileMetadata[key]; + delete fileMetadata[key]; + if (val !== null && val !== undefined) { + fileMetadata[key] = val; + } + } + } + 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}`; + const mediaJson = JSON.stringify(mediaTypeModel, (key, value: unknown) => (key === 'lyrics' ? undefined : value)); + fileContent = `---\n<%* const media = ${mediaJson} %>\n${stringifyYaml(fileMetadata)}---\n${fileContent}`; } else { fileContent = `---\n${stringifyYaml(fileMetadata)}---\n${fileContent}`; } @@ -554,14 +1216,102 @@ export default class MediaDbPlugin extends Plugin { return fileContent; } - async attachFile(fileMetadata: Metadata, fileContent: string, fileToAttach?: TFile): Promise<{ fileMetadata: Metadata; fileContent: string }> { + extractManualTags(metadata: Record, autoTagEntries: { key: string; prefix: string }[]): string[] { + const allTagsRaw = metadata.tags; + const allTags = Array.isArray(allTagsRaw) ? allTagsRaw : typeof allTagsRaw === 'string' ? [allTagsRaw] : []; + if (allTags.length === 0) return []; + + const autoTagValues = new Set(); + + for (const [key, value] of Object.entries(metadata)) { + const entry = autoTagEntries.find(e => e.key.toLowerCase() === key.toLowerCase()); + if (entry && value) { + const prefix = entry.prefix.trim().replace(/\/$/, ''); + const valuesToTag = Array.isArray(value) ? value : [value]; + for (const v of valuesToTag) { + if (typeof v === 'string') { + let clean = v.replace(/^\[\[(.*?)\]\]$/, '$1'); + if (clean.includes('|')) clean = clean.split('|')[1]; + const sanitized = clean + .trim() + .replace(/\s+/g, '-') + .replace(/[^\wığüşöçIĞÜŞÖÇ/-]/g, '') + .toLowerCase(); + if (sanitized) autoTagValues.add(prefix ? `${prefix}/${sanitized}` : sanitized); + } + } + } + } + + return allTags.map(t => String(t).trim()).filter(t => t && !autoTagValues.has(t.toLowerCase()) && !t.toLowerCase().startsWith('mediadb/')); + } + + async attachFile(fileMetadata: Metadata, fileContent: string, fileToAttach?: TFile, preservePropertyOrder?: boolean, templateStr?: string): Promise<{ fileMetadata: Metadata; fileContent: string }> { if (!fileToAttach) { return { fileMetadata: fileMetadata, fileContent: fileContent }; } const attachFileMetadata = this.getMetadataFromFileCache(fileToAttach); - // TODO: better object merging - fileMetadata = Object.assign(attachFileMetadata, fileMetadata); + + // Rescue arrays that Object.assign would normally crush + const rescueArray = (key: string) => { + const arr = attachFileMetadata[key]; + if (Array.isArray(arr)) return [...(arr as string[])]; + if (typeof arr === 'string' && arr.trim()) return [arr]; + return []; + }; + const mediaType = attachFileMetadata.type ?? fileMetadata.type; + const autoTagEntries = this.modelPropertyMapper.getAutoTagKeys(mediaType); + const oldManualTags = this.extractManualTags(attachFileMetadata, autoTagEntries); + const oldAliases = rescueArray('aliases'); + + if (preservePropertyOrder) { + // Messy legacy behavior: old attachFileMetadata acts as the base, preserving its currently unordered key layout + fileMetadata = Object.assign(attachFileMetadata, fileMetadata); + } else { + // Enforce strict property order from the new mapping + const orderedMetadata: Record = {}; + for (const key of Object.keys(fileMetadata)) { + orderedMetadata[key] = fileMetadata[key]; + } + + // Smart Sort: extract predefined order from template (if available) + let templateMetadata: Record = {}; + const templateKeys: string[] = []; + if (templateStr) { + templateMetadata = this.getMetaDataFromFileContent(templateStr); + templateKeys.push(...Object.keys(templateMetadata)); + } + + // Add properties matching the template order first + for (const tKey of templateKeys) { + if (tKey in attachFileMetadata && !(tKey in orderedMetadata)) { + orderedMetadata[tKey] = attachFileMetadata[tKey]; + } else if (!(tKey in attachFileMetadata) && !(tKey in orderedMetadata)) { + orderedMetadata[tKey] = templateMetadata[tKey]; + } + } + + // Then add any remaining unexpected properties (at the very bottom) + for (const [key, value] of Object.entries(attachFileMetadata)) { + if (!(key in orderedMetadata)) { + orderedMetadata[key] = value; + } + } + fileMetadata = orderedMetadata; + } + + // Merge tags cleanly (Preserving only manual user tags, discarding old ghost auto-tags!) + const newObjTags = fileMetadata.tags; + const finalTags = new Set([...oldManualTags, ...(Array.isArray(newObjTags) ? newObjTags : typeof newObjTags === 'string' ? [newObjTags] : [])].map(t => String(t).trim())); + if (finalTags.size > 0) fileMetadata.tags = Array.from(finalTags); + + // Merge aliases cleanly + const newObjAliases = fileMetadata.aliases; + const finalAliases = new Set( + [...oldAliases, ...(Array.isArray(newObjAliases) ? newObjAliases : typeof newObjAliases === 'string' ? [newObjAliases] : [])].map(a => String(a).trim()), + ); + if (finalAliases.size > 0) fileMetadata.aliases = Array.from(finalAliases); let attachFileContent: string = await this.app.vault.read(fileToAttach); const regExp = new RegExp(this.frontMatterRexExpPattern); @@ -578,8 +1328,12 @@ export default class MediaDbPlugin extends Plugin { } const templateMetadata = this.getMetaDataFromFileContent(template); - // TODO: better object merging - fileMetadata = Object.assign(templateMetadata, fileMetadata); + // Merge: API data wins and stays at top; template-only keys are appended at the bottom + for (const [key, value] of Object.entries(templateMetadata)) { + if (!(key in fileMetadata)) { + fileMetadata[key] = value; + } + } const regExp = new RegExp(this.frontMatterRexExpPattern); const attachFileContent = template.replace(regExp, ''); @@ -626,6 +1380,16 @@ export default class MediaDbPlugin extends Plugin { * @param fileContent * @param options */ + getResolvedImportPath(mediaTypeModel: MediaTypeModel): string { + let folderPath = this.mediaTypeManager.mediaFolderMap.get(mediaTypeModel.getMediaType()) ?? '/'; + folderPath = this.mediaTypeManager.expandFolderPathForModel(folderPath, mediaTypeModel); + let fileName = this.mediaTypeManager.getFileName(mediaTypeModel); + fileName = replaceIllegalFileNameCharactersInString(fileName); + const dir = folderPath.replace(/^\/+|\/+$/g, ''); + const relative = dir.length > 0 ? `${dir}/${fileName}.md` : `${fileName}.md`; + return normalizePath(relative); + } + 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('/'); @@ -640,11 +1404,22 @@ export default class MediaDbPlugin extends Plugin { // 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(); - }); + let choice = options.overwrite ? ConfirmOverwriteChoice.Overwrite : null; + if (!choice) { + choice = await new Promise(resolve => { + new ConfirmOverwriteModal(this.app, fileName, resolve).open(); + }); + } - if (!shouldOverwrite) { + if (choice !== ConfirmOverwriteChoice.Overwrite) { + // To keep old Promise compatibility, return the existing file if kept, or throw + if (choice === ConfirmOverwriteChoice.KeepExisting && file instanceof TFile) { + if (options.openNote) { + const activeLeaf = this.app.workspace.getUnpinnedLeaf(); + if (activeLeaf) await activeLeaf.openFile(file, { state: { mode: 'source' } }); + } + return file; + } throw new Error('MDB | file creation cancelled by user'); } @@ -668,32 +1443,75 @@ export default class MediaDbPlugin extends Plugin { return targetFile; } + // --- AutoTracker Ribbon Logic --- + public _ribbonEl: HTMLElement | null = null; + refreshAutoTrackerRibbon() { + if (!this._ribbonEl) { + this._ribbonEl = this.addRibbonIcon('sync', 'Media DB: Auto-Tracker Sync', () => { + if (this.autoTrackerHelper.isScanning) { + new Notice('Auto-Tracker is currently syncing in the background.'); + } else { + new BulkUpdateConfirmModal( + this.app, + (silentUpdate: boolean) => { + this.autoTrackerHelper.startBackgroundScan(silentUpdate); + }, + 'Auto Tracker Sync', + 'You are about to scan and automatically update Airing/Released status for tracked media across your vault.' + ).open(); + } + }); + this._ribbonEl.addClass('obsidian-media-db-plugin-ribbon-class'); + } + + if (this.autoTrackerHelper.isScanning) { + this._ribbonEl.addClass('media-db-spin-animation'); + } else { + this._ribbonEl.removeClass('media-db-spin-animation'); + } + } + /** * 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. + * Tries to read the type and id of the active note (and dataSource when required). If successful it will query the api, delete the old note and create a new one. */ - async updateActiveNote(onlyMetadata: boolean = false): Promise { + async updateActiveNote(onlyMetadata: boolean = false, preserveOrder: boolean = false): Promise { const activeFile = this.app.workspace.getActiveFile() ?? undefined; if (!activeFile) { throw new Error('MDB | there is no active note'); } + return this.updateNote(activeFile, onlyMetadata, preserveOrder, true, false); + } + async updateNote(activeFile: TFile, onlyMetadata: boolean = false, preserveOrder: boolean = false, openNoteFinal: boolean = true, overwrite: boolean = false): Promise { let metadata = this.getMetadataFromFileCache(activeFile); metadata = this.modelPropertyMapper.convertObjectBack(metadata); console.debug(`MDB | read metadata`, metadata); - if (!metadata?.type || !metadata?.dataSource || !metadata?.id) { + if (!metadata?.type || !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; + const mediaType = resolveMetadataTypeToMediaType(this.settings, metadata.type); + if (mediaType === undefined) { + throw new Error('MDB | active note type is not recognized; check Settings → Note type for each media kind'); + } + let dataSource = typeof metadata.dataSource === 'string' ? metadata.dataSource.trim() : ''; + if (!dataSource && musicBrainzRegisteredApiName(mediaType)) { + dataSource = MUSICBRAINZ_NOTE_DATA_SOURCE; + } + if (!dataSource) { + throw new Error('MDB | active note is missing dataSource (required for this media type)'); + } + + const validOldMetadata: MediaTypeModelObj = { ...metadata, dataSource } as unknown as MediaTypeModelObj; console.debug(`MDB | validOldMetadata`, validOldMetadata); - const oldMediaTypeModel = this.mediaTypeManager.createMediaTypeModelFromMediaType(validOldMetadata, validOldMetadata.type); + const oldMediaTypeModel = this.mediaTypeManager.createMediaTypeModelFromMediaType(validOldMetadata, mediaType); console.debug(`MDB | oldMediaTypeModel created`, oldMediaTypeModel); - let newMediaTypeModel = await this.apiManager.queryDetailedInfoById(validOldMetadata.id, validOldMetadata.dataSource); + let newMediaTypeModel = await this.apiManager.queryDetailedInfoById(validOldMetadata.id, validOldMetadata.dataSource, mediaType); if (!newMediaTypeModel) { return; } @@ -702,9 +1520,9 @@ export default class MediaDbPlugin extends Plugin { console.debug(`MDB | newMediaTypeModel after merge`, newMediaTypeModel); if (onlyMetadata) { - await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachFile: activeFile, folder: activeFile.parent ?? undefined, openNote: true }); + await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachFile: activeFile, folder: activeFile.parent ?? undefined, openNote: openNoteFinal, overwrite, preservePropertyOrder: preserveOrder }); } else { - await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachTemplate: true, folder: activeFile.parent ?? undefined, openNote: true }); + await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachTemplate: true, folder: activeFile.parent ?? undefined, openNote: openNoteFinal, overwrite }); } } @@ -719,8 +1537,21 @@ export default class MediaDbPlugin extends Plugin { defaultSettings.propertyMappingModels.map(m => PropertyMappingModel.fromJSON(m)), ); - // Store as plain data for serialization - loadedSettings.propertyMappingModels = migratedModels.map(m => m.toJSON()); + // Store as plain data for serialization (canonical order matches settings UI) + loadedSettings.propertyMappingModels = propertyMappingModelsInDisplayOrder(migratedModels.map(m => m.toJSON())); + + // --- MIGRATION: Band to Artist --- + const anyLoaded = diskSettings as any; + if (anyLoaded) { + if (anyLoaded.bandTemplate && !loadedSettings.artistTemplate) loadedSettings.artistTemplate = anyLoaded.bandTemplate; + if (anyLoaded.bandFolder && !loadedSettings.artistFolder) loadedSettings.artistFolder = anyLoaded.bandFolder; + if (anyLoaded.bandFileNameTemplate && !loadedSettings.artistFileNameTemplate) loadedSettings.artistFileNameTemplate = anyLoaded.bandFileNameTemplate; + if (anyLoaded.bandNoteType && !loadedSettings.artistNoteType) loadedSettings.artistNoteType = anyLoaded.bandNoteType; + if (anyLoaded.bandUseFileTreeForSongs !== undefined && loadedSettings.artistUseFileTreeForSongs === false) + loadedSettings.artistUseFileTreeForSongs = anyLoaded.bandUseFileTreeForSongs; + if (anyLoaded.MusicBrainzBandAPI_disabledMediaTypes && !loadedSettings.MusicBrainzArtistAPI_disabledMediaTypes) + loadedSettings.MusicBrainzArtistAPI_disabledMediaTypes = anyLoaded.MusicBrainzBandAPI_disabledMediaTypes; + } this.settings = loadedSettings; } @@ -732,4 +1563,4 @@ export default class MediaDbPlugin extends Plugin { await this.saveData(this.settings); } -} \ No newline at end of file +} diff --git a/src/modals/BulkRecreateConfirmModal.ts b/src/modals/BulkRecreateConfirmModal.ts new file mode 100644 index 00000000..104542b2 --- /dev/null +++ b/src/modals/BulkRecreateConfirmModal.ts @@ -0,0 +1,71 @@ +import type { App } from 'obsidian'; +import { Modal, Setting } from 'obsidian'; + +export type BulkRecreateMode = 'reorder' | 'full'; + +const MODE_DESCRIPTIONS: Record = { + reorder: + 'All existing note data and custom template values are preserved. Only the property order and Pin settings from your current Property Mapping configuration are re-applied.', + full: '⚠️ Each note is completely rebuilt from scratch using your template. Any changes you made to the note after it was created (e.g. custom fields added by your template) will be reset to their template defaults.', +}; + +export class BulkRecreateConfirmModal extends Modal { + onSubmit: (mode: BulkRecreateMode, silent: boolean) => void; + mode: BulkRecreateMode = 'reorder'; + silentUpdate: boolean = false; + + private descEl!: HTMLParagraphElement; + + constructor(app: App, onSubmit: (mode: BulkRecreateMode, silent: boolean) => void) { + super(app); + this.onSubmit = onSubmit; + } + + onOpen() { + const { contentEl } = this; + contentEl.createEl('h2', { text: 'Bulk Recreate Notes' }); + contentEl.createEl('p', { + text: 'You are about to process all Media DB notes in this folder. Choose how each note should be rebuilt:', + cls: 'mod-muted', + }); + + this.descEl = contentEl.createEl('p', { + text: MODE_DESCRIPTIONS[this.mode], + }); + this.descEl.style.cssText = 'padding: 8px 12px; border-left: 3px solid var(--interactive-accent); margin-bottom: 12px; font-size: var(--font-ui-small);'; + + new Setting(contentEl) + .setName('Recreate Mode') + .addDropdown(drop => + drop + .addOption('reorder', 'Apply Property Order (Safe)') + .addOption('full', 'Full Template Reset') + .setValue(this.mode) + .onChange(value => { + this.mode = value as BulkRecreateMode; + this.descEl.setText(MODE_DESCRIPTIONS[this.mode]); + this.descEl.style.borderLeftColor = this.mode === 'full' ? 'var(--color-red)' : 'var(--interactive-accent)'; + }), + ); + + new Setting(contentEl) + .setName('Update Silently (No Confirmations)') + .setDesc('If enabled, all updates will run without asking for individual confirmation for each file.') + .addToggle(toggle => toggle.setValue(this.silentUpdate).onChange(value => (this.silentUpdate = value))); + + new Setting(contentEl).addButton(btn => + btn + .setButtonText('Start Recreate') + .setWarning() + .onClick(() => { + this.close(); + this.onSubmit(this.mode, this.silentUpdate); + }), + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/modals/BulkUpdateConfirmModal.ts b/src/modals/BulkUpdateConfirmModal.ts new file mode 100644 index 00000000..4a621100 --- /dev/null +++ b/src/modals/BulkUpdateConfirmModal.ts @@ -0,0 +1,47 @@ +import type {App} from 'obsidian'; +import { Modal, Setting } from 'obsidian'; + +export class BulkUpdateConfirmModal extends Modal { + onSubmit: (silent: boolean) => void; + silentUpdate: boolean = false; + customTitle: string; + customDesc: string; + + constructor( + app: App, + onSubmit: (silent: boolean) => void, + customTitle: string = 'Bulk Update Metadata', + customDesc: string = 'You are about to scan and update metadata for notes in this folder.' + ) { + super(app); + this.onSubmit = onSubmit; + this.customTitle = customTitle; + this.customDesc = customDesc; + } + + onOpen() { + const { contentEl } = this; + contentEl.createEl('h2', { text: this.customTitle }); + contentEl.createEl('p', { text: this.customDesc }); + + new Setting(contentEl) + .setName('Update Silently (No Confirmations)') + .setDesc('If enabled, all updates will aggressively overwrite the note frontmatter without asking for individual confirmation for each file.') + .addToggle(toggle => toggle.setValue(this.silentUpdate).onChange(value => (this.silentUpdate = value))); + + new Setting(contentEl).addButton(btn => + btn + .setButtonText('Start Update') + .setCta() + .onClick(() => { + this.close(); + this.onSubmit(this.silentUpdate); + }), + ); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/modals/CompletionModal.ts b/src/modals/CompletionModal.ts new file mode 100644 index 00000000..f9a717f1 --- /dev/null +++ b/src/modals/CompletionModal.ts @@ -0,0 +1,90 @@ +import type {App} from 'obsidian'; +import { Modal, ButtonComponent, setIcon } from 'obsidian'; + +export interface CompletionResult { + /** Title shown in the modal header */ + title: string; + /** Lucide icon name for the operation type */ + icon?: string; + /** Total number of items processed */ + total: number; + /** Number of successfully processed items */ + success: number; + /** Number of failed items */ + errors: number; + /** Number of skipped items (optional) */ + skipped?: number; + /** Elapsed time in milliseconds */ + elapsedMs?: number; + /** Optional extra lines shown below the stats */ + notes?: string[]; +} + +export class CompletionModal extends Modal { + private result: CompletionResult; + + constructor(app: App, result: CompletionResult) { + super(app); + this.result = result; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('mdb-completion-modal'); + + const r = this.result; + const icon = r.icon ?? 'check-circle'; + const allSuccess = r.errors === 0; + + // Header + const header = contentEl.createEl('div', { cls: 'mdb-completion-header media-db-list-item-flex' }); + header.style.alignItems = 'center'; + const iconEl = header.createEl('span', { cls: 'mdb-completion-icon' }); + setIcon(iconEl, allSuccess ? icon : 'alert-triangle'); + header.createEl('h2', { cls: 'mdb-completion-title', text: r.title }); + + // Stats + const stats = contentEl.createEl('div', { cls: 'mdb-completion-stats' }); + + this.addStatRow(stats, 'file-text', 'Total', `${r.total}`); + this.addStatRow(stats, 'check-circle', 'Successful', `${r.success}`, 'success'); + this.addStatRow(stats, 'x-circle', 'Errors', `${r.errors}`, r.errors > 0 ? 'error' : undefined); + + if (r.skipped !== undefined) { + this.addStatRow(stats, 'skip-forward', 'Skipped', `${r.skipped}`, 'skipped'); + } + + if (r.elapsedMs !== undefined) { + const secs = (r.elapsedMs / 1000).toFixed(1); + this.addStatRow(stats, 'clock', 'Duration', `${secs}s`); + } + + // Notes + if (r.notes && r.notes.length > 0) { + const notesEl = contentEl.createEl('div', { cls: 'mdb-completion-notes' }); + for (const note of r.notes) { + notesEl.createEl('p', { text: note }); + } + } + + // Close button + const footer = contentEl.createEl('div', { cls: 'mdb-completion-footer' }); + new ButtonComponent(footer) + .setButtonText('Close') + .setCta() + .onClick(() => this.close()); + } + + onClose(): void { + this.contentEl.empty(); + } + + private addStatRow(container: HTMLElement, iconIcon: string, label: string, value: string, cls?: string): void { + const row = container.createEl('div', { cls: 'mdb-completion-row' }); + const iconEl = row.createEl('span', { cls: 'mdb-completion-row-icon' }); + setIcon(iconEl, iconIcon); + row.createEl('span', { cls: 'mdb-completion-label', text: label }); + row.createEl('span', { cls: `mdb-completion-value${cls ? ' mdb-stat-' + cls : ''}`, text: value }); + } +} diff --git a/src/modals/ConfirmOverwriteModal.ts b/src/modals/ConfirmOverwriteModal.ts index e300c325..48d0cff7 100644 --- a/src/modals/ConfirmOverwriteModal.ts +++ b/src/modals/ConfirmOverwriteModal.ts @@ -1,44 +1,125 @@ import type { App } from 'obsidian'; import { Modal, Setting } from 'obsidian'; +export enum ConfirmOverwriteChoice { + Overwrite = 'overwrite', + Skip = 'skip', + Abort = 'abort', + KeepExisting = 'keepExisting', +} + export class ConfirmOverwriteModal extends Modal { - result: boolean = false; - onSubmit: (result: boolean) => void; + choice: ConfirmOverwriteChoice = ConfirmOverwriteChoice.Skip; + onSubmit: (choice: ConfirmOverwriteChoice) => void; fileName: string; + private readonly showAbortRemaining: boolean; + private readonly showSkip: boolean; + /** + * When set: body text plus Abort (red) / optional Skip / No / Yes. + * When unset and showAbortRemaining: legacy Skip / warning Abort / Yes. + */ + private readonly detail: string | undefined; - constructor(app: App, fileName: string, onSubmit: (result: boolean) => void) { + constructor( + app: App, + fileName: string, + onSubmit: (choice: ConfirmOverwriteChoice) => void, + opts?: { + showAbortRemaining?: boolean; + showSkip?: boolean; + /** Explains overwrite vs keep vs abort for chained imports (artist discography, release + tracks). */ + detail?: string; + }, + ) { super(app); this.fileName = fileName; this.onSubmit = onSubmit; + this.showAbortRemaining = opts?.showAbortRemaining ?? false; + this.showSkip = opts?.showSkip ?? false; + this.detail = opts?.detail; } onOpen(): void { const { contentEl } = this; contentEl.createEl('h2', { text: 'File already exists' }); - contentEl.createEl('p', { text: `The file "${this.fileName}" already exists. Do you want to overwrite it?` }); + + const defaultParagraph = `The file "${this.fileName}" already exists. Do you want to overwrite it?`; + contentEl.createEl('p', { text: this.detail ?? defaultParagraph }); contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); const bottomSettingRow = new Setting(contentEl); - bottomSettingRow.addButton(btn => { - btn.setButtonText('No'); - btn.onClick(() => this.close()); - btn.buttonEl.addClass('media-db-plugin-button'); - }); - bottomSettingRow.addButton(btn => { - btn.setButtonText('Yes'); - btn.setCta(); - btn.onClick(() => { - this.result = true; - this.close(); + if (this.detail !== undefined) { + if (this.showAbortRemaining) { + bottomSettingRow.addButton(btn => { + btn.setButtonText('Abort'); + btn.onClick(() => { + this.choice = ConfirmOverwriteChoice.Abort; + this.close(); + }); + btn.buttonEl.addClass('media-db-plugin-button'); + btn.buttonEl.addClass('media-db-plugin-abort-button'); + }); + } + if (this.showSkip) { + bottomSettingRow.addButton(btn => { + btn.setButtonText('Skip'); + btn.onClick(() => { + this.choice = ConfirmOverwriteChoice.Skip; + this.close(); + }); + btn.buttonEl.addClass('media-db-plugin-button'); + }); + } + bottomSettingRow.addButton(btn => { + btn.setButtonText('No'); + btn.onClick(() => { + this.choice = ConfirmOverwriteChoice.KeepExisting; + this.close(); + }); + btn.buttonEl.addClass('media-db-plugin-button'); + }); + bottomSettingRow.addButton(btn => { + btn.setButtonText('Yes'); + btn.setCta(); + btn.onClick(() => { + this.choice = ConfirmOverwriteChoice.Overwrite; + this.close(); + }); + btn.buttonEl.addClass('media-db-plugin-button'); + }); + } else { + if (this.showAbortRemaining) { + bottomSettingRow.addButton(btn => { + btn.setButtonText('Abort'); + btn.setWarning(); + btn.onClick(() => { + this.choice = ConfirmOverwriteChoice.Abort; + this.close(); + }); + btn.buttonEl.addClass('media-db-plugin-button'); + }); + } + bottomSettingRow.addButton(btn => { + btn.setButtonText(this.showAbortRemaining ? 'Skip' : 'No'); + btn.onClick(() => this.close()); + btn.buttonEl.addClass('media-db-plugin-button'); + }); + bottomSettingRow.addButton(btn => { + btn.setButtonText('Yes'); + btn.setCta(); + btn.onClick(() => { + this.choice = ConfirmOverwriteChoice.Overwrite; + this.close(); + }); + btn.buttonEl.addClass('media-db-plugin-button'); }); - btn.buttonEl.addClass('media-db-plugin-button'); - }); + } } onClose(): void { const { contentEl } = this; contentEl.empty(); - this.onSubmit(this.result); + this.onSubmit(this.choice); } } diff --git a/src/modals/MediaDbPreviewModal.ts b/src/modals/MediaDbPreviewModal.ts index 931556c1..3880f964 100644 --- a/src/modals/MediaDbPreviewModal.ts +++ b/src/modals/MediaDbPreviewModal.ts @@ -57,6 +57,10 @@ export class MediaDbPreviewModal extends Modal { } catch (e) { console.warn(`mdb | error during rendering of preview`, e); } + + const importPath = this.plugin.getResolvedImportPath(result); + const pathRow = previewWrapper.createDiv({ cls: 'media-db-plugin-preview-import-path' }); + pathRow.createEl('code', { text: importPath }); } contentEl.createDiv({ cls: 'media-db-plugin-spacer' }); diff --git a/src/modals/MediaDbSearchModal.ts b/src/modals/MediaDbSearchModal.ts index 986cb8a8..8332f5ab 100644 --- a/src/modals/MediaDbSearchModal.ts +++ b/src/modals/MediaDbSearchModal.ts @@ -5,7 +5,7 @@ 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 { mediaTypeDisplayName } from '../utils/Utils'; export class MediaDbSearchModal extends Modal { plugin: MediaDbPlugin; @@ -92,12 +92,12 @@ export class MediaDbSearchModal extends Modal { const apiToggleListElementWrapper = contentEl.createEl('div', { 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' }); + apiToggleTextWrapper.createEl('span', { text: mediaTypeDisplayName(mediaType), cls: 'media-db-plugin-list-text' }); const apiToggleComponentWrapper = apiToggleListElementWrapper.createEl('div', { cls: 'media-db-plugin-list-toggle' }); const apiToggleComponent = new ToggleComponent(apiToggleComponentWrapper); - apiToggleComponent.setTooltip(unCamelCase(mediaType)); + apiToggleComponent.setTooltip(mediaTypeDisplayName(mediaType)); apiToggleComponent.setValue(this.selectedTypes.contains(mediaType)); if (apiToggleComponent.getValue()) { currentToggle = apiToggleComponent; diff --git a/src/modals/MediaDbSearchResultModal.ts b/src/modals/MediaDbSearchResultModal.ts index 786df40a..cd646e34 100644 --- a/src/modals/MediaDbSearchResultModal.ts +++ b/src/modals/MediaDbSearchResultModal.ts @@ -39,11 +39,87 @@ export class MediaDbSearchResultModal extends SelectModal { this.skipCallback = skipCallback; } + // Different rate limit delay based on API source, MAL APIs = max 3 per second so 400ms between requests to be safe + private getDelayForApi(dataSource: string): number { + const isMalApi = dataSource === 'MALAPI' || dataSource === 'MALAPIManga'; + return isMalApi ? 400 : 200; + } + // Renders each suggestion item. renderElement(item: MediaTypeModel, el: HTMLElement): void { - el.createEl('div', { 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}` }); + el.addClass('media-db-list-item-flex'); + + const thumb = el.createDiv({ cls: 'media-db-plugin-select-thumb' }); + + let imgEl: HTMLImageElement | undefined; + + const setImage = (url: string) => { + if (!imgEl) { + imgEl = document.createElement('img'); + imgEl.loading = 'lazy'; + imgEl.alt = item.title; + thumb.empty(); + thumb.appendChild(imgEl); + imgEl.style.width = '100%'; + imgEl.style.height = '100%'; + imgEl.style.objectFit = 'cover'; + imgEl.onerror = () => { + thumb.empty(); + const placeholderSpan = thumb.createEl('span', { text: '📷' }); + placeholderSpan.style.fontSize = '24px'; + }; + } + imgEl.src = url; + }; + + const content = el.createDiv({ cls: 'media-db-plugin-select-content' }); + + const titleEl = content.createEl('div', { text: this.plugin.mediaTypeManager.getFileName(item), cls: 'media-db-plugin-select-title' }); + const summaryEl = content.createEl('small', { text: `${item.getSummary()}\n` }); + content.createEl('small', { text: `${item.type.toUpperCase() + (item.subType ? ` (${item.subType})` : '')} from ${item.dataSource}` }); + + const updateSummary = () => { + titleEl.textContent = this.plugin.mediaTypeManager.getFileName(item); + summaryEl.textContent = `${item.getSummary()}\n`; + }; + + if (item.image && item.image !== 'NSFW') { + if (String(item.image).includes('null')) { + thumb.empty(); + const placeholderSpan = thumb.createEl('span', { text: '📷' }); + placeholderSpan.style.fontSize = '24px'; + } else { + setImage(item.image); + } + } else if (item.image === 'NSFW') { + thumb.createEl('span', { text: 'NSFW' }); + } else { + thumb.empty(); + const placeholderSpan = thumb.createEl('span', { text: '📷' }); + placeholderSpan.style.fontSize = '24px'; + + const needsFetch = !item.image || !item.year; + if (needsFetch) { + const apiDelay = this.getDelayForApi(item.dataSource); + const delayMs = (parseInt(el.id.split('-').pop() ?? '0') ?? 0) * apiDelay; + setTimeout(async () => { + if (item.image && item.year) return; + try { + const detailed = await this.plugin.apiManager.queryDetailedInfo(item); + if (detailed?.image && !item.image) { + item.image = detailed.image; + setImage(detailed.image); + } + if (!item.year && detailed?.year) { + item.year = detailed.year; + updateSummary(); + } + } catch (e) { + console.warn('MDB | Failed to fetch detail', e); + } + }, delayMs); + } + } } // Perform action on the selected suggestion. diff --git a/src/modals/MediaDbSeasonSelectModal.ts b/src/modals/MediaDbSeasonSelectModal.ts index 0066c31e..c56fc7d3 100644 --- a/src/modals/MediaDbSeasonSelectModal.ts +++ b/src/modals/MediaDbSeasonSelectModal.ts @@ -24,9 +24,38 @@ export class MediaDbSeasonSelectModal extends SelectModal { + thumb.empty(); + const placeholderSpan = thumb.createEl('span', { text: '📷' }); + placeholderSpan.style.fontSize = '24px'; + }; + thumb.appendChild(img); + } else { + thumb.createEl('span', { text: '📷' }).style.fontSize = '24px'; + } + + const content = el.createDiv(); + content.style.flex = '1'; + content.style.minWidth = '0'; + content.createEl('div', { text: `${season.name}` }); if (season.air_date) { - el.createEl('small', { text: `Air date: ${season.air_date}` }); + content.createEl('small', { text: `Air date: ${season.air_date}` }); } } diff --git a/src/modals/PropertyMappingModal.ts b/src/modals/PropertyMappingModal.ts new file mode 100644 index 00000000..388abbb3 --- /dev/null +++ b/src/modals/PropertyMappingModal.ts @@ -0,0 +1,59 @@ +import type { App } from 'obsidian'; +import { Modal } from 'obsidian'; +import { render } from 'solid-js/web'; +import type MediaDbPlugin from '../main'; +import type { PropertyMappingModelData } from '../settings/PropertyMapping'; +import PropertyMappingModelComponent from '../settings/PropertyMappingModelComponent'; +import type { MediaType } from '../utils/MediaType'; +import { mediaTypeDisplayName } from '../utils/Utils'; + +export class PropertyMappingModal extends Modal { + private disposeSolid?: () => void; + + constructor( + app: App, + private readonly plugin: MediaDbPlugin, + private readonly mediaType: MediaType, + ) { + super(app); + } + + onOpen(): void { + const { contentEl } = this; + this.setTitle(`Property mappings — ${mediaTypeDisplayName(this.mediaType)}`); + + const modelData = this.plugin.settings.propertyMappingModels.find(m => m.type === this.mediaType); + if (!modelData) { + contentEl.createEl('p', { text: 'No property mapping model found for this media type.' }); + return; + } + + contentEl.createEl('p', { + cls: 'mod-muted', + text: 'Choose whether each metadata field stays as-is, is renamed in front matter, or is omitted. Changes are saved automatically when valid.', + }); + + const root = contentEl.createDiv(); + this.disposeSolid = render( + () => + PropertyMappingModelComponent({ + model: structuredClone(modelData), + showMediaTypeTitle: false, + save: (model: PropertyMappingModelData): void => { + const index = this.plugin.settings.propertyMappingModels.findIndex(m => m.type === model.type); + if (index !== -1) { + this.plugin.settings.propertyMappingModels[index] = model; + } + void this.plugin.saveSettings(); + }, + }), + root, + ); + } + + onClose(): void { + this.disposeSolid?.(); + this.disposeSolid = undefined; + this.contentEl.empty(); + } +} diff --git a/src/models/ArtistModel.ts b/src/models/ArtistModel.ts new file mode 100644 index 00000000..9683da38 --- /dev/null +++ b/src/models/ArtistModel.ts @@ -0,0 +1,63 @@ +import { MediaType } from '../utils/MediaType'; +import type { ModelToData } from '../utils/Utils'; +import { mediaDbTag, migrateObject } from '../utils/Utils'; +import { MediaTypeModel } from './MediaTypeModel'; + +export type ArtistData = ModelToData; + +export class ArtistModel extends MediaTypeModel { + genres: string[]; + country: string; + image: string; + officialWebsite: string; + disambiguation: string; + /** ISNI(s) from the data source; comma-separated if multiple. */ + isni: string; + beginYear: string; + releaseDate: string; + + userData: { + personalRating: number; + }; + + constructor(obj: ArtistData) { + super(); + + this.genres = []; + this.country = ''; + this.image = ''; + this.officialWebsite = ''; + this.disambiguation = ''; + this.isni = ''; + this.beginYear = ''; + this.releaseDate = ''; + + this.userData = { + personalRating: 0, + }; + + migrateObject(this, obj, this); + + if (!Object.hasOwn(obj, 'userData')) { + migrateObject(this.userData, obj, this.userData); + } + + this.type = this.getMediaType(); + this.releaseDate = obj.releaseDate ?? ''; + } + + getTags(): string[] { + return [mediaDbTag, 'music', 'artist']; + } + + getMediaType(): MediaType { + return MediaType.Artist; + } + + getSummary(): string { + let summary = this.title; + if (this.beginYear) summary += ` (formed ${this.beginYear})`; + if (this.disambiguation) summary += ` — ${this.disambiguation}`; + return summary; + } +} diff --git a/src/models/BoardGameModel.ts b/src/models/BoardGameModel.ts index e782137d..c65599b9 100644 --- a/src/models/BoardGameModel.ts +++ b/src/models/BoardGameModel.ts @@ -59,6 +59,6 @@ export class BoardGameModel extends MediaTypeModel { } getSummary(): string { - return this.englishTitle + ' (' + this.year + ')'; + return this.englishTitle + (this.year > 0 ? ` (${this.year})` : ''); } } diff --git a/src/models/BookModel.ts b/src/models/BookModel.ts index e75da93d..e2f52e48 100644 --- a/src/models/BookModel.ts +++ b/src/models/BookModel.ts @@ -59,6 +59,6 @@ export class BookModel extends MediaTypeModel { } getSummary(): string { - return this.englishTitle + ' (' + this.year + ') - ' + this.author; + return this.englishTitle + (this.year > 0 ? ` (${this.year})` : '') + ' - ' + this.author; } } diff --git a/src/models/ComicMangaModel.ts b/src/models/ComicMangaModel.ts index ec47a959..0d4c64a3 100644 --- a/src/models/ComicMangaModel.ts +++ b/src/models/ComicMangaModel.ts @@ -75,6 +75,6 @@ export class ComicMangaModel extends MediaTypeModel { } getSummary(): string { - return this.title + ' (' + this.year + ')'; + return this.title + (this.year > 0 ? ` (${this.year})` : ''); } } diff --git a/src/models/GameModel.ts b/src/models/GameModel.ts index 5661f6ec..90b972a2 100644 --- a/src/models/GameModel.ts +++ b/src/models/GameModel.ts @@ -11,6 +11,10 @@ export class GameModel extends MediaTypeModel { genres: string[]; onlineRating: number; image: string; + summary: string; + series: string[]; + gameModes: string[]; + platforms: string[]; released: boolean; releaseDate: string; @@ -28,6 +32,10 @@ export class GameModel extends MediaTypeModel { this.genres = []; this.onlineRating = 0; this.image = ''; + this.summary = ''; + this.series = []; + this.gameModes = []; + this.platforms = []; this.released = false; this.releaseDate = ''; @@ -55,6 +63,6 @@ export class GameModel extends MediaTypeModel { } getSummary(): string { - return this.englishTitle + ' (' + this.year + ')'; + return this.englishTitle + (this.year > 0 ? ` (${this.year})` : ''); } } diff --git a/src/models/MediaTypeModel.ts b/src/models/MediaTypeModel.ts index e4d03a09..c72da64c 100644 --- a/src/models/MediaTypeModel.ts +++ b/src/models/MediaTypeModel.ts @@ -5,7 +5,7 @@ export abstract class MediaTypeModel { subType: string; title: string; englishTitle: string; - year: string; + year: number; dataSource: string; url: string; id: string; @@ -18,7 +18,7 @@ export abstract class MediaTypeModel { this.subType = ''; this.title = ''; this.englishTitle = ''; - this.year = ''; + this.year = 0; this.dataSource = ''; this.url = ''; this.id = ''; @@ -35,7 +35,28 @@ export abstract class MediaTypeModel { abstract getTags(): string[]; toMetaDataObject(): Record { - return { ...this.getWithOutUserData(), ...this.userData, tags: this.getTags().join('/') }; + const base = { ...this.getWithOutUserData() }; + + // Extract description-like fields to pin them just before tags + const hasSummary = Object.hasOwn(base, 'summary'); + const hasPlot = Object.hasOwn(base, 'plot'); + const summary = base.summary; + const plot = base.plot; + delete base.summary; + delete base.plot; + + const obj: Record = { ...base, ...this.userData }; + + // year: 0 means "unknown" — write null so YAML shows blank (None) instead of 0 + if (obj.year === 0) obj.year = null; + + // Pin summary / plot just above tags — always include them if the model has the field + // (empty string → null so YAML renders as blank rather than empty quotes) + if (hasSummary) obj.summary = summary || null; + if (hasPlot) obj.plot = plot || null; + + obj.tags = this.getTags().join('/'); + return obj; } getWithOutUserData(): Record { diff --git a/src/models/MovieModel.ts b/src/models/MovieModel.ts index 563a0f98..08b7a1b9 100644 --- a/src/models/MovieModel.ts +++ b/src/models/MovieModel.ts @@ -1,6 +1,6 @@ import { MediaType } from '../utils/MediaType'; import type { ModelToData } from '../utils/Utils'; -import { mediaDbTag, migrateObject } from '../utils/Utils'; +import { coerceMovieDurationMinutes, mediaDbTag, migrateObject } from '../utils/Utils'; import { MediaTypeModel } from './MediaTypeModel'; export type MovieData = ModelToData; @@ -12,14 +12,19 @@ export class MovieModel extends MediaTypeModel { director: string[]; writer: string[]; studio: string[]; - duration: string; + /** Total runtime in minutes. */ + duration: number; onlineRating: number; actors: string[]; image: string; released: boolean; country: string[]; - boxOffice: string; + language: string[]; + /** Production budget in USD (e.g. from TMDB). */ + budget: string; + /** Box-office gross (e.g. worldwide from TMDB; OMDb US figure when from IMDb). */ + revenue: string; ageRating: string; streamingServices: string[]; premiere: string; @@ -39,14 +44,16 @@ export class MovieModel extends MediaTypeModel { this.director = []; this.writer = []; this.studio = []; - this.duration = ''; + this.duration = 0; this.onlineRating = 0; this.actors = []; this.image = ''; this.released = false; this.country = []; - this.boxOffice = ''; + this.language = []; + this.budget = ''; + this.revenue = ''; this.ageRating = ''; this.streamingServices = []; this.premiere = ''; @@ -58,6 +65,7 @@ export class MovieModel extends MediaTypeModel { }; migrateObject(this, obj, this); + this.duration = coerceMovieDurationMinutes(this.duration as unknown); if (!Object.hasOwn(obj, 'userData')) { migrateObject(this.userData, obj, this.userData); @@ -75,6 +83,6 @@ export class MovieModel extends MediaTypeModel { } getSummary(): string { - return this.englishTitle + ' (' + this.year + ')'; + return this.englishTitle + (this.year > 0 ? ` (${this.year})` : ''); } } diff --git a/src/models/MusicReleaseModel.ts b/src/models/MusicReleaseModel.ts index 38427a91..3c3d6634 100644 --- a/src/models/MusicReleaseModel.ts +++ b/src/models/MusicReleaseModel.ts @@ -19,6 +19,8 @@ export class MusicReleaseModel extends MediaTypeModel { title: string; duration: string; featuredArtists: string[]; + /** MusicBrainz recording MBID; used to resolve Spotify and other links. */ + recordingId?: string; }[]; userData: { @@ -60,7 +62,7 @@ export class MusicReleaseModel extends MediaTypeModel { } getSummary(): string { - let summary = this.title + ' (' + this.year + ')'; + let summary = this.title + (this.year > 0 ? ` (${this.year})` : ''); if (this.artists.length > 0) summary += ' - ' + this.artists.join(', '); return summary; } diff --git a/src/models/SeriesModel.ts b/src/models/SeriesModel.ts index b87a32c0..4ca1662b 100644 --- a/src/models/SeriesModel.ts +++ b/src/models/SeriesModel.ts @@ -19,6 +19,8 @@ export class SeriesModel extends MediaTypeModel { released: boolean; country: string[]; + language: string[]; + network: string[]; ageRating: string; streamingServices: string[]; airing: boolean; @@ -47,6 +49,8 @@ export class SeriesModel extends MediaTypeModel { this.released = false; this.country = []; + this.language = []; + this.network = []; this.ageRating = ''; this.streamingServices = []; this.airing = false; @@ -77,6 +81,6 @@ export class SeriesModel extends MediaTypeModel { } getSummary(): string { - return this.title + ' (' + this.year + ')'; + return this.title + (this.year > 0 ? ` (${this.year})` : ''); } } diff --git a/src/models/SongModel.ts b/src/models/SongModel.ts new file mode 100644 index 00000000..6c45c8aa --- /dev/null +++ b/src/models/SongModel.ts @@ -0,0 +1,84 @@ +import { MediaType } from '../utils/MediaType'; +import type { ModelToData } from '../utils/Utils'; +import { mediaDbTag, migrateObject } from '../utils/Utils'; +import { MediaTypeModel } from './MediaTypeModel'; + +export type SongData = ModelToData; + +export class SongModel extends MediaTypeModel { + genres: string[]; + artists: string[]; + albumTitle: string; + albumReleaseGroupId: string; + trackNumber: number; + duration: string; + featuredArtists: string[]; + geniusUrl: string; + /** Open track URL from MusicBrainz (e.g. https://open.spotify.com/track/…) when available. */ + spotifyUrl: string; + lyrics: string; + image: string; + releaseDate: string; + + userData: { + personalRating: number; + }; + + constructor(obj: SongData) { + super(); + + this.genres = []; + this.artists = []; + this.albumTitle = ''; + this.albumReleaseGroupId = ''; + this.trackNumber = 0; + this.duration = ''; + this.featuredArtists = []; + this.geniusUrl = ''; + this.spotifyUrl = ''; + this.lyrics = ''; + this.image = ''; + this.releaseDate = ''; + + this.userData = { + personalRating: 0, + }; + + migrateObject(this, obj, this); + + if (!Object.hasOwn(obj, 'userData')) { + migrateObject(this.userData, obj, this.userData); + } + + this.type = this.getMediaType(); + this.trackNumber = obj.trackNumber ?? 0; + this.albumTitle = obj.albumTitle ?? ''; + this.albumReleaseGroupId = obj.albumReleaseGroupId ?? ''; + this.duration = obj.duration ?? ''; + this.featuredArtists = obj.featuredArtists ?? []; + this.geniusUrl = obj.geniusUrl ?? ''; + this.spotifyUrl = obj.spotifyUrl ?? ''; + this.lyrics = obj.lyrics ?? ''; + this.releaseDate = obj.releaseDate ?? ''; + } + + getTags(): string[] { + return [mediaDbTag, 'music', 'song']; + } + + getMediaType(): MediaType { + return MediaType.Song; + } + + getSummary(): string { + const albumPart = this.albumTitle ? ` — ${this.albumTitle}` : ''; + const artists = this.artists.length > 0 ? this.artists.join(', ') : ''; + return `${this.title}${albumPart}${artists ? ` (${artists})` : ''}`; + } + + getWithOutUserData(): Record { + const copy = super.getWithOutUserData(); + delete copy.lyrics; + return copy; + } +} diff --git a/src/settings/PropertyMapper.ts b/src/settings/PropertyMapper.ts index bf08d32b..73e1219d 100644 --- a/src/settings/PropertyMapper.ts +++ b/src/settings/PropertyMapper.ts @@ -1,6 +1,9 @@ -import type { MediaType } from 'src/utils/MediaType'; import type MediaDbPlugin from '../main'; -import { MEDIA_TYPES } from '../utils/MediaTypeManager'; +import { ArtistModel } from '../models/ArtistModel'; +import { MusicReleaseModel } from '../models/MusicReleaseModel'; +import { MediaType } from '../utils/MediaType'; +import { noteTypeValueForMedia, resolveMetadataTypeToMediaType } from '../utils/noteTypeSettings'; +import { coerceYear } from '../utils/Utils'; import { PropertyMappingOption } from './PropertyMapping'; export class PropertyMapper { @@ -23,11 +26,12 @@ export class PropertyMapper { // console.log(obj.type); - if (MEDIA_TYPES.filter(x => x.toString() == obj.type).length < 1) { + const internalMediaType = resolveMetadataTypeToMediaType(this.plugin.settings, obj.type); + if (!internalMediaType) { return obj; } - const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type); + const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === internalMediaType); if (!propertyMappingModel) { return obj; } @@ -35,29 +39,63 @@ export class PropertyMapper { const propertyMappings = propertyMappingModel.properties; const newObj: Record = {}; + const handledKeys = new Set(); + + // 1. Process keys exactly in the order of the user's Property Mappings array + for (const propertyMapping of propertyMappings) { + const key = propertyMapping.property; + if (key === 'aliases') { + handledKeys.add(key); + continue; + } + + if (Object.hasOwn(obj, key)) { + const value = obj[key]; + handledKeys.add(key); + + let finalValue = value; + if (propertyMapping.wikilink) { + if (typeof value === 'string') { + finalValue = `[[${value}]]`; + } else if (Array.isArray(value)) { + finalValue = value.map((v: unknown) => { + if (typeof v !== 'string') { + return v; + } + return `[[${v}]]`; + }); + } + } + if (propertyMapping.mapping === PropertyMappingOption.Map) { + newObj[propertyMapping.newProperty] = finalValue; + } else if (propertyMapping.mapping === PropertyMappingOption.Remove) { + // do nothing + } else if (propertyMapping.mapping === PropertyMappingOption.Default) { + newObj[key] = finalValue; + } + } + } + + // 2. Append any remaining unmatched keys from obj (to preserve unhandled data) 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; + if (!handledKeys.has(key) && key !== 'aliases') { + newObj[key] = value; + } + } + + // 3. Handle aliases + if (Object.hasOwn(obj, 'aliases')) { + const aliasesPm = propertyMappings.find(p => p.property === 'aliases'); + if (aliasesPm?.mapping !== PropertyMappingOption.Remove) { + const incoming = obj['aliases']; + const targetKey = + aliasesPm?.mapping === PropertyMappingOption.Map && aliasesPm.newProperty + ? aliasesPm.newProperty + : 'aliases'; + const merged = PropertyMapper.mergeAliasValues(newObj[targetKey], incoming); + if (merged.length > 0) { + newObj[targetKey] = merged; } } } @@ -65,6 +103,70 @@ export class PropertyMapper { return newObj; } + getPinnedBottomKeys(type: unknown): string[] { + const internalMediaType = resolveMetadataTypeToMediaType(this.plugin.settings, type); + if (!internalMediaType) return []; + + const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === internalMediaType); + if (!propertyMappingModel) return []; + + const pinnedKeys: string[] = []; + for (const mapping of propertyMappingModel.properties) { + if (mapping.pinBottom) { + // The key to pin is the NEW key if mapped + if (mapping.mapping === PropertyMappingOption.Map) { + pinnedKeys.push(mapping.newProperty); + } else if (mapping.mapping === PropertyMappingOption.Default) { + pinnedKeys.push(mapping.property); + } + } + } + return pinnedKeys; + } + + getAutoTagKeys(type: unknown): { key: string; prefix: string }[] { + const internalMediaType = resolveMetadataTypeToMediaType(this.plugin.settings, type); + if (!internalMediaType) return []; + + const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === internalMediaType); + if (!propertyMappingModel) return []; + + const autoTagKeys: { key: string; prefix: string }[] = []; + for (const mapping of propertyMappingModel.properties) { + if (mapping.autoTag && mapping.mapping !== PropertyMappingOption.Remove) { + const key = mapping.mapping === PropertyMappingOption.Map ? mapping.newProperty : mapping.property; + autoTagKeys.push({ key, prefix: mapping.autoTagPrefix ?? '' }); + } + } + return autoTagKeys; + } + + private static mergeAliasValues(existing: unknown, added: unknown): string[] { + const toStrings = (v: unknown): string[] => { + if (v == null) { + return []; + } + if (Array.isArray(v)) { + return v.flatMap(x => (typeof x === 'string' ? x : String(x))).filter(s => s.length > 0); + } + if (typeof v === 'string') { + return v.length > 0 ? [v] : []; + } + return []; + }; + + const combined = [...toStrings(existing), ...toStrings(added)]; + const seen = new Set(); + const out: string[] = []; + for (const s of combined) { + if (!seen.has(s)) { + seen.add(s); + out.push(s); + } + } + return out; + } + /** * Converts an object back using the conversion rules for its type. * Returns an unaltered object if object.type is null or undefined or if there are no conversion rules for the type. @@ -72,40 +174,53 @@ export class PropertyMapper { * @param obj */ convertObjectBack(obj: Record): Record { - if (!Object.hasOwn(obj, 'type')) { - return obj; + const models = this.plugin.settings.propertyMappingModels; + + let matchedModel: (typeof models)[number] | undefined; + for (const model of models) { + const typePm = model.properties.find(p => p.property === 'type'); + const typeKey = + typePm?.mapping === PropertyMappingOption.Map && typePm.newProperty + ? typePm.newProperty + : 'type'; + if (!Object.hasOwn(obj, typeKey)) { + continue; + } + let typeVal: unknown = obj[typeKey]; + if (typeVal === 'manga') { + typeVal = 'comicManga'; + console.debug(`MDB | updated metadata type`, typeVal); + } + const typeStr = String(typeVal).trim(); + if ( + typeStr === (model.type as string) || + typeStr === noteTypeValueForMedia(this.plugin.settings, model.type) + ) { + matchedModel = model; + break; + } } - if (obj.type === 'manga') { - obj.type = 'comicManga'; - console.debug(`MDB | updated metadata type`, obj.type); - } - if (MEDIA_TYPES.contains(obj.type as MediaType)) { + if (!matchedModel) { return obj; } - const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type); - const propertyMappings = propertyMappingModel?.properties ?? []; - + const propertyMappings = matchedModel.properties; const originalObj: Record = {}; 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; } } - - // otherwise see if it is a mapped property for (const propertyMapping of propertyMappings) { - if (propertyMapping.newProperty === key) { - // @ts-ignore + if ( + propertyMapping.mapping === PropertyMappingOption.Map && + propertyMapping.newProperty === key + ) { originalObj[propertyMapping.property] = value; - continue objLoop; } } @@ -113,4 +228,6 @@ export class PropertyMapper { return originalObj; } + + // --- Helper logic removed to src/utils/musicFormatHelper.ts per developer feedback --- } diff --git a/src/settings/PropertyMapping.ts b/src/settings/PropertyMapping.ts index 82d044ea..30fed3b9 100644 --- a/src/settings/PropertyMapping.ts +++ b/src/settings/PropertyMapping.ts @@ -1,252 +1,297 @@ -import type { MediaType } from '../utils/MediaType'; -import { containsOnlyLettersAndUnderscores, PropertyMappingNameConflictError, PropertyMappingValidationError } from '../utils/Utils'; - -// Plain object interfaces for serialization -export interface PropertyMappingData { - property: string; - newProperty: string; - mapping: PropertyMappingOption; - locked?: boolean; - wikilink?: boolean; -} - -export interface PropertyMappingModelData { - type: MediaType; - properties: PropertyMappingData[]; -} - -export enum PropertyMappingOption { - Default = 'default', - Map = 'remap', - Remove = 'remove', -} - -export const propertyMappingOptions = [PropertyMappingOption.Default, PropertyMappingOption.Map, PropertyMappingOption.Remove]; - -export class PropertyMappingModel { - type: MediaType; - properties: PropertyMapping[]; - - constructor(type: MediaType, properties?: PropertyMapping[]) { - this.type = type; - this.properties = properties ?? []; - } - - validate(): { res: boolean; err?: Error } { - console.debug(`MDB | validated property mappings for ${this.type}`); - - // check properties - for (const property of this.properties) { - const propertyValidation = property.validate(); - if (!propertyValidation.res) { - return { - res: false, - err: propertyValidation.err, - }; - } - } - - // check for name collisions - for (const property of this.getMappedProperties()) { - const propertiesWithSameTarget = this.getMappedProperties().filter(x => x.newProperty === property.newProperty); - if (propertiesWithSameTarget.length === 0) { - // if we get there, then something in this code is wrong - } else if (propertiesWithSameTarget.length === 1) { - // all good - } else { - // two or more properties are mapped to the same property - return { - res: false, - err: new PropertyMappingNameConflictError( - `Multiple remapped properties (${propertiesWithSameTarget.map(x => x.toString()).toString()}) may not share the same name.`, - ), - }; - } - } - // remapped properties may not have the same name as any original property - for (const property of this.getMappedProperties()) { - const propertiesWithSameTarget = this.properties.filter(x => x.newProperty === property.property); - if (propertiesWithSameTarget.length === 0) { - // all good - } else { - // a mapped property shares the same name with an original property - return { - res: false, - err: new PropertyMappingNameConflictError(`Remapped property (${property}) may not share it's new name with an existing property.`), - }; - } - } - - return { - res: true, - }; - } - - getMappedProperties(): PropertyMapping[] { - return this.properties.filter(x => x.mapping === PropertyMappingOption.Map); - } - - copy(): PropertyMappingModel { - const copy = new PropertyMappingModel(this.type); - for (const property of this.properties) { - const propertyCopy = new PropertyMapping(property.property, property.newProperty, property.mapping, property.locked, property.wikilink); - copy.properties.push(propertyCopy); - } - return copy; - } - - // Serialization - returns a plain object that can be JSON.stringify'd - toJSON(): PropertyMappingModelData { - return { - type: this.type, - properties: this.properties.map(p => p.toJSON()), - }; - } - - // Deserialization - creates a PropertyMappingModel from a plain object - static fromJSON(json: PropertyMappingModelData): PropertyMappingModel { - return new PropertyMappingModel( - json.type, - json.properties.map(p => PropertyMapping.fromJSON(p)), - ); - } - - /** - * Migrates loaded settings to match the structure of default settings. - * - Adds new properties from defaults that don't exist in loaded settings - * - Preserves user customizations from loaded settings - * - Updates locked status from defaults - * - * @param loadedModels - Models loaded from disk (may be outdated) - * @param defaultModels - Current default models (source of truth for structure) - * @returns Migrated models with correct structure and preserved user settings - */ - static migrateModels(loadedModels: PropertyMappingModelData[], defaultModels: PropertyMappingModel[]): PropertyMappingModel[] { - const migratedModels: PropertyMappingModel[] = []; - - for (const defaultModel of defaultModels) { - const loadedModel = loadedModels.find(m => m.type === defaultModel.type); - - if (!loadedModel) { - // New model type - use default - migratedModels.push(defaultModel); - continue; - } - - // Migrate properties - const migratedProperties: PropertyMapping[] = []; - for (const defaultProperty of defaultModel.properties) { - const loadedProperty = loadedModel.properties.find(p => p.property === defaultProperty.property); - - if (!loadedProperty) { - // New property - use default - migratedProperties.push(defaultProperty); - } else { - // Existing property - merge: take locked from default, customizations from loaded - migratedProperties.push( - new PropertyMapping( - loadedProperty.property, - loadedProperty.newProperty, - loadedProperty.mapping, - defaultProperty.locked, // locked status from default - loadedProperty.wikilink ?? false, - ), - ); - } - } - - migratedModels.push(new PropertyMappingModel(defaultModel.type, migratedProperties)); - } - - return migratedModels; - } -} - -export class PropertyMapping { - property: string; - newProperty: string; - locked: boolean; - mapping: PropertyMappingOption; - wikilink: boolean; - - constructor(property: string, newProperty: string, mapping: PropertyMappingOption, locked?: boolean, wikilink?: boolean) { - this.property = property; - this.newProperty = newProperty; - this.mapping = mapping; - this.locked = locked ?? false; - this.wikilink = wikilink ?? false; - } - - validate(): { res: boolean; err?: Error } { - // locked property may only be default - if (this.locked) { - if (this.mapping === PropertyMappingOption.Remove) { - return { - res: false, - err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be removed.`), - }; - } - if (this.mapping === PropertyMappingOption.Map) { - return { - res: false, - err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be remapped.`), - }; - } - } - - if (this.mapping === PropertyMappingOption.Default) { - return { res: true }; - } - if (this.mapping === PropertyMappingOption.Remove) { - return { res: true }; - } - - if (!this.property || !containsOnlyLettersAndUnderscores(this.property)) { - return { - res: false, - err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": property may not be empty and may only contain letters and underscores.`), - }; - } - - if (!this.newProperty || !containsOnlyLettersAndUnderscores(this.newProperty)) { - return { - res: false, - err: new PropertyMappingValidationError( - `Error in property mapping "${this.toString()}": new property may not be empty and may only contain letters and underscores.`, - ), - }; - } - - return { - res: true, - }; - } - - toString(): string { - if (this.mapping === PropertyMappingOption.Default) { - return this.property; - } else if (this.mapping === PropertyMappingOption.Map) { - return `${this.property} -> ${this.newProperty}`; - } else if (this.mapping === PropertyMappingOption.Remove) { - return `remove ${this.property}`; - } - - return this.property; - } - - // Serialization - returns a plain object - toJSON(): PropertyMappingData { - return { - property: this.property, - newProperty: this.newProperty, - mapping: this.mapping, - locked: this.locked, - wikilink: this.wikilink, - }; - } - - // Deserialization - creates a PropertyMapping from a plain object - static fromJSON(json: PropertyMappingData): PropertyMapping { - return new PropertyMapping(json.property, json.newProperty, json.mapping, json.locked, json.wikilink); - } -} +import { musicBrainzRegisteredApiName } from '../api/musicBrainzConstants'; +import type { MediaType } from '../utils/MediaType'; +import { containsOnlyLettersAndUnderscores, PropertyMappingNameConflictError, PropertyMappingValidationError } from '../utils/Utils'; + +// Plain object interfaces for serialization +export interface PropertyMappingData { + property: string; + newProperty: string; + mapping: PropertyMappingOption; + locked?: boolean; + wikilink?: boolean; + pinBottom?: boolean; + autoTag?: boolean; + autoTagPrefix?: string; +} + +export interface PropertyMappingModelData { + type: MediaType; + properties: PropertyMappingData[]; +} + +export enum PropertyMappingOption { + Default = 'default', + Map = 'remap', + Remove = 'remove', +} + +export const propertyMappingOptions = [PropertyMappingOption.Default, PropertyMappingOption.Map, PropertyMappingOption.Remove]; + +const METADATA_KEYS_REQUIRED_IN_NOTE = ['type', 'id'] as const; + +export class PropertyMappingModel { + type: MediaType; + properties: PropertyMapping[]; + + constructor(type: MediaType, properties?: PropertyMapping[]) { + this.type = type; + this.properties = properties ?? []; + } + + validate(): { res: boolean; err?: Error } { + console.debug(`MDB | validated property mappings for ${this.type}`); + + // check properties + for (const property of this.properties) { + const propertyValidation = property.validate(); + if (!propertyValidation.res) { + return { + res: false, + err: propertyValidation.err, + }; + } + } + + // check for name collisions + for (const property of this.getMappedProperties()) { + const propertiesWithSameTarget = this.getMappedProperties().filter(x => x.newProperty === property.newProperty); + if (propertiesWithSameTarget.length === 0) { + // if we get there, then something in this code is wrong + } else if (propertiesWithSameTarget.length === 1) { + // all good + } else { + // two or more properties are mapped to the same property + return { + res: false, + err: new PropertyMappingNameConflictError( + `Multiple remapped properties (${propertiesWithSameTarget.map(x => x.toString()).toString()}) may not share the same name.`, + ), + }; + } + } + // remapped properties may not have the same name as any original property + for (const property of this.getMappedProperties()) { + const propertiesWithSameTarget = this.properties.filter(x => x.newProperty === property.property); + if (propertiesWithSameTarget.length === 0) { + // all good + } else { + // a mapped property shares the same name with an original property + return { + res: false, + err: new PropertyMappingNameConflictError(`Remapped property (${property}) may not share it's new name with an existing property.`), + }; + } + } + + const dataSourceRule = this.properties.find(p => p.property === 'dataSource'); + if (dataSourceRule?.mapping === PropertyMappingOption.Remove && !musicBrainzRegisteredApiName(this.type)) { + return { + res: false, + err: new PropertyMappingValidationError( + `Removing dataSource is only allowed for artist, music release, and song (MusicBrainz). For "${this.type}" notes, dataSource is required to choose an API.`, + ), + }; + } + + return { + res: true, + }; + } + + getMappedProperties(): PropertyMapping[] { + return this.properties.filter(x => x.mapping === PropertyMappingOption.Map); + } + + copy(): PropertyMappingModel { + const copy = new PropertyMappingModel(this.type); + for (const property of this.properties) { + const propertyCopy = new PropertyMapping( + property.property, + property.newProperty, + property.mapping, + property.locked, + property.wikilink, + property.pinBottom, + property.autoTag, + property.autoTagPrefix, + ); + copy.properties.push(propertyCopy); + } + return copy; + } + + // Serialization - returns a plain object that can be JSON.stringify'd + toJSON(): PropertyMappingModelData { + return { + type: this.type, + properties: this.properties.map(p => p.toJSON()), + }; + } + + // Deserialization - creates a PropertyMappingModel from a plain object + static fromJSON(json: PropertyMappingModelData): PropertyMappingModel { + return new PropertyMappingModel( + json.type, + json.properties.map(p => PropertyMapping.fromJSON(p)), + ); + } + + /** + * Migrates loaded settings to match the structure of default settings. + * - Adds new properties from defaults that don't exist in loaded settings + * - Preserves user customizations from loaded settings + * - Updates locked status from defaults + * + * @param loadedModels - Models loaded from disk (may be outdated) + * @param defaultModels - Current default models (source of truth for structure) + * @returns Migrated models with correct structure and preserved user settings + */ + static migrateModels(loadedModels: PropertyMappingModelData[], defaultModels: PropertyMappingModel[]): PropertyMappingModel[] { + const migratedModels: PropertyMappingModel[] = []; + + for (const defaultModel of defaultModels) { + const loadedModel = loadedModels.find(m => m.type === defaultModel.type); + + if (!loadedModel) { + // New model type - use default + migratedModels.push(defaultModel); + continue; + } + + // Migrate properties + const migratedProperties: PropertyMapping[] = []; + for (const defaultProperty of defaultModel.properties) { + const loadedProperty = loadedModel.properties.find(p => p.property === defaultProperty.property); + + if (!loadedProperty) { + // New property - use default + migratedProperties.push(defaultProperty); + } else { + // Existing property - merge: take locked from default, customizations from loaded + migratedProperties.push( + new PropertyMapping( + loadedProperty.property, + loadedProperty.newProperty, + loadedProperty.mapping, + defaultProperty.locked, + loadedProperty.wikilink ?? false, + loadedProperty.pinBottom ?? false, + loadedProperty.autoTag ?? false, + loadedProperty.autoTagPrefix ?? '', + ), + ); + } + } + + migratedModels.push(new PropertyMappingModel(defaultModel.type, migratedProperties)); + } + + return migratedModels; + } +} + +export class PropertyMapping { + property: string; + newProperty: string; + locked: boolean; + mapping: PropertyMappingOption; + wikilink: boolean; + pinBottom: boolean; + autoTag: boolean; + autoTagPrefix: string; + + constructor(property: string, newProperty: string, mapping: PropertyMappingOption, locked?: boolean, wikilink?: boolean, pinBottom?: boolean, autoTag?: boolean, autoTagPrefix?: string) { + this.property = property; + this.newProperty = newProperty; + this.mapping = mapping; + this.locked = locked ?? false; + this.wikilink = wikilink ?? false; + this.pinBottom = pinBottom ?? false; + this.autoTag = autoTag ?? false; + this.autoTagPrefix = autoTagPrefix ?? ''; + } + + validate(): { res: boolean; err?: Error } { + // locked property may only be default + if (this.locked) { + if (this.mapping === PropertyMappingOption.Remove) { + return { + res: false, + err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be removed.`), + }; + } + if (this.mapping === PropertyMappingOption.Map) { + return { + res: false, + err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be remapped.`), + }; + } + } + + if ((METADATA_KEYS_REQUIRED_IN_NOTE as readonly string[]).includes(this.property) && this.mapping === PropertyMappingOption.Remove) { + return { + res: false, + err: new PropertyMappingValidationError( + `Error in property mapping "${this.toString()}": type and id must appear in the note (you can remap them, but not remove them).`, + ), + }; + } + + if (this.mapping === PropertyMappingOption.Default) { + return { res: true }; + } + if (this.mapping === PropertyMappingOption.Remove) { + return { res: true }; + } + + if (!this.property || !containsOnlyLettersAndUnderscores(this.property)) { + return { + res: false, + err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": property may not be empty and may only contain letters and underscores.`), + }; + } + + if (!this.newProperty || !containsOnlyLettersAndUnderscores(this.newProperty)) { + return { + res: false, + err: new PropertyMappingValidationError( + `Error in property mapping "${this.toString()}": new property may not be empty and may only contain letters and underscores.`, + ), + }; + } + + return { + res: true, + }; + } + + toString(): string { + if (this.mapping === PropertyMappingOption.Default) { + return this.property; + } else if (this.mapping === PropertyMappingOption.Map) { + return `${this.property} -> ${this.newProperty}`; + } else if (this.mapping === PropertyMappingOption.Remove) { + return `remove ${this.property}`; + } + + return this.property; + } + + // Serialization - returns a plain object + toJSON(): PropertyMappingData { + return { + property: this.property, + newProperty: this.newProperty, + mapping: this.mapping, + locked: this.locked, + wikilink: this.wikilink, + pinBottom: this.pinBottom, + autoTag: this.autoTag, + autoTagPrefix: this.autoTagPrefix, + }; + } + + static fromJSON(json: PropertyMappingData): PropertyMapping { + return new PropertyMapping(json.property, json.newProperty, json.mapping, json.locked, json.wikilink, json.pinBottom, json.autoTag, json.autoTagPrefix); + } +} diff --git a/src/settings/PropertyMappingModelComponent.tsx b/src/settings/PropertyMappingModelComponent.tsx index d9a90173..a67f4a31 100644 --- a/src/settings/PropertyMappingModelComponent.tsx +++ b/src/settings/PropertyMappingModelComponent.tsx @@ -1,138 +1,224 @@ -import { createSignal, createMemo, For, Show } from 'solid-js'; -import { createStore } from 'solid-js/store'; -import { PropertyMappingModel, PropertyMappingOption, propertyMappingOptions, type PropertyMappingModelData } from './PropertyMapping'; -import { capitalizeFirstLetter } from '../utils/Utils'; -import Icon from './Icon'; - -interface PropertyMappingModelComponentProps { - model: PropertyMappingModelData; - save: (model: PropertyMappingModelData) => void; -} - -export default function PropertyMappingModelComponent(props: PropertyMappingModelComponentProps) { - const [unsavedChanges, setUnsavedChanges] = createSignal(false); - - // Create a store from the model's plain data - const [modelData, setModelData] = createStore(props.model); - - // Derive the validation result reactively - const validationResult = createMemo(() => { - const model = PropertyMappingModel.fromJSON(modelData); - return model.validate(); - }); - - const onModelUpdate = () => { - setUnsavedChanges(true); - }; - - const handleSave = () => { - const model = PropertyMappingModel.fromJSON(modelData); - if (model.validate().res) { - props.save(model); - setUnsavedChanges(false); - } - }; - - return ( -
-
-
{capitalizeFirstLetter(modelData.type)}
- -
- -
Unsaved changes
-
- - -
-
- - -
{validationResult().err?.message}
-
- -
- - - - - - - - - - - - {(property, index) => ( - - - - -
property cannot be remapped
- - } - > -
- - - - - - - )} - - -
PropertyMappingNew nameWikilink
- {property.property} - - - - —} - > -
- - { - setModelData('properties', index(), 'newProperty', e.currentTarget.value); - onModelUpdate(); - }} - /> -
-
-
-
-
- ); -} +import { createMemo, For, Show } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { PropertyMappingModel, PropertyMappingOption, propertyMappingOptions, type PropertyMappingModelData } from './PropertyMapping'; +import type { MediaType } from '../utils/MediaType'; +import { mediaTypeDisplayName } from '../utils/Utils'; +import Icon from './Icon'; + +interface PropertyMappingModelComponentProps { + model: PropertyMappingModelData; + save: (model: PropertyMappingModelData) => void; + /** When false, hides the media-type heading (e.g. modal title already shows it). Default true. */ + showMediaTypeTitle?: boolean; +} + +export default function PropertyMappingModelComponent(props: PropertyMappingModelComponentProps) { + // Create a store from the model's plain data + const [modelData, setModelData] = createStore(props.model); + + // Derive the validation result reactively + const validationResult = createMemo(() => { + const model = PropertyMappingModel.fromJSON(modelData); + return model.validate(); + }); + + let draggedIndex: number | null = null; + + const onDragStart = (e: DragEvent, index: number) => { + draggedIndex = index; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + // Firefox requires data to be set to drag + e.dataTransfer.setData('text/plain', index.toString()); + } + }; + + const onDragOver = (e: DragEvent, index: number) => { + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'move'; + } + }; + + const onDrop = (e: DragEvent, dropIndex: number) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === dropIndex) { + draggedIndex = null; + return; + } + + const newProperties = [...modelData.properties]; + const item = newProperties.splice(draggedIndex, 1)[0]; + newProperties.splice(dropIndex, 0, item); + + setModelData('properties', newProperties); + persistIfValid(); + draggedIndex = null; + }; + + const persistIfValid = () => { + const model = PropertyMappingModel.fromJSON(modelData); + if (model.validate().res) { + props.save(model); + } + }; + + const showTitle = () => props.showMediaTypeTitle !== false; + + return ( +
+ +
+
{mediaTypeDisplayName(modelData.type as MediaType)}
+
+
+ + +
{validationResult().err?.message}
+
+ +
+ + + + + + + + + + + + + + + + {(property, index) => ( + onDragStart(e, index())} + onDragOver={(e) => onDragOver(e, index())} + onDrop={(e) => onDrop(e, index())} + style={{ + cursor: property.locked ? 'default' : 'grab', + }} + > + + + + +
property cannot be remapped
+ + } + > +
+ + + + + + + + + + + + + )} + + +
PropertyMappingNew nameTagWikilinkPin
+ + + ≡ + + + + {property.property} + + + + —} + > +
+ + { + setModelData('properties', index(), 'newProperty', e.currentTarget.value); + persistIfValid(); + }} + /> +
+
+
+ + { + setModelData('properties', index(), 'autoTagPrefix', e.currentTarget.value); + persistIfValid(); + }} + /> + + + + + +
+
+
+ ); +} diff --git a/src/settings/PropertyMappingModelsComponent.tsx b/src/settings/PropertyMappingModelsComponent.tsx deleted file mode 100644 index 35274ff0..00000000 --- a/src/settings/PropertyMappingModelsComponent.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { For } from 'solid-js'; -import { type PropertyMappingModelData } from './PropertyMapping'; -import PropertyMappingModelComponent from './PropertyMappingModelComponent'; - -interface PropertyMappingModelsComponentProps { - models?: PropertyMappingModelData[]; - save: (model: PropertyMappingModelData) => void; -} - -export default function PropertyMappingModelsComponent(props: PropertyMappingModelsComponentProps) { - return ( -
- {model => } -
- ); -} diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 9566bfc0..92c2bcb9 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -1,36 +1,66 @@ -import type { App } from 'obsidian'; -import { Notice, PluginSettingTab, SettingGroup } from 'obsidian'; -import { render } from 'solid-js/web'; +import type { App, IconName } from 'obsidian'; +import { Platform, PluginSettingTab, SecretComponent, SettingGroup, setIcon } from 'obsidian'; import { MediaType } from 'src/utils/MediaType'; import type MediaDbPlugin from '../main'; +import { PropertyMappingModal } from '../modals/PropertyMappingModal'; import type { MediaTypeModel } from '../models/MediaTypeModel'; import { MEDIA_TYPES } from '../utils/MediaTypeManager'; -import { fragWithHTML, unCamelCase } from '../utils/Utils'; +import { noteTypeValueForMedia, setNoteTypeForMedia } from '../utils/noteTypeSettings'; +import { fragWithHTML, mediaTypeDisplayName, unCamelCase } from '../utils/Utils'; +import { ApiSecretID } from './apiSecretsHelper'; 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 mediaTypeTabIcon(mediaType: MediaType): IconName { + switch (mediaType) { + case MediaType.Artist: + return 'mic-2'; + case MediaType.BoardGame: + return 'dice-3'; + case MediaType.Book: + return 'book-marked'; + case MediaType.ComicManga: + return 'book-open'; + case MediaType.Game: + return 'gamepad-2'; + case MediaType.Movie: + return 'film'; + case MediaType.MusicRelease: + return 'disc-3'; + case MediaType.Season: + return 'calendar-range'; + case MediaType.Series: + return 'tv'; + case MediaType.Song: + return 'music-4'; + case MediaType.Wiki: + return 'library-big'; + } +} + // MARK: Settings export interface MediaDbPluginSettings { - OMDbKey: string; - TMDBKey: string; - MobyGamesKey: string; - GiantBombKey: string; - IGDBClientId: string; - IGDBClientSecret: string; - RAWGAPIKey: string; - ComicVineKey: string; - BoardgameGeekKey: string; sfwFilter: boolean; templates: boolean; customDateFormat: string; openNoteInNewTab: boolean; useDefaultFrontMatter: boolean; + /** When true, add an Obsidian `aliases` entry with an ASCII form of the title when it uses diacritics or letters like ø (e.g. Likbør → Likbor). */ + autoTrackerAiringKey: string; + autoTrackerReleasedKey: string; enableTemplaterIntegration: boolean; imageDownload: boolean; imageFolder: string; + tmdbRegion: string; + enableAutoTagging: boolean; + autoTagEntities: string; + autoTagProperties: string; + enableWikiLinkParsing: boolean; + autoUpdateAiringMode: boolean; + addNormalizeTitlesAsAlias: boolean; + useObjectFormatForCurrencyValues: boolean; BoardgameGeekAPI_disabledMediaTypes: MediaType[]; ComicVineAPI_disabledMediaTypes: MediaType[]; @@ -41,6 +71,7 @@ export interface MediaDbPluginSettings { MALAPIManga_disabledMediaTypes: MediaType[]; MobyGamesAPI_disabledMediaTypes: MediaType[]; MusicBrainzAPI_disabledMediaTypes: MediaType[]; + MusicBrainzArtistAPI_disabledMediaTypes: MediaType[]; OMDbAPI_disabledMediaTypes: MediaType[]; OpenLibraryAPI_disabledMediaTypes: MediaType[]; SteamAPI_disabledMediaTypes: MediaType[]; @@ -57,6 +88,8 @@ export interface MediaDbPluginSettings { gameTemplate: string; wikiTemplate: string; musicReleaseTemplate: string; + artistTemplate: string; + songTemplate: string; boardgameTemplate: string; bookTemplate: string; @@ -67,6 +100,8 @@ export interface MediaDbPluginSettings { gameFileNameTemplate: string; wikiFileNameTemplate: string; musicReleaseFileNameTemplate: string; + artistFileNameTemplate: string; + songFileNameTemplate: string; boardgameFileNameTemplate: string; bookFileNameTemplate: string; @@ -77,21 +112,32 @@ export interface MediaDbPluginSettings { gameFolder: string; wikiFolder: string; musicReleaseFolder: string; + artistFolder: string; + songFolder: string; + + /** Frontmatter `type` for each media kind (empty = default internal id, e.g. movie, musicRelease). */ + movieNoteType: string; + seriesNoteType: string; + seasonNoteType: string; + mangaNoteType: string; + gameNoteType: string; + wikiNoteType: string; + musicReleaseNoteType: string; + artistNoteType: string; + songNoteType: string; + boardgameNoteType: string; + bookNoteType: string; + /** When true, importing an artist also creates album and song notes from their discography. */ + artistAutomaticallyImportReleases: boolean; + /** When true, artist discography import nests albums and songs under artistFolder/ArtistName/… instead of using album/song import folders. */ + artistUseFileTreeForSongs: boolean; + /** When true, each imported album also creates a note per track (standalone album import or artist discography). */ + musicReleaseAutomaticallyImportSongs: boolean; boardgameFolder: string; bookFolder: string; propertyMappingModels: PropertyMappingModelData[]; - - // DEPRECATED: Use propertyMappingModels instead - moviePropertyConversionRules: string; - seriesPropertyConversionRules: string; - seasonPropertyConversionRules: string; - mangaPropertyConversionRules: string; - gamePropertyConversionRules: string; - wikiPropertyConversionRules: string; - musicReleasePropertyConversionRules: string; - boardgamePropertyConversionRules: string; - bookPropertyConversionRules: string; + linkedApiSecretIds: Record; } /** @@ -106,37 +152,41 @@ class MediaTypeMappedSettings { getTemplate(settings: MediaDbPluginSettings): string { switch (this.mediaType) { - case MediaType.Movie: - return settings.movieTemplate; - case MediaType.Series: - return settings.seriesTemplate; - case MediaType.Season: - return settings.seasonTemplate; + case MediaType.Artist: + return settings.artistTemplate; + case MediaType.BoardGame: + return settings.boardgameTemplate; + case MediaType.Book: + return settings.bookTemplate; case MediaType.ComicManga: return settings.mangaTemplate; case MediaType.Game: return settings.gameTemplate; - case MediaType.Wiki: - return settings.wikiTemplate; + case MediaType.Movie: + return settings.movieTemplate; case MediaType.MusicRelease: return settings.musicReleaseTemplate; - case MediaType.BoardGame: - return settings.boardgameTemplate; - case MediaType.Book: - return settings.bookTemplate; + case MediaType.Season: + return settings.seasonTemplate; + case MediaType.Series: + return settings.seriesTemplate; + case MediaType.Song: + return settings.songTemplate; + case MediaType.Wiki: + return settings.wikiTemplate; } } setTemplate(settings: MediaDbPluginSettings, template: string): void { switch (this.mediaType) { - case MediaType.Movie: - settings.movieTemplate = template; + case MediaType.Artist: + settings.artistTemplate = template; break; - case MediaType.Series: - settings.seriesTemplate = template; + case MediaType.BoardGame: + settings.boardgameTemplate = template; break; - case MediaType.Season: - settings.seasonTemplate = template; + case MediaType.Book: + settings.bookTemplate = template; break; case MediaType.ComicManga: settings.mangaTemplate = template; @@ -144,54 +194,64 @@ class MediaTypeMappedSettings { case MediaType.Game: settings.gameTemplate = template; break; - case MediaType.Wiki: - settings.wikiTemplate = template; + case MediaType.Movie: + settings.movieTemplate = template; break; case MediaType.MusicRelease: settings.musicReleaseTemplate = template; break; - case MediaType.BoardGame: - settings.boardgameTemplate = template; + case MediaType.Season: + settings.seasonTemplate = template; break; - case MediaType.Book: - settings.bookTemplate = template; + case MediaType.Series: + settings.seriesTemplate = template; + break; + case MediaType.Song: + settings.songTemplate = template; + break; + case MediaType.Wiki: + settings.wikiTemplate = template; break; } } getFileNameTemplate(settings: MediaDbPluginSettings): string { switch (this.mediaType) { - case MediaType.Movie: - return settings.movieFileNameTemplate; - case MediaType.Series: - return settings.seriesFileNameTemplate; - case MediaType.Season: - return settings.seasonFileNameTemplate; + case MediaType.Artist: + return settings.artistFileNameTemplate; + case MediaType.BoardGame: + return settings.boardgameFileNameTemplate; + case MediaType.Book: + return settings.bookFileNameTemplate; case MediaType.ComicManga: return settings.mangaFileNameTemplate; case MediaType.Game: return settings.gameFileNameTemplate; - case MediaType.Wiki: - return settings.wikiFileNameTemplate; + case MediaType.Movie: + return settings.movieFileNameTemplate; case MediaType.MusicRelease: return settings.musicReleaseFileNameTemplate; - case MediaType.BoardGame: - return settings.boardgameFileNameTemplate; - case MediaType.Book: - return settings.bookFileNameTemplate; + case MediaType.Season: + return settings.seasonFileNameTemplate; + case MediaType.Series: + return settings.seriesFileNameTemplate; + case MediaType.Song: + return settings.songFileNameTemplate; + case MediaType.Wiki: + return settings.wikiFileNameTemplate; } } setFileNameTemplate(settings: MediaDbPluginSettings, template: string): void { switch (this.mediaType) { - case MediaType.Movie: - settings.movieFileNameTemplate = template; + case MediaType.Artist: + settings.artistFileNameTemplate = template; break; - case MediaType.Series: - settings.seriesFileNameTemplate = template; + case MediaType.BoardGame: + settings.boardgameFileNameTemplate = template; break; - case MediaType.Season: - settings.seasonFileNameTemplate = template; + case MediaType.Book: + settings.bookFileNameTemplate = template; break; case MediaType.ComicManga: settings.mangaFileNameTemplate = template; @@ -199,54 +259,64 @@ class MediaTypeMappedSettings { case MediaType.Game: settings.gameFileNameTemplate = template; break; - case MediaType.Wiki: - settings.wikiFileNameTemplate = template; + case MediaType.Movie: + settings.movieFileNameTemplate = template; break; case MediaType.MusicRelease: settings.musicReleaseFileNameTemplate = template; break; - case MediaType.BoardGame: - settings.boardgameFileNameTemplate = template; + case MediaType.Season: + settings.seasonFileNameTemplate = template; break; - case MediaType.Book: - settings.bookFileNameTemplate = template; + case MediaType.Series: + settings.seriesFileNameTemplate = template; + break; + case MediaType.Song: + settings.songFileNameTemplate = template; + break; + case MediaType.Wiki: + settings.wikiFileNameTemplate = template; break; } } getFolder(settings: MediaDbPluginSettings): string { switch (this.mediaType) { - case MediaType.Movie: - return settings.movieFolder; - case MediaType.Series: - return settings.seriesFolder; - case MediaType.Season: - return settings.seasonFolder; + case MediaType.Artist: + return settings.artistFolder; + case MediaType.BoardGame: + return settings.boardgameFolder; + case MediaType.Book: + return settings.bookFolder; case MediaType.ComicManga: return settings.mangaFolder; case MediaType.Game: return settings.gameFolder; - case MediaType.Wiki: - return settings.wikiFolder; + case MediaType.Movie: + return settings.movieFolder; case MediaType.MusicRelease: return settings.musicReleaseFolder; - case MediaType.BoardGame: - return settings.boardgameFolder; - case MediaType.Book: - return settings.bookFolder; + case MediaType.Season: + return settings.seasonFolder; + case MediaType.Series: + return settings.seriesFolder; + case MediaType.Song: + return settings.songFolder; + case MediaType.Wiki: + return settings.wikiFolder; } } setFolder(settings: MediaDbPluginSettings, folder: string): void { switch (this.mediaType) { - case MediaType.Movie: - settings.movieFolder = folder; + case MediaType.Artist: + settings.artistFolder = folder; break; - case MediaType.Series: - settings.seriesFolder = folder; + case MediaType.BoardGame: + settings.boardgameFolder = folder; break; - case MediaType.Season: - settings.seasonFolder = folder; + case MediaType.Book: + settings.bookFolder = folder; break; case MediaType.ComicManga: settings.mangaFolder = folder; @@ -254,41 +324,62 @@ class MediaTypeMappedSettings { case MediaType.Game: settings.gameFolder = folder; break; - case MediaType.Wiki: - settings.wikiFolder = folder; + case MediaType.Movie: + settings.movieFolder = folder; break; case MediaType.MusicRelease: settings.musicReleaseFolder = folder; break; - case MediaType.BoardGame: - settings.boardgameFolder = folder; + case MediaType.Season: + settings.seasonFolder = folder; break; - case MediaType.Book: - settings.bookFolder = folder; + case MediaType.Series: + settings.seriesFolder = folder; + break; + case MediaType.Song: + settings.songFolder = folder; + break; + case MediaType.Wiki: + settings.wikiFolder = folder; break; } } + + getNoteType(settings: MediaDbPluginSettings): string { + const configured = noteTypeValueForMedia(settings, this.mediaType); + return configured === this.mediaType ? '' : configured; + } + + setNoteType(settings: MediaDbPluginSettings, value: string): void { + const trimmed = value.trim(); + if (trimmed === '' || trimmed === this.mediaType) { + setNoteTypeForMedia(settings, this.mediaType, ''); + return; + } + setNoteTypeForMedia(settings, this.mediaType, value); + } } // MARK: Defaults const DEFAULT_SETTINGS: MediaDbPluginSettings = { - OMDbKey: '', - TMDBKey: '', - MobyGamesKey: '', - GiantBombKey: '', - IGDBClientId: '', - IGDBClientSecret: '', - RAWGAPIKey: '', - ComicVineKey: '', - BoardgameGeekKey: '', sfwFilter: true, templates: true, customDateFormat: 'L', openNoteInNewTab: true, useDefaultFrontMatter: true, + autoTrackerAiringKey: 'airing', + autoTrackerReleasedKey: 'released', enableTemplaterIntegration: false, imageDownload: false, imageFolder: 'Media DB/images', + enableAutoTagging: false, + autoTagEntities: '', + autoTagProperties: '', + enableWikiLinkParsing: false, + autoUpdateAiringMode: false, + tmdbRegion: 'US', + addNormalizeTitlesAsAlias: false, + useObjectFormatForCurrencyValues: false, BoardgameGeekAPI_disabledMediaTypes: [], ComicVineAPI_disabledMediaTypes: [], @@ -299,6 +390,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { MALAPIManga_disabledMediaTypes: [], MobyGamesAPI_disabledMediaTypes: [], MusicBrainzAPI_disabledMediaTypes: [], + MusicBrainzArtistAPI_disabledMediaTypes: [], OMDbAPI_disabledMediaTypes: [], OpenLibraryAPI_disabledMediaTypes: [], SteamAPI_disabledMediaTypes: [], @@ -315,6 +407,8 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { gameTemplate: '', wikiTemplate: '', musicReleaseTemplate: '', + artistTemplate: '', + songTemplate: '', boardgameTemplate: '', bookTemplate: '', @@ -324,7 +418,9 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { mangaFileNameTemplate: '{{ title }} ({{ year }})', gameFileNameTemplate: '{{ title }} ({{ year }})', wikiFileNameTemplate: '{{ title }}', - musicReleaseFileNameTemplate: '{{ title }} (by {{ ENUM:artists }} - {{ year }})', + musicReleaseFileNameTemplate: '{{ title }} ({{ FIRST:artists }} - {{ year }})', + artistFileNameTemplate: '{{ title }}', + songFileNameTemplate: '{{ trackNumber }}. {{ title }} ({{ albumTitle }})', boardgameFileNameTemplate: '{{ title }} ({{ year }})', bookFileNameTemplate: '{{ title }} ({{ year }})', @@ -335,24 +431,45 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { gameFolder: 'Media DB/games', wikiFolder: 'Media DB/wiki', musicReleaseFolder: 'Media DB/music', + artistFolder: 'Media DB/artists', + songFolder: 'Media DB/music/songs', + artistAutomaticallyImportReleases: true, + artistUseFileTreeForSongs: false, + musicReleaseAutomaticallyImportSongs: true, boardgameFolder: 'Media DB/boardgames', bookFolder: 'Media DB/books', + movieNoteType: '', + seriesNoteType: '', + seasonNoteType: '', + mangaNoteType: '', + gameNoteType: '', + wikiNoteType: '', + musicReleaseNoteType: '', + artistNoteType: '', + songNoteType: '', + boardgameNoteType: '', + bookNoteType: '', + propertyMappingModels: [], - // DEPRECATED - moviePropertyConversionRules: '', - seriesPropertyConversionRules: '', - seasonPropertyConversionRules: '', - mangaPropertyConversionRules: '', - gamePropertyConversionRules: '', - wikiPropertyConversionRules: '', - musicReleasePropertyConversionRules: '', - boardgamePropertyConversionRules: '', - bookPropertyConversionRules: '', + linkedApiSecretIds: { + [ApiSecretID.omdb]: '', + [ApiSecretID.tmdb]: '', + [ApiSecretID.mobyGames]: '', + [ApiSecretID.giantBomb]: '', + [ApiSecretID.igdbClientId]: '', + [ApiSecretID.igdbClientSecret]: '', + [ApiSecretID.rawg]: '', + [ApiSecretID.comicVine]: '', + [ApiSecretID.boardgameGeek]: '', + [ApiSecretID.genius]: '', + [ApiSecretID.spotifyClientId]: '', + [ApiSecretID.spotifyClientSecret]: '', + }, }; -export const lockedPropertyMappings: string[] = ['type', 'id', 'dataSource']; +export const lockedPropertyMappings: string[] = []; export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings { const defaultSettings = DEFAULT_SETTINGS; @@ -385,303 +502,344 @@ export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings return defaultSettings; } +interface MediaDbSettingsTabNavEntry { + id: string; + nav: HTMLElement; + panel: HTMLElement; +} + +/** Stable order for property-mapping UI and persisted settings (`MEDIA_TYPES`; settings tabs move Board game last). */ +export function propertyMappingModelsInDisplayOrder(models: PropertyMappingModelData[]): PropertyMappingModelData[] { + const order = new Map(MEDIA_TYPES.map((t, i) => [t, i])); + return [...models].sort((a, b) => (order.get(a.type) ?? 999) - (order.get(b.type) ?? 999)); +} + // MARK: Settings Tab export class MediaDbSettingTab extends PluginSettingTab { plugin: MediaDbPlugin; + private activeSettingsTabId: string | null = null; constructor(app: App, plugin: MediaDbPlugin) { super(app, plugin); this.plugin = plugin; } - display(): void { - const { containerEl } = this; - containerEl.empty(); - - const mediaTypeSettings = MEDIA_TYPES.map(mt => new MediaTypeMappedSettings(mt)); - - // MARK: General settings - const generalGroup = new SettingGroup(containerEl); - - generalGroup.addSetting( - setting => - void setting - .setName('SFW filter') - .setDesc('Only shows SFW results for APIs that offer filtering.') - .addToggle(cb => { - cb.setValue(this.plugin.settings.sfwFilter).onChange(data => { - this.plugin.settings.sfwFilter = data; - void this.plugin.saveSettings(); - }); - }), + private addApiSecretSetting(group: SettingGroup, name: string, description: string, slot: ApiSecretID): void { + group.addSetting(setting => + setting + .setName(name) + .setDesc(description) + .addComponent(el => { + const component = new SecretComponent(this.app, el); + const { linkedApiSecretIds } = this.plugin.settings; + const linkedId = linkedApiSecretIds[slot] ?? ''; + component.setValue(linkedId).onChange((secretId: string) => { + linkedApiSecretIds[slot] = secretId; + this.plugin.saveSettings(); + }); + return component; + }), ); + } - generalGroup.addSetting( - setting => - void setting - .setName('Resolve {{ tags }} in templates') - .setDesc('Whether to resolve {{ tags }} in templates. The spaces inside the curly braces are important.') - .addToggle(cb => { - cb.setValue(this.plugin.settings.templates).onChange(data => { - this.plugin.settings.templates = data; - void this.plugin.saveSettings(); - }); - }), - ); + private static readonly MUSIC_SETTINGS_MEDIA_TYPES: readonly MediaType[] = [MediaType.Artist, MediaType.MusicRelease, MediaType.Song]; - generalGroup.addSetting( - 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() + - '', - ), - ) - .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'); - if (previewEl) { - previewEl.textContent = this.plugin.dateFormatter.getPreview(newDateFormat); // update preview - } - void this.plugin.saveSettings(); - }); - }), - ); + private static readonly BOOK_SETTINGS_MEDIA_TYPES: readonly MediaType[] = [MediaType.Book, MediaType.ComicManga]; - generalGroup.addSetting( - setting => - void setting - .setName('Open note in new tab') - .setDesc('Open the newly created note in a new tab.') - .addToggle(cb => { - cb.setValue(this.plugin.settings.openNoteInNewTab).onChange(data => { - this.plugin.settings.openNoteInNewTab = data; - void this.plugin.saveSettings(); - }); - }), - ); + private static readonly VIDEO_SETTINGS_MEDIA_TYPES: readonly MediaType[] = [MediaType.Movie, MediaType.Series, MediaType.Season]; - generalGroup.addSetting( - setting => - void setting - .setName('Use default front matter') - .setDesc('Whether to use the default front matter. If disabled, the front matter from the template will be used. Same as mapping everything to remove.') - .addToggle(cb => { - cb.setValue(this.plugin.settings.useDefaultFrontMatter).onChange(data => { - this.plugin.settings.useDefaultFrontMatter = data; - void this.plugin.saveSettings(); - // Redraw settings to display/remove the property mappings - this.display(); - }); - }), - ); + private renderMediaTypeSection( + panel: HTMLElement, + mediaTypeSetting: MediaTypeMappedSettings, + mediaTypeApiMap: Map, + options?: { + sectionHeading?: string; + hideImportFolder?: boolean; + appendToSection?: (group: SettingGroup) => void; + }, + ): void { + const mediaType = mediaTypeSetting.mediaType; + const descNoun = options?.sectionHeading?.toLowerCase() ?? mediaTypeDisplayName(mediaType).toLowerCase(); - generalGroup.addSetting( - setting => - void setting - .setName('Enable Templater integration') - .setDesc( - 'Enable integration with the templater plugin, this also needs templater to be installed. Warning: Templater allows you to execute arbitrary JavaScript code and system commands.', - ) - .addToggle(cb => { - cb.setValue(this.plugin.settings.enableTemplaterIntegration).onChange(data => { - this.plugin.settings.enableTemplaterIntegration = data; - void this.plugin.saveSettings(); - }); - }), - ); + if (options?.sectionHeading) { + panel.createEl('h3', { text: options.sectionHeading }); + } - generalGroup.addSetting( - setting => - void setting - .setName('Download images') - .setDesc('Downloads images for new notes in the folder below') - .addToggle(cb => { - cb.setValue(this.plugin.settings.imageDownload).onChange(data => { - this.plugin.settings.imageDownload = data; - void this.plugin.saveSettings(); - }); - }), - ); + const mediaTypeGroup = new SettingGroup(panel); - generalGroup.addSetting( - setting => - void setting - .setName('Image folder') - .setDesc('Where downloaded images should be stored.') - .addSearch(cb => { - const suggester = new FolderSuggest(this.app, cb.inputEl); - suggester.onSelect(folder => { - cb.setValue(folder.path); - this.plugin.settings.imageFolder = folder.path; - void this.plugin.saveSettings(); - suggester.close(); - }); - cb.setPlaceholder(DEFAULT_SETTINGS.imageFolder) - .setValue(this.plugin.settings.imageFolder) - .onChange(data => { - this.plugin.settings.imageFolder = data; + if (!options?.hideImportFolder) { + mediaTypeGroup.addSetting( + setting => + void setting + .setName('Import folder') + .setDesc(`Where newly imported ${descNoun} notes should be placed.`) + .addSearch(cb => { + const suggester = new FolderSuggest(this.app, cb.inputEl); + suggester.onSelect(folder => { + cb.setValue(folder.path); + mediaTypeSetting.setFolder(this.plugin.settings, folder.path); void this.plugin.saveSettings(); + suggester.close(); }); - }), - ); - - // MARK: API keys - const apiKeyGroup = new SettingGroup(containerEl); - apiKeyGroup.setHeading('API Keys'); + cb.setPlaceholder(mediaTypeSetting.getFolder(DEFAULT_SETTINGS)) + .setValue(mediaTypeSetting.getFolder(this.plugin.settings)) + .onChange(data => { + mediaTypeSetting.setFolder(this.plugin.settings, data); + void this.plugin.saveSettings(); + }); + }), + ); + } - apiKeyGroup.addSetting( - setting => - void setting - .setName('OMDb API key') - .setDesc('API key for "www.omdbapi.com".') - // .addComponent((el) => { - // let component = new SecretComponent(this.app, el); - - // component.setValue(this.plugin.settings.OMDbKey).onChange(data => { - // this.plugin.settings.OMDbKey = 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(); - }); - }), - ); - apiKeyGroup.addSetting( + mediaTypeGroup.addSetting( setting => void setting - .setName('TMDB API Token') - .setDesc('API Read Access Token for "https://www.themoviedb.org".') + .setName('Note type') + .setDesc(`Value for the "type" field in frontmatter. Leave blank to use the default (${mediaType}).`) .addText(cb => { - cb.setPlaceholder('API key') - .setValue(this.plugin.settings.TMDBKey) + cb.setPlaceholder(String(mediaType)) + .setValue(mediaTypeSetting.getNoteType(this.plugin.settings)) .onChange(data => { - this.plugin.settings.TMDBKey = data; + mediaTypeSetting.setNoteType(this.plugin.settings, data); void this.plugin.saveSettings(); }); }), ); - apiKeyGroup.addSetting( - setting => - 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(); - }); - }), - ); - apiKeyGroup.addSetting( - setting => - 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(); - }); - }), - ); - apiKeyGroup.addSetting( - setting => - void setting - .setName('IGDB Client ID') - .setDesc('Client ID for IGDB API (Required for Twitch OAuth).') - .addText(cb => { - cb.setPlaceholder('Client ID') - .setValue(this.plugin.settings.IGDBClientId) - .onChange(data => { - this.plugin.settings.IGDBClientId = data; - void this.plugin.saveSettings(); - }); - }), - ); - apiKeyGroup.addSetting( + + mediaTypeGroup.addSetting( setting => void setting - .setName('IGDB Client Secret') - .setDesc('Client Secret for IGDB API.') - .addText(cb => { - cb.setPlaceholder('Client Secret') - .setValue(this.plugin.settings.IGDBClientSecret) + .setName('Template') + .setDesc(`Template file used when creating a new ${descNoun} note.`) + .addSearch(cb => { + const suggester = new FileSuggest(this.app, cb.inputEl); + suggester.onSelect(file => { + cb.setValue(file.path); + mediaTypeSetting.setTemplate(this.plugin.settings, file.path); + void this.plugin.saveSettings(); + suggester.close(); + }); + cb.setPlaceholder(`Example: ${descNoun.replace(/ /g, '')}Template.md`) + .setValue(mediaTypeSetting.getTemplate(this.plugin.settings)) .onChange(data => { - this.plugin.settings.IGDBClientSecret = data; + mediaTypeSetting.setTemplate(this.plugin.settings, data); void this.plugin.saveSettings(); }); }), ); - apiKeyGroup.addSetting( + + mediaTypeGroup.addSetting( setting => void setting - .setName('RAWG API Key') - .setDesc('API key for "rawg.io".') + .setName('File name template') + .setDesc(`File name template for new ${descNoun} notes.`) .addText(cb => { - cb.setPlaceholder('API key') - .setValue(this.plugin.settings.RAWGAPIKey) + cb.setPlaceholder(`Example: ${mediaTypeSetting.getFileNameTemplate(DEFAULT_SETTINGS)}`) + .setValue(mediaTypeSetting.getFileNameTemplate(this.plugin.settings)) .onChange(data => { - this.plugin.settings.RAWGAPIKey = data; + mediaTypeSetting.setFileNameTemplate(this.plugin.settings, data); void this.plugin.saveSettings(); }); }), ); - apiKeyGroup.addSetting( - setting => - 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(); + + const apis = mediaTypeApiMap.get(mediaType) ?? []; + if (apis.length > 1) { + for (const apiName of apis) { + const api = this.plugin.apiManager.apis.find(a => a.apiName === apiName); + if (api) { + const disabledMediaTypes = api.getDisabledMediaTypes(); + + mediaTypeGroup.addSetting( + setting => + void setting + .setName(apiName) + .setDesc(`Use ${apiName} for ${descNoun} search and import.`) + .addToggle(cb => { + cb.setValue(!disabledMediaTypes.includes(mediaType)).onChange(data => { + if (data) { + const index = disabledMediaTypes.indexOf(mediaType); + if (index != -1) { + disabledMediaTypes.splice(index, 1); + } + } else { + disabledMediaTypes.push(mediaType); + } + void this.plugin.saveSettings(); + }); + }), + ); + } + } + } + + options?.appendToSection?.(mediaTypeGroup); + + if (this.plugin.settings.useDefaultFrontMatter) { + mediaTypeGroup.addSetting( + setting => + void setting + .setName('Property mappings') + .setDesc(`How metadata fields map to frontmatter for ${descNoun} notes.`) + .addButton(btn => { + btn.setButtonText('Edit'); + btn.onClick(() => { + new PropertyMappingModal(this.app, this.plugin, mediaType).open(); }); - }), - ); - apiKeyGroup.addSetting( + }), + ); + } + } + + private renderMusicSettingsTab(panel: HTMLElement, mediaTypeSettings: MediaTypeMappedSettings[], mediaTypeApiMap: Map): void { + const byType = (mt: MediaType): MediaTypeMappedSettings => mediaTypeSettings.find(s => s.mediaType === mt)!; + const fileTree = this.plugin.settings.artistUseFileTreeForSongs; + + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + + this.renderMediaTypeSection(panel, byType(MediaType.Artist), mediaTypeApiMap, { + sectionHeading: 'Artist', + appendToSection: group => { + group.addSetting( + setting => + void setting + .setName('Automatically Import Releases') + .setDesc('When importing an artist, also create notes for their studio albums and tracks.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.artistAutomaticallyImportReleases).onChange(data => { + this.plugin.settings.artistAutomaticallyImportReleases = data; + void this.plugin.saveSettings(); + }); + }), + ); + group.addSetting( + setting => + void setting + .setName('Use file trees for songs') + .setDesc('Use a file tree hierarchy to store albums and songs for each artist.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.artistUseFileTreeForSongs).onChange(data => { + this.plugin.settings.artistUseFileTreeForSongs = data; + void this.plugin.saveSettings(); + this.display(); + }); + }), + ); + }, + }); + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + this.renderMediaTypeSection(panel, byType(MediaType.MusicRelease), mediaTypeApiMap, { + sectionHeading: 'Album', + hideImportFolder: fileTree, + appendToSection: group => { + group.addSetting( + setting => + void setting + .setName('Automatically Import Songs') + .setDesc('When importing an album (on its own or as part of an artist import), also create a note for each track.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.musicReleaseAutomaticallyImportSongs).onChange(data => { + this.plugin.settings.musicReleaseAutomaticallyImportSongs = data; + void this.plugin.saveSettings(); + }); + }), + ); + }, + }); + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + this.renderMediaTypeSection(panel, byType(MediaType.Song), mediaTypeApiMap, { + sectionHeading: 'Song', + hideImportFolder: fileTree, + }); + } + + private renderBookSettingsTab(panel: HTMLElement, mediaTypeSettings: MediaTypeMappedSettings[], mediaTypeApiMap: Map): void { + const byType = (mt: MediaType): MediaTypeMappedSettings => mediaTypeSettings.find(s => s.mediaType === mt)!; + + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + + this.renderMediaTypeSection(panel, byType(MediaType.Book), mediaTypeApiMap, { + sectionHeading: 'Book', + }); + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + this.renderMediaTypeSection(panel, byType(MediaType.ComicManga), mediaTypeApiMap, { + sectionHeading: 'Comic & Manga', + }); + } + + private renderVideoSettingsTab(panel: HTMLElement, mediaTypeSettings: MediaTypeMappedSettings[], mediaTypeApiMap: Map): void { + const byType = (mt: MediaType): MediaTypeMappedSettings => mediaTypeSettings.find(s => s.mediaType === mt)!; + + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + + this.renderMediaTypeSection(panel, byType(MediaType.Movie), mediaTypeApiMap, { + sectionHeading: 'Movie', + }); + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + this.renderMediaTypeSection(panel, byType(MediaType.Series), mediaTypeApiMap, { + sectionHeading: 'Series', + }); + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + this.renderMediaTypeSection(panel, byType(MediaType.Season), mediaTypeApiMap, { + sectionHeading: 'Season', + }); + + panel.createDiv({ cls: 'media-db-plugin-spacer' }); + panel.createEl('h3', { text: 'Region' }); + const regionGroup = new SettingGroup(panel); + regionGroup.addSetting( setting => 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(); - }); - }), + .setName('TMDB Region') + .setDesc('ISO-3166-1 region code for TMDB localized metadata (e.g., US, TR, GB). Default is US.') + .addText(text => + text + .setPlaceholder('US') + .setValue(this.plugin.settings.tmdbRegion) + .onChange(async value => { + this.plugin.settings.tmdbRegion = value; + await this.plugin.saveSettings(); + }), + ), ); + } - // MARK: Media type settings + display(): void { + const { containerEl } = this; + containerEl.empty(); - // Create a map to store APIs for each media type - const mediaTypeApiMap = new Map(); + const headerNav = containerEl.createEl('nav', { cls: 'media-db-setting-header' }); + const tabGroup = headerNav.createDiv({ cls: 'media-db-setting-tab-group' }); + const settingsContentEl = containerEl.createDiv({ cls: 'media-db-setting-content' }); + + const tabEntries: MediaDbSettingsTabNavEntry[] = []; + + const selectTab = (id: string): void => { + for (const { id: tid, nav, panel } of tabEntries) { + const on = tid === id; + panel.toggleClass('media-db-tab-settings--hidden', !on); + nav.toggleClass('media-db-navigation-item-selected', on); + } + this.activeSettingsTabId = id; + }; - // Populate the map with APIs for each media type dynamically + const addTab = (id: string, title: string, icon: IconName, render: (panel: HTMLElement) => void): void => { + const nav = tabGroup.createDiv({ cls: 'media-db-navigation-item' }); + nav.addClass(Platform.isMobile ? 'media-db-mobile' : 'media-db-desktop'); + setIcon(nav.createSpan({ cls: 'media-db-navigation-item-icon' }), icon); + nav.createSpan().setText(title); + const panel = settingsContentEl.createDiv({ cls: 'media-db-tab-settings media-db-tab-settings--hidden' }); + render(panel); + tabEntries.push({ id, nav, panel }); + nav.addEventListener('click', () => selectTab(id)); + }; + + const mediaTypeSettings = [ + ...MEDIA_TYPES.filter(mt => mt !== MediaType.BoardGame).map(mt => new MediaTypeMappedSettings(mt)), + new MediaTypeMappedSettings(MediaType.BoardGame), + ]; + + const mediaTypeApiMap = new Map(); for (const api of this.plugin.apiManager.apis) { for (const mediaType of api.types) { if (!mediaTypeApiMap.has(mediaType)) { @@ -691,142 +849,345 @@ export class MediaDbSettingTab extends PluginSettingTab { } } - for (const mediaTypeSetting of mediaTypeSettings) { - const mediaTypeGroup = new SettingGroup(containerEl); - const mediaType = mediaTypeSetting.mediaType; - const mediaTypeName = unCamelCase(mediaTypeSetting.mediaType); - const mediaTypeNameLower = mediaTypeName.toLowerCase(); + addTab('general', 'General', 'sliders-horizontal', panel => { + const generalGroup = new SettingGroup(panel); - mediaTypeGroup.setHeading(`${mediaTypeName} settings`); + generalGroup.addSetting( + setting => + void setting + .setName('SFW filter') + .setDesc('Only shows SFW results for APIs that offer filtering.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.sfwFilter).onChange(data => { + this.plugin.settings.sfwFilter = data; + void this.plugin.saveSettings(); + }); + }), + ); - // Folder - mediaTypeGroup.addSetting( + generalGroup.addSetting( setting => void setting - .setName(`Import Folder`) - .setDesc(`Where newly imported ${mediaTypeNameLower} should be placed.`) - .addSearch(cb => { - const suggester = new FolderSuggest(this.app, cb.inputEl); - suggester.onSelect(folder => { - cb.setValue(folder.path); - mediaTypeSetting.setFolder(this.plugin.settings, folder.path); + .setName('Resolve {{ tags }} in templates') + .setDesc('Whether to resolve {{ tags }} in templates. The spaces inside the curly braces are important.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.templates).onChange(data => { + this.plugin.settings.templates = data; void this.plugin.saveSettings(); - suggester.close(); }); - cb.setPlaceholder(mediaTypeSetting.getFolder(DEFAULT_SETTINGS)) - .setValue(mediaTypeSetting.getFolder(this.plugin.settings)) + }), + ); + + generalGroup.addSetting( + 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() + + '', + ), + ) + .addText(cb => { + cb.setPlaceholder(DEFAULT_SETTINGS.customDateFormat) + .setValue(this.plugin.settings.customDateFormat === DEFAULT_SETTINGS.customDateFormat ? '' : this.plugin.settings.customDateFormat) .onChange(data => { - mediaTypeSetting.setFolder(this.plugin.settings, data); + const newDateFormat = data ? data : DEFAULT_SETTINGS.customDateFormat; + this.plugin.settings.customDateFormat = newDateFormat; + const previewEl = document.getElementById('media-db-dateformat-preview'); + if (previewEl) { + previewEl.textContent = this.plugin.dateFormatter.getPreview(newDateFormat); // update preview + } void this.plugin.saveSettings(); }); }), ); - // Template - mediaTypeGroup.addSetting( + generalGroup.addSetting( + setting => + void setting + .setName('Open note in new tab') + .setDesc('Open the newly created note in a new tab.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.openNoteInNewTab).onChange(data => { + this.plugin.settings.openNoteInNewTab = data; + void this.plugin.saveSettings(); + }); + }), + ); + + generalGroup.addSetting( + setting => + void setting + .setName('Use default front matter') + .setDesc('Whether to use the default front matter. If disabled, the front matter from the template will be used. Same as mapping everything to remove.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.useDefaultFrontMatter).onChange(data => { + this.plugin.settings.useDefaultFrontMatter = data; + void this.plugin.saveSettings(); + this.display(); + }); + }), + ); + + generalGroup.addSetting( setting => void setting - .setName(`Template`) - .setDesc(`Template file to be used when creating a new note for a ${mediaTypeNameLower}.`) + .setName('Enable Templater integration') + .setDesc( + 'Enable integration with the templater plugin, this also needs templater to be installed. Warning: Templater allows you to execute arbitrary JavaScript code and system commands.', + ) + .addToggle(cb => { + cb.setValue(this.plugin.settings.enableTemplaterIntegration).onChange(data => { + this.plugin.settings.enableTemplaterIntegration = data; + void this.plugin.saveSettings(); + }); + }), + ); + + generalGroup.addSetting( + setting => + void setting + .setName('Download images') + .setDesc('Downloads images for new notes in the folder below') + .addToggle(cb => { + cb.setValue(this.plugin.settings.imageDownload).onChange(data => { + this.plugin.settings.imageDownload = data; + void this.plugin.saveSettings(); + }); + }), + ); + + generalGroup.addSetting( + setting => + void setting + .setName('Image folder') + .setDesc('Where downloaded images should be stored.') .addSearch(cb => { - const suggester = new FileSuggest(this.app, cb.inputEl); - suggester.onSelect(file => { - cb.setValue(file.path); - mediaTypeSetting.setTemplate(this.plugin.settings, file.path); + const suggester = new FolderSuggest(this.app, cb.inputEl); + suggester.onSelect(folder => { + cb.setValue(folder.path); + this.plugin.settings.imageFolder = folder.path; void this.plugin.saveSettings(); suggester.close(); }); - cb.setPlaceholder(`Example: ${mediaTypeNameLower}Template.md`) - .setValue(mediaTypeSetting.getTemplate(this.plugin.settings)) + cb.setPlaceholder(DEFAULT_SETTINGS.imageFolder) + .setValue(this.plugin.settings.imageFolder) .onChange(data => { - mediaTypeSetting.setTemplate(this.plugin.settings, data); + this.plugin.settings.imageFolder = data; void this.plugin.saveSettings(); }); }), ); - // File name template - mediaTypeGroup.addSetting( + generalGroup.addSetting( setting => void setting - .setName(`File name template`) - .setDesc(`Template for the file name used when creating a new note for a ${mediaTypeNameLower}.`) - .addText(cb => { - cb.setPlaceholder(`Example: ${mediaTypeSetting.getFileNameTemplate(DEFAULT_SETTINGS)}`) - .setValue(mediaTypeSetting.getFileNameTemplate(this.plugin.settings)) - .onChange(data => { - mediaTypeSetting.setFileNameTemplate(this.plugin.settings, data); - void this.plugin.saveSettings(); - }); + .setName('Add Normalized Titles as Alias') + .setDesc('If the title contains non-ASCII characters, add a normalized ASCII version of the title in aliases.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.addNormalizeTitlesAsAlias).onChange(data => { + this.plugin.settings.addNormalizeTitlesAsAlias = data; + void this.plugin.saveSettings(); + }); }), ); - // APIs - const apis = mediaTypeApiMap.get(mediaType) ?? []; - if (apis.length > 1) { - for (const apiName of apis) { - const api = this.plugin.apiManager.apis.find(api => api.apiName === apiName); - if (api) { - const disabledMediaTypes = api.getDisabledMediaTypes(); - - mediaTypeGroup.addSetting( - setting => - void setting - .setName(apiName) - .setDesc(`Use ${apiName} API for ${unCamelCase(mediaType)}.`) - .addToggle(cb => { - cb.setValue(!disabledMediaTypes.includes(mediaType)).onChange(data => { - if (data) { - const index = disabledMediaTypes.indexOf(mediaType); - if (index != -1) { - disabledMediaTypes.splice(index, 1); - } - } else { - disabledMediaTypes.push(mediaType); - } + generalGroup.addSetting( + setting => + void setting + .setName('Use object format for currency values') + .setDesc( + 'For movies, store Budget and Revenue as nested objects with numeric value and currency (e.g. USD) instead of a single formatted string.', + ) + .addToggle(cb => { + cb.setValue(this.plugin.settings.useObjectFormatForCurrencyValues).onChange(data => { + this.plugin.settings.useObjectFormatForCurrencyValues = data; + void this.plugin.saveSettings(); + }); + }), + ); + + panel.createEl('h3', { text: 'Auto-Tracker' }).style.marginTop = '1.5em'; + const autoTrackerGroup = new SettingGroup(panel); + + autoTrackerGroup.addSetting( + setting => + void setting + .setName('Auto-Update Airing & Unreleased Media') + .setDesc('At startup, automatically searches background for any active medias with "released: false" or "airing: true" and updates them via API.') + .addToggle(cb => { + cb.setValue(this.plugin.settings.autoUpdateAiringMode).onChange(data => { + this.plugin.settings.autoUpdateAiringMode = data; + void this.plugin.saveSettings(); + }); + }), + ); + + autoTrackerGroup.addSetting( + setting => + void setting + .setName('Auto-Tracker "Airing" Property') + .setDesc('Property key to check if a media item is currently airing. Default is "airing".') + .addText(text => { + text.setValue(this.plugin.settings.autoTrackerAiringKey).onChange(data => { + this.plugin.settings.autoTrackerAiringKey = data.trim() || 'airing'; + void this.plugin.saveSettings(); + }); + }), + ); + + autoTrackerGroup.addSetting( + setting => + void setting + .setName('Auto-Tracker "Released" Property') + .setDesc('Property key to check if a media item is unreleased. Default is "released".') + .addText(text => { + text.setValue(this.plugin.settings.autoTrackerReleasedKey).onChange(data => { + this.plugin.settings.autoTrackerReleasedKey = data.trim() || 'released'; + void this.plugin.saveSettings(); + }); + }), + ); + }); + + // Render individual media type tabs + // Game tab + const renderGameTab = (panel: HTMLElement, setting: MediaTypeMappedSettings) => { + this.renderMediaTypeSection(panel, setting, mediaTypeApiMap); + }; + + // Wiki tab — inject Wiki-Link settings + const renderWikiTab = (panel: HTMLElement, setting: MediaTypeMappedSettings) => { + this.renderMediaTypeSection(panel, setting, mediaTypeApiMap, { + appendToSection: group => { + group.addSetting( + s => + void s + .setName('Wiki-Link parsing') + .setDesc( + 'When enabled, properties listed below are formatted as Obsidian [[Wiki-Links]] across ALL media types globally. This complements the per-property wikilink checkbox in Property Mappings, which only affects that specific property.', + ) + .addToggle(cb => { + cb.setValue(this.plugin.settings.enableWikiLinkParsing).onChange(data => { + this.plugin.settings.enableWikiLinkParsing = data; + void this.plugin.saveSettings(); + }); + }), + ); + group.addSetting( + s => + void s + .setName('Wiki-Link properties') + .setDesc( + 'Comma-separated property names to convert to [[Wiki-Links]] for ALL media types. Use this for custom or cross-type properties (e.g. storefront, launcher). For standard properties like genres or studio, the wikilink checkbox inside Property Mappings also works.', + ) + .addTextArea(cb => { + cb.setPlaceholder('genres, storefront, category') + .setValue(this.plugin.settings.autoTagEntities) + .onChange(value => { + this.plugin.settings.autoTagEntities = value; void this.plugin.saveSettings(); }); - }), - ); - } + }), + ); + }, + }); + }; + + addTab('api-keys', 'API keys', 'key', panel => { + const apiKeyGroup = new SettingGroup(panel); + + this.addApiSecretSetting(apiKeyGroup, 'OMDb API key', 'API key for "www.omdbapi.com".', ApiSecretID.omdb); + this.addApiSecretSetting(apiKeyGroup, 'TMDB API Token', 'API Read Access Token for "https://www.themoviedb.org".', ApiSecretID.tmdb); + + this.addApiSecretSetting(apiKeyGroup, 'Moby Games key', 'API key for "www.mobygames.com".', ApiSecretID.mobyGames); + this.addApiSecretSetting(apiKeyGroup, 'Giant Bomb Key', 'API key for "www.giantbomb.com".', ApiSecretID.giantBomb); + this.addApiSecretSetting(apiKeyGroup, 'IGDB Client ID', 'Client ID for IGDB API (Required for Twitch OAuth).', ApiSecretID.igdbClientId); + this.addApiSecretSetting(apiKeyGroup, 'IGDB Client Secret', 'Client Secret for IGDB API.', ApiSecretID.igdbClientSecret); + this.addApiSecretSetting(apiKeyGroup, 'RAWG API Key', 'API key for "rawg.io".', ApiSecretID.rawg); + this.addApiSecretSetting(apiKeyGroup, 'Comic Vine Key', 'API key for "www.comicvine.gamespot.com".', ApiSecretID.comicVine); + this.addApiSecretSetting(apiKeyGroup, 'Boardgame Geek Key', 'API key for "www.boardgamegeek.com".', ApiSecretID.boardgameGeek); + this.addApiSecretSetting( + apiKeyGroup, + 'Genius API access token', + 'Client access token from https://genius.com/api-clients — used to search songs and load lyrics when importing an artist.', + ApiSecretID.genius, + ); + this.addApiSecretSetting( + apiKeyGroup, + 'Spotify Client ID', + 'From https://developer.spotify.com/dashboard — used to resolve track links when MusicBrainz has no Spotify URL (with Client Secret).', + ApiSecretID.spotifyClientId, + ); + this.addApiSecretSetting( + apiKeyGroup, + 'Spotify Client Secret', + 'Pair with Spotify Client ID for client-credentials access to search tracks during artist import.', + ApiSecretID.spotifyClientSecret, + ); + }); + + let musicTabAdded = false; + let bookTabAdded = false; + let videoTabAdded = false; + for (const mediaTypeSetting of mediaTypeSettings) { + const mediaType = mediaTypeSetting.mediaType; + + if (MediaDbSettingTab.MUSIC_SETTINGS_MEDIA_TYPES.includes(mediaType)) { + if (!musicTabAdded) { + musicTabAdded = true; + addTab('media-music', 'Music', 'disc-3', panel => { + this.renderMusicSettingsTab(panel, mediaTypeSettings, mediaTypeApiMap); + }); } + continue; } - } - // MARK: Property mappings + if (MediaDbSettingTab.BOOK_SETTINGS_MEDIA_TYPES.includes(mediaType)) { + if (!bookTabAdded) { + bookTabAdded = true; + addTab('media-book', 'Book', mediaTypeTabIcon(MediaType.Book), panel => { + this.renderBookSettingsTab(panel, mediaTypeSettings, mediaTypeApiMap); + }); + } + continue; + } - if (this.plugin.settings.useDefaultFrontMatter) { - 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.

', - ), - ); + if (MediaDbSettingTab.VIDEO_SETTINGS_MEDIA_TYPES.includes(mediaType)) { + if (!videoTabAdded) { + videoTabAdded = true; + addTab('media-movie', 'Movie', mediaTypeTabIcon(MediaType.Movie), panel => { + this.renderVideoSettingsTab(panel, mediaTypeSettings, mediaTypeApiMap); + }); + } + continue; + } - render( - () => - PropertyMappingModelsComponent({ - models: structuredClone(this.plugin.settings.propertyMappingModels), - save: (model: PropertyMappingModelData): void => { - // Update the matching model in settings (stored as plain data) - const index = this.plugin.settings.propertyMappingModels.findIndex(m => m.type === model.type); - if (index !== -1) { - this.plugin.settings.propertyMappingModels[index] = model; - } - - new Notice(`MDB: Property mappings for ${model.type} saved successfully.`); - void this.plugin.saveSettings(); - }, - }), - setting.descEl, - ); - }); + const mediaTypeName = unCamelCase(mediaTypeSetting.mediaType); + if (mediaType === MediaType.Game) { + addTab(`media-${mediaType}`, mediaTypeName, mediaTypeTabIcon(mediaType), panel => { + renderGameTab(panel, mediaTypeSetting); + }); + } else if (mediaType === MediaType.Wiki) { + addTab(`media-${mediaType}`, mediaTypeName, mediaTypeTabIcon(mediaType), panel => { + renderWikiTab(panel, mediaTypeSetting); + }); + } else { + addTab(`media-${mediaType}`, mediaTypeName, mediaTypeTabIcon(mediaType), panel => { + this.renderMediaTypeSection(panel, mediaTypeSetting, mediaTypeApiMap); + }); + } } + + const validIds = new Set(tabEntries.map(t => t.id)); + let initialId = this.activeSettingsTabId && validIds.has(this.activeSettingsTabId) ? this.activeSettingsTabId : 'general'; + if (!validIds.has(initialId)) { + initialId = 'general'; + } + selectTab(initialId); } -} \ No newline at end of file +} diff --git a/src/settings/apiSecretsHelper.ts b/src/settings/apiSecretsHelper.ts new file mode 100644 index 00000000..8729b9ac --- /dev/null +++ b/src/settings/apiSecretsHelper.ts @@ -0,0 +1,28 @@ +import type { App } from 'obsidian'; + +/** + * Settings slots for API credentials. Each slot stores the Obsidian SecretStorage **id** + * the user selects (including via SecretComponent → Link), not the raw secret. + * @see https://docs.obsidian.md/plugins/guides/secret-storage + */ +export enum ApiSecretID { + omdb, + tmdb, + mobyGames, + giantBomb, + igdbClientId, + igdbClientSecret, + rawg, + comicVine, + boardgameGeek, + genius, + /** Spotify Developer Dashboard — used when MusicBrainz has no streaming URL for a recording. */ + spotifyClientId, + spotifyClientSecret, +} + +export function getApiSecretValue(app: App, linked: Record | undefined, slot: ApiSecretID): string { + const id = linked?.[slot] ?? ''; + if (id === '') return ''; + return app.secretStorage.getSecret(id) ?? ''; +} diff --git a/src/styles.css b/src/styles.css index b204d1d6..26baede5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -41,6 +41,46 @@ small.media-db-plugin-list-text { font-size: 16px; } +.media-db-plugin-select-element-flex { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.media-db-plugin-select-thumb { + width: 48px; + height: 72px; + flex: 0 0 48px; + background: var(--background-modifier-hover); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + overflow: hidden; +} + +.media-db-plugin-select-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.media-db-plugin-select-content { + flex: 1; + min-width: 0; +} + +.media-db-plugin-select-title { + font-weight: 600; +} + +.media-db-plugin-select-thumb span { + color: var(--text-muted); + font-size: 12px; + text-align: center; +} + .media-db-plugin-select-element-selected { border-left: 5px solid var(--interactive-accent) !important; background: var(--background-secondary-alt); @@ -101,6 +141,10 @@ small.media-db-plugin-list-text { gap: var(--size-4-3); } +.media-db-plugin-property-mappings-model-header--actions-only { + justify-content: flex-end; +} + .media-db-plugin-property-mappings-model-header .setting-item-name { font-weight: var(--font-semibold); font-size: var(--font-ui-medium); @@ -145,9 +189,15 @@ small.media-db-plugin-list-text { overflow-x: auto; } +/* Widen the property-mapping popup to fit all columns comfortably */ +.modal:has(.media-db-plugin-property-mappings-table) { + width: min(92vw, 750px) !important; +} + .media-db-plugin-property-mappings-table { width: 100%; border-collapse: collapse; + table-layout: fixed; border-spacing: 0; font-size: var(--font-ui-small); } @@ -182,22 +232,64 @@ small.media-db-plugin-list-text { border-bottom: none; } +.col-drag { + width: 4%; + text-align: center; + vertical-align: middle; +} + .col-property { - width: 25%; + width: 19%; white-space: nowrap; } .col-mapping { - width: 20%; + width: 12%; } .col-new-name { - width: 40%; + width: 21%; } .col-wikilink { + width: 11%; + text-align: center !important; + vertical-align: middle; + white-space: nowrap; +} + +.col-pin { + width: 9%; + text-align: center !important; + vertical-align: middle; + white-space: nowrap; +} + +.col-tag { + width: 9%; + text-align: center !important; + vertical-align: middle; + white-space: nowrap; + padding-left: 2px !important; +} + +.col-tag-prefix { width: 15%; - text-align: center; + vertical-align: middle; + padding-right: 4px !important; +} + +.media-db-plugin-tag-prefix-input { + width: calc(100% + 14px); + box-sizing: border-box; + margin-right: -14px; + font-size: var(--font-ui-smaller); + padding: 2px 4px; + border-radius: var(--radius-s); + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); + color: var(--text-normal); + min-width: 0; } .col-locked { @@ -244,16 +336,223 @@ small.media-db-plugin-list-text { font-size: var(--font-ui-medium); } -.media-db-plugin-property-mapping-wikilink-label { - display: inline-flex; - align-items: center; - justify-content: center; +.media-db-plugin-property-mapping-wikilink-label, +.media-db-plugin-property-mapping-pin-label { + display: inline-block; cursor: pointer; - padding: var(--size-4-1); + padding: 0; + margin: 0; + line-height: 0; } -.media-db-plugin-property-mapping-wikilink-label input[type='checkbox'] { +.media-db-plugin-property-mapping-wikilink-label input[type='checkbox'], +.media-db-plugin-property-mapping-pin-label input[type='checkbox'] { cursor: pointer; width: var(--checkbox-size); height: var(--checkbox-size); + margin: 0; +} + +/* + * Settings tabs: same layout pattern as Obsidian Linter (horizontal icon tabs). + * Adapted from https://github.com/platers/obsidian-linter/blob/master/src/styles.css (MIT). + */ +.media-db-navigation-item { + cursor: pointer; + border-radius: 8px 8px 2px 2px; + border: 1px solid var(--background-modifier-border); + font-weight: bold; + font-size: 16px; + display: flex; + flex-direction: row; + white-space: nowrap; + padding: 4px 6px; + align-items: center; + gap: 4px; + overflow: hidden; + background-color: var(--background-primary-secondary-alt); + transition: + color 0.25s ease-in-out, + padding 0.25s ease-in-out, + background-color 0.35s cubic-bezier(0.45, 0.25, 0.83, 0.67), + max-width 0.35s cubic-bezier(0.57, 0.04, 0.58, 1); + height: 32px; +} + +@media screen and (max-width: 1325px) { + .media-db-navigation-item.media-db-desktop { + max-width: 32px; + } +} + +@media screen and (max-width: 800px) { + .media-db-navigation-item.media-db-mobile { + max-width: 32px; + } +} + +.media-db-navigation-item-icon { + padding-top: 5px; +} + +.media-db-navigation-item:hover { + border-color: var(--interactive-accent-hover); + border-bottom: 0; +} + +.media-db-navigation-item-selected { + background-color: var(--interactive-accent) !important; + color: var(--text-on-accent); + padding: 4px 9px !important; + max-width: 100% !important; + border: 1px solid var(--background-modifier-border); + border-radius: 8px 8px 2px 2px; + border-bottom: 0; + transition: + color 0.25s ease-in-out, + padding 0.25s ease-in-out, + background-color 0.35s cubic-bezier(0.45, 0.25, 0.83, 0.67), + max-width 0.45s cubic-bezier(0.57, 0.04, 0.58, 1) 0.2s; +} + +.media-db-setting-header { + margin-bottom: 24px; + overflow-y: hidden; + overflow-x: auto; +} + +.media-db-setting-header .media-db-setting-tab-group { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + width: 100%; +} + +.media-db-setting-tab-group { + margin-top: 6px; + padding-left: 2px; + padding-right: 2px; + border-bottom: 2px solid var(--background-modifier-border); +} + +.media-db-tab-settings--hidden { + display: none !important; +} + +.media-db-navigation-item:not(.media-db-navigation-item-selected) > span:nth-child(2), +.media-db-visually-hidden { + border: 0; + clip: rect(0 0 0 0); + clip-path: rect(0 0 0 0); + height: auto; + margin: 0; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; +} + +/* ── Completion Modal ─────────────────────────────── */ +.mdb-completion-modal { + padding: 8px 4px 4px; + min-width: 280px; +} + +.mdb-completion-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +.mdb-completion-icon { + font-size: 1.6em; + line-height: 1; +} + +.mdb-completion-title { + margin: 0; + font-size: 1.15em; + font-weight: 600; + color: var(--text-normal); +} + +.mdb-completion-stats { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 24px; +} + +.mdb-completion-row { + display: grid; + grid-template-columns: 32px 1fr auto; + align-items: center; + padding: 7px 0; + border-bottom: 1px solid var(--background-modifier-border); +} + +.mdb-completion-row-icon { + display: flex; + align-items: center; + color: var(--text-muted); +} + +.mdb-completion-label { + color: var(--text-muted); + font-size: 0.92em; + text-align: center; +} + +.mdb-completion-value { + font-weight: 600; + color: var(--text-normal); + font-size: 0.95em; + text-align: right; +} + +.mdb-stat-success { + color: var(--color-green); +} +.mdb-stat-error { + color: var(--color-red); +} +.mdb-stat-skipped { + color: var(--text-muted); +} + +.mdb-completion-notes { + margin-bottom: 20px; + padding: 8px 12px; + background: var(--background-secondary); + border-radius: var(--radius-s); + font-size: 0.88em; + color: var(--text-muted); +} + +.mdb-completion-notes p { + margin: 0; +} + +.mdb-completion-footer { + display: flex; + justify-content: flex-end; + padding-top: 12px; +} + +/* ── Modals Inline Style Replacements ─────────────────────────────── */ + +.media-db-list-item-flex { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.media-db-list-item-thumb { + background: var(--background-modifier-hover); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; } diff --git a/src/utils/AutoTrackerHelper.ts b/src/utils/AutoTrackerHelper.ts new file mode 100644 index 00000000..c7780594 --- /dev/null +++ b/src/utils/AutoTrackerHelper.ts @@ -0,0 +1,89 @@ +import type { TFile, TFolder } from 'obsidian'; +import { Notice } from 'obsidian'; +import type MediaDbPlugin from 'src/main'; +import { CompletionModal } from 'src/modals/CompletionModal'; +import { dateTimeToString, markdownTable } from './Utils'; + +export class AutoTrackerHelper { + readonly plugin: MediaDbPlugin; + public isScanning: boolean = false; + + constructor(plugin: MediaDbPlugin) { + this.plugin = plugin; + } + + async startBackgroundScan(silent: boolean = false, targetFolder?: TFolder): Promise { + if (this.isScanning) return; + this.isScanning = true; + this.plugin.refreshAutoTrackerRibbon(); + await this.runAutoUpdate(silent, targetFolder); + this.isScanning = false; + this.plugin.refreshAutoTrackerRibbon(); + } + + async runAutoUpdate(silent: boolean = false, targetFolder?: TFolder): Promise { + const allFiles = targetFolder ? this.plugin.app.vault.getMarkdownFiles().filter(f => f.path.startsWith(targetFolder.path)) : this.plugin.app.vault.getMarkdownFiles(); + + const filesToUpdate: TFile[] = []; + + for (const file of allFiles) { + const metadata = this.plugin.getMetadataFromFileCache(file); + if (metadata?.dataSource && metadata.id) { + const airingKey = this.plugin.settings.autoTrackerAiringKey; + const releasedKey = this.plugin.settings.autoTrackerReleasedKey; + if (metadata[airingKey] === true || metadata[releasedKey] === false) { + filesToUpdate.push(file); + } + } + } + + if (filesToUpdate.length === 0) { + if (!silent) { + new Notice('MDB Tracker | No airing or unreleased media found to update.'); + } + return; + } + + const noticeMsg = `MDB Tracker | Found ${filesToUpdate.length} ongoing/unreleased notes. Updating in background...`; + if (!silent) { + new Notice(noticeMsg); + } + console.log(noticeMsg); + + const startTime = Date.now(); + let successCount = 0; + let failCount = 0; + const erroredFiles: { filePath: string; error: string }[] = []; + + for (const file of filesToUpdate) { + try { + await this.plugin.updateNote(file, true, false, false, silent); + successCount++; + } catch (e) { + console.warn(`MDB Tracker | Failed to auto-update ${file.path}: `, e); + failCount++; + erroredFiles.push({ filePath: file.path, error: `${e}` }); + } + // Sleep longer (1s) to be completely safe during background checks + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + if (failCount > 0 && erroredFiles.length > 0) { + const title = `MDB - auto tracker error report ${dateTimeToString(new Date())}`; + const filePath = `${title}.md`; + const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error])); + const fileContent = markdownTable(table); + await this.plugin.app.vault.create(filePath, fileContent); + } + + new CompletionModal(this.plugin.app, { + title: 'Auto Tracker Complete', + icon: 'target', + total: filesToUpdate.length, + success: successCount, + errors: failCount, + elapsedMs: Date.now() - startTime, + notes: failCount > 0 ? ['Some notes could not be updated. A detailed report file has been created in your vault folder.'] : [], + }).open(); + } +} diff --git a/src/utils/BulkImportHelper.ts b/src/utils/BulkImportHelper.ts index dcbaabb3..85e4e730 100644 --- a/src/utils/BulkImportHelper.ts +++ b/src/utils/BulkImportHelper.ts @@ -1,6 +1,7 @@ import type { TFolder } from 'obsidian'; import { TFile } from 'obsidian'; import type MediaDbPlugin from 'src/main'; +import { CompletionModal } from 'src/modals/CompletionModal'; import { MediaDbBulkImportModal as MediaDbBulkImportModal } from 'src/modals/MediaDbBulkImportModal'; import type { MediaTypeModel } from 'src/models/MediaTypeModel'; import { ModalResultCode } from './ModalHelper'; @@ -27,6 +28,8 @@ export class BulkImportHelper { async import(folder: TFolder): Promise { const erroredFiles: BulkImportError[] = []; let canceled: boolean = false; + let successCount = 0; + const startTime = Date.now(); const { selectedAPI, lookupMethod, fieldName, appendContent } = await new Promise<{ selectedAPI: string; @@ -60,6 +63,8 @@ export class BulkImportHelper { const error = await this.importById(file, lookupValue, selectedAPI, appendContent); if (error) { erroredFiles.push(error); + } else { + successCount++; } } else if (lookupMethod === BulkImportLookupMethod.TITLE) { const error = await this.importByTitle(file, lookupValue, selectedAPI, appendContent); @@ -68,6 +73,8 @@ export class BulkImportHelper { canceled = true; } erroredFiles.push(error); + } else { + successCount++; } } else { erroredFiles.push({ filePath: file.path, error: `invalid lookup type` }); @@ -78,6 +85,18 @@ export class BulkImportHelper { if (erroredFiles.length > 0) { await this.createErroredFilesReport(erroredFiles); } + + const total = successCount + erroredFiles.length; + new CompletionModal(this.plugin.app, { + title: 'Bulk Import Complete', + icon: 'folder-down', + total, + success: successCount, + errors: erroredFiles.filter(e => !e.canceled).length, + skipped: erroredFiles.filter(e => e.canceled).length, + elapsedMs: Date.now() - startTime, + notes: erroredFiles.length > 0 ? ['Error report saved to vault.'] : [], + }).open(); } private async importById(file: TFile, lookupValue: string, selectedAPI: string, appendContent: boolean): Promise { @@ -144,7 +163,7 @@ export class BulkImportHelper { const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error])); - const fileContent = `# ${title}\n\n${markdownTable(table)}`; + const fileContent = markdownTable(table); await this.plugin.app.vault.create(filePath, fileContent); } } diff --git a/src/utils/BulkRecreateHelper.ts b/src/utils/BulkRecreateHelper.ts new file mode 100644 index 00000000..1bbd7987 --- /dev/null +++ b/src/utils/BulkRecreateHelper.ts @@ -0,0 +1,69 @@ +import type { TFolder } from 'obsidian'; +import { TFile, Notice } from 'obsidian'; +import type MediaDbPlugin from 'src/main'; +import { BulkRecreateConfirmModal, type BulkRecreateMode } from 'src/modals/BulkRecreateConfirmModal'; +import { CompletionModal } from 'src/modals/CompletionModal'; +import { dateTimeToString, markdownTable } from './Utils'; + +export class BulkRecreateHelper { + readonly plugin: MediaDbPlugin; + + constructor(plugin: MediaDbPlugin) { + this.plugin = plugin; + } + + async recreateFolder(folder: TFolder): Promise { + const mediaFiles = folder.children.filter((child): child is TFile => { + if (!(child instanceof TFile)) return false; + const metadata = this.plugin.getMetadataFromFileCache(child); + return Boolean(metadata?.dataSource && metadata.id); + }); + + if (mediaFiles.length === 0) { + new Notice('MDB | No Media DB files found in this folder.'); + return; + } + + new BulkRecreateConfirmModal(this.plugin.app, async (mode: BulkRecreateMode, silent: boolean) => { + // 'reorder' = only metadata (keeps user values, re-applies property order + pin) + // 'full' = full recreate with template (resets custom values) + const onlyMetadata = mode === 'reorder'; + + new Notice(`MDB | Bulk recreating ${mediaFiles.length} files (mode: ${mode}). Please wait...`); + const startTime = Date.now(); + let successCount = 0; + let failCount = 0; + const erroredFiles: { filePath: string; error: string }[] = []; + + for (const file of mediaFiles) { + try { + await this.plugin.updateNote(file, onlyMetadata, false, false, silent); + successCount++; + } catch (e) { + console.error(`MDB | Failed to bulk recreate ${file.path}: `, e); + failCount++; + erroredFiles.push({ filePath: file.path, error: `${e}` }); + } + await new Promise(resolve => setTimeout(resolve, 800)); + } + + if (failCount > 0 && erroredFiles.length > 0) { + const title = `MDB - bulk recreate error report ${dateTimeToString(new Date())}`; + const filePath = `${title}.md`; + const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error])); + const fileContent = markdownTable(table); + await this.plugin.app.vault.create(filePath, fileContent); + } + + new CompletionModal(this.plugin.app, { + title: 'Bulk Recreate Complete', + icon: 'file-stack', + total: mediaFiles.length, + success: successCount, + errors: failCount, + elapsedMs: Date.now() - startTime, + notes: failCount > 0 ? ['Some files could not be recreated. A detailed report file has been created in your vault folder.'] : [], + }).open(); + }).open(); + } +} diff --git a/src/utils/BulkUpdateHelper.ts b/src/utils/BulkUpdateHelper.ts new file mode 100644 index 00000000..153b77a2 --- /dev/null +++ b/src/utils/BulkUpdateHelper.ts @@ -0,0 +1,65 @@ +import type { TFolder} from 'obsidian'; +import { TFile, Notice } from 'obsidian'; +import type MediaDbPlugin from 'src/main'; +import { BulkUpdateConfirmModal } from 'src/modals/BulkUpdateConfirmModal'; +import { CompletionModal } from 'src/modals/CompletionModal'; +import { dateTimeToString, markdownTable } from './Utils'; + +export class BulkUpdateHelper { + readonly plugin: MediaDbPlugin; + + constructor(plugin: MediaDbPlugin) { + this.plugin = plugin; + } + + async updateFolder(folder: TFolder): Promise { + const mediaFiles = folder.children.filter((child): child is TFile => { + if (!(child instanceof TFile)) return false; + const metadata = this.plugin.getMetadataFromFileCache(child); + return Boolean(metadata?.dataSource && metadata.id); + }); + + if (mediaFiles.length === 0) { + new Notice('MDB | No Media DB files found in this folder.'); + return; + } + + new BulkUpdateConfirmModal(this.plugin.app, async (silent: boolean) => { + new Notice(`MDB | Bulk updating ${mediaFiles.length} files. Please wait...`); + const startTime = Date.now(); + let successCount = 0; + let failCount = 0; + const erroredFiles: { filePath: string; error: string }[] = []; + + for (const file of mediaFiles) { + try { + await this.plugin.updateNote(file, true, false, false, silent); + successCount++; + } catch (e) { + console.error(`MDB | Failed to bulk update ${file.path}: `, e); + failCount++; + erroredFiles.push({ filePath: file.path, error: `${e}` }); + } + await new Promise(resolve => setTimeout(resolve, 800)); + } + + if (failCount > 0 && erroredFiles.length > 0) { + const title = `MDB - bulk update error report ${dateTimeToString(new Date())}`; + const filePath = `${title}.md`; + const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error])); + const fileContent = markdownTable(table); + await this.plugin.app.vault.create(filePath, fileContent); + } + + new CompletionModal(this.plugin.app, { + title: 'Bulk Update Complete', + icon: 'refresh-cw', + total: mediaFiles.length, + success: successCount, + errors: failCount, + elapsedMs: Date.now() - startTime, + notes: failCount > 0 ? ['Some files could not be updated. A detailed report file has been created in your vault folder.'] : [], + }).open(); + }).open(); + } +} diff --git a/src/utils/IllegalFilenameCharactersList.ts b/src/utils/IllegalFilenameCharactersList.ts index 220ca9ad..23332276 100644 --- a/src/utils/IllegalFilenameCharactersList.ts +++ b/src/utils/IllegalFilenameCharactersList.ts @@ -9,8 +9,6 @@ export const ILLEGAL_FILENAME_CHARACTERS = [ ['|', ' - '], ['?', ''], ['*', ''], - ['[', '('], - [']', ')'], ['^', ''], ['#', ''], ]; diff --git a/src/utils/MediaType.ts b/src/utils/MediaType.ts index 857050fc..a9dfb548 100644 --- a/src/utils/MediaType.ts +++ b/src/utils/MediaType.ts @@ -1,11 +1,13 @@ export enum MediaType { - Movie = 'movie', - Series = 'series', - Season = 'season', + Artist = 'artist', + BoardGame = 'boardgame', + Book = 'book', ComicManga = 'comicManga', Game = 'game', + Movie = 'movie', MusicRelease = 'musicRelease', + Season = 'season', + Series = 'series', + Song = 'song', Wiki = 'wiki', - BoardGame = 'boardgame', - Book = 'book', } diff --git a/src/utils/MediaTypeManager.ts b/src/utils/MediaTypeManager.ts index d9685734..eb20d1e6 100644 --- a/src/utils/MediaTypeManager.ts +++ b/src/utils/MediaTypeManager.ts @@ -1,5 +1,6 @@ import type { App, TFile } from 'obsidian'; -import { TFolder } from 'obsidian'; +import { normalizePath, TFolder } from 'obsidian'; +import { ArtistModel } from '../models/ArtistModel'; import { BoardGameModel } from '../models/BoardGameModel'; import { BookModel } from '../models/BookModel'; import { ComicMangaModel } from '../models/ComicMangaModel'; @@ -9,14 +10,16 @@ import { MovieModel } from '../models/MovieModel'; import { MusicReleaseModel } from '../models/MusicReleaseModel'; import { SeasonModel } from '../models/SeasonModel'; import { SeriesModel } from '../models/SeriesModel'; +import { SongModel } from '../models/SongModel'; 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 { replaceIllegalFileNameCharactersInString, replaceTags } from './Utils'; // All media types in alphabetical order export const MEDIA_TYPES: MediaType[] = [ + MediaType.Artist, MediaType.BoardGame, MediaType.Book, MediaType.ComicManga, @@ -25,6 +28,7 @@ export const MEDIA_TYPES: MediaType[] = [ MediaType.MusicRelease, MediaType.Series, MediaType.Season, + MediaType.Song, MediaType.Wiki, ]; @@ -41,6 +45,7 @@ export class MediaTypeManager { updateTemplates(settings: MediaDbPluginSettings): void { this.mediaFileNameTemplateMap = new Map(); + this.mediaFileNameTemplateMap.set(MediaType.Artist, settings.artistFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Movie, settings.movieFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Series, settings.seriesFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Season, settings.seasonFileNameTemplate); @@ -50,8 +55,10 @@ export class MediaTypeManager { this.mediaFileNameTemplateMap.set(MediaType.MusicRelease, settings.musicReleaseFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.BoardGame, settings.boardgameFileNameTemplate); this.mediaFileNameTemplateMap.set(MediaType.Book, settings.bookFileNameTemplate); + this.mediaFileNameTemplateMap.set(MediaType.Song, settings.songFileNameTemplate); this.mediaTemplateMap = new Map(); + this.mediaTemplateMap.set(MediaType.Artist, settings.artistTemplate); this.mediaTemplateMap.set(MediaType.Movie, settings.movieTemplate); this.mediaTemplateMap.set(MediaType.Series, settings.seriesTemplate); this.mediaTemplateMap.set(MediaType.Season, settings.seasonTemplate); @@ -61,10 +68,12 @@ export class MediaTypeManager { this.mediaTemplateMap.set(MediaType.MusicRelease, settings.musicReleaseTemplate); this.mediaTemplateMap.set(MediaType.BoardGame, settings.boardgameTemplate); this.mediaTemplateMap.set(MediaType.Book, settings.bookTemplate); + this.mediaTemplateMap.set(MediaType.Song, settings.songTemplate); } updateFolders(settings: MediaDbPluginSettings): void { this.mediaFolderMap = new Map(); + this.mediaFolderMap.set(MediaType.Artist, settings.artistFolder); this.mediaFolderMap.set(MediaType.Movie, settings.movieFolder); this.mediaFolderMap.set(MediaType.Series, settings.seriesFolder); this.mediaFolderMap.set(MediaType.Season, settings.seasonFolder); @@ -74,6 +83,7 @@ export class MediaTypeManager { this.mediaFolderMap.set(MediaType.MusicRelease, settings.musicReleaseFolder); this.mediaFolderMap.set(MediaType.BoardGame, settings.boardgameFolder); this.mediaFolderMap.set(MediaType.Book, settings.bookFolder); + this.mediaFolderMap.set(MediaType.Song, settings.songFolder); } getFileName(mediaTypeModel: MediaTypeModel): string { @@ -88,6 +98,19 @@ export class MediaTypeManager { return cleanedFileName.replaceAll(/ +/g, ' '); } + /** Expands {{ tags }} in a folder path and sanitizes each segment for vault paths. */ + expandFolderPathForModel(folderPath: string, mediaTypeModel: MediaTypeModel): string { + const expanded = replaceTags(folderPath, mediaTypeModel, true); + const segments = expanded + .split('/') + .map(seg => replaceIllegalFileNameCharactersInString(seg).replaceAll(/ +/g, ' ').trim()) + .filter(seg => seg.length > 0); + if (segments.length === 0) { + return '/'; + } + return normalizePath(segments.join('/')); + } + async getTemplate(mediaTypeModel: MediaTypeModel, app: App): Promise { const templateFilePath = this.mediaTemplateMap.get(mediaTypeModel.getMediaType()); @@ -119,7 +142,7 @@ export class MediaTypeManager { let folderPath = this.mediaFolderMap.get(mediaTypeModel.getMediaType()); folderPath ??= `/`; - // console.log(folderPath); + folderPath = this.expandFolderPathForModel(folderPath, mediaTypeModel); if (!(await app.vault.adapter.exists(folderPath))) { await app.vault.createFolder(folderPath); @@ -158,6 +181,10 @@ export class MediaTypeManager { return new BoardGameModel(obj); } else if (mediaType === MediaType.Book) { return new BookModel(obj); + } else if (mediaType === MediaType.Artist) { + return new ArtistModel(obj); + } else if (mediaType === MediaType.Song) { + return new SongModel(obj); } throw new Error(`Unknown media type: ${mediaType}`); diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 9b25b34c..7138b8cb 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -2,6 +2,7 @@ import { iso6392 } from 'iso-639-2'; import type { TFile, TFolder, App } from 'obsidian'; import { requestUrl } from 'obsidian'; import type { MediaTypeModel } from '../models/MediaTypeModel'; +import { MediaType } from './MediaType'; export const pluginName: string = 'obsidian-media-db-plugin'; export const contactEmail: string = 'm.projects.code@gmail.com'; @@ -21,7 +22,7 @@ export function containsOnlyLettersAndUnderscores(str: string): boolean { } export function replaceIllegalFileNameCharactersInString(string: string): string { - return string.replace(/[\\,#%&{}/*<>$"@.?]*/g, '').replace(/:+/g, ' -'); + return string.replace(/[\\/:"*?<>|]/g, '-'); } export function replaceTags(template: string, mediaTypeModel: MediaTypeModel, ignoreUndefined: boolean = false): string { @@ -43,6 +44,10 @@ function replaceTag(match: string, mediaTypeModel: MediaTypeModel, ignoreUndefin if (obj === undefined) { return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}'; } + // year: 0 means "unknown" — return empty string so filename templates stay clean (e.g. "Title ()") + if (path[path.length - 1] === 'year' && obj === 0) { + return ''; + } // eslint-disable-next-line @typescript-eslint/no-base-to-string return obj?.toString() ?? 'null'; @@ -201,11 +206,73 @@ export interface CreateNoteOptions { attachFile?: TFile; openNote?: boolean; folder?: TFolder; + overwrite?: boolean; + preservePropertyOrder?: boolean; +} + +/** Runtime in whole minutes (TMDB/OMDb/MAL). 0 when unknown. Parses legacy string frontmatter (e.g. "136 min", "2 hr 5 min"). */ +export function coerceMovieDurationMinutes(value: unknown): number { + if (value === undefined || value === null) { + return 0; + } + if (typeof value === 'number') { + const n = Math.trunc(value); + return Number.isFinite(n) && n >= 0 ? n : 0; + } + if (typeof value === 'string') { + const t = value.trim(); + if (t === '' || t.toLowerCase() === 'unknown' || t.toUpperCase() === 'N/A' || t === 'TBA') { + return 0; + } + let total = 0; + const hours = t.match(/(\d+)\s*(?:hours?|hrs?)\b/i) ?? t.match(/(\d+)\s*h\b/i); + const mins = t.match(/(\d+)\s*(?:minutes?|mins?)\b/i) ?? t.match(/(\d+)\s*min\b/i); + if (hours) { + total += parseInt(hours[1], 10) * 60; + } + if (mins) { + total += parseInt(mins[1], 10); + } + if (total > 0) { + return total; + } + const n = parseInt(t, 10); + return Number.isFinite(n) && n >= 0 ? n : 0; + } + return 0; +} + +/** Normalizes release year for metadata: integer, 0 when unknown or non-numeric. */ +export function coerceYear(value: unknown): number { + if (value === undefined || value === null) return 0; + if (typeof value === 'number') { + const n = Math.trunc(value); + return Number.isFinite(n) ? n : 0; + } + if (typeof value === 'string') { + const t = value.trim(); + if (t === '' || t.toLowerCase() === 'unknown' || t === 'TBA' || t.toUpperCase() === 'N/A') { + return 0; + } + const n = parseInt(t, 10); + return Number.isFinite(n) ? n : 0; + } + return 0; } export function migrateObject(object: T, oldData: Record, defaultData: T): void { for (const key in object) { - object[key] = Object.hasOwn(oldData, key) && oldData[key] !== undefined && oldData[key] !== null ? (oldData[key] as T[typeof key]) : defaultData[key]; + const has = Object.hasOwn(oldData, key) && oldData[key] !== undefined && oldData[key] !== null; + if (!has) { + object[key] = defaultData[key]; + continue; + } + const raw = oldData[key]; + if (key === 'year') { + (object as Record)[key] = coerceYear(raw); + continue; + } + object[key] = raw as T[typeof key]; } } @@ -223,6 +290,14 @@ export function unCamelCase(str: string): string { ); } +/** User-facing label for a media type (e.g. MusicRelease → Album). */ +export function mediaTypeDisplayName(mediaType: MediaType): string { + if (mediaType === MediaType.MusicRelease) { + return 'Album'; + } + return unCamelCase(mediaType); +} + /* eslint-disable */ export function hasTemplaterPlugin(app: App): boolean { @@ -242,6 +317,14 @@ export async function useTemplaterPluginInFile(app: App, file: TFile): Promise = { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [K in keyof T as T[K] extends Function ? never : K]?: T[K] | null; @@ -303,3 +386,15 @@ export function getLanguageName(code: string): string | null { return language?.name ?? null; } + +export function normalizeTitleForAsciiAlias(title: string): string | null { + const normalized = title.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + if (normalized !== title) return normalized; + return null; +} + +export function parseUsdWholeDollarsFromDisplayString(value: string): number | null { + const cleaned = value.replace(/[^0-9]/g, ''); + if (cleaned) return parseInt(cleaned, 10); + return null; +} diff --git a/src/utils/musicFormatHelper.ts b/src/utils/musicFormatHelper.ts new file mode 100644 index 00000000..53813a25 --- /dev/null +++ b/src/utils/musicFormatHelper.ts @@ -0,0 +1,63 @@ +import type MediaDbPlugin from '../main'; +import { ArtistModel } from '../models/ArtistModel'; +import { MusicReleaseModel } from '../models/MusicReleaseModel'; +import { coerceYear } from './Utils'; + +export function artistTitleWikilink(artistTitle: string, plugin: MediaDbPlugin): string { + const title = artistTitle.trim(); + const artistModel = new ArtistModel({ + type: 'artist', + title, + englishTitle: title, + year: 0, + beginYear: '', + releaseDate: '', + dataSource: '', + url: '', + id: '', + country: '', + disambiguation: '', + isni: '', + genres: [], + image: '', + officialWebsite: '', + subType: 'artist', + userData: { personalRating: 0 }, + }); + const linkTarget = plugin.mediaTypeManager.getFileName(artistModel); + if (linkTarget === title) { + return `[[${linkTarget}]]`; + } + return `[[${linkTarget}|${title}]]`; +} + +export function songAlbumTitleWikilink(albumTitle: string, songMeta: Record, plugin: MediaDbPlugin): string { + const title = albumTitle.trim(); + const artistsRaw = songMeta.artists; + const artists = Array.isArray(artistsRaw) + ? artistsRaw.filter((a): a is string => typeof a === 'string') + : []; + const year = coerceYear(songMeta.year); + const releaseModel = new MusicReleaseModel({ + type: 'musicRelease', + title, + englishTitle: title, + year, + releaseDate: '', + dataSource: '', + url: '', + id: '', + image: '', + artists, + genres: [], + subType: 'album', + language: '', + rating: 0, + userData: { personalRating: 0 }, + }); + const linkTarget = plugin.mediaTypeManager.getFileName(releaseModel); + if (linkTarget === title) { + return `[[${linkTarget}]]`; + } + return `[[${linkTarget}|${title}]]`; +} diff --git a/src/utils/normalizeTitleForAlias.ts b/src/utils/normalizeTitleForAlias.ts new file mode 100644 index 00000000..fd229be0 --- /dev/null +++ b/src/utils/normalizeTitleForAlias.ts @@ -0,0 +1,52 @@ +/** + * ASCII-style form of a title for use as an Obsidian `aliases` entry (e.g. Likbør → Likbor). + * Returns null when the title should not get an extra alias (unchanged after normalization). + * + * NFKD + stripping marks handles most Latin letters; pairs below cover letters that do not + * decompose usefully (ligatures, stroke letters, eth/thorn, eng, Turkish dotless i, …). + */ +const ASCII_ALIAS_FOLDS: readonly [string, string][] = [ + ['æ', 'ae'], + ['Æ', 'Ae'], + ['œ', 'oe'], + ['Œ', 'Oe'], + ['ø', 'o'], + ['Ø', 'O'], + ['ß', 'ss'], + ['ẞ', 'SS'], + ['ð', 'd'], + ['Ð', 'D'], + ['þ', 'th'], + ['Þ', 'Th'], + ['đ', 'd'], + ['Đ', 'D'], + ['ı', 'i'], + ['ħ', 'h'], + ['Ħ', 'H'], + ['ŋ', 'ng'], + ['Ŋ', 'Ng'], + ['Ł', 'L'], + ['ł', 'l'], + ['Ŀ', 'L'], + ['ŀ', 'l'], + ['ĸ', 'k'], + ['ʼn', "'n"], + ['ƿ', 'w'], +]; + +export function normalizeTitleForAsciiAlias(title: string): string | null { + const trimmed = title.trim(); + if (!trimmed) { + return null; + } + + let s = trimmed.normalize('NFKD').replace(/\p{M}/gu, ''); + for (const [from, to] of ASCII_ALIAS_FOLDS) { + s = s.replaceAll(from, to); + } + + if (s === trimmed) { + return null; + } + return s; +} diff --git a/src/utils/noteTypeSettings.ts b/src/utils/noteTypeSettings.ts new file mode 100644 index 00000000..71b32f82 --- /dev/null +++ b/src/utils/noteTypeSettings.ts @@ -0,0 +1,60 @@ +import type { MediaDbPluginSettings } from '../settings/Settings'; +import { MediaType } from './MediaType'; +import { MEDIA_TYPES } from './MediaTypeManager'; + +const MEDIA_TYPE_TO_NOTE_TYPE_KEY: Record = { + [MediaType.Artist]: 'artistNoteType', + [MediaType.BoardGame]: 'boardgameNoteType', + [MediaType.Book]: 'bookNoteType', + [MediaType.ComicManga]: 'mangaNoteType', + [MediaType.Game]: 'gameNoteType', + [MediaType.Movie]: 'movieNoteType', + [MediaType.MusicRelease]: 'musicReleaseNoteType', + [MediaType.Season]: 'seasonNoteType', + [MediaType.Series]: 'seriesNoteType', + [MediaType.Song]: 'songNoteType', + [MediaType.Wiki]: 'wikiNoteType', +}; + +/** + * Value written to frontmatter `type` for this media kind. Falls back to the internal + * {@link MediaType} string when the setting is empty. + */ +export function noteTypeValueForMedia(settings: MediaDbPluginSettings, mediaType: MediaType): string { + const key = MEDIA_TYPE_TO_NOTE_TYPE_KEY[mediaType]; + const raw = settings[key]; + const s = typeof raw === 'string' ? raw.trim() : ''; + return s !== '' ? s : mediaType; +} + +export function setNoteTypeForMedia(settings: MediaDbPluginSettings, mediaType: MediaType, value: string): void { + const key = MEDIA_TYPE_TO_NOTE_TYPE_KEY[mediaType]; + (settings as unknown as Record)[key as string] = value; +} + +/** + * Maps a frontmatter `type` string (legacy enum id or configured custom string) to {@link MediaType}. + */ +export function resolveMetadataTypeToMediaType(settings: MediaDbPluginSettings, noteType: unknown): MediaType | undefined { + if (noteType === undefined || noteType === null) { + return undefined; + } + let s = String(noteType).trim(); + if (s === '') { + return undefined; + } + if (s === 'manga') { + s = MediaType.ComicManga; + } + for (const mt of MEDIA_TYPES) { + if (mt === s) { + return mt; + } + } + for (const mt of MEDIA_TYPES) { + if (noteTypeValueForMedia(settings, mt) === s) { + return mt; + } + } + return undefined; +} diff --git a/test/genius-lyrics.test.ts b/test/genius-lyrics.test.ts new file mode 100644 index 00000000..4a76bee2 --- /dev/null +++ b/test/genius-lyrics.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; + +import { extractLyricsFromGeniusHtml } from '../src/api/helpers/geniusLyricsExtract'; + +describe('extractLyricsFromGeniusHtml', () => { + test('keeps all lines when lyrics use nested divs (balanced extraction)', () => { + const html = ` +
+

Line one

+

Line two nested

+

Line three

+
+ `; + const out = extractLyricsFromGeniusHtml(html); + expect(out).toContain('Line one'); + expect(out).toContain('Line two nested'); + expect(out).toContain('Line three'); + }); + + test('removes Genius 2024+ in-column header without breaking nested __Group / __Title classes', () => { + const html = `
+
+ +
+
+

Book of the Fallen Lyrics

+
+
+
+

First verse line
Second line

+
`; + const out = extractLyricsFromGeniusHtml(html); + expect(out).not.toMatch(/contributors/i); + expect(out).not.toContain('Book of the Fallen Lyrics'); + expect(out).toContain('First verse line'); + expect(out).toContain('Second line'); + }); +}); diff --git a/test/normalizeTitleForAlias.test.ts b/test/normalizeTitleForAlias.test.ts new file mode 100644 index 00000000..56d713c3 --- /dev/null +++ b/test/normalizeTitleForAlias.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; + +import { normalizeTitleForAsciiAlias } from '../src/utils/normalizeTitleForAlias'; + +describe('normalizeTitleForAsciiAlias', () => { + test('maps Polish ł and Ł to ASCII l and L', () => { + expect(normalizeTitleForAsciiAlias('Bełchatów')).toBe('Belchatow'); + expect(normalizeTitleForAsciiAlias('Łódź')).toBe('Lodz'); + }); + + test('maps ligatures æ œ and stroke / Nordic / German letters', () => { + expect(normalizeTitleForAsciiAlias('Rændering')).toBe('Raendering'); + expect(normalizeTitleForAsciiAlias('Œdipus')).toBe('Oedipus'); + expect(normalizeTitleForAsciiAlias('København')).toBe('Kobenhavn'); + expect(normalizeTitleForAsciiAlias('Straße')).toBe('Strasse'); + }); + + test('maps eth, thorn, d-bar, Turkish dotless i, eng, apostrophe-n', () => { + expect(normalizeTitleForAsciiAlias('Þór')).toBe('Thor'); + expect(normalizeTitleForAsciiAlias('Đặc')).toBe('Dac'); + expect(normalizeTitleForAsciiAlias('Bağcılar')).toBe('Bagcilar'); + expect(normalizeTitleForAsciiAlias('Eĸa')).toBe('Eka'); + expect(normalizeTitleForAsciiAlias('Muŋ')).toBe('Mung'); + }); + + test('returns null when input is already ASCII-equivalent after NFKD', () => { + expect(normalizeTitleForAsciiAlias('Plain Title')).toBe(null); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c6eb6724..9dc9afed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "allowSyntheticDefaultImports": true, "jsx": "preserve", "jsxImportSource": "solid-js", - "types": ["vite/client"] + "types": ["vite/client", "bun"] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"] } diff --git a/vite.config.ts b/vite.config.mts similarity index 89% rename from vite.config.ts rename to vite.config.mts index ffa6b85d..d833a09a 100644 --- a/vite.config.ts +++ b/vite.config.mts @@ -1,11 +1,14 @@ +import { builtinModules } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import solidPlugin from 'vite-plugin-solid'; -import builtins from 'builtin-modules'; import { getBuildBanner } from './automation/build/buildBanner'; 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'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const entryFile = 'src/main.ts'; @@ -72,7 +75,7 @@ export default defineConfig(({ mode }) => { '@lezer/common', '@lezer/highlight', '@lezer/lr', - ...builtins, + ...builtinModules, ], }, },