add obsidian eslint plugin, solve major issues

This commit is contained in:
Moritz Jung 2026-05-06 16:26:59 +02:00
parent 84214a1c3c
commit 498e2611ae
105 changed files with 5702 additions and 5308 deletions

View file

@ -0,0 +1,24 @@
import { onMount, Show } from 'solid-js';
import { setIcon } from 'obsidian';
interface IconProps {
iconName?: string;
}
export default function Icon(props: IconProps) {
let iconEl: HTMLDivElement | undefined;
onMount(() => {
if (iconEl) {
setIcon(iconEl, props.iconName || '');
}
});
return (
<Show when={(props.iconName || '').length > 0}>
<div class="icon-wrapper">
<div ref={iconEl} class="icon"></div>
</div>
</Show>
);
}

View file

@ -0,0 +1,108 @@
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { PropertyMappingOption } from 'packages/obsidian/src/settings/PropertyMapping';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
import { MEDIA_TYPES } from 'packages/obsidian/src/utils/MediaTypeManager';
export class PropertyMapper {
plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
this.plugin = plugin;
}
/**
* Converts an object using the conversion rules for its type.
* Returns an unaltered object if object.type is null or undefined or if there are no conversion rules for the type.
*
* @param obj
*/
convertObject(obj: Record<string, unknown>): Record<string, unknown> {
if (!Object.hasOwn(obj, 'type')) {
return obj;
}
if (!MEDIA_TYPES.includes(obj.type as MediaType)) {
return obj;
}
const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type);
if (!propertyMappingModel) {
return obj;
}
const propertyMappings = propertyMappingModel.properties;
const propertyMappingByProperty = new Map(propertyMappings.map(mapping => [mapping.property, mapping]));
const newObj: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const propertyMapping = propertyMappingByProperty.get(key);
if (!propertyMapping) {
newObj[key] = value;
continue;
}
let finalValue = value;
if (propertyMapping.wikilink) {
if (typeof value === 'string') {
finalValue = `[[${value}]]`;
} else if (Array.isArray(value)) {
finalValue = (value as unknown[]).map((v: unknown) => (typeof v === 'string' ? `[[${v}]]` : v));
}
}
if (propertyMapping.mapping === PropertyMappingOption.Map) {
newObj[propertyMapping.newProperty] = finalValue;
} else if (propertyMapping.mapping === PropertyMappingOption.Default) {
newObj[key] = finalValue;
}
}
return newObj;
}
/**
* Converts an object back using the conversion rules for its type.
* Returns an unaltered object if object.type is null or undefined or if there are no conversion rules for the type.
*
* @param obj
*/
convertObjectBack(obj: Record<string, unknown>): Record<string, unknown> {
if (!Object.hasOwn(obj, 'type')) {
return obj;
}
if (obj.type === 'manga') {
obj.type = 'comicManga';
Logger.debug(`MDB | updated metadata type`, obj.type);
}
if (!MEDIA_TYPES.includes(obj.type as MediaType)) {
return obj;
}
const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type);
const propertyMappings = propertyMappingModel?.properties ?? [];
const propertyMappingByOriginal = new Map(propertyMappings.map(mapping => [mapping.property, mapping]));
const propertyMappingByMapped = new Map(propertyMappings.map(mapping => [mapping.newProperty, mapping]));
const originalObj: Record<string, unknown> = { ...obj };
for (const [key, value] of Object.entries(obj)) {
const normalProperty = propertyMappingByOriginal.get(key);
if (normalProperty) {
originalObj[key] = value;
continue;
}
const mappedProperty = propertyMappingByMapped.get(key);
if (mappedProperty) {
originalObj[mappedProperty.property] = value;
delete originalObj[key];
}
}
return originalObj;
}
}

View file

