bye svelte; welcome solid

This commit is contained in:
Moritz Jung 2026-01-29 12:19:27 +01:00
parent 97cf350a32
commit 2fe6f6fc37
21 changed files with 686 additions and 569 deletions

1
.github/FUNDING.yml vendored Normal file
View file

@ -0,0 +1 @@
github: ['mProjectsCode']

View file

@ -37,7 +37,7 @@ jobs:
bun install
bun run build
mkdir ${{ env.PLUGIN_NAME }}
cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }}
cp dist/main.js dist/manifest.json dist/styles.css ${{ env.PLUGIN_NAME }}
zip -r ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip ${{ env.PLUGIN_NAME }}
ls
@ -49,6 +49,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
files: |
${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip
main.js
manifest.json
styles.css
dist/main.js
dist/manifest.json
dist/styles.css

2
.gitignore vendored
View file

@ -37,4 +37,4 @@ exampleVault/.obsidian/plugins/obsidian-media-db-plugin/*
exampleVault/Media DB/*
meta.txt
main.js
dist/

View file

@ -1,4 +1,4 @@
import manifest from '../../manifest.json' assert { type: 'json' };
import manifest from '../../manifest.json' with { type: 'json' };
export function getBuildBanner(buildType: string, getVersion: (version: string) => string) {
return `/*
@ -9,30 +9,16 @@ By: ${manifest.author} (${manifest.authorUrl})
Time: ${new Date().toUTCString()}
Version: ${getVersion(manifest.version)}
-------------------------------------------
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
THIS IS A GENERATED/BUNDLED FILE
if you want to view the source, please visit the github repository of this plugin
-------------------------------------------
MIT License
Copyright (c) ${new Date().getFullYear()} ${manifest.author}
-------------------------------------------
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
`;
}

BIN
bun.lockb

Binary file not shown.

View file

@ -7,7 +7,7 @@ import * as plugin_import from 'eslint-plugin-import';
export default tseslint.config(
{
ignores: ['npm/', 'node_modules/', 'exampleVault/', 'automation/', 'main.js', '*.svelte', 'src/api/schemas/'],
ignores: ['npm/', 'node_modules/', 'exampleVault/', 'automation/', 'main.js', '*.css', 'src/api/schemas/'],
},
{
files: ['src/**/*.ts'],

View file

@ -4,18 +4,17 @@
"description": "A plugin that can query multiple APIs for movies, series, anime, manga, books, comics, games, music and wiki articles, and import them into your vault.",
"main": "main.js",
"scripts": {
"dev": "bun run automation/build/esbuild.dev.config.ts",
"build": "bun run tsc && bun run automation/build/esbuild.config.ts",
"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",
"format": "prettier --write --plugin prettier-plugin-svelte .",
"format:check": "prettier --check --plugin prettier-plugin-svelte .",
"lint": "eslint --max-warnings=0 src/**",
"lint:fix": "eslint --max-warnings=0 --fix src/**",
"svelte-check": "svelte-check --compiler-warnings \"unused-export-let:ignore\"",
"check": "bun run format:check && bun run tsc && bun run lint && bun run svelte-check",
"check:fix": "bun run format && bun run tsc && bun run lint:fix && bun run svelte-check",
"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"
},
@ -28,9 +27,6 @@
"@popperjs/core": "^2.11.8",
"@types/bun": "^1.3.7",
"builtin-modules": "^5.0.0",
"esbuild": "^0.25.12",
"esbuild-plugin-copy-watch": "^2.3.1",
"esbuild-svelte": "^0.9.4",
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-only-warn": "^1.1.0",
@ -39,13 +35,14 @@
"openapi-fetch": "^0.14.1",
"openapi-typescript": "^7.10.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"solid-js": "^1.9.3",
"string-argv": "^0.3.2",
"svelte": "^5.48.5",
"svelte-check": "^4.3.5",
"svelte-preprocess": "^6.0.3",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0"
"typescript-eslint": "^8.54.0",
"vite": "^6.0.5",
"vite-plugin-banner": "^0.8.1",
"vite-plugin-solid": "^2.11.0",
"vite-plugin-static-copy": "^3.2.0"
}
}

View file

