diff --git a/src/main.ts b/src/main.ts index eae5fef..e101a16 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,8 @@ -import {FrontMatterCache, Notice, Plugin, TFile} from 'obsidian'; +import {Notice, Plugin, TFile, TFolder} from 'obsidian'; import {DEFAULT_SETTINGS, MediaDbPluginSettings, MediaDbSettingTab} from './settings/Settings'; import {APIManager} from './api/APIManager'; import {MediaTypeModel} from './models/MediaTypeModel'; -import {replaceIllegalFileNameCharactersInString} from './utils/Utils'; +import {dateTimeToString, markdownTable, replaceIllegalFileNameCharactersInString} from './utils/Utils'; import {OMDbAPI} from './api/apis/OMDbAPI'; import {MediaDbAdvancedSearchModal} from './modals/MediaDbAdvancedSearchModal'; import {MediaDbSearchResultModal} from './modals/MediaDbSearchResultModal'; @@ -14,6 +14,7 @@ import {MediaTypeManager} from './utils/MediaTypeManager'; import {SteamAPI} from './api/apis/SteamAPI'; import {ModelPropertyMapper} from './settings/ModelPropertyMapper'; import {YAMLConverter} from './utils/YAMLConverter'; +import {MediaDbFolderImportModal} from './modals/MediaDbFolderImportModal'; export default class MediaDbPlugin extends Plugin { settings: MediaDbPluginSettings; @@ -30,6 +31,16 @@ export default class MediaDbPlugin extends Plugin { ); ribbonIconEl.addClass('obsidian-media-db-plugin-ribbon-class'); + this.registerEvent(this.app.workspace.on('file-menu', (menu, file) => { + if (file instanceof TFolder) { + menu.addItem(item => { + item.setTitle('Create Media DB entries from folder') + .setIcon('database') + .onClick(() => this.createEntriesFromFolder(file as TFolder)); + }); + } + })); + // register command to open search modal this.addCommand({ id: 'open-media-db-search-modal', @@ -74,16 +85,23 @@ export default class MediaDbPlugin extends Plugin { this.modelPropertyMapper = new ModelPropertyMapper(this.settings); } - async createMediaDbNote(modal: () => Promise): Promise { + async createMediaDbNote(modal: () => Promise): Promise { + let models: MediaTypeModel[] = []; try { - let data: MediaTypeModel = await modal(); - data = await this.apiManager.queryDetailedInfo(data); - - await this.createMediaDbNoteFromModel(data); + models = await modal(); } catch (e) { console.warn(e); new Notice(e.toString()); } + + for (const model of models) { + try { + await this.createMediaDbNoteFromModel(await this.apiManager.queryDetailedInfo(model)); + } catch (e) { + console.warn(e); + new Notice(e.toString()); + } + } } async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel): Promise { @@ -126,27 +144,6 @@ export default class MediaDbPlugin extends Plugin { } } - async openMediaDbAdvancedSearchModal(): Promise { - return new Promise(((resolve, reject) => { - new MediaDbAdvancedSearchModal(this.app, this, (err, results) => { - if (err) return reject(err); - new MediaDbSearchResultModal(this.app, this, results, (err2, res) => { - if (err2) return reject(err2); - resolve(res); - }).open(); - }).open(); - })); - } - - async openMediaDbIdSearchModal(): Promise { - return new Promise(((resolve, reject) => { - new MediaDbIdSearchModal(this.app, this, (err, res) => { - if (err) return reject(err); - resolve(res); - }).open(); - })); - } - async updateActiveNote() { const activeFile: TFile = this.app.workspace.getActiveFile(); if (!activeFile) { @@ -154,16 +151,16 @@ export default class MediaDbPlugin extends Plugin { } let metadata: any = this.app.metadataCache.getFileCache(activeFile).frontmatter; + metadata = JSON.parse(JSON.stringify(metadata)); // deep copy delete metadata.position; // remove unnecessary data from the FrontMatterCache metadata = this.modelPropertyMapper.convertObjectBack(metadata); - console.log(metadata) + console.log(metadata); if (!metadata?.type || !metadata?.dataSource || !metadata?.id) { throw new Error('MDB | active note is not a Media DB entry or is missing metadata'); } - let oldMediaTypeModel = this.mediaTypeManager.createMediaTypeModelFromMediaType(metadata, metadata.type); let newMediaTypeModel = await this.apiManager.queryDetailedInfoById(metadata.id, metadata.dataSource); @@ -178,6 +175,94 @@ export default class MediaDbPlugin extends Plugin { await this.createMediaDbNoteFromModel(newMediaTypeModel); } + async createEntriesFromFolder(folder: TFolder) { + const erroredFiles: { filePath: string, error: string }[] = []; + + const {selectedAPI, titleFieldName} = await new Promise((resolve, reject) => { + new MediaDbFolderImportModal(this.app, this, ((selectedAPI, titleFieldName) => { + resolve({selectedAPI, titleFieldName}); + })).open(); + }); + + const selectedAPIs = {}; + for (const api of this.apiManager.apis) { + // @ts-ignore + selectedAPIs[api.apiName] = api.apiName === selectedAPI; + } + + for (const child of folder.children) { + if (child instanceof TFile) { + const file = child as TFile; + let metadata: any = this.app.metadataCache.getFileCache(file).frontmatter; + + let title = metadata[titleFieldName]; + if (!title) { + erroredFiles.push({filePath: file.path, error: `metadata field \'${titleFieldName}\' not found or empty`}); + continue; + } + + let results: MediaTypeModel[] = []; + try { + results = await this.apiManager.query(title, selectedAPIs); + } catch (e) { + erroredFiles.push({filePath: file.path, error: e.toString()}); + continue; + } + if (!results || results.length === 0) { + erroredFiles.push({filePath: file.path, error: `no search results`}); + continue; + } + + let selectedResults: MediaTypeModel[] = await new Promise((resolve, reject) => { + const searchResultModal = new MediaDbSearchResultModal(this.app, this, results, (err, res) => { + if (err) return reject(err); + resolve(res); + }); + searchResultModal.title = `Results for \'${title}\'`; + searchResultModal.open(); + }); + + await this.createMediaDbNote(async () => selectedResults); + } + } + + if (erroredFiles.length > 0) { + const title = `bulk import error report ${dateTimeToString(new Date())}`; + const filePath = `${this.settings.folder.replace(/\/$/, '')}/${title}.md`; + + const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error])); + // console.log(table) + let fileContent = `# ${title}\n\n${markdownTable(table)}`; + + const targetFile = await this.app.vault.create(filePath, fileContent); + } + } + + async openMediaDbAdvancedSearchModal(): Promise { + return new Promise(((resolve, reject) => { + new MediaDbAdvancedSearchModal(this.app, this, (err, results) => { + if (err) { + return reject(err); + } + new MediaDbSearchResultModal(this.app, this, results, (err2, res) => { + if (err2) { + return reject(err2); + } + resolve(res); + }).open(); + }).open(); + })); + } + + async openMediaDbIdSearchModal(): Promise { + return new Promise(((resolve, reject) => { + new MediaDbIdSearchModal(this.app, this, (err, res) => { + if (err) return reject(err); + resolve(res); + }).open(); + })); + } + async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } diff --git a/src/modals/MediaDbFolderImportModal.ts b/src/modals/MediaDbFolderImportModal.ts new file mode 100644 index 0000000..69b579f --- /dev/null +++ b/src/modals/MediaDbFolderImportModal.ts @@ -0,0 +1,59 @@ +import {App, ButtonComponent, DropdownComponent, Modal, Setting, TextComponent} from 'obsidian'; +import MediaDbPlugin from '../main'; + +export class MediaDbFolderImportModal extends Modal { + plugin: MediaDbPlugin; + onSubmit: (selectedAPI: string, titleFieldName: string) => void; + selectedApi: string; + searchBtn: ButtonComponent; + titleFieldName: string; + + constructor(app: App, plugin: MediaDbPlugin, onSubmit: (selectedAPI: string, titleFieldName: string) => void) { + super(app); + this.plugin = plugin; + this.onSubmit = onSubmit; + this.selectedApi = plugin.apiManager.apis[0].apiName; + } + + submit() { + this.onSubmit(this.selectedApi, this.titleFieldName); + this.close(); + } + + onOpen() { + const {contentEl} = this; + + contentEl.createEl('h2', {text: 'Create Media DB entries from folder'}); + + const apiSelectorWrapper = contentEl.createEl('div', {cls: 'media-db-plugin-list-wrapper'}); + const apiSelectorTextWrapper = apiSelectorWrapper.createEl('div', {cls: 'media-db-plugin-list-text-wrapper'}); + apiSelectorTextWrapper.createEl('span', {text: 'API to search', cls: 'media-db-plugin-list-text'}); + + const apiSelectorComponent = new DropdownComponent(apiSelectorWrapper); + apiSelectorComponent.onChange((value: string) => { + this.selectedApi = value; + }); + for (const api of this.plugin.apiManager.apis) { + apiSelectorComponent.addOption(api.apiName, api.apiName); + } + apiSelectorWrapper.appendChild(apiSelectorComponent.selectEl); + + + const placeholder = 'Title metadata field name'; + const titleFieldNameComponent = new TextComponent(contentEl); + titleFieldNameComponent.inputEl.style.width = '100%'; + titleFieldNameComponent.setPlaceholder(placeholder); + titleFieldNameComponent.onChange(value => this.titleFieldName = value); + + contentEl.appendChild(titleFieldNameComponent.inputEl); + + new Setting(contentEl) + .addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close())) + .addButton(btn => btn.setButtonText('Ok').setCta().onClick(() => this.submit())); + } + + onClose() { + const {contentEl} = this; + contentEl.empty(); + } +} diff --git a/src/modals/MediaDbSearchResultModal.ts b/src/modals/MediaDbSearchResultModal.ts index 7cab85f..2306e77 100644 --- a/src/modals/MediaDbSearchResultModal.ts +++ b/src/modals/MediaDbSearchResultModal.ts @@ -1,35 +1,32 @@ -import {App, SuggestModal} from 'obsidian'; +import {App} from 'obsidian'; import {MediaTypeModel} from '../models/MediaTypeModel'; import MediaDbPlugin from '../main'; +import {SelectModal} from './SelectModal'; -export class MediaDbSearchResultModal extends SuggestModal { - suggestion: MediaTypeModel[]; +export class MediaDbSearchResultModal extends SelectModal { plugin: MediaDbPlugin; - onChoose: (error: Error, result?: MediaTypeModel) => void; + heading: string; + onChoose: (error: Error, result: MediaTypeModel[]) => void; - constructor(app: App, plugin: MediaDbPlugin, suggestion: MediaTypeModel[], onChoose: (error: Error, result?: MediaTypeModel) => void) { - super(app); + constructor(app: App, plugin: MediaDbPlugin, elements: MediaTypeModel[], onChoose: (error: Error, result: MediaTypeModel[]) => void) { + super(app, elements); this.plugin = plugin; - this.suggestion = suggestion; this.onChoose = onChoose; - } - getSuggestions(query: string): MediaTypeModel[] { - return this.suggestion.filter(item => { - const searchQuery = query.toLowerCase(); - return item.title.toLowerCase().includes(searchQuery); - }); + this.title = 'Search Results'; + this.description = 'Select one or multiple search results.'; } // Renders each suggestion item. - renderSuggestion(item: MediaTypeModel, el: HTMLElement) { + renderElement(item: MediaTypeModel, el: HTMLElement) { el.createEl('div', {text: this.plugin.mediaTypeManager.getFileName(item)}); el.createEl('small', {text: `${item.englishTitle}\n`}); el.createEl('small', {text: `${item.type.toUpperCase() + (item.subType ? ` (${item.subType})` : '')} from ${item.dataSource}`}); } // Perform action on the selected suggestion. - onChooseSuggestion(item: MediaTypeModel, evt: MouseEvent | KeyboardEvent) { - this.onChoose(null, item); + submit() { + this.onChoose(null, this.selectModalElements.filter(x => x.isActive()).map(x => x.value)); + this.close(); } } diff --git a/src/modals/SelectModal.ts b/src/modals/SelectModal.ts new file mode 100644 index 0000000..6b11073 --- /dev/null +++ b/src/modals/SelectModal.ts @@ -0,0 +1,72 @@ +import {App, Modal, Setting} from 'obsidian'; +import {SelectModalElement} from './SelectModalElement'; + +export abstract class SelectModal extends Modal { + multiSelect: boolean; + allowMultiSelect: boolean; + + title: string; + description: string; + + elements: T[]; + selectModalElements: SelectModalElement[]; + + + constructor(app: App, elements: T[]) { + super(app); + this.elements = elements; + this.allowMultiSelect = true; + + this.selectModalElements = []; + } + + abstract renderElement(value: T, el: HTMLElement): any; + + abstract submit(): void; + + disableAllOtherElements(elementId: number) { + for (const selectModalElement of this.selectModalElements) { + if (selectModalElement.id !== elementId) { + selectModalElement.setActive(false); + } + } + } + + async onOpen() { + const {contentEl} = this; + + contentEl.createEl('h2', {text: this.title}); + contentEl.createEl('p', {text: this.description}); + + if (this.allowMultiSelect) { + new Setting(contentEl) + .setName('Select Multiple') + .addToggle(cb => { + cb.setValue(this.multiSelect); + cb.onChange(value => { + this.multiSelect = value; + for (const selectModalElement of this.selectModalElements) { + selectModalElement.setActive(false); + } + }); + }); + } + + const elementWrapper = contentEl.createDiv({cls: 'media-db-plugin-select-wrapper'}); + + let i = 0; + for (const element of this.elements) { + const selectModalElement = new SelectModalElement(element, contentEl, i, this, false); + + this.selectModalElements.push(selectModalElement); + + this.renderElement(element, selectModalElement.element); + + i += 1; + } + + new Setting(contentEl) + .addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close())) + .addButton(btn => btn.setButtonText('Ok').setCta().onClick(() => this.submit())); + } +} diff --git a/src/modals/SelectModalElement.ts b/src/modals/SelectModalElement.ts new file mode 100644 index 0000000..40c983f --- /dev/null +++ b/src/modals/SelectModalElement.ts @@ -0,0 +1,72 @@ +import {SelectModal} from './SelectModal'; + +export class SelectModalElement { + selectModal: SelectModal; + value: T; + readonly id: number; + element: HTMLDivElement; + cssClass: string; + activeClass: string; + hoverClass: string; + private active: boolean; + + constructor(value: T, parentElement: HTMLElement, id: number, selectModal: SelectModal, active: boolean = false) { + this.value = value; + this.id = id; + this.active = active; + this.selectModal = selectModal; + + this.cssClass = 'media-db-plugin-select-element'; + this.activeClass = 'media-db-plugin-select-element-selected'; + this.hoverClass = 'media-db-plugin-select-element-hover'; + + this.element = parentElement.createDiv({cls: this.cssClass}); + this.element.id = this.getHTMLId(); + this.element.on('click', '#' + this.getHTMLId(), () => { + this.setActive(!this.active); + if (!this.selectModal.allowMultiSelect || !this.selectModal.multiSelect) { + this.selectModal.disableAllOtherElements(this.id); + } + }); + this.element.on('mouseenter', '#' + this.getHTMLId(), () => { + this.addClass(this.hoverClass); + }); + this.element.on('mouseleave', '#' + this.getHTMLId(), () => { + this.removeClass(this.hoverClass); + }); + } + + isActive(): boolean { + return this.active; + } + + getHTMLId(): string { + return `media-db-plugin-select-element-${this.id}`; + } + + setActive(active: boolean): void { + this.active = active; + this.update(); + } + + update(): void { + if (this.active) { + this.addClass(this.activeClass); + } else { + this.removeClass(this.activeClass); + } + } + + addClass(cssClass: string): void { + if (!this.element.hasClass(cssClass)) { + this.element.addClass(cssClass); + } + } + + removeClass(cssClass: string): void { + if (this.element.hasClass(cssClass)) { + this.element.removeClass(cssClass); + } + } + +} diff --git a/src/models/MediaTypeModel.ts b/src/models/MediaTypeModel.ts index d8fbd4a..d82216d 100644 --- a/src/models/MediaTypeModel.ts +++ b/src/models/MediaTypeModel.ts @@ -1,5 +1,4 @@ import {MediaType} from '../utils/MediaType'; -import {YAMLConverter} from '../utils/YAMLConverter'; export abstract class MediaTypeModel { type: string; diff --git a/src/settings/ModelPropertyMapper.ts b/src/settings/ModelPropertyMapper.ts index a97ed44..f9dc6ad 100644 --- a/src/settings/ModelPropertyMapper.ts +++ b/src/settings/ModelPropertyMapper.ts @@ -29,7 +29,7 @@ export class ModelPropertyMapper { return obj; } - const conversionRules: ModelPropertyConversionRule[] = [] + const conversionRules: ModelPropertyConversionRule[] = []; for (const conversionRuleString of conversionRulesString.split('\n')) { if (conversionRuleString) { conversionRules.push(new ModelPropertyConversionRule(conversionRuleString)); @@ -74,7 +74,7 @@ export class ModelPropertyMapper { return obj; } - const conversionRules: ModelPropertyConversionRule[] = [] + const conversionRules: ModelPropertyConversionRule[] = []; for (const conversionRuleString of conversionRulesString.split('\n')) { if (conversionRuleString) { conversionRules.push(new ModelPropertyConversionRule(conversionRuleString)); diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 0c8334f..847e3cb 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -5,7 +5,7 @@ export const pluginName: string = 'obsidian-media-db-plugin'; export const contactEmail: string = 'm.projects.code@gmail.com'; export const mediaDbTag: string = 'mediaDB'; export const mediaDbVersion: string = '0.2.1'; -export const debug: boolean = false; +export const debug: boolean = true; export function wrapAround(value: number, size: number): number { return ((value % size) + size) % size; @@ -88,3 +88,67 @@ function traverseMetaData(path: Array, mediaTypeModel: MediaTypeModel): return o; } + +export function markdownTable(content: string[][]): string { + let rows = content.length; + if (rows === 0) { + return ''; + } + + let columns = content[0].length; + if (columns === 0) { + return ''; + } + for (const row of content) { + if (row.length !== columns) { + return ''; + } + } + + let longestStringInColumns: number[] = []; + + for (let i = 0; i < columns; i++) { + let longestStringInColumn = 0; + for (const row of content) { + if (row[i].length > longestStringInColumn) { + longestStringInColumn = row[i].length; + } + } + + longestStringInColumns.push(longestStringInColumn); + } + + let table = ''; + + for (let i = 0; i < rows; i++) { + table += '|'; + for (let j = 0; j < columns; j++) { + let element = content[i][j]; + element += ' '.repeat(longestStringInColumns[j] - element.length); + table += ' ' + element + ' |'; + } + table += '\n'; + if (i === 0) { + table += '|'; + for (let j = 0; j < columns; j++) { + table += ' ' + '-'.repeat(longestStringInColumns[j]) + ' |'; + } + table += '\n'; + } + } + + return table; +} + +export function dateToString(date: Date) { + return `${date.getMonth()}-${date.getDay()}-${date.getFullYear()}`; +} + +export function timeToString(time: Date) { + return `${time.getHours()}-${time.getMinutes()}-${time.getSeconds()}`; +} + +export function dateTimeToString(dateTime: Date) { + return `${dateToString(dateTime)} ${timeToString(dateTime)}`; +} + diff --git a/styles.css b/styles.css index 272d000..221a228 100644 --- a/styles.css +++ b/styles.css @@ -21,3 +21,26 @@ small.media-db-plugin-list-text{ color: var(--text-muted); } + +.media-db-plugin-select-wrapper { + margin: 5px; +} + +.media-db-plugin-select-element { + cursor: pointer; + border-left: 5px solid transparent; + padding: 5px; + margin: 5px 0 5px 0; + border-radius: 5px; + white-space: pre-wrap; + font-size: 16px; +} + +.media-db-plugin-select-element-selected { + border-left: 5px solid var(--interactive-accent) !important; + background: var(--background-secondary-alt); +} + +.media-db-plugin-select-element-hover { + background: var(--background-secondary-alt); +}