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,6 +1,8 @@
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 =
`/* `/*
@ -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;
} }
async createEntryWithIdSearchModal() { proceed = await this.modalHelper.openPreviewModal({elements: selectResults}, async (previewModalData) => {
let result: MediaTypeModel = undefined; return previewModalData.confirmed;
try { });
const {query, api} = await this.openMediaDbIdSearchModal();
new Notice('MediaDB Searching...');
result = await this.apiManager.queryDetailedInfoById(query, api);
} catch (e) {
console.warn(e);
new Notice(e.toString());
} }
debugLog(result); await this.createMediaDbNotes(selectResults);
await this.createMediaDbNoteFromModel(result); }
async createEntryWithIdSearchModal(): Promise<void> {
let idSearchResult: MediaTypeModel;
let proceed: boolean;
while (!proceed) {
idSearchResult = await this.modalHelper.openIdSearchModal({}, async (idSearchModalData) => {
return await this.apiManager.queryDetailedInfoById(idSearchModalData.query, idSearchModalData.api);
});
if (!idSearchResult) {
return;
}
proceed = await this.modalHelper.openPreviewModal({elements: [idSearchResult]}, async (previewModalData) => {
return previewModalData.confirmed;
});
}
await this.createMediaDbNoteFromModel(idSearchResult, {attachTemplate: true, openNote: true});
} }
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,9 +386,14 @@ 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) {
@ -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});
if (selectModalResult.code === ModalResultCode.CLOSE) {
erroredFiles.push({filePath: file.path, error: 'user canceled'});
selectModal.close();
canceled = true; canceled = true;
continue; 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.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;
constructor(app: App, plugin: MediaDbPlugin, onSubmit?: (res: {query: string, apis: string[]}, err?: Error) => void) { searchBtn: ButtonComponent;
super(app);
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);
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(() => { })
.addButton(btn => {
btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => {
this.search(); 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(() => { })
.addButton(btn => {
btn.setButtonText('Ok');
btn.setCta();
btn.onClick(() => {
this.search(); 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') containerEl.createEl('h3', {text: 'Property Mappings'});
.setDesc('Mappings for the property names of a movie.')
.addTextArea(cb => { let propertyMappingExplanation = containerEl.createEl('div');
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`) propertyMappingExplanation.innerHTML = `
.setValue(this.plugin.settings.moviePropertyConversionRules) <p>Allow you to remap the metadata fields of newly created media db entries.</p>
.onChange(data => { <p>
this.plugin.settings.moviePropertyConversionRules = data; The different options are:
<lu>
<li>"default": does no remapping and keeps the metadata field as it is</li>
<li>"remap": renames the metadata field to what ever you specify</li>
<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 PropertyMappingModelsComponent({
target: this.containerEl,
props: {
models: this.plugin.settings.propertyMappingModels.map(x => x.copy()),
save: (model: PropertyMappingModel) => {
let propertyMappingModels: PropertyMappingModel[] = [];
for (const model2 of this.plugin.settings.propertyMappingModels) {
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(); this.plugin.saveSettings();
}); },
},
}); });
new Setting(containerEl)
.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)
.setName('Game model property mappings')
.setDesc('Mappings for the property names of a game.')
.addTextArea(cb => {
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`)
.setValue(this.plugin.settings.gamePropertyConversionRules)
.onChange(data => {
this.plugin.settings.gamePropertyConversionRules = data;
this.plugin.saveSettings();
});
});
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)
.setName('Music Release model property mappings')
.setDesc('Mappings for the property names of a music release.')
.addTextArea(cb => {
cb.setPlaceholder(`Example: \ntitle -> name\nyear -> releaseYear`)
.setValue(this.plugin.settings.musicReleasePropertyConversionRules)
.onChange(data => {
this.plugin.settings.musicReleasePropertyConversionRules = data;
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,5 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"types": [
"svelte",
"node",
"jest"
],
"baseUrl": ".", "baseUrl": ".",
"inlineSourceMap": true, "inlineSourceMap": true,
"inlineSources": true, "inlineSources": true,
@ -10,6 +15,8 @@
"moduleResolution": "node", "moduleResolution": "node",
"importHelpers": true, "importHelpers": true,
"isolatedModules": true, "isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"lib": [ "lib": [
"DOM", "DOM",
"ES5", "ES5",