@ -19,11 +19,11 @@ import { TMDBSeriesAPI } from './api/apis/TMDBSeriesAPI';
import { VNDBAPI } from './api/apis/VNDBAPI';
import { WikipediaAPI } from './api/apis/WikipediaAPI';
import { ConfirmOverwriteModal } from './modals/ConfirmOverwriteModal';
import type {SeasonSelectModalElement} from './modals/MediaDbSeasonSelectModal';
import { MediaDbSeasonSelectModal } from './modals/MediaDbSeasonSelectModal';
import type { SeasonSelectModalElement } from './modals/MediaDbSeasonSelectModal';
import { MediaDbSeasonSelectModal } from './modals/MediaDbSeasonSelectModal';
import type { MediaTypeModel } from './models/MediaTypeModel';
import { PropertyMapper } from './settings/PropertyMapper';
import { PropertyMapping, PropertyMappingModel } from './settings/PropertyMapping';
import { PropertyMappingModel } from './settings/PropertyMapping';
import type { MediaDbPluginSettings } from './settings/Settings';
import { getDefaultSettings, MediaDbSettingTab } from './settings/Settings';
import { BulkImportHelper } from './utils/BulkImportHelper';
@ -33,6 +33,7 @@ import type { SearchModalOptions } from './utils/ModalHelper';
import { ModalHelper } from './utils/ModalHelper';
import type { CreateNoteOptions } from './utils/Utils';
import { replaceIllegalFileNameCharactersInString, unCamelCase, hasTemplaterPlugin, useTemplaterPluginInFile } from './utils/Utils';
import 'src/styles.css';
export type Metadata = Record<string, unknown>;
@ -652,39 +653,18 @@ export default class MediaDbPlugin extends Plugin {
}
async loadSettings(): Promise<void> {
// console.log(DEFAULT_SETTINGS);
const diskSettings: MediaDbPluginSettings = (await this.loadData()) as MediaDbPluginSettings;
const defaultSettings: MediaDbPluginSettings = getDefaultSettings(this);
const loadedSettings: MediaDbPluginSettings = Object.assign({}, defaultSettings, diskSettings);
// migrate the settings loaded from the disk to match the structure of the default settings
const newPropertyMappings: PropertyMappingModel[] = [];
for (const defaultPropertyMappingModel of defaultSettings.propertyMappingModels) {
const newPropertyMappingModel = loadedSettings.propertyMappingModels.find(x => x.type === defaultPropertyMappingModel.type);
if (newPropertyMappingModel === undefined) {
// if the propertyMappingModel exists in the default settings but not the loaded settings, add it
newPropertyMappings.push(defaultPropertyMappingModel);
} else {
// if the propertyMappingModel also exists in the loaded settings, add it from there
const newProperties: PropertyMapping[] = [];
// Migrate property mappings using the dedicated migration method
const migratedModels = PropertyMappingModel.migrateModels(
loadedSettings.propertyMappingModels || [],
defaultSettings.propertyMappingModels.map(m => PropertyMappingModel.fromJSON(m)),
);
for (const defaultProperty of defaultPropertyMappingModel.properties) {
const newProperty = newPropertyMappingModel.properties.find(x => x.property === defaultProperty.property);
if (newProperty === undefined) {
// default property is an instance
newProperties.push(defaultProperty);
} else {
// newProperty is just an object and take locked status from default property
newProperties.push(
new PropertyMapping(newProperty.property, newProperty.newProperty, newProperty.mapping, defaultProperty.locked, newProperty.wikilink ?? false),
);
}
}
newPropertyMappings.push(new PropertyMappingModel(newPropertyMappingModel.type, newProperties));
}
}
loadedSettings.propertyMappingModels = newPropertyMappings;
// Store as plain data for serialization
loadedSettings.propertyMappingModels = migratedModels.map(m => m.toJSON());
this.settings = loadedSettings;
}

View file

