Merge pull request #64 from mProjectsCode/master

merge into release
This commit is contained in:
Moritz Jung 2022-10-14 16:29:12 +02:00 committed by GitHub
commit 5b79a8665c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 8121 additions and 659 deletions

32
__mocks__/obsidian.ts Normal file
View file

@ -0,0 +1,32 @@
import { RequestUrlParam, RequestUrlResponse } from "obsidian";
export function requestUrl(request: RequestUrlParam): Promise<RequestUrlResponse> {
return fetch(request.url, {
method: request.method,
headers: request.headers,
body: request.body,
}).then(async (response) => {
if (response.status >= 400 && request.throw) {
throw new Error(`Request failed, ${response.status}`);
}
// Turn response headers into Record<string, string> object
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const arraybuffer = await response.arrayBuffer();
const text = arraybuffer ? new TextDecoder().decode(arraybuffer) : '';
const json = text ? JSON.parse(text) : {};
let response_body: RequestUrlResponse = {
status: response.status,
headers: headers,
arrayBuffer: arraybuffer,
json: json,
text: text,
};
return response_body;
});
}

View file

@ -1,9 +1,11 @@
import esbuild from "esbuild"; import esbuild from 'esbuild';
import process from "process"; import process from 'process';
import builtins from 'builtin-modules' import builtins from 'builtin-modules';
import esbuildSvelte from 'esbuild-svelte';
import sveltePreprocess from 'svelte-preprocess';
const banner = const banner =
`/* `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin if you want to view the source, please visit the github repository of this plugin
*/ */
@ -45,8 +47,14 @@ esbuild.build({
format: 'cjs', format: 'cjs',
watch: !prod, watch: !prod,
target: 'es2016', target: 'es2016',
logLevel: "info", logLevel: 'info',
sourcemap: prod ? false : 'inline', sourcemap: prod ? false : 'inline',
treeShaking: true, treeShaking: true,
outfile: 'main.js', outfile: 'main.js',
plugins: [
esbuildSvelte({
compilerOptions: { css: true },
preprocess: sveltePreprocess(),
}),
],
}).catch(() => process.exit(1)); }).catch(() => process.exit(1));

13
jest.config.js Normal file
View file

@ -0,0 +1,13 @@
module.exports = {
"roots": [
"<rootDir>/src",
"<rootDir>"
],
"testMatch": [
"**/__tests__/**/*.+(ts|tsx|js)",
"**/?(*.)+(spec|test).+(ts|tsx|js)"
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
},
}

6189
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,38 @@
{ {
"name": "obsidian-media-db-plugin", "name": "obsidian-media-db-plugin",
"version": "0.3.1", "version": "0.3.2",
"description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.", "description": "A plugin that can query multiple APIs for movies, series, anime, games, music and wiki articles, and import them into your vault.",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json",
"test": "jest"
}, },
"keywords": [], "keywords": [],
"author": "Moritz Jung", "author": "Moritz Jung",
"license": "GPL-3.0", "license": "GPL-3.0",
"devDependencies": { "devDependencies": {
"@popperjs/core": "^2.11.5", "@popperjs/core": "^2.11.5",
"@tsconfig/svelte": "^3.0.0",
"@types/jest": "^28.1.3",
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.2.0", "@typescript-eslint/eslint-plugin": "^5.2.0",
"@typescript-eslint/parser": "^5.2.0", "@typescript-eslint/parser": "^5.2.0",
"builtin-modules": "^3.2.0", "builtin-modules": "^3.2.0",
"esbuild": "0.13.12", "esbuild": "0.13.12",
"esbuild-svelte": "^0.7.1",
"jest": "^28.1.2",
"jest-fetch-mock": "^3.0.3",
"obsidian": "latest", "obsidian": "latest",
"svelte": "^3.50.1",
"svelte-preprocess": "^4.10.7",
"ts-jest": "^28.0.5",
"tslib": "2.3.1", "tslib": "2.3.1",
"typescript": "4.4.4" "typescript": "4.4.4"
},
"dependencies": {
"ts-node": "^10.8.1",
"yarn": "^1.22.19"
} }
} }

View file