@ -0,0 +1,253 @@
import { Logger } from 'packages/obsidian/src/utils/Logger';
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
import { containsOnlyLettersAndUnderscores, PropertyMappingNameConflictError, PropertyMappingValidationError } from 'packages/obsidian/src/utils/Utils';
// Plain object interfaces for serialization
export interface PropertyMappingData {
property: string;
newProperty: string;
mapping: PropertyMappingOption;
locked?: boolean;
wikilink?: boolean;
}
export interface PropertyMappingModelData {
type: MediaType;
properties: PropertyMappingData[];
}
export enum PropertyMappingOption {
Default = 'default',
Map = 'remap',
Remove = 'remove',
}
export const propertyMappingOptions = [PropertyMappingOption.Default, PropertyMappingOption.Map, PropertyMappingOption.Remove];
export class PropertyMappingModel {
type: MediaType;
properties: PropertyMapping[];
constructor(type: MediaType, properties?: PropertyMapping[]) {
this.type = type;
this.properties = properties ?? [];
}
validate(): { res: boolean; err?: Error } {
Logger.debug(`MDB | validated property mappings for ${this.type}`);
// check properties
for (const property of this.properties) {
const propertyValidation = property.validate();
if (!propertyValidation.res) {
return {
res: false,
err: propertyValidation.err,
};
}
}
// check for name collisions
for (const property of this.getMappedProperties()) {
const propertiesWithSameTarget = this.getMappedProperties().filter(x => x.newProperty === property.newProperty);
if (propertiesWithSameTarget.length === 0) {
// if we get there, then something in this code is wrong
} else if (propertiesWithSameTarget.length === 1) {
// all good
} else {
// two or more properties are mapped to the same property
return {
res: false,
err: new PropertyMappingNameConflictError(
`Multiple remapped properties (${propertiesWithSameTarget.map(x => x.toString()).toString()}) may not share the same name.`,
),
};
}
}
// remapped properties may not have the same name as any original property
for (const property of this.getMappedProperties()) {
const propertiesWithSameTarget = this.properties.filter(x => x.newProperty === property.property);
if (propertiesWithSameTarget.length === 0) {
// all good
} else {
// a mapped property shares the same name with an original property
return {
res: false,
err: new PropertyMappingNameConflictError(`Remapped property (${property}) may not share it's new name with an existing property.`),
};
}
}
return {
res: true,
};
}
getMappedProperties(): PropertyMapping[] {
return this.properties.filter(x => x.mapping === PropertyMappingOption.Map);
}
copy(): PropertyMappingModel {
const copy = new PropertyMappingModel(this.type);
for (const property of this.properties) {
const propertyCopy = new PropertyMapping(property.property, property.newProperty, property.mapping, property.locked, property.wikilink);
copy.properties.push(propertyCopy);
}
return copy;
}
// Serialization - returns a plain object that can be JSON.stringify'd
toJSON(): PropertyMappingModelData {
return {
type: this.type,
properties: this.properties.map(p => p.toJSON()),
};
}
// Deserialization - creates a PropertyMappingModel from a plain object
static fromJSON(json: PropertyMappingModelData): PropertyMappingModel {
return new PropertyMappingModel(
json.type,
json.properties.map(p => PropertyMapping.fromJSON(p)),
);
}
/**
* Migrates loaded settings to match the structure of default settings.
* - Adds new properties from defaults that don't exist in loaded settings
* - Preserves user customizations from loaded settings
* - Updates locked status from defaults
*
* @param loadedModels - Models loaded from disk (may be outdated)
* @param defaultModels - Current default models (source of truth for structure)
* @returns Migrated models with correct structure and preserved user settings
*/
static migrateModels(loadedModels: PropertyMappingModelData[], defaultModels: PropertyMappingModel[]): PropertyMappingModel[] {
const migratedModels: PropertyMappingModel[] = [];
for (const defaultModel of defaultModels) {
const loadedModel = loadedModels.find(m => m.type === defaultModel.type);
if (!loadedModel) {
// New model type - use default
migratedModels.push(defaultModel);
continue;
}
// Migrate properties
const migratedProperties: PropertyMapping[] = [];
for (const defaultProperty of defaultModel.properties) {
const loadedProperty = loadedModel.properties.find(p => p.property === defaultProperty.property);
if (!loadedProperty) {
// New property - use default
migratedProperties.push(defaultProperty);
} else {
// Existing property - merge: take locked from default, customizations from loaded
migratedProperties.push(
new PropertyMapping(
loadedProperty.property,
loadedProperty.newProperty,
loadedProperty.mapping,
defaultProperty.locked, // locked status from default
loadedProperty.wikilink ?? false,
),
);
}
}
migratedModels.push(new PropertyMappingModel(defaultModel.type, migratedProperties));
}
return migratedModels;
}
}
export class PropertyMapping {
property: string;
newProperty: string;
locked: boolean;
mapping: PropertyMappingOption;
wikilink: boolean;
constructor(property: string, newProperty: string, mapping: PropertyMappingOption, locked?: boolean, wikilink?: boolean) {
this.property = property;
this.newProperty = newProperty;
this.mapping = mapping;
this.locked = locked ?? false;
this.wikilink = wikilink ?? false;
}
validate(): { res: boolean; err?: Error } {
// locked property may only be default
if (this.locked) {
if (this.mapping === PropertyMappingOption.Remove) {
return {
res: false,
err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be removed.`),
};
}
if (this.mapping === PropertyMappingOption.Map) {
return {
res: false,
err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": locked property may not be remapped.`),
};
}
}
if (this.mapping === PropertyMappingOption.Default) {
return { res: true };
}
if (this.mapping === PropertyMappingOption.Remove) {
return { res: true };
}
if (!this.property || !containsOnlyLettersAndUnderscores(this.property)) {
return {
res: false,
err: new PropertyMappingValidationError(`Error in property mapping "${this.toString()}": property may not be empty and may only contain letters and underscores.`),
};
}
if (!this.newProperty || !containsOnlyLettersAndUnderscores(this.newProperty)) {
return {
res: false,
err: new PropertyMappingValidationError(
`Error in property mapping "${this.toString()}": new property may not be empty and may only contain letters and underscores.`,
),
};
}
return {
res: true,
};
}
toString(): string {
if (this.mapping === PropertyMappingOption.Default) {
return this.property;
} else if (this.mapping === PropertyMappingOption.Map) {
return `${this.property} -> ${this.newProperty}`;
} else if (this.mapping === PropertyMappingOption.Remove) {
return `remove ${this.property}`;
}
return this.property;
}
// Serialization - returns a plain object
toJSON(): PropertyMappingData {
return {
property: this.property,
newProperty: this.newProperty,
mapping: this.mapping,
locked: this.locked,
wikilink: this.wikilink,
};
}
// Deserialization - creates a PropertyMapping from a plain object
static fromJSON(json: PropertyMappingData): PropertyMapping {
return new PropertyMapping(json.property, json.newProperty, json.mapping, json.locked, json.wikilink);
}
}