@ -1,39 +0,0 @@
<!--adapted from @joethei's code: https://github.com/joethei/obsidian-rss/blob/master/src/view/IconComponent.svelte-->
<!--adapted from @javalent's code: https://discord.com/channels/686053708261228577/840286264964022302/902949764209987654-->
<script lang="ts">
import { setIcon } from 'obsidian';
import { onMount } from 'svelte';
interface Props {
iconName?: string;
}
let { iconName = '' }: Props = $props();
let iconEl: HTMLElement | undefined = $state();
onMount(() => {
setIcon(iconEl!, iconName);
});
</script>
{#if iconName.length > 0}
<div class="icon-wrapper">
<div bind:this={iconEl} class="icon"></div>
</div>
{/if}
<style>
.icon-wrapper {
display: inline-block;
position: relative;
width: 20px;
}
.icon {
position: absolute;
height: 20px;
width: 20px;
top: calc(50% - 10px);
}
</style>

24
src/settings/Icon.tsx Normal file
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

@ -27,8 +27,12 @@ export class PropertyMapper {
return obj;
}
// @ts-ignore
const propertyMappings = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type).properties;
const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type);
if (!propertyMappingModel) {
return obj;
}
const propertyMappings = propertyMappingModel.properties;
const newObj: Record<string, unknown> = {};
@ -80,7 +84,8 @@ export class PropertyMapper {
return obj;
}
const propertyMappings = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type)?.properties ?? [];
const propertyMappingModel = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type);
const propertyMappings = propertyMappingModel?.properties ?? [];
const originalObj: Record<string, unknown> = {};

View file

