diff --git a/README.md b/README.md index 1bf3b3c..bf1c232 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,23 @@ title -> name year -> releaseYear ``` +#### Bulk Import +The plugin allows you to import your preexisting media collection and upgrade them to Media DB entries. + +##### Prerequisites +The preexisting media notes must be inside a folder in your vault. +For the plugin to be able to query them they need one metadata field that is used as the title the piece of media is searched by. +This can be achieved by for example using a `csv` import plugin to import an existing list from outside of obsidian. + +##### Importing +To start the import process, right-click on the folder and select the `Import folder as Media DB entries` option. +Then specify the API to search, if the current note content and metadata should be appended to the Media DB entry and the name of the metadata field that contains the title of the piece of media. + +Then the plugin will go through every file in the folder and prompt you to select from the search results. + +##### Post import +After all files have been imported or the import was canceled, you will find the new entries as well as an error report that contains any errors or skipped/canceled files in the folder specified in the setting of the plugin. + ### How to install **The plugin is now released, so it can be installed directly through obsidian's plugin installer.** @@ -113,6 +130,14 @@ Now you select the result you want and the plugin will cast it's magic and creat You are more than welcome to open an issue on [GitHub](https://github.com/mProjectsCode/obsidian-media-db-plugin/issues). ### Changelog +#### 0.3.0 +- Added bulk import. Import a folder of media notes as Media DB entries (thanks to [PaperOrb](https://github.com/PaperOrb) on GitHub for their input and for helping me test this feature) +- Added a custom result select modal that allows you to select multiple results at once +- Fixed a bug where the note creation would fail when the metadata included a field with the values `null` or `undefined` + +#### 0.2.1 +- fixed a small bug with the initial selection of an API in the ID search modal + #### 0.2.0 - Added the option to rename metadata fields through property mappings - fixed note creation falling, when the folder set in the settings did not exist diff --git a/manifest.json b/manifest.json index c0f6377..ecbf5cd 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "id": "obsidian-media-db-plugin", "name": "Media DB Plugin", - "version": "0.2.1", + "version": "0.3.0", "minAppVersion": "0.14.0", - "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.", "author": "Moritz Jung", "authorUrl": "https://mprojectscode.github.io/", "isDesktopOnly": false diff --git a/package.json b/package.json index 7611c0e..b627065 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "obsidian-media-db-plugin", - "version": "0.1.0", - "description": "Coming soon TM", + "version": "0.3.0", + "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", "scripts": { "dev": "node esbuild.config.mjs", @@ -9,8 +9,8 @@ "version": "node version-bump.mjs && git add manifest.json versions.json" }, "keywords": [], - "author": "", - "license": "MIT", + "author": "Moritz Jung", + "license": "GPL-3.0", "devDependencies": { "@popperjs/core": "^2.11.5", "@types/node": "^16.11.6", diff --git a/src/main.ts b/src/main.ts index eae5fef..2583bfc 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, debugLog, markdownTable, replaceIllegalFileNameCharactersInString, UserCancelError, UserSkipError} 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; @@ -26,21 +27,31 @@ export default class MediaDbPlugin extends Plugin { // add icon to the left ribbon const ribbonIconEl = this.addRibbonIcon('database', 'Add new Media DB entry', (evt: MouseEvent) => - this.createMediaDbNote(this.openMediaDbAdvancedSearchModal.bind(this)), + this.createMediaDbNotes(this.openMediaDbAdvancedSearchModal.bind(this)), ); 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('Import folder as Media DB entries') + .setIcon('database') + .onClick(() => this.createEntriesFromFolder(file as TFolder)); + }); + } + })); + // register command to open search modal this.addCommand({ id: 'open-media-db-search-modal', name: 'Add new Media DB entry', - callback: () => this.createMediaDbNote(this.openMediaDbAdvancedSearchModal.bind(this)), + callback: () => this.createMediaDbNotes(this.openMediaDbAdvancedSearchModal.bind(this)), }); // register command to open id search modal this.addCommand({ id: 'open-media-db-id-search-modal', name: 'Add new Media DB entry by id', - callback: () => this.createMediaDbNote(this.openMediaDbIdSearchModal.bind(this)), + callback: () => this.createMediaDbNotes(this.openMediaDbIdSearchModal.bind(this)), }); // register command to update the open note this.addCommand({ @@ -74,79 +85,92 @@ export default class MediaDbPlugin extends Plugin { this.modelPropertyMapper = new ModelPropertyMapper(this.settings); } - async createMediaDbNote(modal: () => Promise): Promise { + async createMediaDbNotes(modal: () => Promise, attachFile?: TFile): Promise { + let models: MediaTypeModel[] = []; try { - let data: MediaTypeModel = await modal(); - data = await this.apiManager.queryDetailedInfo(data); + models = await modal(); + } catch (e) { + console.warn(e); + new Notice(e.toString()); + } - await this.createMediaDbNoteFromModel(data); + for (const model of models) { + try { + await this.createMediaDbNoteFromModel(await this.apiManager.queryDetailedInfo(model), attachFile); + } catch (e) { + console.warn(e); + new Notice(e.toString()); + } + } + } + + async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, attachFile?: TFile): Promise { + try { + console.log('MDB | Creating new note...'); + // console.log(mediaTypeModel); + + let metadata = this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject()); + if (attachFile) { + let attachFileMetadata: any = this.app.metadataCache.getFileCache(attachFile).frontmatter; + if (attachFileMetadata) { + attachFileMetadata = JSON.parse(JSON.stringify(attachFileMetadata)); // deep copy + delete attachFileMetadata.position; + } else { + attachFileMetadata = {}; + } + + metadata = Object.assign(attachFileMetadata, metadata); + } + + debugLog(metadata); + + let fileContent = `---\n${YAMLConverter.toYaml(metadata)}---\n`; + + if (this.settings.templates) { + fileContent += await this.mediaTypeManager.getContent(mediaTypeModel, this.app); + } + + if (attachFile) { + let attachFileContent: string = await this.app.vault.read(attachFile); + const regExp = new RegExp('^(---)\\n[\\s\\S]*\\n---'); + attachFileContent = attachFileContent.replace(regExp, ''); + fileContent += '\n\n' + attachFileContent; + } + + await this.createNote(this.mediaTypeManager.getFileName(mediaTypeModel), fileContent); } catch (e) { console.warn(e); new Notice(e.toString()); } } - async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel): Promise { - try { - console.log('MDB | Creating new note...'); - // console.log(mediaTypeModel); + async createNote(fileName: string, fileContent: string, openFile: boolean = false) { + fileName = replaceIllegalFileNameCharactersInString(fileName); + const filePath = `${this.settings.folder.replace(/\/$/, '')}/${fileName}.md`; - let fileContent = `---\n${YAMLConverter.toYaml(this.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject()))}---\n`; + const folder = this.app.vault.getAbstractFileByPath(this.settings.folder); + if (!folder) { + await this.app.vault.createFolder(this.settings.folder.replace(/\/$/, '')); + } - if (this.settings.templates) { - fileContent += await this.mediaTypeManager.getContent(mediaTypeModel, this.app); - } + const file = this.app.vault.getAbstractFileByPath(filePath); + if (file) { + await this.app.vault.delete(file); + } - const fileName = replaceIllegalFileNameCharactersInString(this.mediaTypeManager.getFileName(mediaTypeModel)); - const filePath = `${this.settings.folder.replace(/\/$/, '')}/${fileName}.md`; + const targetFile = await this.app.vault.create(filePath, fileContent); - const folder = this.app.vault.getAbstractFileByPath(this.settings.folder); - if (!folder) { - await this.app.vault.createFolder(this.settings.folder.replace(/\/$/, '')); - } - - const file = this.app.vault.getAbstractFileByPath(filePath); - if (file) { - await this.app.vault.delete(file); - } - - const targetFile = await this.app.vault.create(filePath, fileContent); - - // open file + // open file + if (openFile) { const activeLeaf = this.app.workspace.getUnpinnedLeaf(); if (!activeLeaf) { console.warn('MDB | no active leaf, not opening media db note'); return; } await activeLeaf.openFile(targetFile, {state: {mode: 'source'}}); - - } catch (e) { - console.warn(e); - new Notice(e.toString()); } } - 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 +178,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) + debugLog(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 +202,129 @@ export default class MediaDbPlugin extends Plugin { await this.createMediaDbNoteFromModel(newMediaTypeModel); } + async createEntriesFromFolder(folder: TFolder) { + const erroredFiles: { filePath: string, error: string }[] = []; + let canceled: boolean = false; + + const {selectedAPI, titleFieldName, appendContent} = await new Promise((resolve, reject) => { + new MediaDbFolderImportModal(this.app, this, ((selectedAPI, titleFieldName, appendContent) => { + resolve({selectedAPI, titleFieldName, appendContent}); + })).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; + if (canceled) { + erroredFiles.push({filePath: file.path, error: 'user canceled'}); + continue; + } + + 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[] = []; + try { + selectedResults = await new Promise((resolve, reject) => { + const searchResultModal = new MediaDbSearchResultModal(this.app, this, results, true, (err, res) => { + if (err) { + return reject(err); + } + resolve(res); + }, () => { + reject(new UserCancelError('user canceled')); + }, () => { + reject(new UserSkipError('user skipped')); + }); + + searchResultModal.title = `Results for \'${title}\'`; + searchResultModal.open(); + }); + } catch (e) { + if (e instanceof UserCancelError) { + erroredFiles.push({filePath: file.path, error: e.message}); + canceled = true; + continue; + } else if (e instanceof UserSkipError) { + erroredFiles.push({filePath: file.path, error: e.message}); + continue; + } else { + erroredFiles.push({filePath: file.path, error: e.message}); + continue; + } + } + + if (selectedResults.length === 0) { + erroredFiles.push({filePath: file.path, error: `no search results selected`}); + continue; + } + + await this.createMediaDbNotes(async () => selectedResults, appendContent ? file : null); + } + } + + 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, false, (err2, res) => { + if (err2) { + return reject(err2); + } + resolve(res); + }, () => resolve([])).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/MediaDbAdvancedSearchModal.ts b/src/modals/MediaDbAdvancedSearchModal.ts index c878e61..5b28370 100644 --- a/src/modals/MediaDbAdvancedSearchModal.ts +++ b/src/modals/MediaDbAdvancedSearchModal.ts @@ -81,6 +81,7 @@ export class MediaDbAdvancedSearchModal extends Modal { contentEl.appendChild(searchComponent.inputEl); searchComponent.inputEl.focus(); + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); contentEl.createEl('h3', {text: 'APIs to search'}); const apiToggleComponents: Component[] = []; @@ -102,6 +103,7 @@ export class MediaDbAdvancedSearchModal extends Modal { apiToggleComponentWrapper.appendChild(apiToggleComponent.toggleEl); } + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); new Setting(contentEl) .addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close())) diff --git a/src/modals/MediaDbFolderImportModal.ts b/src/modals/MediaDbFolderImportModal.ts new file mode 100644 index 0000000..39a7fdc --- /dev/null +++ b/src/modals/MediaDbFolderImportModal.ts @@ -0,0 +1,87 @@ +import {App, ButtonComponent, DropdownComponent, Modal, Setting, TextComponent, ToggleComponent} from 'obsidian'; +import MediaDbPlugin from '../main'; + +export class MediaDbFolderImportModal extends Modal { + plugin: MediaDbPlugin; + onSubmit: (selectedAPI: string, titleFieldName: string, appendContent: boolean) => void; + selectedApi: string; + searchBtn: ButtonComponent; + titleFieldName: string; + appendContent: boolean; + + constructor(app: App, plugin: MediaDbPlugin, onSubmit: (selectedAPI: string, titleFieldName: string, appendContent: boolean) => void) { + super(app); + this.plugin = plugin; + this.onSubmit = onSubmit; + this.selectedApi = plugin.apiManager.apis[0].apiName; + } + + submit() { + this.onSubmit(this.selectedApi, this.titleFieldName, this.appendContent); + this.close(); + } + + onOpen() { + const {contentEl} = this; + + contentEl.createEl('h2', {text: 'Import folder as Media DB entries'}); + + 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); + + + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); + contentEl.createEl('h3', {text: 'Append note content to Media DB entry.'}); + + const appendContentToggleElementWrapper = contentEl.createEl('div', {cls: 'media-db-plugin-list-wrapper'}); + const appendContentToggleTextWrapper = appendContentToggleElementWrapper.createEl('div', {cls: 'media-db-plugin-list-text-wrapper'}); + appendContentToggleTextWrapper.createEl('span', { + text: 'If this is enabled, the plugin will override meta data fields with the same name.', + cls: 'media-db-plugin-list-text', + }); + + const appendContentToggleComponentWrapper = appendContentToggleElementWrapper.createEl('div', {cls: 'media-db-plugin-list-toggle'}); + + const appendContentToggle = new ToggleComponent(appendContentToggleElementWrapper); + appendContentToggle.setValue(false); + appendContentToggle.onChange(value => this.appendContent = value); + appendContentToggleComponentWrapper.appendChild(appendContentToggle.toggleEl); + + + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); + contentEl.createEl('h3', {text: 'The name of the mata data field that should be used as the title to query'}); + + const placeholder = 'title'; + const titleFieldNameComponent = new TextComponent(contentEl); + titleFieldNameComponent.inputEl.style.width = '100%'; + titleFieldNameComponent.setPlaceholder(placeholder); + titleFieldNameComponent.onChange(value => this.titleFieldName = value); + titleFieldNameComponent.inputEl.addEventListener('keydown', (ke) => { + if (ke.key === 'Enter') { + this.submit(); + } + }); + contentEl.appendChild(titleFieldNameComponent.inputEl); + + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); + + 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/MediaDbIdSearchModal.ts b/src/modals/MediaDbIdSearchModal.ts index 035e8fb..31c74d6 100644 --- a/src/modals/MediaDbIdSearchModal.ts +++ b/src/modals/MediaDbIdSearchModal.ts @@ -75,6 +75,8 @@ export class MediaDbIdSearchModal extends Modal { contentEl.appendChild(searchComponent.inputEl); searchComponent.inputEl.focus(); + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); + 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'}); @@ -88,6 +90,8 @@ export class MediaDbIdSearchModal extends Modal { } apiSelectorWrapper.appendChild(apiSelectorComponent.selectEl); + contentEl.createDiv({cls: 'media-db-plugin-spacer'}); + new Setting(contentEl) .addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close())) .addButton(btn => { diff --git a/src/modals/MediaDbSearchResultModal.ts b/src/modals/MediaDbSearchResultModal.ts index 7cab85f..ec7015a 100644 --- a/src/modals/MediaDbSearchResultModal.ts +++ b/src/modals/MediaDbSearchResultModal.ts @@ -1,35 +1,54 @@ -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; + onSubmit: (error: Error, result: MediaTypeModel[]) => void; + onCancel: () => void; + onSkip: () => void; - constructor(app: App, plugin: MediaDbPlugin, suggestion: MediaTypeModel[], onChoose: (error: Error, result?: MediaTypeModel) => void) { - super(app); + sendCallback: boolean; + + constructor(app: App, plugin: MediaDbPlugin, elements: MediaTypeModel[], skipButton: boolean, onSubmit: (error: Error, result: MediaTypeModel[]) => void, onCancel: () => void, onSkip?: () => void) { + super(app, elements); this.plugin = plugin; - this.suggestion = suggestion; - this.onChoose = onChoose; - } + this.onSubmit = onSubmit; + this.onCancel = onCancel; + this.onSkip = onSkip; - 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.'; + this.skipButton = skipButton; + + this.sendCallback = false; } // 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.onSubmit(null, this.selectModalElements.filter(x => x.isActive()).map(x => x.value)); + this.sendCallback = true; + this.close(); + } + + skip() { + this.onSkip(); + this.sendCallback = true; + this.close(); + } + + onClose() { + if (!this.sendCallback) { + this.onCancel(); + } } } diff --git a/src/modals/SelectModal.ts b/src/modals/SelectModal.ts new file mode 100644 index 0000000..de2cdc3 --- /dev/null +++ b/src/modals/SelectModal.ts @@ -0,0 +1,145 @@ +import {App, Modal, Setting} from 'obsidian'; +import {SelectModalElement} from './SelectModalElement'; +import {mod} from '../utils/Utils'; + +export abstract class SelectModal extends Modal { + allowMultiSelect: boolean; + + title: string; + description: string; + skipButton: boolean; + + elements: T[]; + selectModalElements: SelectModalElement[]; + + + protected constructor(app: App, elements: T[]) { + super(app); + this.allowMultiSelect = true; + + this.title = ''; + this.description = ''; + this.skipButton = false; + + this.elements = elements; + this.selectModalElements = []; + + this.scope.register([], 'ArrowUp', () => { + this.highlightUp(); + }); + this.scope.register([], 'ArrowDown', () => { + this.highlightDown(); + }); + this.scope.register([], 'ArrowRight', () => { + this.activateHighlighted(); + }); + this.scope.register([], 'Enter', () => this.submit()); + } + + abstract renderElement(value: T, el: HTMLElement): any; + + abstract submit(): void; + + abstract skip(): void; + + disableAllOtherElements(elementId: number) { + for (const selectModalElement of this.selectModalElements) { + if (selectModalElement.id !== elementId) { + selectModalElement.setActive(false); + } + } + } + + deHighlightAllOtherElements(elementId: number) { + for (const selectModalElement of this.selectModalElements) { + if (selectModalElement.id !== elementId) { + selectModalElement.setHighlighted(false); + } + } + } + + async onOpen() { + const {contentEl} = this; + + /* + contentEl.id = 'media-db-plugin-modal' + + contentEl.on('keydown', '#' + contentEl.id, (ev, delegateTarget) => { + console.log(ev.key); + }); + */ + + contentEl.createEl('h2', {text: this.title}); + contentEl.createEl('p', {text: this.description}); + + 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; + } + + const bottomSetting = new Setting(contentEl); + bottomSetting.addButton(btn => btn.setButtonText('Cancel').onClick(() => this.close())); + if (this.skipButton) { + bottomSetting.addButton(btn => btn.setButtonText('Skip').onClick(() => this.skip())); + } + bottomSetting.addButton(btn => btn.setButtonText('Ok').setCta().onClick(() => this.submit())); + } + + activateHighlighted() { + for (const selectModalElement of this.selectModalElements) { + if (selectModalElement.isHighlighted()) { + selectModalElement.setActive(!selectModalElement.isActive()); + if (!this.allowMultiSelect) { + this.disableAllOtherElements(selectModalElement.id); + } + } + } + } + + highlightUp() { + for (const selectModalElement of this.selectModalElements) { + if (selectModalElement.isHighlighted()) { + this.getPreviousSelectModalElement(selectModalElement).setHighlighted(true); + return; + } + } + + // nothing is highlighted + this.selectModalElements.last().setHighlighted(true); + } + + highlightDown() { + for (const selectModalElement of this.selectModalElements) { + if (selectModalElement.isHighlighted()) { + this.getNextSelectModalElement(selectModalElement).setHighlighted(true); + return; + } + } + + // nothing is highlighted + this.selectModalElements.first().setHighlighted(true); + } + + private getNextSelectModalElement(selectModalElement: SelectModalElement): SelectModalElement { + let nextId = selectModalElement.id + 1; + nextId = mod(nextId, this.selectModalElements.length); + + return this.selectModalElements.filter(x => x.id === nextId).first(); + } + + private getPreviousSelectModalElement(selectModalElement: SelectModalElement): SelectModalElement { + let nextId = selectModalElement.id - 1; + nextId = mod(nextId, this.selectModalElements.length); + + return this.selectModalElements.filter(x => x.id === nextId).first(); + } + +} diff --git a/src/modals/SelectModalElement.ts b/src/modals/SelectModalElement.ts new file mode 100644 index 0000000..4aadb9e --- /dev/null +++ b/src/modals/SelectModalElement.ts @@ -0,0 +1,87 @@ +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; + private highlighted: 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.disableAllOtherElements(this.id); + } + }); + this.element.on('mouseenter', '#' + this.getHTMLId(), () => { + this.setHighlighted(true); + }); + this.element.on('mouseleave', '#' + this.getHTMLId(), () => { + this.setHighlighted(false); + }); + } + + getHTMLId(): string { + return `media-db-plugin-select-element-${this.id}`; + } + + isHighlighted(): boolean { + return this.highlighted; + } + + setHighlighted(value: boolean) { + this.highlighted = value; + if (this.highlighted) { + this.addClass(this.hoverClass); + this.selectModal.deHighlightAllOtherElements(this.id); + } else { + this.removeClass(this.hoverClass); + } + } + + isActive(): boolean { + return this.active; + } + + 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..a855b23 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -4,7 +4,7 @@ import {MediaTypeModel} from '../models/MediaTypeModel'; 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 mediaDbVersion: string = '0.3.0'; export const debug: boolean = false; export function wrapAround(value: number, size: number): number { @@ -88,3 +88,84 @@ 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() + 1}-${date.getDate()}-${date.getFullYear()}`; +} + +export function timeToString(time: Date) { + return `${time.getHours()}-${time.getMinutes()}-${time.getSeconds()}`; +} + +export function dateTimeToString(dateTime: Date) { + return `${dateToString(dateTime)} ${timeToString(dateTime)}`; +} + +export class UserCancelError extends Error { + constructor(message: string) { + super(message); + } +} + +export class UserSkipError extends Error { + constructor(message: string) { + super(message); + } +} + +// js can't even implement modulo correctly... +export function mod(n: number, m: number): number { + return ((n % m) + m) % m; +} + diff --git a/src/utils/YAMLConverter.ts b/src/utils/YAMLConverter.ts index 7cec238..f9e013f 100644 --- a/src/utils/YAMLConverter.ts +++ b/src/utils/YAMLConverter.ts @@ -10,6 +10,10 @@ export class YAMLConverter { } private static toYamlString(value: any, indentation: number): string { + if (value == null) { + return 'null'; + } + if (typeof value === 'boolean') { return value ? 'true' : 'false'; } else if (typeof value === 'number') { diff --git a/styles.css b/styles.css index 272d000..7befc32 100644 --- a/styles.css +++ b/styles.css @@ -21,3 +21,30 @@ 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); +} + +.media-db-plugin-spacer { + margin-bottom: 10px; +}