View file

@ -0,0 +1,138 @@
import { createSignal, createMemo, For, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { PropertyMappingModel, PropertyMappingOption, propertyMappingOptions, type PropertyMappingModelData } from './PropertyMapping';
import { capitalizeFirstLetter } from '../utils/Utils';
import Icon from './Icon';
interface PropertyMappingModelComponentProps {
model: PropertyMappingModelData;
save: (model: PropertyMappingModelData) => void;
}
export default function PropertyMappingModelComponent(props: PropertyMappingModelComponentProps) {
const [unsavedChanges, setUnsavedChanges] = createSignal(false);
// Create a store from the model's plain data
const [modelData, setModelData] = createStore(props.model);
// Derive the validation result reactively
const validationResult = createMemo(() => {
const model = PropertyMappingModel.fromJSON(modelData);
return model.validate();
});
const onModelUpdate = () => {
setUnsavedChanges(true);
};
const handleSave = () => {
const model = PropertyMappingModel.fromJSON(modelData);
if (model.validate().res) {
props.save(model);
setUnsavedChanges(false);
}
};
return (
<div class="media-db-plugin-property-mappings-model-container">
<div class="media-db-plugin-property-mappings-model-header">
<div class="setting-item-name">{capitalizeFirstLetter(modelData.type)}</div>
<div class="media-db-plugin-property-mappings-model-actions">
<Show when={unsavedChanges()}>
<div class="media-db-plugin-property-mapping-unsaved-changes">Unsaved changes</div>
</Show>
<button class={`media-db-plugin-property-mappings-save-button ${validationResult().res ? 'mod-cta' : 'mod-muted'}`} onClick={handleSave}>
Save
</button>
</div>
</div>
<Show when={!validationResult().res}>
<div class="media-db-plugin-property-mapping-validation">{validationResult().err?.message}</div>
</Show>
<div class="media-db-plugin-property-mappings-table-container">
<table class="media-db-plugin-property-mappings-table">
<thead>
<tr>
<th class="col-property">Property</th>
<th class="col-mapping">Mapping</th>
<th class="col-new-name">New name</th>
<th class="col-wikilink">Wikilink</th>
</tr>
</thead>
<tbody>
<For each={modelData.properties}>
{(property, index) => (
<tr>
<td class="col-property">
<code>{property.property}</code>
</td>
<Show
when={!property.locked}
fallback={
<td class="col-locked" colspan={3}>
<div class="media-db-plugin-property-binding-text">property cannot be remapped</div>
</td>
}
>
<td class="col-mapping">
<select
class="dropdown"
value={property.mapping}
onChange={e => {
setModelData('properties', index(), 'mapping', e.currentTarget.value as PropertyMappingOption);
setModelData('properties', index(), 'newProperty', '');
onModelUpdate();
}}
>
<For each={propertyMappingOptions}>{remappingOption => <option value={remappingOption}>{remappingOption}</option>}</For>
</select>
</td>
<td class="col-new-name">
<Show
when={property.mapping === PropertyMappingOption.Map}
fallback={<span class="media-db-plugin-property-mapping-to-disabled">N/A</span>}
>
<div class="media-db-plugin-property-mapping-to">
<Icon iconName="arrow-right" />
<input
class="media-db-plugin-property-mapping-input"
type="text"
spellcheck={false}
value={property.newProperty}
onInput={e => {
setModelData('properties', index(), 'newProperty', e.currentTarget.value);
onModelUpdate();
}}
/>
</div>
</Show>
</td>
<td class="col-wikilink">
<label class="media-db-plugin-property-mapping-wikilink-label" title="Convert value to wikilink ([[value]])">
<input
type="checkbox"
checked={property.wikilink}
onChange={e => {
setModelData('properties', index(), 'wikilink', e.currentTarget.checked);
onModelUpdate();
}}
/>
</label>
</td>
</Show>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
import { For } from 'solid-js';
import { type PropertyMappingModelData } from './PropertyMapping';
import PropertyMappingModelComponent from './PropertyMappingModelComponent';
interface PropertyMappingModelsComponentProps {
models?: PropertyMappingModelData[];
save: (model: PropertyMappingModelData) => void;
}
export default function PropertyMappingModelsComponent(props: PropertyMappingModelsComponentProps) {
return (
<div class="setting-item" style={{ display: 'flex', gap: '10px', 'flex-direction': 'column', 'align-items': 'stretch' }}>
<For each={props.models || []}>{model => <PropertyMappingModelComponent model={model} save={props.save} />}</For>
</div>
);
}

View file

@ -0,0 +1,861 @@
import type { App } from 'obsidian';
import { Notice, PluginSettingTab, SecretComponent, SettingGroup } from 'obsidian';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { PropertyMappingModelData } from 'packages/obsidian/src/settings/PropertyMapping';
import { PropertyMapping, PropertyMappingModel, PropertyMappingOption } from 'packages/obsidian/src/settings/PropertyMapping';
import PropertyMappingModelsComponent from 'packages/obsidian/src/settings/PropertyMappingModelsComponent';
import { FileSuggest } from 'packages/obsidian/src/settings/suggesters/FileSuggest';
import { FolderSuggest } from 'packages/obsidian/src/settings/suggesters/FolderSuggest';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import { MEDIA_TYPES } from 'packages/obsidian/src/utils/MediaTypeManager';
import { unCamelCase } from 'packages/obsidian/src/utils/Utils';
import { render } from 'solid-js/web';
function createDateFormatDescription(preview: string): DocumentFragment {
return createFragment(frag => {
const container = frag.createDiv();
container.appendText('Your custom date format. Use ');
container.createEl('em', { text: "'YYYY-MM-DD'" });
container.appendText(' for example.');
container.createEl('br');
container.appendText('For more syntax, refer to ');
container.createEl('a', {
href: 'https://momentjs.com/docs/#/displaying/format/',
text: 'format reference',
});
container.appendText('.');
container.createEl('br');
container.appendText('Your current syntax looks like this: ');
container.createEl('em', { text: preview });
});
}
function createPropertyMappingsDescription(): DocumentFragment {
return createFragment(frag => {
const container = frag.createDiv();
container.createEl('p', {
text: 'Here you can customize how metadata fields are mapped to property names in the front matter of the created notes.',
});
container.createEl('p', {
text: 'You can choose to keep the original name, rename the property, or remove it entirely.',
});
const paragraph = container.createEl('p');
paragraph.createEl('strong', {
text: 'Remember to save your changes using the save button for each individual category.',
});
});
}
// MARK: Settings
export interface MediaDbPluginSettings {
OMDbKeyId: string;
TMDBKeyId: string;
MobyGamesKeyId: string;
GiantBombKeyId: string;
IGDBClientId: string;
IGDBClientSecret: string;
RAWGAPIKeyId: string;
ComicVineKeyId: string;
BoardgameGeekKeyId: string;
sfwFilter: boolean;
templates: boolean;
customDateFormat: string;
openNoteInNewTab: boolean;
useDefaultFrontMatter: boolean;
enableTemplaterIntegration: boolean;
imageDownload: boolean;
imageFolder: string;
BoardgameGeekAPI_disabledMediaTypes: MediaType[];
ComicVineAPI_disabledMediaTypes: MediaType[];
GiantBombAPI_disabledMediaTypes: MediaType[];
IGDBAPI_disabledMediaTypes: MediaType[];
RAWGAPI_disabledMediaTypes: MediaType[];
MALAPI_disabledMediaTypes: MediaType[];
MALAPIManga_disabledMediaTypes: MediaType[];
MobyGamesAPI_disabledMediaTypes: MediaType[];
MusicBrainzAPI_disabledMediaTypes: MediaType[];
OMDbAPI_disabledMediaTypes: MediaType[];
OpenLibraryAPI_disabledMediaTypes: MediaType[];
SteamAPI_disabledMediaTypes: MediaType[];
TMDBMovieAPI_disabledMediaTypes: MediaType[];
TMDBSeasonAPI_disabledMediaTypes: MediaType[];
TMDBSeriesAPI_disabledMediaTypes: MediaType[];
VNDBAPI_disabledMediaTypes: MediaType[];
WikipediaAPI_disabledMediaTypes: MediaType[];
movieTemplate: string;
seriesTemplate: string;
seasonTemplate: string;
mangaTemplate: string;
gameTemplate: string;
wikiTemplate: string;
musicReleaseTemplate: string;
boardgameTemplate: string;
bookTemplate: string;
movieFileNameTemplate: string;
seriesFileNameTemplate: string;
seasonFileNameTemplate: string;
mangaFileNameTemplate: string;
gameFileNameTemplate: string;
wikiFileNameTemplate: string;
musicReleaseFileNameTemplate: string;
boardgameFileNameTemplate: string;
bookFileNameTemplate: string;
movieFolder: string;
seriesFolder: string;
seasonFolder: string;
mangaFolder: string;
gameFolder: string;
wikiFolder: string;
musicReleaseFolder: string;
boardgameFolder: string;
bookFolder: string;
propertyMappingModels: PropertyMappingModelData[];
// DEPRECATED: Use propertyMappingModels instead
moviePropertyConversionRules: string;
seriesPropertyConversionRules: string;
seasonPropertyConversionRules: string;
mangaPropertyConversionRules: string;
gamePropertyConversionRules: string;
wikiPropertyConversionRules: string;
musicReleasePropertyConversionRules: string;
boardgamePropertyConversionRules: string;
bookPropertyConversionRules: string;
}
/**
* Helper class to get/set settings for a specific media type.
*/
class MediaTypeMappedSettings {
mediaType: MediaType;
constructor(mediaType: MediaType) {
this.mediaType = mediaType;
}
getTemplate(settings: MediaDbPluginSettings): string {
switch (this.mediaType) {
case MediaType.Movie:
return settings.movieTemplate;
case MediaType.Series:
return settings.seriesTemplate;
case MediaType.Season:
return settings.seasonTemplate;
case MediaType.ComicManga:
return settings.mangaTemplate;
case MediaType.Game:
return settings.gameTemplate;
case MediaType.Wiki:
return settings.wikiTemplate;
case MediaType.MusicRelease:
return settings.musicReleaseTemplate;
case MediaType.BoardGame:
return settings.boardgameTemplate;
case MediaType.Book:
return settings.bookTemplate;
}
}
setTemplate(settings: MediaDbPluginSettings, template: string): void {
switch (this.mediaType) {
case MediaType.Movie:
settings.movieTemplate = template;
break;
case MediaType.Series:
settings.seriesTemplate = template;
break;
case MediaType.Season:
settings.seasonTemplate = template;
break;
case MediaType.ComicManga:
settings.mangaTemplate = template;
break;
case MediaType.Game:
settings.gameTemplate = template;
break;
case MediaType.Wiki:
settings.wikiTemplate = template;
break;
case MediaType.MusicRelease:
settings.musicReleaseTemplate = template;
break;
case MediaType.BoardGame:
settings.boardgameTemplate = template;
break;
case MediaType.Book:
settings.bookTemplate = template;
break;
}
}
getFileNameTemplate(settings: MediaDbPluginSettings): string {
switch (this.mediaType) {
case MediaType.Movie:
return settings.movieFileNameTemplate;
case MediaType.Series:
return settings.seriesFileNameTemplate;
case MediaType.Season:
return settings.seasonFileNameTemplate;
case MediaType.ComicManga:
return settings.mangaFileNameTemplate;
case MediaType.Game:
return settings.gameFileNameTemplate;
case MediaType.Wiki:
return settings.wikiFileNameTemplate;
case MediaType.MusicRelease:
return settings.musicReleaseFileNameTemplate;
case MediaType.BoardGame:
return settings.boardgameFileNameTemplate;
case MediaType.Book:
return settings.bookFileNameTemplate;
}
}
setFileNameTemplate(settings: MediaDbPluginSettings, template: string): void {
switch (this.mediaType) {
case MediaType.Movie:
settings.movieFileNameTemplate = template;
break;
case MediaType.Series:
settings.seriesFileNameTemplate = template;
break;
case MediaType.Season:
settings.seasonFileNameTemplate = template;
break;
case MediaType.ComicManga:
settings.mangaFileNameTemplate = template;
break;
case MediaType.Game:
settings.gameFileNameTemplate = template;
break;
case MediaType.Wiki:
settings.wikiFileNameTemplate = template;
break;
case MediaType.MusicRelease:
settings.musicReleaseFileNameTemplate = template;
break;
case MediaType.BoardGame:
settings.boardgameFileNameTemplate = template;
break;
case MediaType.Book:
settings.bookFileNameTemplate = template;
break;
}
}
getFolder(settings: MediaDbPluginSettings): string {
switch (this.mediaType) {
case MediaType.Movie:
return settings.movieFolder;
case MediaType.Series:
return settings.seriesFolder;
case MediaType.Season:
return settings.seasonFolder;
case MediaType.ComicManga:
return settings.mangaFolder;
case MediaType.Game:
return settings.gameFolder;
case MediaType.Wiki:
return settings.wikiFolder;
case MediaType.MusicRelease:
return settings.musicReleaseFolder;
case MediaType.BoardGame:
return settings.boardgameFolder;
case MediaType.Book:
return settings.bookFolder;
}
}
setFolder(settings: MediaDbPluginSettings, folder: string): void {
switch (this.mediaType) {
case MediaType.Movie:
settings.movieFolder = folder;
break;
case MediaType.Series:
settings.seriesFolder = folder;
break;
case MediaType.Season:
settings.seasonFolder = folder;
break;
case MediaType.ComicManga:
settings.mangaFolder = folder;
break;
case MediaType.Game:
settings.gameFolder = folder;
break;
case MediaType.Wiki:
settings.wikiFolder = folder;
break;
case MediaType.MusicRelease:
settings.musicReleaseFolder = folder;
break;
case MediaType.BoardGame:
settings.boardgameFolder = folder;
break;
case MediaType.Book:
settings.bookFolder = folder;
break;
}
}
}
// MARK: Defaults
const DEFAULT_SETTINGS: MediaDbPluginSettings = {
OMDbKeyId: '',
TMDBKeyId: '',
MobyGamesKeyId: '',
GiantBombKeyId: '',
IGDBClientId: '',
IGDBClientSecret: '',
RAWGAPIKeyId: '',
ComicVineKeyId: '',
BoardgameGeekKeyId: '',
sfwFilter: true,
templates: true,
customDateFormat: 'L',
openNoteInNewTab: true,
useDefaultFrontMatter: true,
enableTemplaterIntegration: false,
imageDownload: false,
imageFolder: 'Media DB/images',
BoardgameGeekAPI_disabledMediaTypes: [],
ComicVineAPI_disabledMediaTypes: [],
GiantBombAPI_disabledMediaTypes: [],
IGDBAPI_disabledMediaTypes: [],
RAWGAPI_disabledMediaTypes: [],
MALAPI_disabledMediaTypes: [],
MALAPIManga_disabledMediaTypes: [],
MobyGamesAPI_disabledMediaTypes: [],
MusicBrainzAPI_disabledMediaTypes: [],
OMDbAPI_disabledMediaTypes: [],
OpenLibraryAPI_disabledMediaTypes: [],
SteamAPI_disabledMediaTypes: [],
TMDBMovieAPI_disabledMediaTypes: [],
TMDBSeasonAPI_disabledMediaTypes: [],
TMDBSeriesAPI_disabledMediaTypes: [],
VNDBAPI_disabledMediaTypes: [],
WikipediaAPI_disabledMediaTypes: [],
movieTemplate: '',
seriesTemplate: '',
seasonTemplate: '',
mangaTemplate: '',
gameTemplate: '',
wikiTemplate: '',
musicReleaseTemplate: '',
boardgameTemplate: '',
bookTemplate: '',
movieFileNameTemplate: '{{ title }} ({{ year }})',
seriesFileNameTemplate: '{{ title }} ({{ year }})',
seasonFileNameTemplate: '{{ title }} ({{ year }})',
mangaFileNameTemplate: '{{ title }} ({{ year }})',
gameFileNameTemplate: '{{ title }} ({{ year }})',
wikiFileNameTemplate: '{{ title }}',
musicReleaseFileNameTemplate: '{{ title }} (by {{ ENUM:artists }} - {{ year }})',
boardgameFileNameTemplate: '{{ title }} ({{ year }})',
bookFileNameTemplate: '{{ title }} ({{ year }})',
movieFolder: 'Media DB/movies',
seriesFolder: 'Media DB/series',
seasonFolder: 'Media DB/series',
mangaFolder: 'Media DB/comics',
gameFolder: 'Media DB/games',
wikiFolder: 'Media DB/wiki',
musicReleaseFolder: 'Media DB/music',
boardgameFolder: 'Media DB/boardgames',
bookFolder: 'Media DB/books',
propertyMappingModels: [],
// DEPRECATED
moviePropertyConversionRules: '',
seriesPropertyConversionRules: '',
seasonPropertyConversionRules: '',
mangaPropertyConversionRules: '',
gamePropertyConversionRules: '',
wikiPropertyConversionRules: '',
musicReleasePropertyConversionRules: '',
boardgamePropertyConversionRules: '',
bookPropertyConversionRules: '',
};
export const lockedPropertyMappings: string[] = ['type', 'id', 'dataSource'];
export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings {
const defaultSettings = DEFAULT_SETTINGS;
// construct property mapping defaults
const propertyMappingModels: PropertyMappingModelData[] = [];
for (const mediaType of MEDIA_TYPES) {
const model: MediaTypeModel = plugin.mediaTypeManager.createMediaTypeModelFromMediaType({}, mediaType);
const metadataObj = model.toMetaDataObject();
const propertyMappingModel: PropertyMappingModel = new PropertyMappingModel(mediaType);
for (const key of Object.keys(metadataObj)) {
propertyMappingModel.properties.push(
new PropertyMapping(
key,
'',
PropertyMappingOption.Default,
lockedPropertyMappings.includes(key),
false, // wikilink default
),
);
}
// Convert to plain data for serialization
propertyMappingModels.push(propertyMappingModel.toJSON());
}
defaultSettings.propertyMappingModels = propertyMappingModels;
return defaultSettings;
}
// MARK: Settings Tab
export class MediaDbSettingTab extends PluginSettingTab {
plugin: MediaDbPlugin;
constructor(app: App, plugin: MediaDbPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
const mediaTypeSettings = MEDIA_TYPES.map(mt => new MediaTypeMappedSettings(mt));
// MARK: General settings
const generalGroup = new SettingGroup(containerEl);
generalGroup.addSetting(
setting =>
void setting
.setName('SFW filter')
.setDesc('Only shows SFW results for APIs that offer filtering.')
.addToggle(cb => {
cb.setValue(this.plugin.settings.sfwFilter).onChange(data => {
this.plugin.settings.sfwFilter = data;
void this.plugin.saveSettings();
});
}),
);
generalGroup.addSetting(
setting =>
void setting
.setName('Resolve {{ tags }} in templates')
.setDesc('Whether to resolve {{ tags }} in templates. The spaces inside the curly braces are important.')
.addToggle(cb => {
cb.setValue(this.plugin.settings.templates).onChange(data => {
this.plugin.settings.templates = data;
void this.plugin.saveSettings();
});
}),
);
generalGroup.addSetting(
setting =>
void setting
.setName('Date format')
.setDesc(createDateFormatDescription(this.plugin.dateFormatter.getPreview()))
.addText(cb => {
cb.setPlaceholder(DEFAULT_SETTINGS.customDateFormat)
.setValue(this.plugin.settings.customDateFormat === DEFAULT_SETTINGS.customDateFormat ? '' : this.plugin.settings.customDateFormat)
.onChange(data => {
const newDateFormat = data ? data : DEFAULT_SETTINGS.customDateFormat;
this.plugin.settings.customDateFormat = newDateFormat;
const previewEl = activeDocument.getElementById('media-db-dateformat-preview');
if (previewEl) {
previewEl.textContent = this.plugin.dateFormatter.getPreview(newDateFormat); // update preview
}
void this.plugin.saveSettings();
});
}),
);
generalGroup.addSetting(
setting =>
void setting
.setName('Open note in new tab')
.setDesc('Open the newly created note in a new tab.')
.addToggle(cb => {
cb.setValue(this.plugin.settings.openNoteInNewTab).onChange(data => {
this.plugin.settings.openNoteInNewTab = data;
void this.plugin.saveSettings();
});
}),
);
generalGroup.addSetting(
setting =>
void setting
.setName('Use default front matter')
.setDesc('Whether to use the default front matter. If disabled, the front matter from the template will be used. Same as mapping everything to remove.')
.addToggle(cb => {
cb.setValue(this.plugin.settings.useDefaultFrontMatter).onChange(data => {
this.plugin.settings.useDefaultFrontMatter = data;
void this.plugin.saveSettings();
// Redraw settings to display/remove the property mappings
this.display();
});
}),
);
generalGroup.addSetting(
setting =>
void setting
.setName('Enable Templater integration')
.setDesc(
'Enable integration with the templater plugin, this also needs templater to be installed. Warning: Templater allows you to execute arbitrary JavaScript code and system commands.',
)
.addToggle(cb => {
cb.setValue(this.plugin.settings.enableTemplaterIntegration).onChange(data => {
this.plugin.settings.enableTemplaterIntegration = data;
void this.plugin.saveSettings();
});
}),
);
generalGroup.addSetting(
setting =>
void setting
.setName('Download images')
.setDesc('Downloads images for new notes in the folder below')
.addToggle(cb => {
cb.setValue(this.plugin.settings.imageDownload).onChange(data => {
this.plugin.settings.imageDownload = data;
void this.plugin.saveSettings();
});
}),
);
generalGroup.addSetting(
setting =>
void setting
.setName('Image folder')
.setDesc('Where downloaded images should be stored.')
.addSearch(cb => {
const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
this.plugin.settings.imageFolder = folder.path;
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(DEFAULT_SETTINGS.imageFolder)
.setValue(this.plugin.settings.imageFolder)
.onChange(data => {
this.plugin.settings.imageFolder = data;
void this.plugin.saveSettings();
});
}),
);
// MARK: API keys
const apiKeyGroup = new SettingGroup(containerEl);
apiKeyGroup.setHeading('API Keys');
apiKeyGroup.addSetting(
setting =>
void setting
.setName('OMDb API key')
.setDesc('API key for "www.omdbapi.com".')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.OMDbKeyId).onChange(data => {
this.plugin.settings.OMDbKeyId = data;
void this.plugin.saveSettings();
});
return component;
}),
);
apiKeyGroup.addSetting(
setting =>
void setting
.setName('TMDB API key')
.setDesc('API Read Access Token for "https://www.themoviedb.org".')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.TMDBKeyId).onChange(data => {
this.plugin.settings.TMDBKeyId = data;
void this.plugin.saveSettings();
});
return component;
}),
);
apiKeyGroup.addSetting(
setting =>
void setting
.setName('Moby Games key')
.setDesc('API key for "www.mobygames.com".')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.MobyGamesKeyId).onChange(data => {
this.plugin.settings.MobyGamesKeyId = data;
void this.plugin.saveSettings();
});
return component;
}),
);
apiKeyGroup.addSetting(
setting =>
void setting
.setName('Giant Bomb Key')
.setDesc('API key for "www.giantbomb.com".')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.GiantBombKeyId).onChange(data => {
this.plugin.settings.GiantBombKeyId = data;
void this.plugin.saveSettings();
});
return component;
}),
);
apiKeyGroup.addSetting(
setting =>
void setting
.setName('IGDB Client ID')
.setDesc('Client ID for IGDB API (Required for Twitch OAuth).')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.IGDBClientId).onChange(data => {
this.plugin.settings.IGDBClientId = data;
void this.plugin.saveSettings();
});
return component;
}),
);
apiKeyGroup.addSetting(
setting =>
void setting
.setName('IGDB Client Secret')
.setDesc('Client Secret for IGDB API.')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.IGDBClientSecret).onChange(data => {
this.plugin.settings.IGDBClientSecret = data;
void this.plugin.saveSettings();
});
return component;
}),
);
apiKeyGroup.addSetting(
setting =>
void setting
.setName('RAWG API Key')
.setDesc('API key for "rawg.io".')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.RAWGAPIKeyId).onChange(data => {
this.plugin.settings.RAWGAPIKeyId = data;
void this.plugin.saveSettings();
});
return component;
}),
);
apiKeyGroup.addSetting(
setting =>
void setting
.setName('Comic Vine Key')
.setDesc('API key for "www.comicvine.gamespot.com".')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.ComicVineKeyId).onChange(data => {
this.plugin.settings.ComicVineKeyId = data;
void this.plugin.saveSettings();
});
return component;
}),
);
apiKeyGroup.addSetting(
setting =>
void setting
.setName('Boardgame Geek Key')
.setDesc('API key for "www.boardgamegeek.com".')
.addComponent(el => {
const component = new SecretComponent(this.app, el);
component.setValue(this.plugin.settings.BoardgameGeekKeyId).onChange(data => {
this.plugin.settings.BoardgameGeekKeyId = data;
void this.plugin.saveSettings();
});
return component;
}),
);
// MARK: Media type settings
// Create a map to store APIs for each media type
const mediaTypeApiMap = new Map<MediaType, string[]>();
// Populate the map with APIs for each media type dynamically
for (const api of this.plugin.apiManager.apis) {
for (const mediaType of api.types) {
if (!mediaTypeApiMap.has(mediaType)) {
mediaTypeApiMap.set(mediaType, []);
}
mediaTypeApiMap.get(mediaType)!.push(api.apiName);
}
}
for (const mediaTypeSetting of mediaTypeSettings) {
const mediaTypeGroup = new SettingGroup(containerEl);
const mediaType = mediaTypeSetting.mediaType;
const mediaTypeName = unCamelCase(mediaTypeSetting.mediaType);
const mediaTypeNameLower = mediaTypeName.toLowerCase();
mediaTypeGroup.setHeading(`${mediaTypeName} settings`);
// Folder
mediaTypeGroup.addSetting(
setting =>
void setting
.setName(`Import Folder`)
.setDesc(`Where newly imported ${mediaTypeNameLower} should be placed.`)
.addSearch(cb => {
const suggester = new FolderSuggest(this.app, cb.inputEl);
suggester.onSelect(folder => {
cb.setValue(folder.path);
mediaTypeSetting.setFolder(this.plugin.settings, folder.path);
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(mediaTypeSetting.getFolder(DEFAULT_SETTINGS))
.setValue(mediaTypeSetting.getFolder(this.plugin.settings))
.onChange(data => {
mediaTypeSetting.setFolder(this.plugin.settings, data);
void this.plugin.saveSettings();
});
}),
);
// Template
mediaTypeGroup.addSetting(
setting =>
void setting
.setName(`Template`)
.setDesc(`Template file to be used when creating a new note for a ${mediaTypeNameLower}.`)
.addSearch(cb => {
const suggester = new FileSuggest(this.app, cb.inputEl);
suggester.onSelect(file => {
cb.setValue(file.path);
mediaTypeSetting.setTemplate(this.plugin.settings, file.path);
void this.plugin.saveSettings();
suggester.close();
});
cb.setPlaceholder(`Example: ${mediaTypeNameLower}Template.md`)
.setValue(mediaTypeSetting.getTemplate(this.plugin.settings))
.onChange(data => {
mediaTypeSetting.setTemplate(this.plugin.settings, data);
void this.plugin.saveSettings();
});
}),
);
// File name template
mediaTypeGroup.addSetting(
setting =>
void setting
.setName(`File name template`)
.setDesc(`Template for the file name used when creating a new note for a ${mediaTypeNameLower}.`)
.addText(cb => {
cb.setPlaceholder(`Example: ${mediaTypeSetting.getFileNameTemplate(DEFAULT_SETTINGS)}`)
.setValue(mediaTypeSetting.getFileNameTemplate(this.plugin.settings))
.onChange(data => {
mediaTypeSetting.setFileNameTemplate(this.plugin.settings, data);
void this.plugin.saveSettings();
});
}),
);
// APIs
const apis = mediaTypeApiMap.get(mediaType) ?? [];
if (apis.length > 1) {
for (const apiName of apis) {
const api = this.plugin.apiManager.apis.find(api => api.apiName === apiName);
if (api) {
const disabledMediaTypes = api.getDisabledMediaTypes();
mediaTypeGroup.addSetting(
setting =>
void setting
.setName(apiName)
.setDesc(`Use ${apiName} API for ${unCamelCase(mediaType)}.`)
.addToggle(cb => {
cb.setValue(!disabledMediaTypes.includes(mediaType)).onChange(data => {
if (data) {
const index = disabledMediaTypes.indexOf(mediaType);
if (index != -1) {
disabledMediaTypes.splice(index, 1);
}
} else {
disabledMediaTypes.push(mediaType);
}
void this.plugin.saveSettings();
});
}),
);
}
}
}
}
// MARK: Property mappings
if (this.plugin.settings.useDefaultFrontMatter) {
const mappingGroup = new SettingGroup(containerEl);
mappingGroup.setHeading('Property mappings');
mappingGroup.addSetting(setting => {
setting.setName('Property mappings explanation').setDesc(createPropertyMappingsDescription());
render(
() =>
PropertyMappingModelsComponent({
models: structuredClone(this.plugin.settings.propertyMappingModels),
save: (model: PropertyMappingModelData): void => {
// Update the matching model in settings (stored as plain data)
const index = this.plugin.settings.propertyMappingModels.findIndex(m => m.type === model.type);
if (index !== -1) {
this.plugin.settings.propertyMappingModels[index] = model;
}
new Notice(`MDB: Property mappings for ${model.type} saved successfully.`);
void this.plugin.saveSettings();
},
}),
setting.descEl,
);
});
}
}
}