@ -1,6 +1,20 @@
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',
@ -80,6 +94,72 @@ export class PropertyMappingModel {
}
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 {
@ -153,4 +233,20 @@ export class PropertyMapping {
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

@ -1,296 +0,0 @@
<script lang="ts">
import { PropertyMappingModel, PropertyMappingOption, propertyMappingOptions } from './PropertyMapping';
import { capitalizeFirstLetter } from '../utils/Utils';
import Icon from './Icon.svelte';
interface Props {
model: PropertyMappingModel;
save: (model: PropertyMappingModel) => void;
}
let { model, save }: Props = $props();
let unsavedChanges = $state(false);
// svelte-ignore state_referenced_locally
let validationResult: { res: boolean; err?: Error } | undefined = $derived(model.validate());
function onModelUpdate() {
unsavedChanges = true;
model = model.copy();
}
</script>
<div class="media-db-plugin-property-mappings-model-container">
<div class="media-db-plugin-property-mappings-model-header">
<div class="setting-item-name">{capitalizeFirstLetter(model.type)}</div>
<div class="media-db-plugin-property-mappings-model-actions">
{#if unsavedChanges}
<div class="media-db-plugin-property-mapping-unsaved-changes">Unsaved changes</div>
{/if}
<button
class="media-db-plugin-property-mappings-save-button {validationResult?.res ? 'mod-cta' : 'mod-muted'}"
onclick={() => {
if (model.validate().res) {
save(model);
unsavedChanges = false;
}
}}
>
Save
</button>
</div>
</div>
{#if !validationResult?.res}
<div class="media-db-plugin-property-mapping-validation">
{validationResult?.err?.message}
</div>
{/if}
<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>
{#each model.properties as property}
<tr>
<td class="col-property">
<code>{property.property}</code>
</td>
{#if property.locked}
<td class="col-locked" colspan="3">
<div class="media-db-plugin-property-binding-text">property cannot be remapped</div>
</td>
{:else}
<td class="col-mapping">
<select class="dropdown" bind:value={property.mapping} onchange={() => onModelUpdate()}>
{#each propertyMappingOptions as remappingOption}
<option value={remappingOption}>
{remappingOption}
</option>
{/each}
</select>
</td>
<td class="col-new-name">
{#if property.mapping === PropertyMappingOption.Map}
<div class="media-db-plugin-property-mapping-to">
<Icon iconName="arrow-right" />
<input
class="media-db-plugin-property-mapping-input"
type="text"
spellcheck="false"
bind:value={property.newProperty}
onchange={() => onModelUpdate()}
/>
</div>
{:else}
<span class="media-db-plugin-property-mapping-to-disabled"></span>
{/if}
</td>
<td class="col-wikilink">
<label class="media-db-plugin-property-mapping-wikilink-label" title="Convert value to wikilink ([[value]])">
<input type="checkbox" bind:checked={property.wikilink} onchange={() => onModelUpdate()} />
</label>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
/* Container */
.media-db-plugin-property-mappings-model-container {
margin-bottom: var(--size-4-8);
}
/* Header and actions */
.media-db-plugin-property-mappings-model-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--size-4-4);
gap: var(--size-4-3);
}
.media-db-plugin-property-mappings-model-header .setting-item-name {
font-weight: var(--font-semibold);
font-size: var(--font-ui-medium);
color: var(--text-normal);
margin: 0;
}
.media-db-plugin-property-mappings-model-actions {
display: flex;
align-items: center;
gap: var(--size-4-3);
}
.media-db-plugin-property-mapping-unsaved-changes {
color: var(--text-warning);
font-size: var(--font-ui-small);
white-space: nowrap;
}
.media-db-plugin-property-mappings-save-button {
white-space: nowrap;
cursor: pointer;
}
.media-db-plugin-property-mappings-save-button.mod-muted {
opacity: 0.5;
cursor: not-allowed;
}
/* Validation error */
.media-db-plugin-property-mapping-validation {
color: var(--text-error);
background: rgba(var(--color-red-rgb), 0.1);
padding: var(--size-4-3) var(--size-4-4);
margin-bottom: var(--size-4-4);
border-left: 3px solid var(--text-error);
font-size: var(--font-ui-small);
line-height: 1.5;
border-radius: var(--radius-s);
}
/* Table container */
.media-db-plugin-property-mappings-table-container {
overflow-x: auto;
}
.media-db-plugin-property-mappings-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
font-size: var(--font-ui-small);
}
/* Table header */
.media-db-plugin-property-mappings-table thead {
border-bottom: 1px solid var(--background-modifier-border);
}
.media-db-plugin-property-mappings-table th {
padding: var(--size-4-2) var(--size-4-3);
padding-left: 0;
text-align: left;
font-weight: var(--font-semibold);
color: var(--text-muted);
font-size: var(--font-ui-smaller);
text-transform: uppercase;
letter-spacing: 0.02em;
border-bottom: none;
}
/* Table body */
.media-db-plugin-property-mappings-table tbody tr {
transition: background-color 0.1s ease;
}
.media-db-plugin-property-mappings-table td {
padding: var(--size-4-3) var(--size-4-3) var(--size-4-3) 0;
border-bottom: 1px solid var(--background-modifier-border-hover);
vertical-align: middle;
}
.media-db-plugin-property-mappings-table tbody tr:last-child td {
border-bottom: none;
}
/* Column widths */
.col-property {
width: 25%;
white-space: nowrap;
}
.col-mapping {
width: 20%;
}
.col-new-name {
width: 40%;
}
.col-wikilink {
width: 15%;
text-align: center;
}
.col-locked {
text-align: center;
font-style: italic;
}
/* Property name styling */
.media-db-plugin-property-mappings-table code {
padding: var(--size-4-1) var(--size-4-2);
margin: 0;
background: var(--code-background);
color: var(--code-normal);
border-radius: var(--radius-s);
font-size: var(--font-ui-smaller);
font-family: var(--font-monospace);
}
/* Locked property text */
.media-db-plugin-property-binding-text {
color: var(--text-muted);
font-size: var(--font-ui-small);
font-style: italic;
}
/* Dropdown select - use Obsidian defaults */
.media-db-plugin-property-mappings-table select.dropdown {
width: 100%;
max-width: 100%;
}
/* Remap input */
.media-db-plugin-property-mapping-to {
display: flex;
align-items: center;
gap: var(--size-4-2);
min-width: 0;
}
.media-db-plugin-property-mapping-input {
flex: 1;
width: 100%;
font-family: var(--font-monospace);
}
.media-db-plugin-property-mapping-to-disabled {
color: var(--text-faint);
font-size: var(--font-ui-medium);
}
/* Wikilink checkbox */
.media-db-plugin-property-mapping-wikilink-label {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: var(--size-4-1);
}
.media-db-plugin-property-mapping-wikilink-label input[type='checkbox'] {
cursor: pointer;
width: var(--checkbox-size);
height: var(--checkbox-size);
}
</style>

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"></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

@ -1,24 +0,0 @@
<script lang="ts">
import { PropertyMappingModel } from './PropertyMapping';
import PropertyMappingModelComponent from './PropertyMappingModelComponent.svelte';
interface Props {
models?: PropertyMappingModel[];
save: (model: PropertyMappingModel) => void;
}
let { models = [], save }: Props = $props();
</script>
<div class="setting-item" style="display: flex; gap: 10px; flex-direction: column; align-items: stretch;">
{#each models as model}
<PropertyMappingModelComponent {model} {save}></PropertyMappingModelComponent>
{/each}
<!--
<pre>{JSON.stringify(models, null, 4)}</pre>
-->
</div>
<style>
</style>

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

@ -1,13 +1,14 @@
import type { App } from 'obsidian';
import { Notice, PluginSettingTab, SettingGroup } from 'obsidian';
import { render } from 'solid-js/web';
import { MediaType } from 'src/utils/MediaType';
import { mount } from 'svelte';
import type MediaDbPlugin from '../main';
import type { MediaTypeModel } from '../models/MediaTypeModel';
import { MEDIA_TYPES } from '../utils/MediaTypeManager';
import { fragWithHTML, unCamelCase } from '../utils/Utils';
import type { PropertyMappingModelData } from './PropertyMapping';
import { PropertyMapping, PropertyMappingModel, PropertyMappingOption } from './PropertyMapping';
import PropertyMappingModelsComponent from './PropertyMappingModelsComponent.svelte';
import PropertyMappingModelsComponent from './PropertyMappingModelsComponent';
import { FileSuggest } from './suggesters/FileSuggest';
import { FolderSuggest } from './suggesters/FolderSuggest';
@ -74,7 +75,7 @@ export interface MediaDbPluginSettings {
boardgameFolder: string;
bookFolder: string;
propertyMappingModels: PropertyMappingModel[];
propertyMappingModels: PropertyMappingModelData[];
// DEPRECATED: Use propertyMappingModels instead
moviePropertyConversionRules: string;
@ -347,12 +348,10 @@ export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings
const defaultSettings = DEFAULT_SETTINGS;
// construct property mapping defaults
const propertyMappingModels: PropertyMappingModel[] = [];
const propertyMappingModels: PropertyMappingModelData[] = [];
for (const mediaType of MEDIA_TYPES) {
const model: MediaTypeModel = plugin.mediaTypeManager.createMediaTypeModelFromMediaType({}, mediaType);
const metadataObj = model.toMetaDataObject();
// console.log(metadataObj);
// console.log(model);
const propertyMappingModel: PropertyMappingModel = new PropertyMappingModel(mediaType);
@ -368,43 +367,14 @@ export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings
);
}
propertyMappingModels.push(propertyMappingModel);
}
// MIGRATION: Ensure all property mappings have wikilink defined (for settings loaded from disk)
if (defaultSettings.propertyMappingModels && Array.isArray(defaultSettings.propertyMappingModels)) {
for (const model of defaultSettings.propertyMappingModels) {
if (model.properties && Array.isArray(model.properties)) {
for (const prop of model.properties) {
if (typeof prop.wikilink === 'undefined') {
prop.wikilink = false;
}
}
}
}
// Convert to plain data for serialization
propertyMappingModels.push(propertyMappingModel.toJSON());
}
defaultSettings.propertyMappingModels = propertyMappingModels;
return defaultSettings;
}
/**
* Ensures all property mappings in loaded settings have the wikilink property defined.
*/
export function ensureWikilinkOnPropertyMappings(settings: MediaDbPluginSettings): void {
if (settings.propertyMappingModels && Array.isArray(settings.propertyMappingModels)) {
for (const model of settings.propertyMappingModels) {
if (model.properties && Array.isArray(model.properties)) {
for (const prop of model.properties) {
if (typeof prop.wikilink === 'undefined') {
prop.wikilink = false;
}
}
}
}
}
}
// MARK: Settings Tab
export class MediaDbSettingTab extends PluginSettingTab {
plugin: MediaDbPlugin;
@ -787,27 +757,23 @@ export class MediaDbSettingTab extends PluginSettingTab {
),
);
mount(PropertyMappingModelsComponent, {
target: setting.descEl,
props: {
models: this.plugin.settings.propertyMappingModels.map(x => x.copy()),
save: (model: PropertyMappingModel): void => {
const propertyMappingModels: PropertyMappingModel[] = [];
for (const model2 of this.plugin.settings.propertyMappingModels) {
if (model2.type === model.type) {
propertyMappingModels.push(model);
} else {
propertyMappingModels.push(model2);
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;
}
}
this.plugin.settings.propertyMappingModels = propertyMappingModels;
new Notice(`MDB: Property mappings for ${model.type} saved successfully.`);
void this.plugin.saveSettings();
},
},
});
new Notice(`MDB: Property mappings for ${model.type} saved successfully.`);
void this.plugin.saveSettings();
},
}),
setting.descEl,
);
});
}
}