@ -1,6 +1,5 @@
import {APIModel} from './APIModel'; import {APIModel} from './APIModel';
import {MediaTypeModel} from '../models/MediaTypeModel'; import {MediaTypeModel} from '../models/MediaTypeModel';
import {debugLog} from '../utils/Utils';
export class APIManager { export class APIManager {
apis: APIModel[]; apis: APIModel[];
@ -10,7 +9,7 @@ export class APIManager {
} }
async query(query: string, apisToQuery: string[]): Promise<MediaTypeModel[]> { async query(query: string, apisToQuery: string[]): Promise<MediaTypeModel[]> {
debugLog(`MDB | api manager queried with "${query}"`); console.debug(`MDB | api manager queried with "${query}"`);
let res: MediaTypeModel[] = []; let res: MediaTypeModel[] = [];

View file

@ -4,7 +4,6 @@ import MediaDbPlugin from '../../main';
import {BoardGameModel} from 'src/models/BoardGameModel'; import {BoardGameModel} from 'src/models/BoardGameModel';
import {debugLog} from '../../utils/Utils'; import {debugLog} from '../../utils/Utils';
import {requestUrl} from 'obsidian'; import {requestUrl} from 'obsidian';
import {MediaType} from '../../utils/MediaType';
export class BoardGameGeekAPI extends APIModel { export class BoardGameGeekAPI extends APIModel {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
@ -32,16 +31,16 @@ export class BoardGameGeekAPI extends APIModel {
} }
const data = fetchData.text; const data = fetchData.text;
const response = new window.DOMParser().parseFromString(data, "text/xml") const response = new window.DOMParser().parseFromString(data, 'text/xml');
debugLog(response); debugLog(response);
let ret: MediaTypeModel[] = []; let ret: MediaTypeModel[] = [];
for (const boardgame of Array.from(response.querySelectorAll("boardgame"))) { for (const boardgame of Array.from(response.querySelectorAll('boardgame'))) {
const id = boardgame.attributes.getNamedItem("objectid")!.value; const id = boardgame.attributes.getNamedItem('objectid')!.value;
const title = boardgame.querySelector("name")!.textContent!; const title = boardgame.querySelector('name')!.textContent!;
const year = boardgame.querySelector("yearpublished")?.textContent ?? ""; const year = boardgame.querySelector('yearpublished')?.textContent ?? '';
ret.push(new BoardGameModel({ ret.push(new BoardGameModel({
dataSource: this.apiName, dataSource: this.apiName,
@ -68,28 +67,27 @@ export class BoardGameGeekAPI extends APIModel {
} }
const data = fetchData.text; const data = fetchData.text;
const response = new window.DOMParser().parseFromString(data, "text/xml") const response = new window.DOMParser().parseFromString(data, 'text/xml');
debugLog(response); debugLog(response);
const boardgame = response.querySelector("boardgame")!; const boardgame = response.querySelector('boardgame')!;
const title = boardgame.querySelector("name")!.textContent!; const title = boardgame.querySelector('name')!.textContent!;
const year = boardgame.querySelector("yearpublished")?.textContent ?? ""; const year = boardgame.querySelector('yearpublished')?.textContent ?? '';
const image = boardgame.querySelector("image")?.textContent ?? undefined; const image = boardgame.querySelector('image')?.textContent ?? undefined;
const onlineRating = Number.parseFloat(boardgame.querySelector("statistics ratings average")?.textContent ?? ""); const onlineRating = Number.parseFloat(boardgame.querySelector('statistics ratings average')?.textContent ?? '');
const genres = Array.from(boardgame.querySelectorAll("boardgamecategory")).map(n => n!.textContent!); const genres = Array.from(boardgame.querySelectorAll('boardgamecategory')).map(n => n!.textContent!);
const model = new BoardGameModel({ const model = new BoardGameModel({
type: MediaType.BoardGame,
title, title,
englishTitle: title, englishTitle: title,
year: year === "0" ? "" : year, year: year === '0' ? '' : year,
dataSource: this.apiName, dataSource: this.apiName,
url: `https://boardgamegeek.com/boardgame/${id}`, url: `https://boardgamegeek.com/boardgame/${id}`,
id, id: id,
genres, genres: genres,
onlineRating, onlineRating: onlineRating,
image, image: image,
released: true, released: true,
userData: { userData: {

View file

@ -66,6 +66,7 @@ export class WikipediaAPI extends APIModel {
englishTitle: result.title, englishTitle: result.title,
year: '', year: '',
dataSource: this.apiName, dataSource: this.apiName,
url: result.fullurl,
id: result.pageid, id: result.pageid,
wikiUrl: result.fullurl, wikiUrl: result.fullurl,

View file

@ -1,36 +1,31 @@
import {Notice, parseYaml, Plugin, stringifyYaml, TFile, TFolder} from 'obsidian'; import {MarkdownView, Notice, parseYaml, Plugin, stringifyYaml, TFile, TFolder} from 'obsidian';
import {DEFAULT_SETTINGS, MediaDbPluginSettings, MediaDbSettingTab} from './settings/Settings'; import {getDefaultSettings, MediaDbPluginSettings, MediaDbSettingTab} from './settings/Settings';
import {APIManager} from './api/APIManager'; import {APIManager} from './api/APIManager';
import {MediaTypeModel} from './models/MediaTypeModel'; import {MediaTypeModel} from './models/MediaTypeModel';
import {dateTimeToString, debugLog, markdownTable, replaceIllegalFileNameCharactersInString, UserCancelError, UserSkipError} from './utils/Utils'; import {CreateNoteOptions, dateTimeToString, markdownTable, replaceIllegalFileNameCharactersInString} from './utils/Utils';
import {OMDbAPI} from './api/apis/OMDbAPI'; import {OMDbAPI} from './api/apis/OMDbAPI';
import {MediaDbAdvancedSearchModal} from './modals/MediaDbAdvancedSearchModal';
import {MediaDbSearchResultModal} from './modals/MediaDbSearchResultModal';
import {MALAPI} from './api/apis/MALAPI'; import {MALAPI} from './api/apis/MALAPI';
import {MediaDbIdSearchModal} from './modals/MediaDbIdSearchModal';
import {WikipediaAPI} from './api/apis/WikipediaAPI'; import {WikipediaAPI} from './api/apis/WikipediaAPI';
import {MusicBrainzAPI} from './api/apis/MusicBrainzAPI'; import {MusicBrainzAPI} from './api/apis/MusicBrainzAPI';
import {MediaTypeManager} from './utils/MediaTypeManager'; import {MediaTypeManager} from './utils/MediaTypeManager';
import {SteamAPI} from './api/apis/SteamAPI'; import {SteamAPI} from './api/apis/SteamAPI';
import {BoardGameGeekAPI} from './api/apis/BoardGameGeekAPI'; import {BoardGameGeekAPI} from './api/apis/BoardGameGeekAPI';
import {ModelPropertyMapper} from './settings/ModelPropertyMapper'; import {PropertyMapper} from './settings/PropertyMapper';
import {YAMLConverter} from './utils/YAMLConverter'; import {YAMLConverter} from './utils/YAMLConverter';
import {MediaDbFolderImportModal} from './modals/MediaDbFolderImportModal'; import {MediaDbFolderImportModal} from './modals/MediaDbFolderImportModal';
import {PropertyMapping, PropertyMappingModel} from './settings/PropertyMapping';
import {ModalHelper, ModalResultCode} from './utils/ModalHelper';
export default class MediaDbPlugin extends Plugin { export default class MediaDbPlugin extends Plugin {
settings: MediaDbPluginSettings; settings: MediaDbPluginSettings;
apiManager: APIManager; apiManager: APIManager;
mediaTypeManager: MediaTypeManager; mediaTypeManager: MediaTypeManager;
modelPropertyMapper: ModelPropertyMapper; modelPropertyMapper: PropertyMapper;
modalHelper: ModalHelper;
frontMatterRexExpPattern: string = '^(---)\\n[\\s\\S]*?\\n---'; frontMatterRexExpPattern: string = '^(---)\\n[\\s\\S]*?\\n---';
async onload() { async onload() {
await this.loadSettings();
// register the settings tab
this.addSettingTab(new MediaDbSettingTab(this.app, this));
this.apiManager = new APIManager(); this.apiManager = new APIManager();
// register APIs // register APIs
this.apiManager.registerAPI(new OMDbAPI(this)); this.apiManager.registerAPI(new OMDbAPI(this));
@ -41,9 +36,15 @@ export default class MediaDbPlugin extends Plugin {
this.apiManager.registerAPI(new BoardGameGeekAPI(this)); this.apiManager.registerAPI(new BoardGameGeekAPI(this));
// this.apiManager.registerAPI(new LocGovAPI(this)); // TODO: parse data // this.apiManager.registerAPI(new LocGovAPI(this)); // TODO: parse data
this.mediaTypeManager = new MediaTypeManager(this.settings); this.mediaTypeManager = new MediaTypeManager();
this.modelPropertyMapper = new ModelPropertyMapper(this.settings); this.modelPropertyMapper = new PropertyMapper(this);
this.modalHelper = new ModalHelper(this);
await this.loadSettings();
// register the settings tab
this.addSettingTab(new MediaDbSettingTab(this.app, this));
this.mediaTypeManager.updateTemplates(this.settings);
// add icon to the left ribbon // add icon to the left ribbon
const ribbonIconEl = this.addRibbonIcon('database', 'Add new Media DB entry', (evt: MouseEvent) => const ribbonIconEl = this.addRibbonIcon('database', 'Add new Media DB entry', (evt: MouseEvent) =>
@ -56,7 +57,7 @@ export default class MediaDbPlugin extends Plugin {
menu.addItem(item => { menu.addItem(item => {
item.setTitle('Import folder as Media DB entries') item.setTitle('Import folder as Media DB entries')
.setIcon('database') .setIcon('database')
.onClick(() => this.createEntriesFromFolder(file as TFolder)); .onClick(() => this.createEntriesFromFolder(file));
}); });
} }
})); }));
@ -76,62 +77,139 @@ export default class MediaDbPlugin extends Plugin {
// register command to update the open note // register command to update the open note
this.addCommand({ this.addCommand({
id: 'update-media-db-note', id: 'update-media-db-note',
name: 'Update the open note, if it is a Media DB entry.', name: 'Update open note (this will recreate the note)',
checkCallback: (checking: boolean) => { checkCallback: (checking: boolean) => {
if (!this.app.workspace.getActiveFile()) { if (!this.app.workspace.getActiveFile()) {
return false; return false;
} }
if (!checking) { if (!checking) {
this.updateActiveNote(); this.updateActiveNote(false);
}
return true;
},
});
this.addCommand({
id: 'update-media-db-note-metadata',
name: 'Update metadata',
checkCallback: (checking: boolean) => {
if (!this.app.workspace.getActiveFile()) {
return false;
}
if (!checking) {
this.updateActiveNote(true);
}
return true;
},
});
// register link insert command
this.addCommand({
id: 'add-media-db-link',
name: 'Insert link',
checkCallback: (checking: boolean) => {
if (!this.app.workspace.getActiveFile()) {
return false;
}
if (!checking) {
this.createLinkWithSearchModal();
} }
return true; return true;
}, },
}); });
} }
/**
* first very simple approach
* TODO:
* - replace the detail query
* - maybe custom link syntax
*/
async createLinkWithSearchModal() {
let apiSearchResults: MediaTypeModel[] = await this.modalHelper.openAdvancedSearchModal({}, async (advancedSearchModalData) => {
return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis);
});
if (!apiSearchResults) {
return;
}
const selectResults: MediaTypeModel[] = await this.modalHelper.openSelectModal({elements: apiSearchResults, multiSelect: false}, async (selectModalData) => {
return await this.queryDetails(selectModalData.selected);
});
if (!selectResults || selectResults.length < 1) {
return;
}
const link = `[${selectResults[0].title}](${selectResults[0].url})`;
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
// Make sure the user is editing a Markdown file.
if (view) {
view.editor.replaceRange(link, view.editor.getCursor());
}
}
async createEntryWithSearchModal() { async createEntryWithSearchModal() {
} }
/**
* TODO: further refactor: extract it into own method, pass the action (api query) as lambda as well as an options object
*/
async createEntryWithAdvancedSearchModal() { async createEntryWithAdvancedSearchModal() {
let results: MediaTypeModel[] = []; let apiSearchResults: MediaTypeModel[] = await this.modalHelper.openAdvancedSearchModal({}, async (advancedSearchModalData) => {
try { return await this.apiManager.query(advancedSearchModalData.query, advancedSearchModalData.apis);
const {query, apis} = await this.openMediaDbAdvancedSearchModal(); });
new Notice('MediaDB Searching...'); if (!apiSearchResults) {
// TODO: add new notice saying no results found?
const apiSearchResults = await this.apiManager.query(query, apis); return;
const selectResults = await this.openMediaDbSelectModal(apiSearchResults, false);
results = await this.queryDetails(selectResults);
} catch (e) {
console.warn(e);
new Notice(e.toString());
} }
debugLog(results); let selectResults: MediaTypeModel[];
await this.createMediaDbNotes(results); let proceed: boolean;
while (!proceed) {
selectResults = await this.modalHelper.openSelectModal({elements: apiSearchResults}, async (selectModalData) => {
return await this.queryDetails(selectModalData.selected);
});
if (!selectResults) {
return;
}
proceed = await this.modalHelper.openPreviewModal({elements: selectResults}, async (previewModalData) => {
return previewModalData.confirmed;
});
}
await this.createMediaDbNotes(selectResults);
} }
async createEntryWithIdSearchModal() { async createEntryWithIdSearchModal(): Promise<void> {
let result: MediaTypeModel = undefined; let idSearchResult: MediaTypeModel;
try { let proceed: boolean;
const {query, api} = await this.openMediaDbIdSearchModal();
new Notice('MediaDB Searching...'); while (!proceed) {
idSearchResult = await this.modalHelper.openIdSearchModal({}, async (idSearchModalData) => {
return await this.apiManager.queryDetailedInfoById(idSearchModalData.query, idSearchModalData.api);
});
if (!idSearchResult) {
return;
}
result = await this.apiManager.queryDetailedInfoById(query, api); proceed = await this.modalHelper.openPreviewModal({elements: [idSearchResult]}, async (previewModalData) => {
} catch (e) { return previewModalData.confirmed;
console.warn(e); });
new Notice(e.toString());
} }
debugLog(result); await this.createMediaDbNoteFromModel(idSearchResult, {attachTemplate: true, openNote: true});
await this.createMediaDbNoteFromModel(result);
} }
async createMediaDbNotes(models: MediaTypeModel[], attachFile?: TFile): Promise<void> { async createMediaDbNotes(models: MediaTypeModel[], attachFile?: TFile): Promise<void> {
for (const model of models) { for (const model of models) {
await this.createMediaDbNoteFromModel(model, attachFile); await this.createMediaDbNoteFromModel(model, {attachTemplate: true, attachFile: attachFile});
} }
} }
@ -148,44 +226,44 @@ export default class MediaDbPlugin extends Plugin {
return detailModels; return detailModels;
} }
async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, attachFile?: TFile): Promise<void> { async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<void> {
try { try {
console.log('MDB | Creating new note...'); console.debug('MDB | creating new note');
// console.log(mediaTypeModel);
let fileMetadata = this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject()); let fileContent = await this.generateMediaDbNoteContents(mediaTypeModel, options);
let fileContent = '';
({fileMetadata, fileContent} = await this.attachFile(fileMetadata, fileContent, attachFile)); await this.createNote(this.mediaTypeManager.getFileName(mediaTypeModel), fileContent, options.openNote);
({fileMetadata, fileContent} = await this.attachTemplate(fileMetadata, fileContent, await this.mediaTypeManager.getTemplate(mediaTypeModel, this.app)));
fileContent = `---\n${this.settings.useCustomYamlStringifier ? YAMLConverter.toYaml(fileMetadata) : stringifyYaml(fileMetadata)}---\n` + fileContent;
await this.createNote(this.mediaTypeManager.getFileName(mediaTypeModel), fileContent);
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
new Notice(e.toString()); new Notice(e.toString());
} }
} }
async generateMediaDbNoteContents(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions) {
let fileMetadata = this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject());
let fileContent = '';
const template = options.attachTemplate ? await this.mediaTypeManager.getTemplate(mediaTypeModel, this.app) : '';
({fileMetadata, fileContent} = await this.attachFile(fileMetadata, fileContent, options.attachFile));
({fileMetadata, fileContent} = await this.attachTemplate(fileMetadata, fileContent, template));
fileContent = `---\n${this.settings.useCustomYamlStringifier ? YAMLConverter.toYaml(fileMetadata) : stringifyYaml(fileMetadata)}---\n` + fileContent;
return fileContent;
}
async attachFile(fileMetadata: any, fileContent: string, fileToAttach?: TFile): Promise<{ fileMetadata: any, fileContent: string }> { async attachFile(fileMetadata: any, fileContent: string, fileToAttach?: TFile): Promise<{ fileMetadata: any, fileContent: string }> {
if (!fileToAttach) { if (!fileToAttach) {
return {fileMetadata: fileMetadata, fileContent: fileContent}; return {fileMetadata: fileMetadata, fileContent: fileContent};
} }
let attachFileMetadata: any = this.app.metadataCache.getFileCache(fileToAttach).frontmatter; let attachFileMetadata: any = this.getMetadataFromFileCache(fileToAttach);
if (attachFileMetadata) {
attachFileMetadata = JSON.parse(JSON.stringify(attachFileMetadata)); // deep copy
delete attachFileMetadata.position;
} else {
attachFileMetadata = {};
}
fileMetadata = Object.assign(attachFileMetadata, fileMetadata); fileMetadata = Object.assign(attachFileMetadata, fileMetadata);
let attachFileContent: string = await this.app.vault.read(fileToAttach); let attachFileContent: string = await this.app.vault.read(fileToAttach);
const regExp = new RegExp('^(---)\\n[\\s\\S]*\\n---'); const regExp = new RegExp(this.frontMatterRexExpPattern);
attachFileContent = attachFileContent.replace(regExp, ''); attachFileContent = attachFileContent.replace(regExp, '');
fileContent += '\n' + attachFileContent; attachFileContent = attachFileContent.startsWith('\n') ? attachFileContent.substring(1) : attachFileContent;
fileContent += attachFileContent;
return {fileMetadata: fileMetadata, fileContent: fileContent}; return {fileMetadata: fileMetadata, fileContent: fileContent};
} }
@ -198,9 +276,9 @@ export default class MediaDbPlugin extends Plugin {
let templateMetadata: any = this.getMetaDataFromFileContent(template); let templateMetadata: any = this.getMetaDataFromFileContent(template);
fileMetadata = Object.assign(templateMetadata, fileMetadata); fileMetadata = Object.assign(templateMetadata, fileMetadata);
const regExp = new RegExp('^(---)\\n[\\s\\S]*\\n---'); const regExp = new RegExp(this.frontMatterRexExpPattern);
const attachFileContent = template.replace(regExp, ''); const attachFileContent = template.replace(regExp, '');
fileContent += '\n' + attachFileContent; fileContent += attachFileContent;
return {fileMetadata: fileMetadata, fileContent: fileContent}; return {fileMetadata: fileMetadata, fileContent: fileContent};
} }
@ -208,7 +286,7 @@ export default class MediaDbPlugin extends Plugin {
getMetaDataFromFileContent(fileContent: string): any { getMetaDataFromFileContent(fileContent: string): any {
let metadata: any; let metadata: any;
const regExp = new RegExp('^(---)\\n[\\s\\S]*\\n---'); const regExp = new RegExp(this.frontMatterRexExpPattern);
const frontMatterRegExpResult = regExp.exec(fileContent); const frontMatterRegExpResult = regExp.exec(fileContent);
if (!frontMatterRegExpResult) { if (!frontMatterRegExpResult) {
return {}; return {};
@ -226,6 +304,19 @@ export default class MediaDbPlugin extends Plugin {
metadata = {}; metadata = {};
} }
console.debug(`MDB | metadata read from file content`, metadata);
return metadata;
}
getMetadataFromFileCache(file: TFile) {
let metadata: any = this.app.metadataCache.getFileCache(file).frontmatter;
if (metadata) {
metadata = Object.assign({}, metadata); // copy
delete metadata.position;
} else {
metadata = {};
}
return metadata; return metadata;
} }
@ -254,12 +345,13 @@ export default class MediaDbPlugin extends Plugin {
// create the file // create the file
const targetFile = await this.app.vault.create(filePath, fileContent); const targetFile = await this.app.vault.create(filePath, fileContent);
console.debug(`MDB | created new file at ${filePath}`);
// open newly crated file // open newly crated file
if (openFile) { if (openFile) {
const activeLeaf = this.app.workspace.getUnpinnedLeaf(); const activeLeaf = this.app.workspace.getUnpinnedLeaf();
if (!activeLeaf) { if (!activeLeaf) {
console.warn('MDB | no active leaf, not opening media db note'); console.warn('MDB | no active leaf, not opening newly created note');
return; return;
} }
await activeLeaf.openFile(targetFile, {state: {mode: 'source'}}); await activeLeaf.openFile(targetFile, {state: {mode: 'source'}});
@ -270,18 +362,16 @@ export default class MediaDbPlugin extends Plugin {
* Update the active note by querying the API again. * 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, id and dataSource of the active note. If successful it will query the api, delete the old note and create a new one.
*/ */
async updateActiveNote() { async updateActiveNote(onlyMetadata: boolean = false) {
const activeFile: TFile = this.app.workspace.getActiveFile(); const activeFile: TFile = this.app.workspace.getActiveFile();
if (!activeFile) { if (!activeFile) {
throw new Error('MDB | there is no active note'); throw new Error('MDB | there is no active note');
} }
let metadata: any = this.app.metadataCache.getFileCache(activeFile).frontmatter; let metadata: any = this.getMetadataFromFileCache(activeFile);
metadata = JSON.parse(JSON.stringify(metadata)); // deep copy
delete metadata.position; // remove unnecessary data from the FrontMatterCache
metadata = this.modelPropertyMapper.convertObjectBack(metadata); metadata = this.modelPropertyMapper.convertObjectBack(metadata);
debugLog(metadata); console.debug(`MDB | read metadata`, metadata);
if (!metadata?.type || !metadata?.dataSource || !metadata?.id) { if (!metadata?.type || !metadata?.dataSource || !metadata?.id) {
throw new Error('MDB | active note is not a Media DB entry or is missing metadata'); throw new Error('MDB | active note is not a Media DB entry or is missing metadata');
@ -296,16 +386,21 @@ export default class MediaDbPlugin extends Plugin {
newMediaTypeModel = Object.assign(oldMediaTypeModel, newMediaTypeModel.getWithOutUserData()); newMediaTypeModel = Object.assign(oldMediaTypeModel, newMediaTypeModel.getWithOutUserData());
console.log('MDB | deleting old entry'); // deletion not happening anymore why is this log statement still here
await this.app.vault.delete(activeFile); console.debug('MDB | deleting old entry');
await this.createMediaDbNoteFromModel(newMediaTypeModel); if (onlyMetadata) {
await this.createMediaDbNoteFromModel(newMediaTypeModel, {attachFile: activeFile, openNote: true});
} else {
await this.createMediaDbNoteFromModel(newMediaTypeModel, {attachTemplate: true, openNote: true});
}
} }
async createEntriesFromFolder(folder: TFolder) { async createEntriesFromFolder(folder: TFolder) {
const erroredFiles: { filePath: string, error: string }[] = []; const erroredFiles: { filePath: string, error: string }[] = [];
let canceled: boolean = false; let canceled: boolean = false;
const {selectedAPI, titleFieldName, appendContent} = await new Promise<{selectedAPI: string, titleFieldName: string, appendContent: boolean}>((resolve, reject) => { const {selectedAPI, titleFieldName, appendContent} = await new Promise<{ selectedAPI: string, titleFieldName: string, appendContent: boolean }>((resolve, reject) => {
new MediaDbFolderImportModal(this.app, this, ((selectedAPI: string, titleFieldName: string, appendContent: boolean) => { new MediaDbFolderImportModal(this.app, this, ((selectedAPI: string, titleFieldName: string, appendContent: boolean) => {
resolve({selectedAPI, titleFieldName, appendContent}); resolve({selectedAPI, titleFieldName, appendContent});
})).open(); })).open();
@ -313,13 +408,13 @@ export default class MediaDbPlugin extends Plugin {
for (const child of folder.children) { for (const child of folder.children) {
if (child instanceof TFile) { if (child instanceof TFile) {
const file = child as TFile; const file: TFile = child;
if (canceled) { if (canceled) {
erroredFiles.push({filePath: file.path, error: 'user canceled'}); erroredFiles.push({filePath: file.path, error: 'user canceled'});
continue; continue;
} }
let metadata: any = this.app.metadataCache.getFileCache(file).frontmatter; let metadata: any = this.getMetadataFromFileCache(file);
let title = metadata[titleFieldName]; let title = metadata[titleFieldName];
if (!title) { if (!title) {
@ -339,44 +434,36 @@ export default class MediaDbPlugin extends Plugin {
continue; continue;
} }
let selectedResults: MediaTypeModel[] = []; let {selectModalResult, selectModal} = await this.modalHelper.createSelectModal({elements: results, skipButton: true, modalTitle: `Results for \'${title}\'`});
try {
selectedResults = await new Promise((resolve, reject) => {
const searchResultModal = new MediaDbSearchResultModal(this.app, this, results, true, (res, err) => {
if (err) {
return reject(err);
}
resolve(res);
}, () => {
reject(new UserCancelError('user canceled'));
}, () => {
reject(new UserSkipError('user skipped'));
});
searchResultModal.title = `Results for \'${title}\'`; if (selectModalResult.code === ModalResultCode.ERROR) {
searchResultModal.open(); erroredFiles.push({filePath: file.path, error: selectModalResult.error.message});
}); selectModal.close();
} catch (e) { continue;
if (e instanceof UserCancelError) {
erroredFiles.push({filePath: file.path, error: e.message});
canceled = true;
continue;
} else if (e instanceof UserSkipError) {
erroredFiles.push({filePath: file.path, error: e.message});
continue;
} else {
erroredFiles.push({filePath: file.path, error: e.message});
continue;
}
} }
if (selectedResults.length === 0) { if (selectModalResult.code === ModalResultCode.CLOSE) {
erroredFiles.push({filePath: file.path, error: 'user canceled'});
selectModal.close();
canceled = true;
continue;
}
if (selectModalResult.code === ModalResultCode.SKIP) {
erroredFiles.push({filePath: file.path, error: 'user skipped'});
selectModal.close();
continue;
}
if (selectModalResult.data.selected.length === 0) {
erroredFiles.push({filePath: file.path, error: `no search results selected`}); erroredFiles.push({filePath: file.path, error: `no search results selected`});
continue; continue;
} }
const detailedResults = await this.queryDetails(selectedResults); const detailedResults = await this.queryDetails(selectModalResult.data.selected);
await this.createMediaDbNotes(detailedResults, appendContent ? file : null); await this.createMediaDbNotes(detailedResults, appendContent ? file : null);
selectModal.close();
} }
} }
@ -390,54 +477,48 @@ export default class MediaDbPlugin extends Plugin {
const filePath = `${this.settings.folder.replace(/\/$/, '')}/${title}.md`; const filePath = `${this.settings.folder.replace(/\/$/, '')}/${title}.md`;
const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error])); const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error]));
// console.log(table)
let fileContent = `# ${title}\n\n${markdownTable(table)}`; let fileContent = `# ${title}\n\n${markdownTable(table)}`;
const targetFile = await this.app.vault.create(filePath, fileContent); const targetFile = await this.app.vault.create(filePath, fileContent);
} }
async openMediaDbAdvancedSearchModal(): Promise<{ query: string, apis: string[] }> {
return await new Promise((resolve, reject) => {
new MediaDbAdvancedSearchModal(this.app, this, (res, err) => {
if (err) {
return reject(err);
}
resolve(res)
}).open();
});
}
async openMediaDbIdSearchModal(): Promise<{ query: string, api: string }> {
return await new Promise((resolve, reject) => {
new MediaDbIdSearchModal(this.app, this, (res, err) => {
if (err) {
return reject(err);
}
resolve(res)
}).open();
});
}
async openMediaDbSelectModal(resultsToDisplay: MediaTypeModel[], skipButton: boolean = false): Promise<MediaTypeModel[]> {
return await new Promise((resolve, reject) => {
new MediaDbSearchResultModal(this.app, this, resultsToDisplay, skipButton, (res, err) => {
if (err) {
return reject(err);
}
resolve(res);
}, () => {
resolve([])
}).open();
});
}
async loadSettings() { async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); // console.log(DEFAULT_SETTINGS);
const diskSettings: MediaDbPluginSettings = await this.loadData();
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
let newPropertyMappings: PropertyMappingModel[] = [];
for (const defaultPropertyMappingModel of defaultSettings.propertyMappingModels) {
let newPropertyMappingModel: PropertyMappingModel = 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
let newProperties: PropertyMapping[] = [];
for (const defaultProperty of defaultPropertyMappingModel.properties) {
let 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));
}
}
newPropertyMappings.push(new PropertyMappingModel(newPropertyMappingModel.type, newProperties));
}
}
loadedSettings.propertyMappingModels = newPropertyMappings;
this.settings = loadedSettings;
} }
async saveSettings() { async saveSettings() {
this.mediaTypeManager.updateTemplates(this.settings); this.mediaTypeManager.updateTemplates(this.settings);
this.modelPropertyMapper.updateConversionRules(this.settings);
await this.saveData(this.settings); await this.saveData(this.settings);
} }

View file

@ -1,38 +1,53 @@
import {App, ButtonComponent, Component, Modal, Notice, Setting, TextComponent, ToggleComponent} from 'obsidian'; import {ButtonComponent, Modal, Notice, Setting, TextComponent, ToggleComponent} from 'obsidian';
import {MediaTypeModel} from '../models/MediaTypeModel'; import {MediaTypeModel} from '../models/MediaTypeModel';
import {debugLog} from '../utils/Utils';
import MediaDbPlugin from '../main'; import MediaDbPlugin from '../main';
import {ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS, AdvancedSearchModalData, AdvancedSearchModalOptions} from '../utils/ModalHelper';
export class MediaDbAdvancedSearchModal extends Modal { export class MediaDbAdvancedSearchModal extends Modal {
plugin: MediaDbPlugin;
query: string; query: string;
isBusy: boolean; isBusy: boolean;
plugin: MediaDbPlugin; title: string;
searchBtn: ButtonComponent; selectedApis: { name: string, selected: boolean }[];
selectedApis: {name: string, selected: boolean}[];
onSubmit: (res: {query: string, apis: string[]}, err?: Error) => void; searchBtn: ButtonComponent;
submitCallback?: (res: AdvancedSearchModalData) => void;
closeCallback?: (err?: Error) => void;
constructor(plugin: MediaDbPlugin, advancedSearchModalOptions: AdvancedSearchModalOptions) {
advancedSearchModalOptions = Object.assign({}, ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS, advancedSearchModalOptions);
super(plugin.app);
constructor(app: App, plugin: MediaDbPlugin, onSubmit?: (res: {query: string, apis: string[]}, err?: Error) => void) {
super(app);
this.plugin = plugin; this.plugin = plugin;
this.onSubmit = onSubmit;
this.selectedApis = []; this.selectedApis = [];
this.title = advancedSearchModalOptions.modalTitle;
this.query = advancedSearchModalOptions.prefilledSearchString;
for (const api of this.plugin.apiManager.apis) { for (const api of this.plugin.apiManager.apis) {
this.selectedApis.push({name: api.apiName, selected: false}); this.selectedApis.push({name: api.apiName, selected: advancedSearchModalOptions.preselectedAPIs.contains(api.apiName)});
} }
} }
submitCallback(event: KeyboardEvent) { setSubmitCallback(submitCallback: (res: AdvancedSearchModalData) => void): void {
this.submitCallback = submitCallback;
}
setCloseCallback(closeCallback: (err?: Error) => void): void {
this.closeCallback = closeCallback;
}
keyPressCallback(event: KeyboardEvent) {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.search(); this.search();
} }
} }
async search(): Promise<MediaTypeModel[]> { async search(): Promise<MediaTypeModel[]> {
debugLog(this.selectedApis);
if (!this.query || this.query.length < 3) { if (!this.query || this.query.length < 3) {
new Notice('MDB | Query to short'); new Notice('MDB | Query too short');
return; return;
} }
@ -44,31 +59,26 @@ export class MediaDbAdvancedSearchModal extends Modal {
} }
if (!this.isBusy) { if (!this.isBusy) {
try { this.isBusy = true;
this.isBusy = true; this.searchBtn.setDisabled(false);
this.searchBtn.setDisabled(false); this.searchBtn.setButtonText('Searching...');
this.searchBtn.setButtonText('Searching...');
this.onSubmit({query: this.query, apis: apis}); this.submitCallback({query: this.query, apis: apis});
} catch (e) {
this.onSubmit(null, e);
} finally {
this.close();
}
} }
} }
onOpen() { onOpen() {
const {contentEl} = this; const {contentEl} = this;
contentEl.createEl('h2', {text: 'Search media db'}); contentEl.createEl('h2', {text: this.title});
const placeholder = 'Search by title'; const placeholder = 'Search by title';
const searchComponent = new TextComponent(contentEl); const searchComponent = new TextComponent(contentEl);
searchComponent.inputEl.style.width = '100%'; searchComponent.inputEl.style.width = '100%';
searchComponent.setPlaceholder(placeholder); searchComponent.setPlaceholder(placeholder);
searchComponent.setValue(this.query);
searchComponent.onChange(value => (this.query = value)); searchComponent.onChange(value => (this.query = value));
searchComponent.inputEl.addEventListener('keydown', this.submitCallback.bind(this)); searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this));
contentEl.appendChild(searchComponent.inputEl); contentEl.appendChild(searchComponent.inputEl);
searchComponent.inputEl.focus(); searchComponent.inputEl.focus();
@ -76,7 +86,7 @@ export class MediaDbAdvancedSearchModal extends Modal {
contentEl.createDiv({cls: 'media-db-plugin-spacer'}); contentEl.createDiv({cls: 'media-db-plugin-spacer'});
contentEl.createEl('h3', {text: 'APIs to search'}); contentEl.createEl('h3', {text: 'APIs to search'});
const apiToggleComponents: Component[] = []; // const apiToggleComponents: Component[] = [];
for (const api of this.plugin.apiManager.apis) { for (const api of this.plugin.apiManager.apis) {
const apiToggleListElementWrapper = contentEl.createEl('div', {cls: 'media-db-plugin-list-wrapper'}); const apiToggleListElementWrapper = contentEl.createEl('div', {cls: 'media-db-plugin-list-wrapper'});
@ -98,21 +108,26 @@ export class MediaDbAdvancedSearchModal extends Modal {
contentEl.createDiv({cls: 'media-db-plugin-spacer'}); contentEl.createDiv({cls: 'media-db-plugin-spacer'});
new Setting(contentEl) new Setting(contentEl)
.addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close()))
.addButton(btn => { .addButton(btn => {
return (this.searchBtn = btn btn.setButtonText('Cancel');
.setButtonText('Ok') btn.onClick(() => this.close());
.setCta() btn.buttonEl.addClass('media-db-plugin-button');
.onClick(() => { })
this.search(); .addButton(btn => {
})); btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => {
this.search();
});
btn.buttonEl.addClass('media-db-plugin-button');
this.searchBtn = btn;
}); });
} }
onClose() { onClose() {
this.closeCallback();
const {contentEl} = this; const {contentEl} = this;
contentEl.empty(); contentEl.empty();
} }
} }

View file

@ -76,8 +76,20 @@ export class MediaDbFolderImportModal extends Modal {
contentEl.createDiv({cls: 'media-db-plugin-spacer'}); contentEl.createDiv({cls: 'media-db-plugin-spacer'});
new Setting(contentEl) new Setting(contentEl)
.addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close())) .addButton(btn => {
.addButton(btn => btn.setButtonText('Ok').setCta().onClick(() => this.submit())); btn.setButtonText('Cancel');
btn.onClick(() => this.close());
btn.buttonEl.addClass('media-db-plugin-button');
})
.addButton(btn => {
btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => {
this.submit();
});
btn.buttonEl.addClass('media-db-plugin-button');
this.searchBtn = btn;
});
} }
onClose() { onClose() {

View file

@ -1,33 +1,46 @@
import {App, ButtonComponent, DropdownComponent, Modal, Notice, Setting, TextComponent} from 'obsidian'; import {ButtonComponent, DropdownComponent, Modal, Notice, Setting, TextComponent} from 'obsidian';
import {MediaTypeModel} from '../models/MediaTypeModel'; import {MediaTypeModel} from '../models/MediaTypeModel';
import {debugLog} from '../utils/Utils';
import MediaDbPlugin from '../main'; import MediaDbPlugin from '../main';
import {ID_SEARCH_MODAL_DEFAULT_OPTIONS, IdSearchModalData, IdSearchModalOptions} from '../utils/ModalHelper';
export class MediaDbIdSearchModal extends Modal { export class MediaDbIdSearchModal extends Modal {
plugin: MediaDbPlugin;
query: string; query: string;
isBusy: boolean; isBusy: boolean;
plugin: MediaDbPlugin; title: string;
searchBtn: ButtonComponent;
selectedApi: string; selectedApi: string;
onSubmit: (res: {query: string, api: string}, err?: Error) => void;
constructor(app: App, plugin: MediaDbPlugin, onSubmit?: (res: {query: string, api: string}, err?: Error) => void) { searchBtn: ButtonComponent;
super(app);
submitCallback?: (res: IdSearchModalData, err?: Error) => void;
closeCallback?: (err?: Error) => void;
constructor(plugin: MediaDbPlugin, idSearchModalOptions: IdSearchModalOptions) {
idSearchModalOptions = Object.assign({}, ID_SEARCH_MODAL_DEFAULT_OPTIONS, idSearchModalOptions);
super(plugin.app);
this.plugin = plugin; this.plugin = plugin;
this.onSubmit = onSubmit; this.title = idSearchModalOptions.modalTitle;
this.selectedApi = plugin.apiManager.apis[0].apiName; this.selectedApi = idSearchModalOptions.preselectedAPI || plugin.apiManager.apis[0].apiName;
} }
submitCallback(event: KeyboardEvent) { setSubmitCallback(submitCallback: (res: IdSearchModalData, err?: Error) => void): void {
this.submitCallback = submitCallback;
}
setCloseCallback(closeCallback: (err?: Error) => void): void {
this.closeCallback = closeCallback;
}
keyPressCallback(event: KeyboardEvent) {
if (event.key === 'Enter') { if (event.key === 'Enter') {
this.search(); this.search();
} }
} }
async search(): Promise<MediaTypeModel> { async search(): Promise<MediaTypeModel> {
debugLog(this.selectedApi);
if (!this.query) { if (!this.query) {
new Notice('MDB | no Id entered'); new Notice('MDB | no Id entered');
return; return;
@ -39,31 +52,25 @@ export class MediaDbIdSearchModal extends Modal {
} }
if (!this.isBusy) { if (!this.isBusy) {
try { this.isBusy = true;
this.isBusy = true; this.searchBtn.setDisabled(false);
this.searchBtn.setDisabled(false); this.searchBtn.setButtonText('Searching...');
this.searchBtn.setButtonText('Searching...');
this.onSubmit({query: this.query, api: this.selectedApi}); this.submitCallback({query: this.query, api: this.selectedApi});
} catch (e) {
this.onSubmit(null, e);
} finally {
this.close();
}
} }
} }
onOpen() { onOpen() {
const {contentEl} = this; const {contentEl} = this;
contentEl.createEl('h2', {text: 'Search media db by id'}); contentEl.createEl('h2', {text: this.title});
const placeholder = 'Search by id'; const placeholder = 'Search by id';
const searchComponent = new TextComponent(contentEl); const searchComponent = new TextComponent(contentEl);
searchComponent.inputEl.style.width = '100%'; searchComponent.inputEl.style.width = '100%';
searchComponent.setPlaceholder(placeholder); searchComponent.setPlaceholder(placeholder);
searchComponent.onChange(value => (this.query = value)); searchComponent.onChange(value => (this.query = value));
searchComponent.inputEl.addEventListener('keydown', this.submitCallback.bind(this)); searchComponent.inputEl.addEventListener('keydown', this.keyPressCallback.bind(this));
contentEl.appendChild(searchComponent.inputEl); contentEl.appendChild(searchComponent.inputEl);
searchComponent.inputEl.focus(); searchComponent.inputEl.focus();
@ -86,21 +93,26 @@ export class MediaDbIdSearchModal extends Modal {
contentEl.createDiv({cls: 'media-db-plugin-spacer'}); contentEl.createDiv({cls: 'media-db-plugin-spacer'});
new Setting(contentEl) new Setting(contentEl)
.addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close()))
.addButton(btn => { .addButton(btn => {
return (this.searchBtn = btn btn.setButtonText('Cancel');
.setButtonText('Ok') btn.onClick(() => this.close());
.setCta() btn.buttonEl.addClass('media-db-plugin-button');
.onClick(() => { })
this.search(); .addButton(btn => {
})); btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => {
this.search();
});
btn.buttonEl.addClass('media-db-plugin-button');
this.searchBtn = btn;
}); });
} }
onClose() { onClose() {
this.closeCallback();
const {contentEl} = this; const {contentEl} = this;
contentEl.empty(); contentEl.empty();
} }
} }

View file

@ -0,0 +1,82 @@
import {ButtonComponent, MarkdownRenderer, Modal, Setting} from 'obsidian';
import MediaDbPlugin from 'src/main';
import {MediaTypeModel} from 'src/models/MediaTypeModel';
import {PREVIEW_MODAL_DEFAULT_OPTIONS, PreviewModalData, PreviewModalOptions} from '../utils/ModalHelper';
import {CreateNoteOptions} from '../utils/Utils';
export class MediaDbPreviewModal extends Modal {
plugin: MediaDbPlugin;
createNoteOptions: CreateNoteOptions;
elements: MediaTypeModel[];
isBusy: boolean;
title: string;
cancelButton: ButtonComponent;
submitButton: ButtonComponent;
submitCallback: (previewModalData: PreviewModalData) => void;
closeCallback: (err?: Error) => void;
constructor(plugin: MediaDbPlugin, previewModalOptions: PreviewModalOptions) {
previewModalOptions = Object.assign({}, PREVIEW_MODAL_DEFAULT_OPTIONS, previewModalOptions);
super(plugin.app);
this.plugin = plugin;
this.title = previewModalOptions.modalTitle;
this.elements = previewModalOptions.elements;
this.createNoteOptions = previewModalOptions.createNoteOptions;
}
setSubmitCallback(submitCallback: (previewModalData: PreviewModalData) => void): void {
this.submitCallback = submitCallback;
}
setCloseCallback(closeCallback: (err?: Error) => void): void {
this.closeCallback = closeCallback;
}
async preview(): Promise<void> {
let {contentEl} = this;
contentEl.addClass('media-db-plugin-preview-modal');
contentEl.createEl('h2', {text: this.title});
const previewWrapper = contentEl.createDiv({cls: 'media-db-plugin-preview-wrapper'});
for (let result of this.elements) {
previewWrapper.createEl('h3', {text: result.englishTitle});
const fileDiv = previewWrapper.createDiv();
let fileContent = await this.plugin.generateMediaDbNoteContents(result, this.createNoteOptions);
fileContent = `\n${fileContent}\n`;
MarkdownRenderer.renderMarkdown(fileContent, fileDiv, null, null);
}
contentEl.createDiv({cls: 'media-db-plugin-spacer'});
const bottomSettingRow = new Setting(contentEl);
bottomSettingRow.addButton(btn => {
btn.setButtonText('Cancel');
btn.onClick(() => this.closeCallback());
btn.buttonEl.addClass('media-db-plugin-button');
this.cancelButton = btn;
});
bottomSettingRow.addButton(btn => {
btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => this.submitCallback({confirmed: true}));
btn.buttonEl.addClass('media-db-plugin-button');
this.submitButton = btn;
});
}
onOpen(): void {
this.preview();
}
onClose(): void {
this.closeCallback();
}
}

View file

@ -1,31 +1,45 @@
import {App} from 'obsidian';
import {MediaTypeModel} from '../models/MediaTypeModel'; import {MediaTypeModel} from '../models/MediaTypeModel';
import MediaDbPlugin from '../main'; import MediaDbPlugin from '../main';
import {SelectModal} from './SelectModal'; import {SelectModal} from './SelectModal';
import {SELECT_MODAL_OPTIONS_DEFAULT, SelectModalData, SelectModalOptions} from '../utils/ModalHelper';
export class MediaDbSearchResultModal extends SelectModal<MediaTypeModel> { export class MediaDbSearchResultModal extends SelectModal<MediaTypeModel> {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
heading: string;
onSubmit: (res: MediaTypeModel[], err?: Error) => void;
onCancel: () => void;
onSkip: () => void;
busy: boolean;
sendCallback: boolean; sendCallback: boolean;
constructor(app: App, plugin: MediaDbPlugin, elements: MediaTypeModel[], skipButton: boolean, onSubmit: (res: MediaTypeModel[], err?: Error) => void, onCancel: () => void, onSkip?: () => void) { submitCallback: (res: SelectModalData) => void;
super(app, elements); closeCallback: (err?: Error) => void;
this.plugin = plugin; skipCallback: () => void;
this.onSubmit = onSubmit;
this.onCancel = onCancel;
this.onSkip = onSkip;
this.title = 'Search Results';
constructor(plugin: MediaDbPlugin, selectModalOptions: SelectModalOptions) {
selectModalOptions = Object.assign({}, SELECT_MODAL_OPTIONS_DEFAULT, selectModalOptions);
super(plugin.app, selectModalOptions.elements, selectModalOptions.multiSelect);
this.plugin = plugin;
this.title = selectModalOptions.modalTitle;
this.description = 'Select one or multiple search results.'; this.description = 'Select one or multiple search results.';
this.skipButton = skipButton; this.addSkipButton = selectModalOptions.skipButton;
this.busy = false;
this.sendCallback = false; this.sendCallback = false;
} }
setSubmitCallback(submitCallback: (res: SelectModalData) => void): void {
this.submitCallback = submitCallback;
}
setCloseCallback(closeCallback: (err?: Error) => void): void {
this.closeCallback = closeCallback;
}
setSkipCallback(skipCallback: () => void): void {
this.skipCallback = skipCallback;
}
// Renders each suggestion item. // Renders each suggestion item.
renderElement(item: MediaTypeModel, el: HTMLElement) { renderElement(item: MediaTypeModel, el: HTMLElement) {
el.createEl('div', {text: this.plugin.mediaTypeManager.getFileName(item)}); el.createEl('div', {text: this.plugin.mediaTypeManager.getFileName(item)});
@ -35,20 +49,20 @@ export class MediaDbSearchResultModal extends SelectModal<MediaTypeModel> {
// Perform action on the selected suggestion. // Perform action on the selected suggestion.
submit() { submit() {
this.onSubmit(this.selectModalElements.filter(x => x.isActive()).map(x => x.value)); if (!this.busy) {
this.sendCallback = true; this.busy = true;
this.close(); this.submitButton.setButtonText('Creating entry...');
this.submitCallback({selected: this.selectModalElements.filter(x => x.isActive()).map(x => x.value)});
}
} }
skip() { skip() {
this.onSkip(); this.skipButton.setButtonText('Skipping...');
this.sendCallback = true; this.skipCallback();
this.close();
} }
onClose() { onClose() {
if (!this.sendCallback) { console.log('close');
this.onCancel(); this.closeCallback();
}
} }
} }

View file

@ -1,4 +1,4 @@
import {App, Modal, Setting} from 'obsidian'; import {App, ButtonComponent, Modal, Setting} from 'obsidian';
import {SelectModalElement} from './SelectModalElement'; import {SelectModalElement} from './SelectModalElement';
import {mod} from '../utils/Utils'; import {mod} from '../utils/Utils';
@ -7,32 +7,50 @@ export abstract class SelectModal<T> extends Modal {
title: string; title: string;
description: string; description: string;
skipButton: boolean; addSkipButton: boolean;
cancelButton?: ButtonComponent;
skipButton?: ButtonComponent;
submitButton?: ButtonComponent;
elementWrapper?: HTMLDivElement;
elements: T[]; elements: T[];
selectModalElements: SelectModalElement<T>[]; selectModalElements: SelectModalElement<T>[];
protected constructor(app: App, elements: T[]) { protected constructor(app: App, elements: T[], allowMultiSelect: boolean = true) {
super(app); super(app);
this.allowMultiSelect = true; this.allowMultiSelect = allowMultiSelect;
this.title = ''; this.title = '';
this.description = ''; this.description = '';
this.skipButton = false; this.addSkipButton = false;
this.cancelButton = undefined;
this.skipButton = undefined;
this.submitButton = undefined;
this.elementWrapper = undefined;
this.elements = elements; this.elements = elements;
this.selectModalElements = []; this.selectModalElements = [];
this.scope.register([], 'ArrowUp', () => { this.scope.register([], 'ArrowUp', (evt) => {
this.highlightUp(); this.highlightUp();
evt.preventDefault();
}); });
this.scope.register([], 'ArrowDown', () => { this.scope.register([], 'ArrowDown', (evt) => {
this.highlightDown(); this.highlightDown();
evt.preventDefault();
}); });
this.scope.register([], 'ArrowRight', () => { this.scope.register([], 'ArrowRight', () => {
this.activateHighlighted(); this.activateHighlighted();
}); });
this.scope.register([], ' ', (evt) => {
if (this.elementWrapper && this.elementWrapper === document.activeElement) {
this.activateHighlighted();
evt.preventDefault();
}
});
this.scope.register([], 'Enter', () => this.submit()); this.scope.register([], 'Enter', () => this.submit());
} }
@ -59,26 +77,18 @@ export abstract class SelectModal<T> extends Modal {
} }
async onOpen() { async onOpen() {
const {contentEl} = this; const {contentEl, titleEl} = this;
/* titleEl.createEl('h2', {text: this.title});
contentEl.id = 'media-db-plugin-modal' contentEl.addClass('media-db-plugin-select-modal');
contentEl.on('keydown', '#' + contentEl.id, (ev, delegateTarget) => {
console.log(ev.key);
});
*/
contentEl.createEl('h2', {text: this.title});
contentEl.createEl('p', {text: this.description}); contentEl.createEl('p', {text: this.description});
contentEl.addClass('media-db-plugin-select-modal'); this.elementWrapper = contentEl.createDiv({cls: 'media-db-plugin-select-wrapper'});
this.elementWrapper.tabIndex = 0;
const elementWrapper = contentEl.createDiv({cls: 'media-db-plugin-select-wrapper'});
let i = 0; let i = 0;
for (const element of this.elements) { for (const element of this.elements) {
const selectModalElement = new SelectModalElement(element, elementWrapper, i, this, false); const selectModalElement = new SelectModalElement(element, this.elementWrapper, i, this, false);
this.selectModalElements.push(selectModalElement); this.selectModalElements.push(selectModalElement);
@ -89,12 +99,28 @@ export abstract class SelectModal<T> extends Modal {
this.selectModalElements.first()?.element.scrollIntoView(); this.selectModalElements.first()?.element.scrollIntoView();
const bottomSetting = new Setting(contentEl); const bottomSettingRow = new Setting(contentEl);
bottomSetting.addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close())); bottomSettingRow.addButton(btn => {
if (this.skipButton) { btn.setButtonText('Cancel');
bottomSetting.addButton(btn => btn.setButtonText('Skip').onClick(() => this.skip())); btn.onClick(() => this.close());
btn.buttonEl.addClass('media-db-plugin-button');
this.cancelButton = btn;
});
if (this.addSkipButton) {
bottomSettingRow.addButton(btn => {
btn.setButtonText('Skip');
btn.onClick(() => this.skip());
btn.buttonEl.addClass('media-db-plugin-button');
this.skipButton = btn;
});
} }
bottomSetting.addButton(btn => btn.setButtonText('Ok').setCta().onClick(() => this.submit())); bottomSettingRow.addButton(btn => {
btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => this.submit());
btn.buttonEl.addClass('media-db-plugin-button');
this.submitButton = btn;
});
} }
activateHighlighted() { activateHighlighted() {

View file

@ -19,6 +19,15 @@ export class BoardGameModel extends MediaTypeModel {
constructor(obj: any = {}) { constructor(obj: any = {}) {
super(); super();
this.genres = undefined;
this.onlineRating = undefined;
this.image = undefined;
this.released = undefined;
this.userData = {
played: undefined,
personalRating: undefined,
};
Object.assign(this, obj); Object.assign(this, obj);
this.type = this.getMediaType(); this.type = this.getMediaType();

View file

@ -4,15 +4,6 @@ import {MediaType} from '../utils/MediaType';
export class GameModel extends MediaTypeModel { export class GameModel extends MediaTypeModel {
type: string;
subType: string;
title: string;
englishTitle: string;
year: string;
dataSource: string;
url: string;
id: string;
genres: string[]; genres: string[];
onlineRating: number; onlineRating: number;
image: string; image: string;
@ -29,6 +20,16 @@ export class GameModel extends MediaTypeModel {
constructor(obj: any = {}) { constructor(obj: any = {}) {
super(); super();
this.genres = undefined;
this.onlineRating = undefined;
this.image = undefined;
this.released = undefined;
this.releaseDate = undefined;
this.userData = {
played: undefined,
personalRating: undefined,
};
Object.assign(this, obj); Object.assign(this, obj);
this.type = this.getMediaType(); this.type = this.getMediaType();

View file

@ -12,6 +12,19 @@ export abstract class MediaTypeModel {
userData: object; userData: object;
protected constructor() {
this.type = undefined;
this.subType = undefined;
this.title = undefined;
this.englishTitle = undefined;
this.year = undefined;
this.dataSource = undefined;
this.url = undefined;
this.id = undefined;
this.userData = {};
}
abstract getMediaType(): MediaType; abstract getMediaType(): MediaType;
//a string that contains enough info to disambiguate from similar media //a string that contains enough info to disambiguate from similar media
@ -24,7 +37,7 @@ export abstract class MediaTypeModel {
} }
getWithOutUserData(): object { getWithOutUserData(): object {
const copy = JSON.parse(JSON.stringify(this)); const copy = Object.assign({}, this);
delete copy.userData; delete copy.userData;
return copy; return copy;
} }

View file

@ -4,15 +4,6 @@ import {MediaType} from '../utils/MediaType';
export class MovieModel extends MediaTypeModel { export class MovieModel extends MediaTypeModel {
type: string;
subType: string;
title: string;
englishTitle: string;
year: string;
dataSource: string;
url: string;
id: string;
genres: string[]; genres: string[];
producer: string; producer: string;
duration: string; duration: string;
@ -31,6 +22,19 @@ export class MovieModel extends MediaTypeModel {
constructor(obj: any = {}) { constructor(obj: any = {}) {
super(); super();
this.genres = undefined;
this.producer = undefined;
this.duration = undefined;
this.onlineRating = undefined;
this.image = undefined;
this.released = undefined;
this.premiere = undefined;
this.userData = {
watched: undefined,
lastWatched: undefined,
personalRating: undefined,
};
Object.assign(this, obj); Object.assign(this, obj);
this.type = this.getMediaType(); this.type = this.getMediaType();

View file

@ -24,6 +24,13 @@ export class MusicReleaseModel extends MediaTypeModel {
constructor(obj: any = {}) { constructor(obj: any = {}) {
super(); super();
this.genres = undefined;
this.artists = undefined;
this.rating = undefined;
this.userData = {
personalRating: undefined,
};
Object.assign(this, obj); Object.assign(this, obj);
this.type = this.getMediaType(); this.type = this.getMediaType();
@ -38,9 +45,9 @@ export class MusicReleaseModel extends MediaTypeModel {
} }
getSummary(): string { getSummary(): string {
var summary = this.title + ' (' + this.year + ')'; let summary = this.title + ' (' + this.year + ')';
if(this.artists.length > 0) if (this.artists.length > 0)
summary += ' - ' + this.artists.join(', ') summary += ' - ' + this.artists.join(', ');
return summary; return summary;
} }
} }

View file

@ -34,6 +34,22 @@ export class SeriesModel extends MediaTypeModel {
constructor(obj: any = {}) { constructor(obj: any = {}) {
super(); super();
this.genres = undefined;
this.studios = undefined;
this.episodes = undefined;
this.duration = undefined;
this.onlineRating = undefined;
this.image = undefined;
this.released = undefined;
this.airing = undefined;
this.airedFrom = undefined;
this.airedTo = undefined;
this.userData = {
watched: undefined,
lastWatched: undefined,
personalRating: undefined,
};
Object.assign(this, obj); Object.assign(this, obj);
this.type = this.getMediaType(); this.type = this.getMediaType();

View file

@ -23,6 +23,12 @@ export class WikiModel extends MediaTypeModel {
constructor(obj: any = {}) { constructor(obj: any = {}) {
super(); super();
this.wikiUrl = undefined;
this.lastUpdated = undefined;
this.length = undefined;
this.article = undefined;
this.userData = {};
Object.assign(this, obj); Object.assign(this, obj);
this.type = this.getMediaType(); this.type = this.getMediaType();
@ -37,7 +43,7 @@ export class WikiModel extends MediaTypeModel {
} }
override getWithOutUserData(): object { override getWithOutUserData(): object {
const copy = JSON.parse(JSON.stringify(this)); const copy = Object.assign({}, this);
delete copy.userData; delete copy.userData;
delete copy.article; delete copy.article;
return copy; return copy;

36
src/settings/Icon.svelte Normal file
View file

@ -0,0 +1,36 @@
<!--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';
export let iconName: string = '';
export let iconSize: number = 20;
let iconEl: HTMLElement;
onMount(() => {
setIcon(iconEl, iconName, iconSize);
});
</script>
<style>
.icon-wrapper {
display: inline-block;
position: relative;
width: 20px;
}
.icon {
position: absolute;
height: 20px;
width: 20px;
top: calc(50% - 10px);
}
</style>
{#if iconName.length > 0}
<div class="icon-wrapper">
<div bind:this={iconEl} class="icon"></div>
</div>
{/if}

View file

@ -1,27 +0,0 @@
import {containsOnlyLettersAndUnderscores} from '../utils/Utils';
export class ModelPropertyConversionRule {
property: string;
newProperty: string;
constructor(conversionRule: string) {
const conversionRuleParts = conversionRule.split('->');
if (conversionRuleParts.length !== 2) {
throw Error(`Conversion rule "${conversionRule}" may only have exactly one "->"`);
}
let property = conversionRuleParts[0].trim();
let newProperty = conversionRuleParts[1].trim();
if (!property || !containsOnlyLettersAndUnderscores(property)) {
throw Error(`Error in conversion rule "${conversionRule}": property may not be empty and only contain letters and underscores.`);
}
if (!newProperty || !containsOnlyLettersAndUnderscores(newProperty)) {
throw Error(`Error in conversion rule "${conversionRule}": new property may not be empty and only contain letters and underscores.`);
}
this.property = property;
this.newProperty = newProperty;
}
}

View file

@ -1,130 +0,0 @@
import {MediaType} from '../utils/MediaType';
import {MediaDbPluginSettings} from './Settings';
import {ModelPropertyConversionRule} from './ModelPropertyConversionRule';
export class ModelPropertyMapper {
conversionRulesMap: Map<MediaType, string>;
constructor(settings: MediaDbPluginSettings) {
this.updateConversionRules(settings);
}
updateConversionRules(settings: MediaDbPluginSettings) {
this.conversionRulesMap = new Map<MediaType, string>();
this.conversionRulesMap.set(MediaType.Movie, settings.moviePropertyConversionRules);
this.conversionRulesMap.set(MediaType.Series, settings.seriesPropertyConversionRules);
this.conversionRulesMap.set(MediaType.Game, settings.gamePropertyConversionRules);
this.conversionRulesMap.set(MediaType.Wiki, settings.wikiPropertyConversionRules);
this.conversionRulesMap.set(MediaType.MusicRelease, settings.musicReleasePropertyConversionRules);
this.conversionRulesMap.set(MediaType.BoardGame, settings.boardgamePropertyConversionRules);
}
/**
* 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: object): object {
if (!obj.hasOwnProperty('type')) {
return obj;
}
// @ts-ignore
// get conversion rules from settings corresponding to the object type
const conversionRulesString: string = this.conversionRulesMap.get(obj['type']);
if (!conversionRulesString) {
return obj;
}
// parse the conversion rules
const conversionRules: ModelPropertyConversionRule[] = [];
for (const conversionRuleString of conversionRulesString.split('\n')) {
if (conversionRuleString) {
conversionRules.push(new ModelPropertyConversionRule(conversionRuleString));
}
}
const newObj: object = {};
for (const [key, value] of Object.entries(obj)) {
// property 'type' can not be remapped
if (key === 'type') {
// @ts-ignore
newObj[key] = value;
continue;
}
let hasConversionRule = false;
for (const conversionRule of conversionRules) {
if (conversionRule.property === key) {
hasConversionRule = true;
// if the conversion rule maps to 'x', then that means it should be ignored
if (conversionRule.newProperty.toLowerCase() !== 'x') {
// @ts-ignore
newObj[conversionRule.newProperty] = value;
}
}
}
if (!hasConversionRule) {
// @ts-ignore
newObj[key] = value;
}
}
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: object): object {
if (!obj.hasOwnProperty('type')) {
return obj;
}
// @ts-ignore
// get conversion rules from settings corresponding to the object type
const conversionRulesString: string = this.conversionRulesMap.get(obj['type']);
if (!conversionRulesString) {
return obj;
}
const conversionRules: ModelPropertyConversionRule[] = [];
// parse the conversion rules
for (const conversionRuleString of conversionRulesString.split('\n')) {
if (conversionRuleString) {
conversionRules.push(new ModelPropertyConversionRule(conversionRuleString));
}
}
const originalObj: object = {};
for (const [key, value] of Object.entries(obj)) {
// property 'type' can not be remapped
if (key === 'type') {
// @ts-ignore
originalObj[key] = value;
continue;
}
let hasConversionRule = false;
for (const conversionRule of conversionRules) {
if (conversionRule.newProperty === key) {
hasConversionRule = true;
// @ts-ignore
originalObj[conversionRule.property] = value;
}
}
if (!hasConversionRule) {
// @ts-ignore
originalObj[key] = value;
}
}
return originalObj;
}
}

View file

@ -0,0 +1,106 @@
import {PropertyMappingOption} from './PropertyMapping';
import {MEDIA_TYPES} from '../utils/MediaTypeManager';
import MediaDbPlugin from '../main';
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: object): object {
console.log('test1');
if (!obj.hasOwnProperty('type')) {
return obj;
}
console.log('test2');
// @ts-ignore
console.log(obj.type);
// @ts-ignore
if (MEDIA_TYPES.filter(x => x.toString() == obj.type).length < 1) {
return obj;
}
console.log('test3');
// @ts-ignore
const propertyMappings = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type).properties;
const newObj: object = {};
for (const [key, value] of Object.entries(obj)) {
for (const propertyMapping of propertyMappings) {
if (propertyMapping.property === key) {
if (propertyMapping.mapping === PropertyMappingOption.Map) {
// @ts-ignore
newObj[propertyMapping.newProperty] = value;
} else if (propertyMapping.mapping === PropertyMappingOption.Remove) {
} else if (propertyMapping.mapping === PropertyMappingOption.Default) {
// @ts-ignore
newObj[key] = value;
}
break;
}
}
}
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: object): object {
if (!obj.hasOwnProperty('type')) {
return obj;
}
// @ts-ignore
if (MEDIA_TYPES.contains(obj.type)) {
return obj;
}
// @ts-ignore
const propertyMappings = this.plugin.settings.propertyMappingModels.find(x => x.type === obj.type).properties;
const originalObj: object = {};
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
originalObj[propertyMapping.property] = value;
continue objLoop;
}
}
}
return originalObj;
}
}

View file

@ -0,0 +1,150 @@
import {containsOnlyLettersAndUnderscores, PropertyMappingNameConflictError, PropertyMappingValidationError} from '../utils/Utils';
import {MediaType} from '../utils/MediaType';
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() {
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);
copy.properties.push(propertyCopy);
}
return copy;
}
}
export class PropertyMapping {
property: string;
newProperty: string;
locked: boolean;
mapping: PropertyMappingOption;
constructor(property: string, newProperty: string, mapping: PropertyMappingOption, locked?: boolean) {
this.property = property;
this.newProperty = newProperty;
this.mapping = mapping;
this.locked = locked ?? 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 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 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;
}
}

View file

@ -0,0 +1,63 @@
<script lang="ts">
import {PropertyMappingModel, PropertyMappingOption, propertyMappingOptions} from './PropertyMapping';
import {capitalizeFirstLetter} from '../utils/Utils';
import Icon from './Icon.svelte';
export let model: PropertyMappingModel;
export let save: (model: PropertyMappingModel) => void;
let validationResult: { res: boolean, err?: Error };
$: modelChanged(model);
function modelChanged(model: PropertyMappingModel) {
validationResult = model.validate();
}
</script>
<style>
</style>
<div class="media-db-plugin-property-mappings-model-container">
<div class="setting-item-name">{capitalizeFirstLetter(model.type)}</div>
<div class="media-db-plugin-property-mappings-container">
{ #each model.properties as property }
<div class="media-db-plugin-property-mapping-element">
<div class="media-db-plugin-property-mapping-element-property-name-wrapper">
<pre
class="media-db-plugin-property-mapping-element-property-name"><code>{property.property}</code></pre>
</div>
{ #if property.locked }
<div class="media-db-plugin-property-binding-text">
property can not be remapped
</div>
{ :else }
<select class="dropdown" bind:value={property.mapping}>
{#each propertyMappingOptions as remappingOption}
<option value={remappingOption}>
{remappingOption}
</option>
{/each}
</select>
{ #if property.mapping === PropertyMappingOption.Map }
<Icon iconName="arrow-right"/>
<div class="media-db-plugin-property-mapping-to">
<input type="text" spellcheck="false" bind:value="{property.newProperty}">
</div>
{ /if }
{ /if }
</div>
{ /each }
</div>
{ #if !validationResult?.res }
<div class="media-db-plugin-property-mapping-validation">
{validationResult?.err?.message}
</div>
{ /if }
<button
class="media-db-plugin-property-mappings-save-button {validationResult?.res ? 'mod-cta' : 'mod-muted'}"
on:click={() => { if(model.validate().res) save(model) }}>Save
</button>
</div>

View file

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

View file

@ -1,8 +1,12 @@
import {App, PluginSettingTab, Setting} from 'obsidian'; import {App, Notice, PluginSettingTab, Setting} from 'obsidian';
import MediaDbPlugin from '../main'; import MediaDbPlugin from '../main';
import {FolderSuggest} from './suggesters/FolderSuggest'; import {FolderSuggest} from './suggesters/FolderSuggest';
import {FileSuggest} from './suggesters/FileSuggest'; import {FileSuggest} from './suggesters/FileSuggest';
import PropertyMappingModelsComponent from './PropertyMappingModelsComponent.svelte';
import {PropertyMapping, PropertyMappingModel, PropertyMappingOption} from './PropertyMapping';
import {MEDIA_TYPES} from '../utils/MediaTypeManager';
import {MediaTypeModel} from '../models/MediaTypeModel';
export interface MediaDbPluginSettings { export interface MediaDbPluginSettings {
@ -34,9 +38,11 @@ export interface MediaDbPluginSettings {
musicReleasePropertyConversionRules: string, musicReleasePropertyConversionRules: string,
boardgamePropertyConversionRules: string, boardgamePropertyConversionRules: string,
propertyMappingModels: PropertyMappingModel[],
} }
export const DEFAULT_SETTINGS: MediaDbPluginSettings = { const DEFAULT_SETTINGS: MediaDbPluginSettings = {
folder: 'Media DB', folder: 'Media DB',
OMDbKey: '', OMDbKey: '',
sfwFilter: true, sfwFilter: true,
@ -64,8 +70,37 @@ export const DEFAULT_SETTINGS: MediaDbPluginSettings = {
musicReleasePropertyConversionRules: '', musicReleasePropertyConversionRules: '',
boardgamePropertyConversionRules: '', boardgamePropertyConversionRules: '',
propertyMappingModels: [],
}; };
export const lockedPropertyMappings: string[] = ['type', 'id', 'dataSource'];
export function getDefaultSettings(plugin: MediaDbPlugin): MediaDbPluginSettings {
let defaultSettings = DEFAULT_SETTINGS;
// construct property mapping defaults
const propertyMappingModels: PropertyMappingModel[] = [];
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);
for (const key of Object.keys(metadataObj)) {
propertyMappingModel.properties.push(
new PropertyMapping(key, '', PropertyMappingOption.Default, lockedPropertyMappings.contains(key)),
);
}
propertyMappingModels.push(propertyMappingModel);
}
defaultSettings.propertyMappingModels = propertyMappingModels;
return defaultSettings;
}
export class MediaDbSettingTab extends PluginSettingTab { export class MediaDbSettingTab extends PluginSettingTab {
plugin: MediaDbPlugin; plugin: MediaDbPlugin;
@ -296,79 +331,48 @@ export class MediaDbSettingTab extends PluginSettingTab {
}); });
// endregion // endregion
containerEl.createEl('h3', {text: 'Property Mappings'});
// region Property Mappings // region Property Mappings
new Setting(containerEl)
.setName('Movie model property mappings')
.setDesc('Mappings for the property names of a movie.')
.addTextArea(cb => {
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`)
.setValue(this.plugin.settings.moviePropertyConversionRules)
.onChange(data => {
this.plugin.settings.moviePropertyConversionRules = data;
this.plugin.saveSettings();
});
});
new Setting(containerEl) containerEl.createEl('h3', {text: 'Property Mappings'});
.setName('Series model property mappings')
.setDesc('Mappings for the property names of a series.')
.addTextArea(cb => {
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`)
.setValue(this.plugin.settings.seriesPropertyConversionRules)
.onChange(data => {
this.plugin.settings.seriesPropertyConversionRules = data;
this.plugin.saveSettings();
});
});
new Setting(containerEl) let propertyMappingExplanation = containerEl.createEl('div');
.setName('Game model property mappings') propertyMappingExplanation.innerHTML = `
.setDesc('Mappings for the property names of a game.') <p>Allow you to remap the metadata fields of newly created media db entries.</p>
.addTextArea(cb => { <p>
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`) The different options are:
.setValue(this.plugin.settings.gamePropertyConversionRules) <lu>
.onChange(data => { <li>"default": does no remapping and keeps the metadata field as it is</li>
this.plugin.settings.gamePropertyConversionRules = data; <li>"remap": renames the metadata field to what ever you specify</li>
this.plugin.saveSettings(); <li>"remove": removes the metadata field entirely</li>
}); </lu>
}); </p>
<p>
Don't forget to save your changes using the save button for each individual category.
</p>`;
new Setting(containerEl)
.setName('Wiki model property mappings')
.setDesc('Mappings for the property names of a wiki entry.')
.addTextArea(cb => {
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`)
.setValue(this.plugin.settings.wikiPropertyConversionRules)
.onChange(data => {
this.plugin.settings.wikiPropertyConversionRules = data;
this.plugin.saveSettings();
});
});
new Setting(containerEl) new PropertyMappingModelsComponent({
.setName('Music Release model property mappings') target: this.containerEl,
.setDesc('Mappings for the property names of a music release.') props: {
.addTextArea(cb => { models: this.plugin.settings.propertyMappingModels.map(x => x.copy()),
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`) save: (model: PropertyMappingModel) => {
.setValue(this.plugin.settings.musicReleasePropertyConversionRules) let propertyMappingModels: PropertyMappingModel[] = [];
.onChange(data => {
this.plugin.settings.musicReleasePropertyConversionRules = data; for (const model2 of this.plugin.settings.propertyMappingModels) {
this.plugin.saveSettings(); if (model2.type === model.type) {
}); propertyMappingModels.push(model);
}); } else {
propertyMappingModels.push(model2);
}
}
this.plugin.settings.propertyMappingModels = propertyMappingModels;
new Notice(`MDB: Property Mappings for ${model.type} saved successfully.`);
this.plugin.saveSettings();
},
},
});
new Setting(containerEl)
.setName('Board Game model property mappings')
.setDesc('Mappings for the property names of a boardgame.')
.addTextArea(cb => {
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`)
.setValue(this.plugin.settings.boardgamePropertyConversionRules)
.onChange(data => {
this.plugin.settings.boardgamePropertyConversionRules = data;
this.plugin.saveSettings();
});
});
// endregion // endregion
} }

View file

@ -80,7 +80,7 @@ export class Suggest<T> {
} }
setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { setSelectedItem(selectedIndex: number, scrollIntoView: boolean) {
const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); const normalizedIndex = this.suggestions.length > 0 ? wrapAround(selectedIndex, this.suggestions.length) : 0;
const prevSelectedSuggestion = this.suggestions[this.selectedItem]; const prevSelectedSuggestion = this.suggestions[this.selectedItem];
const selectedSuggestion = this.suggestions[normalizedIndex]; const selectedSuggestion = this.suggestions[normalizedIndex];

View file

@ -0,0 +1,110 @@
import fetchMock, {enableFetchMocks} from 'jest-fetch-mock';
import {MediaDbPluginSettings} from 'src/settings/Settings';
import {LocGovAPI} from '../api/apis/LocGovAPI';
import {MALAPI} from '../api/apis/MALAPI';
import {MusicBrainzAPI} from '../api/apis/MusicBrainzAPI';
import {OMDbAPI} from '../api/apis/OMDbAPI';
import {SteamAPI} from '../api/apis/SteamAPI';
import {WikipediaAPI} from '../api/apis/WikipediaAPI';
import MediaDbPlugin from '../main';
import {setMALResponseMock, setMusicBrainzResponseMock, setOMDbResponseMock, setSteamResponseMock, setWikipediaResponseMock} from './mockHelpers';
import MALMockMovie from './ResponseMocks/MALMockMovie.json';
import MusicBrainzResponseMock from './ResponseMocks/MusicBrainzMockResponse.json';
import OMDBMockMovie from './ResponseMocks/OMDBMockResponse.json';
import SteamAPIResponseMock from './ResponseMocks/SteamAPIMockResponse.json';
import WikipediaMockResponse from './ResponseMocks/WikipediaMockResponse.json';
enableFetchMocks();
export let apiMock: OMDbAPI | MALAPI | LocGovAPI | MusicBrainzAPI | SteamAPI | WikipediaAPI;
describe.each(
[
{name: OMDbAPI},
{name: MALAPI},
{name: LocGovAPI},
{name: MusicBrainzAPI},
{name: SteamAPI},
{name: WikipediaAPI},
],
)('$name.name', ({name: parameterizedApi}) => {
beforeAll(() => {
let settingsMock: MediaDbPluginSettings = {} as MediaDbPluginSettings;
let pluginMock = {} as MediaDbPlugin;
pluginMock.settings = settingsMock;
// TODO: add fake API key?
apiMock = new parameterizedApi(pluginMock);
});
beforeEach(() => {
fetchMock.resetMocks();
});
test('searchByTitle behavior when API returns garbage data', async () => {
const garbageResponse = JSON.stringify({
data: 'string',
});
fetchMock.mockResponseOnce(garbageResponse);
await expect(apiMock.searchByTitle('sample')).resolves.toEqual([]);
// }
expect(fetch).toHaveBeenCalledTimes(1);
});
test('searchByTitle behavior when requestUrl/fetch returns 401', async () => {
let sampleResponse = {
data: 'string',
};
fetchMock.mockResponse(JSON.stringify(sampleResponse), {status: 401});
// TODO: Check API name and fix message
// TODO: Externalize string
await expect(apiMock.searchByTitle('sample')).rejects.toThrow(`MDB | Received status code ${401} from an API.`);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('searchByTitle behavior when requestUrl/fetch returns 403', async () => {
let sampleResponse = {
data: 'string',
};
fetchMock.mockResponse(JSON.stringify(sampleResponse), {status: 403});
// TODO: Check API name and fix message
// TODO: Externalize string/import?
await expect(apiMock.searchByTitle('sample')).rejects.toThrow(`MDB | Received status code ${403} from an API.`);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('searchByTitle behavior when requestUrl/fetch returns 200', async () => {
let sampleResponse;
let ret;
switch (parameterizedApi) {
case OMDbAPI:
ret = setOMDbResponseMock();
sampleResponse = OMDBMockMovie;
break;
case WikipediaAPI:
ret = setWikipediaResponseMock();
sampleResponse = WikipediaMockResponse;
break;
case MALAPI:
// TODO: MAL needs more tests for different types of content
ret = setMALResponseMock();
sampleResponse = MALMockMovie;
case LocGovAPI:
// TODO: Add soon
break;
case SteamAPI:
sampleResponse = SteamAPIResponseMock;
ret = setSteamResponseMock();
break;
case MusicBrainzAPI:
sampleResponse = MusicBrainzResponseMock;
ret = setMusicBrainzResponseMock();
break;
default:
throw Error();
}
fetchMock.mockResponse(JSON.stringify(sampleResponse), {status: 200});
// TODO: Check API name and fix message
// TODO: Externalize string
await expect(apiMock.searchByTitle('Hooking Season Playtest')).resolves.toEqual(ret);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,30 @@
{
"data": [
{
"mal_id": 2890,
"url": "https://myanimelist.net/anime/2890/Gake_no_Ue_no_Ponyo",
"title": "Gake no Ue no Ponyo",
"title_english": "Ponyo",
"type": "Movie",
"source": "Original",
"episodes": 1,
"aired": {
"from": "2008-07-19T00:00:00+00:00",
"to": null,
"prop": {
"from": {
"day": 19,
"month": 7,
"year": 2008
},
"to": {
"day": null,
"month": null,
"year": null
}
},
"string": "Jul 19, 2008"
}
}
]
}

View file

@ -0,0 +1,25 @@
{
"release-groups": [
{
"id": "9cf08bf9-1948-4087-abe1-783210ea1fae",
"primary-type-id": "f529b476-6e62-324f-b0aa-1f3e33d313fc",
"title": "Halo Halo",
"first-release-date": "2013-07-08",
"primary-type": "Album",
"artist-credit": [
{
"name": "Halo Halo",
"artist": {
"name": "Halo Halo"
}
}
],
"releases": [
{
"id": "58dd1d57-2201-472e-9e36-5d497dcedb6f",
"title": "Halo Halo"
}
]
}
]
}

View file

@ -0,0 +1,13 @@
{
"Search": [
{
"Title": "Guardians of the Galaxy",
"Year": "2014",
"imdbID": "tt2015381",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BMTAwMjU5OTgxNjZeQTJeQWpwZ15BbWU4MDUxNDYxODEx._V1_SX300.jpg"
}
],
"totalResults": "1",
"Response": "True"
}

View file

@ -0,0 +1,14 @@
{
"applist": {
"apps": [
{
"appid": 2076590,
"name": "Hooking Season Playtest"
},
{
"appid": 2076600,
"name": "MonsterTamer"
}
]
}
}

View file

@ -0,0 +1,20 @@
{
"query": {
"searchinfo": {
"totalhits": 1199001,
"suggestion": "book",
"suggestionsnippet": "book"
},
"search": [
{
"ns": 0,
"title": "Book",
"pageid": 3778,
"size": 68829,
"wordcount": 8821,
"snippet": "called <span class=\"searchmatch\">books</span> or chapters or parts, are parts. The intellectual content in a physical book need not be a composition, nor even be called a book. <span class=\"searchmatch\">Books</span> can",
"timestamp": "2022-08-19T19:13:56Z"
}
]
}
}

85
src/tests/mockHelpers.ts Normal file
View file

@ -0,0 +1,85 @@
import {GameModel} from '../models/GameModel';
import {MovieModel} from '../models/MovieModel';
import {MusicReleaseModel} from '../models/MusicReleaseModel';
import {WikiModel} from '../models/WikiModel';
import {MediaType} from '../utils/MediaType';
import {apiMock} from './ParameterizedAPI.test';
import MALMockMovie from './ResponseMocks/MALMockMovie.json';
import MusicBrainzResponseMock from './ResponseMocks/MusicBrainzMockResponse.json';
import OMDBMockMovie from './ResponseMocks/OMDBMockResponse.json';
import SteamAPIResponseMock from './ResponseMocks/SteamAPIMockResponse.json';
import WikipediaMockResponse from './ResponseMocks/WikipediaMockResponse.json';
export function setWikipediaResponseMock() {
let ret = [];
let wikiresponse = WikipediaMockResponse.query.search[0];
ret.push(new WikiModel({
type: 'wiki',
title: wikiresponse.title,
englishTitle: wikiresponse.title,
year: '',
dataSource: apiMock.apiName,
id: wikiresponse.pageid,
}));
return ret;
}
export function setOMDbResponseMock() {
let ret = [];
let omdbresponse = OMDBMockMovie.Search[0];
ret.push(new MovieModel({
type: 'wiki',
title: omdbresponse.Title,
englishTitle: omdbresponse.Title,
year: omdbresponse.Year,
dataSource: apiMock.apiName,
id: omdbresponse.imdbID,
}));
return ret;
}
export function setMALResponseMock() {
let ret = [];
let result = MALMockMovie.data[0];
ret.push(new MovieModel({
type: result.type,
title: result.title,
englishTitle: result.title_english,
year: result.aired.prop.from.year,
dataSource: apiMock.apiName,
id: result.mal_id,
}));
return ret;
}
export function setSteamResponseMock() {
let ret = [];
let steamResponse = SteamAPIResponseMock.applist.apps[0];
ret.push(new GameModel({
type: MediaType.Game,
title: steamResponse.name,
englishTitle: steamResponse.name,
year: '',
dataSource: apiMock.apiName,
id: steamResponse.appid,
}));
return ret;
}
export function setMusicBrainzResponseMock() {
let ret = [];
let result = MusicBrainzResponseMock['release-groups'][0];
ret.push(new MusicReleaseModel({
type: 'musicRelease',
title: result.title,
englishTitle: result.title,
year: (new Date(result['first-release-date'])).getFullYear().toString(),
dataSource: apiMock.apiName,
url: '',
id: result.id,
artists: result['artist-credit'].map((a: any) => a.name),
subType: result['primary-type'],
} as MusicReleaseModel));
return ret;
}

29
src/tests/utils.test.ts Normal file
View file

@ -0,0 +1,29 @@
import {containsOnlyLettersAndUnderscores, replaceIllegalFileNameCharactersInString, wrapAround} from '../utils/Utils';
test('If wrapAround wraps correctly', () => {
expect(wrapAround(100, 5)).toBe(0);
expect(wrapAround(100, 7)).toBe(2);
});
test('If wrapAround errors out when dividing by zero', () => {
expect(wrapAround(100, 0)).toThrow();
});
test('If wrapAround errors out when size is negative', () => {
expect(wrapAround(100, -5)).toThrow();
});
test('Letter and underscore string validity', () => {
expect(containsOnlyLettersAndUnderscores('asdkfj_')).toBe(true);
expect(containsOnlyLettersAndUnderscores('asdkfj0')).toBe(false);
});
// since this is used to check if a string is a valid name for an object property, unicode characters shouldn't be allowed, thus the name of the function is misleading
test('Letter and underscore unicode char test', () => {
expect(containsOnlyLettersAndUnderscores('asdkaÈj')).toBe(true);
expect(containsOnlyLettersAndUnderscores('asdkaÈj0')).toBe(false);
});
test('Valid filename test', () => {
expect(replaceIllegalFileNameCharactersInString('what?is\\this:')).toBe('whatisthis -');
});

View file

@ -10,12 +10,13 @@ import {WikiModel} from '../models/WikiModel';
import {MusicReleaseModel} from '../models/MusicReleaseModel'; import {MusicReleaseModel} from '../models/MusicReleaseModel';
import {BoardGameModel} from '../models/BoardGameModel'; import {BoardGameModel} from '../models/BoardGameModel';
export const MEDIA_TYPES: MediaType[] = [MediaType.Movie, MediaType.Series, MediaType.Game, MediaType.Wiki, MediaType.MusicRelease, MediaType.BoardGame];
export class MediaTypeManager { export class MediaTypeManager {
mediaFileNameTemplateMap: Map<MediaType, string>; mediaFileNameTemplateMap: Map<MediaType, string>;
mediaTemplateMap: Map<MediaType, string>; mediaTemplateMap: Map<MediaType, string>;
constructor(settings: MediaDbPluginSettings) { constructor() {
this.updateTemplates(settings);
} }
updateTemplates(settings: MediaDbPluginSettings) { updateTemplates(settings: MediaDbPluginSettings) {

418
src/utils/ModalHelper.ts Normal file
View file

@ -0,0 +1,418 @@
import {MediaDbAdvancedSearchModal} from '../modals/MediaDbAdvancedSearchModal';
import {MediaDbIdSearchModal} from '../modals/MediaDbIdSearchModal';
import {MediaTypeModel} from '../models/MediaTypeModel';
import {MediaDbSearchResultModal} from '../modals/MediaDbSearchResultModal';
import {Notice} from 'obsidian';
import MediaDbPlugin from '../main';
import {MediaDbPreviewModal} from 'src/modals/MediaDbPreviewModal';
import {CreateNoteOptions} from './Utils';
export enum ModalResultCode {
SUCCESS,
SKIP,
CLOSE,
ERROR,
}
/**
* Object containing the data {@link ModalHelper.createAdvancedSearchModal} returns.
* On {@link ModalResultCode.SUCCESS} this contains {@link AdvancedSearchModalData}.
* On {@link ModalResultCode.ERROR} this contains a reference to that error.
*/
export interface AdvancedSearchModalResult {
code: ModalResultCode.SUCCESS | ModalResultCode.CLOSE | ModalResultCode.ERROR,
data?: AdvancedSearchModalData,
error?: Error,
}
/**
* Object containing the data {@link ModalHelper.createIdSearchModal} returns.
* On {@link ModalResultCode.SUCCESS} this contains {@link IdSearchModalData}.
* On {@link ModalResultCode.ERROR} this contains a reference to that error.
*/
export interface IdSearchModalResult {
code: ModalResultCode.SUCCESS | ModalResultCode.CLOSE | ModalResultCode.ERROR,
data?: IdSearchModalData,
error?: Error,
}
/**
* Object containing the data {@link ModalHelper.createSelectModal} returns.
* On {@link ModalResultCode.SUCCESS} this contains {@link SelectModalData}.
* On {@link ModalResultCode.ERROR} this contains a reference to that error.
*/
export interface SelectModalResult {
code: ModalResultCode.SUCCESS | ModalResultCode.CLOSE | ModalResultCode.SKIP | ModalResultCode.ERROR,
data?: SelectModalData,
error?: Error,
}
/**
* Object containing the data {@link ModalHelper.createPreviewModal} returns.
* On {@link ModalResultCode.SUCCESS} this contains {@link PreviewModalData}.
* On {@link ModalResultCode.ERROR} this contains a reference to that error.
*/
export interface PreviewModalResult {
code: ModalResultCode.SUCCESS | ModalResultCode.CLOSE | ModalResultCode.ERROR,
data?: PreviewModalData,
error?: Error,
}
/**
* The data the advanced search modal returns.
* - query: the query string
* - apis: the selected APIs
*/
export interface AdvancedSearchModalData {
query: string,
apis: string[],
}
/**
* The data the id search modal returns.
* - query: the query string
* - apis: the selected APIs
*/
export interface IdSearchModalData {
query: string,
api: string,
}
/**
* The data the select modal returns.
* - selected: the selected items
*/
export interface SelectModalData {
selected: MediaTypeModel[],
}
/**
* The data the preview modal returns.
* - confirmed: whether the selected element has been confirmed
*/
export interface PreviewModalData {
confirmed: boolean,
}
/**
* Options for the advanced search modal.
* - modalTitle: the title of the modal
* - preselectedAPIs: a list of preselected APIs
* - prefilledSearchString: prefilled query
*/
export interface AdvancedSearchModalOptions {
modalTitle?: string,
preselectedAPIs?: string[],
prefilledSearchString?: string,
}
/**
* Options for the id search modal.
* - modalTitle: the title of the modal
* - preselectedAPIs: a list of preselected APIs
* - prefilledSearchString: prefilled query
*/
export interface IdSearchModalOptions {
modalTitle?: string,
preselectedAPI?: string,
prefilledSearchString?: string,
}
/**
* Options for the select modal.
* - modalTitle: the title of the modal
* - elements: the elements the user can select from
* - multiSelect: whether to allow multiselect
* - skipButton: whether to add a skip button to the modal
*/
export interface SelectModalOptions {
modalTitle?: string,
elements?: MediaTypeModel[],
multiSelect?: boolean,
skipButton?: boolean,
}
/**
* Options for the preview modal.
* - modalTitle: the title of the modal
* - elements: the elements to preview
*/
export interface PreviewModalOptions {
modalTitle?: string,
elements?: MediaTypeModel[],
createNoteOptions?: CreateNoteOptions,
}
export const ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS: AdvancedSearchModalOptions = {
modalTitle: 'Media DB Advanced Search',
preselectedAPIs: [],
prefilledSearchString: '',
};
export const ID_SEARCH_MODAL_DEFAULT_OPTIONS: IdSearchModalOptions = {
modalTitle: 'Media DB Id Search',
preselectedAPI: '',
prefilledSearchString: '',
};
export const SELECT_MODAL_OPTIONS_DEFAULT: SelectModalOptions = {
modalTitle: 'Media DB Search Results',
elements: [],
multiSelect: true,
skipButton: false,
};
export const PREVIEW_MODAL_DEFAULT_OPTIONS: PreviewModalOptions = {
modalTitle: 'Media DB Preview',
elements: [],
createNoteOptions: {attachTemplate: true},
};
/**
* A class providing multiple usefull functions for dealing with the plugins modals.
*/
export class ModalHelper {
plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
this.plugin = plugin;
}
/**
* Creates an {@link MediaDbAdvancedSearchModal}, then sets callbacks and awaits them,
* returning either the user input once submitted or nothing once closed.
* The modal needs ot be manually closed by calling `close()` on the modal reference.
*
* @param advancedSearchModalOptions the options for the modal, see {@link ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS}
* @returns the user input or nothing and a reference to the modal.
*/
async createAdvancedSearchModal(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise<{ advancedSearchModalResult: AdvancedSearchModalResult, advancedSearchModal: MediaDbAdvancedSearchModal }> {
const modal = new MediaDbAdvancedSearchModal(this.plugin, advancedSearchModalOptions);
const res: AdvancedSearchModalResult = await new Promise((resolve, reject) => {
modal.setSubmitCallback(res => resolve({code: ModalResultCode.SUCCESS, data: res}));
modal.setCloseCallback(err => {
if (err) {
resolve({code: ModalResultCode.ERROR, error: err});
}
resolve({code: ModalResultCode.CLOSE});
});
modal.open();
});
return {advancedSearchModalResult: res, advancedSearchModal: modal};
}
/**
* Opens an {@link MediaDbAdvancedSearchModal} and awaits its result,
* then executes the `submitCallback` returning the callbacks result and closing the modal.
*
* @param advancedSearchModalOptions the options for the modal, see {@link ADVANCED_SEARCH_MODAL_DEFAULT_OPTIONS}
* @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed
* @returns the user input or nothing and a reference to the modal.
*/
async openAdvancedSearchModal(advancedSearchModalOptions: AdvancedSearchModalOptions, submitCallback: (advancedSearchModalData: AdvancedSearchModalData) => Promise<MediaTypeModel[]>): Promise<MediaTypeModel[]> {
const {advancedSearchModalResult, advancedSearchModal} = await this.createAdvancedSearchModal(advancedSearchModalOptions);
if (advancedSearchModalResult.code === ModalResultCode.ERROR) {
// there was an error in the modal itself
console.warn(advancedSearchModalResult.error);
new Notice(advancedSearchModalResult.error.toString());
advancedSearchModal.close();
return undefined;
}
if (advancedSearchModalResult.code === ModalResultCode.CLOSE) {
// modal is already being closed
return undefined;
}
try {
let callbackRes: MediaTypeModel[];
callbackRes = await submitCallback(advancedSearchModalResult.data);
advancedSearchModal.close();
return callbackRes;
} catch (e) {
console.warn(e);
new Notice(e.toString());
advancedSearchModal.close();
return undefined;
}
}
/**
* Creates an {@link MediaDbIdSearchModal}, then sets callbacks and awaits them,
* returning either the user input once submitted or nothing once closed.
* The modal needs ot be manually closed by calling `close()` on the modal reference.
*
* @param idSearchModalOptions the options for the modal, see {@link ID_SEARCH_MODAL_DEFAULT_OPTIONS}
* @returns the user input or nothing and a reference to the modal.
*/
async createIdSearchModal(idSearchModalOptions: IdSearchModalOptions): Promise<{ idSearchModalResult: IdSearchModalResult, idSearchModal: MediaDbIdSearchModal }> {
const modal = new MediaDbIdSearchModal(this.plugin, idSearchModalOptions);
const res: IdSearchModalResult = await new Promise((resolve, reject) => {
modal.setSubmitCallback(res => resolve({code: ModalResultCode.SUCCESS, data: res}));
modal.setCloseCallback(err => {
if (err) {
resolve({code: ModalResultCode.ERROR, error: err});
}
resolve({code: ModalResultCode.CLOSE});
});
modal.open();
});
return {idSearchModalResult: res, idSearchModal: modal};
}
/**
* Opens an {@link MediaDbIdSearchModal} and awaits its result,
* then executes the `submitCallback` returning the callbacks result and closing the modal.
*
* @param idSearchModalOptions the options for the modal, see {@link ID_SEARCH_MODAL_DEFAULT_OPTIONS}
* @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed
* @returns the user input or nothing and a reference to the modal.
*/
async openIdSearchModal(idSearchModalOptions: IdSearchModalOptions, submitCallback: (idSearchModalData: IdSearchModalData) => Promise<MediaTypeModel>): Promise<MediaTypeModel> {
const {idSearchModalResult, idSearchModal} = await this.createIdSearchModal(idSearchModalOptions);
if (idSearchModalResult.code === ModalResultCode.ERROR) {
// there was an error in the modal itself
console.warn(idSearchModalResult.error);
new Notice(idSearchModalResult.error.toString());
idSearchModal.close();
return undefined;
}
if (idSearchModalResult.code === ModalResultCode.CLOSE) {
// modal is already being closed
return undefined;
}
try {
let callbackRes: MediaTypeModel;
callbackRes = await submitCallback(idSearchModalResult.data);
idSearchModal.close();
return callbackRes;
} catch (e) {
console.warn(e);
new Notice(e.toString());
idSearchModal.close();
return undefined;
}
}
/**
* Creates an {@link MediaDbSearchResultModal}, then sets callbacks and awaits them,
* returning either the user input once submitted or nothing once closed.
* The modal needs ot be manually closed by calling `close()` on the modal reference.
*
* @param selectModalOptions the options for the modal, see {@link SELECT_MODAL_OPTIONS_DEFAULT}
* @returns the user input or nothing and a reference to the modal.
*/
async createSelectModal(selectModalOptions: SelectModalOptions): Promise<{ selectModalResult: SelectModalResult, selectModal: MediaDbSearchResultModal }> {
const modal = new MediaDbSearchResultModal(this.plugin, selectModalOptions);
const res: SelectModalResult = await new Promise((resolve, reject) => {
modal.setSubmitCallback(res => resolve({code: ModalResultCode.SUCCESS, data: res}));
modal.setSkipCallback(() => resolve({code: ModalResultCode.SKIP}));
modal.setCloseCallback(err => {
if (err) {
resolve({code: ModalResultCode.ERROR, error: err});
}
resolve({code: ModalResultCode.CLOSE});
});
modal.open();
});
return {selectModalResult: res, selectModal: modal};
}
/**
* Opens an {@link MediaDbSearchResultModal} and awaits its result,
* then executes the `submitCallback` returning the callbacks result and closing the modal.
*
* @param selectModalOptions the options for the modal, see {@link SELECT_MODAL_OPTIONS_DEFAULT}
* @param submitCallback the callback that gets executed after the modal has been submitted, but after it has been closed
* @returns the user input or nothing and a reference to the modal.
*/
async openSelectModal(selectModalOptions: SelectModalOptions, submitCallback: (selectModalData: SelectModalData) => Promise<MediaTypeModel[]>): Promise<MediaTypeModel[]> {
const {selectModalResult, selectModal} = await this.createSelectModal(selectModalOptions);
if (selectModalResult.code === ModalResultCode.ERROR) {
// there was an error in the modal itself
console.warn(selectModalResult.error);
new Notice(selectModalResult.error.toString());
selectModal.close();
return undefined;
}
if (selectModalResult.code === ModalResultCode.CLOSE) {
// modal is already being closed
return undefined;
}
if (selectModalResult.code === ModalResultCode.SKIP) {
// selection was skipped
return undefined;
}
try {
let callbackRes: MediaTypeModel[];
callbackRes = await submitCallback(selectModalResult.data);
selectModal.close();
return callbackRes;
} catch (e) {
console.warn(e);
new Notice(e.toString());
selectModal.close();
return;
}
}
async createPreviewModal(previewModalOptions: PreviewModalOptions): Promise<{ previewModalResult: PreviewModalResult, previewModal: MediaDbPreviewModal }> {
//todo: handle attachFile for existing files
const modal = new MediaDbPreviewModal(this.plugin, previewModalOptions);
const res: PreviewModalResult = await new Promise((resolve, reject) => {
modal.setSubmitCallback(res => resolve({code: ModalResultCode.SUCCESS, data: res}));
modal.setCloseCallback(err => {
if (err) {
resolve({code: ModalResultCode.ERROR, error: err});
}
resolve({code: ModalResultCode.CLOSE});
});
modal.open();
});
return {previewModalResult: res, previewModal: modal};
}
async openPreviewModal(previewModalOptions: PreviewModalOptions, submitCallback: (previewModalData: PreviewModalData) => Promise<boolean>): Promise<boolean> {
const {previewModalResult, previewModal} = await this.createPreviewModal(previewModalOptions);
if (previewModalResult.code === ModalResultCode.ERROR) {
// there was an error in the modal itself
console.warn(previewModalResult.error);
new Notice(previewModalResult.error.toString());
previewModal.close();
return undefined;
}
if (previewModalResult.code === ModalResultCode.CLOSE) {
// modal is already being closed
return undefined;
}
try {
let callbackRes: boolean;
callbackRes = await submitCallback(previewModalResult.data);
previewModal.close();
return callbackRes;
} catch (e) {
console.warn(e);
new Notice(e.toString());
previewModal.close();
return;
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -23,13 +23,13 @@ small.media-db-plugin-list-text{
} }
.media-db-plugin-select-modal { .media-db-plugin-select-modal {
display: flex; display: contents;
flex-direction: column;
} }
.media-db-plugin-select-wrapper { .media-db-plugin-select-wrapper {
display: flex;
flex-direction: column;
margin: 5px; margin: 5px;
flex: 1;
overflow-y: auto; overflow-y: auto;
} }
@ -52,6 +52,71 @@ small.media-db-plugin-list-text{
background: var(--background-secondary-alt); 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 { .media-db-plugin-spacer {
margin-bottom: 10px; margin-bottom: 10px;
} }
/* region property mappings */
.media-db-plugin-property-mappings-model-container {
border: 1px solid var(--background-modifier-border);
border-radius: 5px;
padding: 10px;
width: 100%;
}
.media-db-plugin-property-mappings-container {
margin: 10px 0;
display: flex;
flex-direction: column;
gap: 5px;
}
.media-db-plugin-property-mapping-element {
display: flex;
flex-direction: row;
gap: 10px;
}
.media-db-plugin-property-mapping-element-property-name-wrapper {
min-width: 160px;
background: var(--background-modifier-form-field);
padding: 2px 5px;
border-radius: 5px;
display: flex;
align-items: center;
}
.media-db-plugin-property-mapping-element-property-name {
margin: 0;
}
.media-db-plugin-property-mappings-save-button {
margin: 0;
}
.media-db-plugin-property-mapping-to {
display: flex;
align-items: center;
}
.media-db-plugin-property-mapping-validation {
color: var(--text-error);
margin-bottom: 5px;
}
.media-db-plugin-button:focus {
/*outline: 1px solid white;*/
}
/* endregion */

View file

@ -1,23 +1,30 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "types": [
"inlineSourceMap": true, "svelte",
"inlineSources": true, "node",
"module": "ESNext", "jest"
"target": "ES6", ],
"allowJs": true, "baseUrl": ".",
"noImplicitAny": true, "inlineSourceMap": true,
"moduleResolution": "node", "inlineSources": true,
"importHelpers": true, "module": "ESNext",
"isolatedModules": true, "target": "ES6",
"lib": [ "allowJs": true,
"DOM", "noImplicitAny": true,
"ES5", "moduleResolution": "node",
"ES6", "importHelpers": true,
"ES7" "isolatedModules": true,
] "esModuleInterop": true,
}, "resolveJsonModule": true,
"include": [ "lib": [
"**/*.ts" "DOM",
] "ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
} }