View file

@ -0,0 +1,17 @@
import { AbstractInputSuggest, TFile } from 'obsidian';
export class FileSuggest extends AbstractInputSuggest<TFile> {
protected getSuggestions(query: string): TFile[] | Promise<TFile[]> {
const lowerCaseInputStr = query.toLowerCase();
// we do two filters because otherwise TS type inference does convert the array to TFile[]
return this.app.vault
.getAllLoadedFiles()
.filter(file => file instanceof TFile)
.filter(file => file.path.toLowerCase().includes(lowerCaseInputStr));
}
renderSuggestion(value: TFile, el: HTMLElement): void {
el.setText(value.path);
}
}

View file

@ -0,0 +1,17 @@
import { AbstractInputSuggest, TFolder } from 'obsidian';
export class FolderSuggest extends AbstractInputSuggest<TFolder> {
protected getSuggestions(query: string): TFolder[] | Promise<TFolder[]> {
const lowerCaseInputStr = query.toLowerCase();
// we do two filters because otherwise TS type inference does convert the array to TFolder[]
return this.app.vault
.getAllLoadedFiles()
.filter(file => file instanceof TFolder)
.filter(file => file.path.toLowerCase().includes(lowerCaseInputStr));
}
renderSuggestion(value: TFolder, el: HTMLElement): void {
el.setText(value.path);
}
}