259
src/styles.css Normal file
View file

@ -0,0 +1,259 @@
.media-db-plugin-list-wrapper {
display: flex;
align-content: center;
margin-bottom: 5px;
margin-top: 5px;
}
.media-db-plugin-list-toggle {
}
.media-db-plugin-list-text-wrapper {
flex: 1;
}
.media-db-plugin-list-text {
display: block;
}
small.media-db-plugin-list-text {
color: var(--text-muted);
}
.media-db-plugin-select-modal {
display: contents;
}
.media-db-plugin-select-wrapper {
display: flex;
flex-direction: column;
margin: 5px;
overflow-y: auto;
}
.media-db-plugin-select-element {
cursor: pointer;
border-left: 5px solid transparent;
padding: 5px;
margin: 5px 0 5px 0;
border-radius: 5px;
white-space: pre-wrap;
font-size: 16px;
}
.media-db-plugin-select-element-selected {
border-left: 5px solid var(--interactive-accent) !important;
background: var(--background-secondary-alt);
}
.media-db-plugin-select-element-hover {
background: var(--background-secondary-alt);
}
.media-db-plugin-preview-modal {
display: contents;
}
.media-db-plugin-preview-wrapper {
display: flex;
flex-direction: column;
overflow-y: auto;
}
.media-db-plugin-spacer {
margin-bottom: 10px;
}
.media-db-plugin-button:focus {
/*outline: 1px solid white;*/
}
.media-db-plugin-preview {
border-radius: var(--modal-radius);
border: var(--modal-border-width) solid var(--modal-border-color);
padding: var(--size-4-4);
}
/* Icon Component Styles */
.icon-wrapper {
display: inline-block;
position: relative;
width: 20px;
}
.icon {
position: absolute;
height: 20px;
width: 20px;
top: calc(50% - 10px);
}
/* Property Mapping Component Styles */
.media-db-plugin-property-mappings-model-container {
margin-bottom: var(--size-4-8);
}
.media-db-plugin-property-mappings-model-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--size-4-4);
gap: var(--size-4-3);
}
.media-db-plugin-property-mappings-model-header .setting-item-name {
font-weight: var(--font-semibold);
font-size: var(--font-ui-medium);
color: var(--text-normal);
margin: 0;
}
.media-db-plugin-property-mappings-model-actions {
display: flex;
align-items: center;
gap: var(--size-4-3);
}
.media-db-plugin-property-mapping-unsaved-changes {
color: var(--text-warning);
font-size: var(--font-ui-small);
white-space: nowrap;
}
.media-db-plugin-property-mappings-save-button {
white-space: nowrap;
cursor: pointer;
}
.media-db-plugin-property-mappings-save-button.mod-muted {
opacity: 0.5;
cursor: not-allowed;
}
.media-db-plugin-property-mapping-validation {
color: var(--text-error);
background: rgba(var(--color-red-rgb), 0.1);
padding: var(--size-4-3) var(--size-4-4);
margin-bottom: var(--size-4-4);
border-left: 3px solid var(--text-error);
font-size: var(--font-ui-small);
line-height: 1.5;
border-radius: var(--radius-s);
}
.media-db-plugin-property-mappings-table-container {
overflow-x: auto;
}
.media-db-plugin-property-mappings-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
font-size: var(--font-ui-small);
}
.media-db-plugin-property-mappings-table thead {
border-bottom: 1px solid var(--background-modifier-border);
}
.media-db-plugin-property-mappings-table th {
padding: var(--size-4-2) var(--size-4-3);
padding-left: 0;
text-align: left;
font-weight: var(--font-semibold);
color: var(--text-muted);
font-size: var(--font-ui-smaller);
text-transform: uppercase;
letter-spacing: 0.02em;
border-bottom: none;
}
.media-db-plugin-property-mappings-table tbody tr {
transition: background-color 0.1s ease;
}
.media-db-plugin-property-mappings-table td {
padding: var(--size-4-3) var(--size-4-3) var(--size-4-3) 0;
border-bottom: 1px solid var(--background-modifier-border-hover);
vertical-align: middle;
}
.media-db-plugin-property-mappings-table tbody tr:last-child td {
border-bottom: none;
}
.col-property {
width: 25%;
white-space: nowrap;
}
.col-mapping {
width: 20%;
}
.col-new-name {
width: 40%;
}
.col-wikilink {
width: 15%;
text-align: center;
}
.col-locked {
text-align: center;
font-style: italic;
}
.media-db-plugin-property-mappings-table code {
padding: var(--size-4-1) var(--size-4-2);
margin: 0;
background: var(--code-background);
color: var(--code-normal);
border-radius: var(--radius-s);
font-size: var(--font-ui-smaller);
font-family: var(--font-monospace);
}
.media-db-plugin-property-binding-text {
color: var(--text-muted);
font-size: var(--font-ui-small);
font-style: italic;
}
.media-db-plugin-property-mappings-table select.dropdown {
width: 100%;
max-width: 100%;
}
.media-db-plugin-property-mapping-to {
display: flex;
align-items: center;
gap: var(--size-4-2);
min-width: 0;
}
.media-db-plugin-property-mapping-input {
flex: 1;
width: 100%;
font-family: var(--font-monospace);
}
.media-db-plugin-property-mapping-to-disabled {
color: var(--text-faint);
font-size: var(--font-ui-medium);
}
.media-db-plugin-property-mapping-wikilink-label {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: var(--size-4-1);
}
.media-db-plugin-property-mapping-wikilink-label input[type='checkbox'] {
cursor: pointer;
width: var(--checkbox-size);
height: var(--checkbox-size);
}

View file

@ -1,75 +0,0 @@
.media-db-plugin-list-wrapper {
display: flex;
align-content: center;
margin-bottom: 5px;
margin-top: 5px;
}
.media-db-plugin-list-toggle {
}
.media-db-plugin-list-text-wrapper {
flex: 1;
}
.media-db-plugin-list-text {
display: block;
}
small.media-db-plugin-list-text {
color: var(--text-muted);
}
.media-db-plugin-select-modal {
display: contents;
}
.media-db-plugin-select-wrapper {
display: flex;
flex-direction: column;
margin: 5px;
overflow-y: auto;
}
.media-db-plugin-select-element {
cursor: pointer;
border-left: 5px solid transparent;
padding: 5px;
margin: 5px 0 5px 0;
border-radius: 5px;
white-space: pre-wrap;
font-size: 16px;
}
.media-db-plugin-select-element-selected {
border-left: 5px solid var(--interactive-accent) !important;
background: var(--background-secondary-alt);
}
.media-db-plugin-select-element-hover {
background: var(--background-secondary-alt);
}
.media-db-plugin-preview-modal {
display: contents;
}
.media-db-plugin-preview-wrapper {
display: flex;
flex-direction: column;
overflow-y: auto;
}
.media-db-plugin-spacer {
margin-bottom: 10px;
}
.media-db-plugin-button:focus {
/*outline: 1px solid white;*/
}
.media-db-plugin-preview {
border-radius: var(--modal-radius);
border: var(--modal-border-width) solid var(--modal-border-color);
padding: var(--size-4-4);
}

View file

@ -18,7 +18,10 @@
"moduleDetection": "force",
"sourceMap": true,
"lib": ["DOM", "ESNext"],
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
}

80
vite.config.ts Normal file
View file

@ -0,0 +1,80 @@
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 entryFile = 'src/main.ts';
export default defineConfig(({ mode }) => {
const prod = mode === 'production';
const outDir = prod ? 'dist/' : `exampleVault/.obsidian/plugins/${manifest.id}`;
const plugins = [
solidPlugin(),
banner({
outDir: outDir,
content: getBuildBanner(prod ? 'Release Build' : 'Dev Build', version => version),
}),
viteStaticCopy({
targets: [
{
src: 'manifest.json',
dest: outDir,
},
],
}),
];
return {
plugins,
resolve: {
alias: {
src: path.resolve(__dirname, './src'),
},
},
build: {
lib: {
entry: path.resolve(__dirname, entryFile),
name: 'main',
fileName: () => 'main.js',
formats: ['cjs'],
},
minify: prod,
sourcemap: prod ? false : 'inline',
cssCodeSplit: false,
emptyOutDir: false,
outDir: '',
rollupOptions: {
input: {
main: path.resolve(__dirname, entryFile),
},
output: {
dir: outDir,
entryFileNames: 'main.js',
assetFileNames: 'styles.css',
},
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtins,
],
},
},
};
});