add obsidian eslint plugin, solve major issues

This commit is contained in:
Moritz Jung 2026-05-06 16:26:59 +02:00
parent 84214a1c3c
commit 498e2611ae
105 changed files with 5702 additions and 5308 deletions

View file

@ -0,0 +1,24 @@
export enum AppErrorKind {
Validation = 'Validation',
Api = 'Api',
Network = 'Network',
Vault = 'Vault',
Modal = 'Modal',
Cancelled = 'Cancelled',
Unexpected = 'Unexpected',
}
export interface AppError {
kind: AppErrorKind;
message: string;
userMessage?: string;
cause?: unknown;
context?: Record<string, unknown>;
}
export const appError = (error: AppError): AppError => error;
export const toAppError = (cause: unknown, fallback: Omit<AppError, 'cause'>): AppError => {
const message = cause instanceof Error ? cause.message : String(cause);
return { ...fallback, message: fallback.message || message, cause };
};

View file

@ -0,0 +1,144 @@
import type { TFolder } from 'obsidian';
import { TFile } from 'obsidian';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { MediaDbBulkImportModal as MediaDbBulkImportModal } from 'packages/obsidian/src/modals/MediaDbBulkImportModal';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { OutcomeStatus } from 'packages/obsidian/src/utils/result';
import { dateTimeToString, markdownTable } from 'packages/obsidian/src/utils/Utils';
export enum BulkImportLookupMethod {
ID = 'id',
TITLE = 'title',
}
interface BulkImportError {
filePath: string;
error: string;
canceled?: boolean;
}
export class BulkImportHelper {
readonly plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
this.plugin = plugin;
}
async import(folder: TFolder): Promise<void> {
const erroredFiles: BulkImportError[] = [];
let canceled: boolean = false;
const { selectedAPI, lookupMethod, fieldName, appendContent } = await new Promise<{
selectedAPI: string;
lookupMethod: BulkImportLookupMethod;
fieldName: string;
appendContent: boolean;
}>(resolve => {
new MediaDbBulkImportModal(this.plugin, (selectedAPI: string, lookupMethod: BulkImportLookupMethod, fieldName: string, appendContent: boolean) => {
resolve({ selectedAPI, lookupMethod, fieldName, appendContent });
}).open();
});
for (const child of folder.children) {
if (!(child instanceof TFile)) {
continue;
}
const file: TFile = child;
if (canceled) {
erroredFiles.push({ filePath: file.path, error: 'user canceled' });
continue;
}
const metadata = this.plugin.fileHelper.getMetadataFromFileCache(file);
const lookupValue = metadata[fieldName];
if (!lookupValue || typeof lookupValue !== 'string') {
erroredFiles.push({ filePath: file.path, error: `metadata field '${fieldName}' not found, empty, or not a string` });
continue;
} else if (lookupMethod === BulkImportLookupMethod.ID) {
const error = await this.importById(file, lookupValue, selectedAPI, appendContent);
if (error) {
erroredFiles.push(error);
}
} else if (lookupMethod === BulkImportLookupMethod.TITLE) {
const error = await this.importByTitle(file, lookupValue, selectedAPI, appendContent);
if (error) {
if (error.canceled) {
canceled = true;
}
erroredFiles.push(error);
}
} else {
erroredFiles.push({ filePath: file.path, error: `invalid lookup type` });
continue;
}
}
if (erroredFiles.length > 0) {
await this.createErroredFilesReport(erroredFiles);
}
}
private async importById(file: TFile, lookupValue: string, selectedAPI: string, appendContent: boolean): Promise<BulkImportError | undefined> {
const modelResult = await this.plugin.apiManager.queryDetailedInfoById(lookupValue, selectedAPI);
if (!modelResult.ok) {
return { filePath: file.path, error: modelResult.error.userMessage ?? modelResult.error.message };
}
if (modelResult.value) {
await this.plugin.fileHelper.createMediaDbNotes([modelResult.value], appendContent ? file : undefined);
return undefined;
}
return { filePath: file.path, error: `Failed to query API with id: ${lookupValue}` };
}
private async importByTitle(file: TFile, lookupValue: string, selectedAPI: string, appendContent: boolean): Promise<BulkImportError | undefined> {
const resultsResult = await this.plugin.apiManager.query(lookupValue, [selectedAPI]);
if (!resultsResult.ok) {
return { filePath: file.path, error: resultsResult.error.userMessage ?? resultsResult.error.message };
}
const results: MediaTypeModel[] = resultsResult.value.items;
if (!results || results.length === 0) {
return { filePath: file.path, error: `no search results` };
}
const selectModalResult = await this.plugin.modalHelper.createSelectModalOutcome({
elements: results,
skipButton: true,
modalTitle: `Results for '${lookupValue}'`,
});
if (selectModalResult.status === OutcomeStatus.Error) {
return { filePath: file.path, error: selectModalResult.error.userMessage ?? selectModalResult.error.message };
}
if (selectModalResult.status === OutcomeStatus.Cancelled) {
return { filePath: file.path, error: 'user canceled', canceled: true };
}
if (selectModalResult.status === OutcomeStatus.Skipped) {
return { filePath: file.path, error: 'user skipped' };
}
if (selectModalResult.data.selected.length === 0) {
return { filePath: file.path, error: `no search results selected` };
}
const detailedResults = await this.plugin.entryHelper.queryDetails(selectModalResult.data.selected);
await this.plugin.fileHelper.createMediaDbNotes(detailedResults, appendContent ? file : undefined);
return undefined;
}
private async createErroredFilesReport(erroredFiles: BulkImportError[]): Promise<void> {
const title = `MDB - bulk import error report ${dateTimeToString(new Date())}`;
const filePath = `${title}.md`;
const table = [['file', 'error']].concat(erroredFiles.map(x => [x.filePath, x.error]));
const fileContent = `# ${title}\n\n${markdownTable(table)}`;
await this.plugin.app.vault.create(filePath, fileContent);
}
}

View file

@ -0,0 +1,73 @@
import { moment } from 'obsidian';
const momentFn = 'default' in moment ? moment.default : moment;
export class DateFormatter {
private static readonly RFC2822_FORMAT = 'ddd, DD MMM YYYY HH:mm:ss ZZ';
toFormat: string;
locale: string;
constructor() {
this.toFormat = 'YYYY-MM-DD';
// get locale of this machine (e.g. en, en-gb, de, fr, etc.)
this.locale = new Intl.DateTimeFormat().resolvedOptions().locale;
}
setFormat(format: string): void {
this.toFormat = format;
}
getPreview(format?: string): string {
const today = momentFn();
format ??= this.toFormat;
return today.locale(this.locale).format(format);
}
/**
* Tries to format a given date string with the currently set date format.
* You can set a date format by calling `setFormat()`.
*
* @param dateString the date string to be formatted
* @param dateFormat the current format of `dateString`. When this is `null` and the actual format of the
* given date string is not `C2822` or `ISO` format, this function will try to guess the format by using the native `Date` module.
* @param locale the locale of `dateString`. This is needed when `dateString` includes a month or day name and its locale format differs
* from the locale of this machine.
* @returns formatted date string or null if `dateString` is not a valid date
*/
format(dateString: string | null | undefined, dateFormat?: string, locale: string = 'en'): string | null {
if (!dateString) {
return null;
}
let date: moment.Moment;
if (!dateFormat) {
date = this.parseWithoutFormat(dateString);
} else {
date = momentFn(dateString, dateFormat, locale);
}
// format date (if it is valid)
return date.isValid() ? date.locale(this.locale).format(this.toFormat) : null;
}
private parseWithoutFormat(dateString: string): moment.Moment {
// reading date formats other than C2822 or ISO with moment is deprecated
// see https://momentjs.com/docs/#/parsing/string/
if (this.hasMomentFormat(dateString)) {
// expect C2822 or ISO format
return momentFn(dateString);
}
// fall back to native Date parsing for unknown formats
return momentFn(new Date(dateString));
}
private hasMomentFormat(dateString: string): boolean {
const date = momentFn(dateString, [moment.ISO_8601, DateFormatter.RFC2822_FORMAT], true); // strict mode
return date.isValid();
}
}

View file

@ -0,0 +1,23 @@
import { Notice } from 'obsidian';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
export class ErrorReporter {
notice(error: AppError): void {
const message = error.userMessage ?? error.message;
new Notice(message);
}
log(error: AppError): void {
Logger.warn('MDB | error', error);
}
report(error: AppError): void {
this.log(error);
if (error.kind !== AppErrorKind.Cancelled) {
this.notice(error);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
// Illegal characters in the form `[illegal_character, replacement][]`
export const ILLEGAL_FILENAME_CHARACTERS = [
['/', '-'],
['\\', '-'],
['<', ''],
['>', ''],
[':', ' - '],
['"', "'"],
['|', ' - '],
['?', ''],
['*', ''],
['[', '('],
[']', ')'],
['^', ''],
['#', ''],
];

View file

@ -0,0 +1,38 @@
declare const __LOG_LEVEL__: number;
enum LogLevel {
NONE = 0,
ERROR = 1,
WARN = 2,
LOG = 3,
DEBUG = 4,
}
const logLevel: LogLevel = typeof __LOG_LEVEL__ !== 'undefined' ? __LOG_LEVEL__ : LogLevel.LOG;
export class Logger {
static debug(...args: unknown[]): void {
if (logLevel >= LogLevel.DEBUG) {
console.debug(...args);
}
}
static log(...args: unknown[]): void {
if (logLevel >= LogLevel.LOG) {
// eslint-disable-next-line obsidianmd/rule-custom-message -- These log statements are disabled for production builds.
console.log(...args);
}
}
static warn(...args: unknown[]): void {
if (logLevel >= LogLevel.WARN) {
console.warn(...args);
}
}
static error(...args: unknown[]): void {
if (logLevel >= LogLevel.ERROR) {
console.error(...args);
}
}
}

View file

@ -0,0 +1,289 @@
import { MarkdownView, Notice } from 'obsidian';
import type { TMDBSeasonAPI } from 'packages/obsidian/src/api/apis/TMDBSeasonAPI';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import type { SeasonSelectModalElement } from 'packages/obsidian/src/modals/MediaDbSeasonSelectModal';
import { MediaDbSeasonSelectModal } from 'packages/obsidian/src/modals/MediaDbSeasonSelectModal';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { SeasonModel } from 'packages/obsidian/src/models/SeasonModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind } from 'packages/obsidian/src/utils/AppError';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { SearchModalOptions } from 'packages/obsidian/src/utils/ModalHelper';
export class MediaDbEntryHelper {
readonly plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
this.plugin = plugin;
}
private reportAppError(error: AppError): void {
this.plugin.errorReporter.report(error);
}
async createLinkWithSearchModal(): Promise<void> {
const advancedSearch = await this.plugin.modalHelper.promptAdvancedSearchModal({});
if (!advancedSearch) {
return;
}
const apiSearchResults = await this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis);
if (!apiSearchResults.ok) {
this.reportAppError(apiSearchResults.error);
return;
}
if (!apiSearchResults.value.items || apiSearchResults.value.items.length === 0) {
this.reportAppError({ kind: AppErrorKind.Validation, message: 'No results found.', userMessage: 'No results found.' });
return;
}
const selectResults = await this.plugin.modalHelper.promptSelectModal({ elements: apiSearchResults.value.items, multiSelect: false });
if (!selectResults || selectResults.selected.length < 1) {
return;
}
const detailedResults = await this.queryDetails(selectResults.selected);
if (detailedResults.length < 1) {
return;
}
const link = `[${detailedResults[0].title}](${detailedResults[0].url})`;
const view = this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
view.editor.replaceRange(link, view.editor.getCursor());
}
}
async createEntryWithSearchModal(searchModalOptions?: SearchModalOptions): Promise<void> {
const searchData = await this.plugin.modalHelper.promptSearchModal(searchModalOptions ?? {});
if (!searchData) {
return;
}
const types = searchData.types;
const apis = this.plugin.apiManager.apis.filter(api => api.hasTypeOverlap(types)).map(api => api.apiName);
const apiSearchResults = await this.plugin.apiManager.query(searchData.query, apis);
if (!apiSearchResults.ok) {
this.reportAppError(apiSearchResults.error);
return;
}
if (!apiSearchResults.value.items || apiSearchResults.value.items.length === 0) {
this.reportAppError({ kind: AppErrorKind.Validation, message: 'No results found.', userMessage: 'No results found.' });
return;
}
const filteredSearchResults = apiSearchResults.value.items.filter(result => types.includes(result.getMediaType()));
if (filteredSearchResults.length === 0) {
this.reportAppError({ kind: AppErrorKind.Validation, message: 'No results found for the selected types.', userMessage: 'No results found for the selected types.' });
return;
}
const selectResultsData =
types.length === 1 && types[0] === MediaType.Season
? await this.plugin.modalHelper.promptSelectModal({
elements: filteredSearchResults,
description: 'Select one search result to proceed.',
submitButtonText: 'Ok',
})
: await this.plugin.modalHelper.promptSelectModal({ elements: filteredSearchResults });
if (!selectResultsData) {
return;
}
const selectResults = types.length === 1 && types[0] === MediaType.Season ? selectResultsData.selected : await this.queryDetails(selectResultsData.selected);
if (selectResults.length === 0) {
return;
}
const seasonHandlingResult = await this.handleSeasonWorkflow(types, selectResults);
if (seasonHandlingResult.handled) {
return;
}
const confirmed = await this.plugin.modalHelper.promptPreviewModal({ elements: selectResults });
if (!confirmed?.confirmed) {
return;
}
await this.plugin.fileHelper.createMediaDbNotes(selectResults);
}
async createEntryWithAdvancedSearchModal(): Promise<void> {
const advancedSearch = await this.plugin.modalHelper.promptAdvancedSearchModal({});
if (!advancedSearch) {
return;
}
const apiSearchResults = await this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis);
if (!apiSearchResults.ok) {
this.reportAppError(apiSearchResults.error);
return;
}
if (!apiSearchResults.value.items || apiSearchResults.value.items.length === 0) {
this.reportAppError({ kind: AppErrorKind.Validation, message: 'No results found.', userMessage: 'No results found.' });
return;
}
const selectResultsData = await this.plugin.modalHelper.promptSelectModal({ elements: apiSearchResults.value.items });
if (!selectResultsData) {
return;
}
const selectResults = await this.queryDetails(selectResultsData.selected);
if (selectResults.length < 1) {
return;
}
const confirmed = await this.plugin.modalHelper.promptPreviewModal({ elements: selectResults });
if (!confirmed?.confirmed) {
return;
}
await this.plugin.fileHelper.createMediaDbNotes(selectResults);
}
async createEntryWithIdSearchModal(): Promise<void> {
let idSearchResult: MediaTypeModel | undefined = undefined;
let proceed = false;
while (!proceed) {
const idSearchData = await this.plugin.modalHelper.promptIdSearchModal({});
if (!idSearchData) {
return;
}
const queriedIdResult = await this.plugin.apiManager.queryDetailedInfoById(idSearchData.query, idSearchData.api);
if (!queriedIdResult.ok) {
this.reportAppError(queriedIdResult.error);
return;
}
idSearchResult = queriedIdResult.value;
if (!idSearchResult) {
return;
}
const previewData = await this.plugin.modalHelper.promptPreviewModal({ elements: [idSearchResult] });
if (!previewData) {
return;
}
proceed = previewData.confirmed;
}
if (!idSearchResult) {
return;
}
const createNoteResult = await this.plugin.fileHelper.createMediaDbNoteFromModel(idSearchResult, { attachTemplate: true, openNote: true });
if (!createNoteResult.ok) {
this.reportAppError(createNoteResult.error);
}
}
async queryDetails(models: MediaTypeModel[]): Promise<MediaTypeModel[]> {
const results = await Promise.all(models.map(model => this.plugin.apiManager.queryDetailedInfo(model)));
const detailModels: MediaTypeModel[] = [];
for (const result of results) {
if (result.ok && result.value) {
detailModels.push(result.value);
} else if (!result.ok) {
this.reportAppError(result.error);
}
}
return detailModels;
}
private async handleSeasonWorkflow(types: string[], selectResults: MediaTypeModel[]): Promise<{ handled: boolean; seasonsCreated?: boolean }> {
if (types.length === 1 && types[0] === 'season' && selectResults.length === 1 && selectResults[0].dataSource === 'TMDBSeasonAPI') {
const created = await this.showSeasonSelectAndCreate(selectResults[0].id, selectResults[0].englishTitle || selectResults[0].title);
return { handled: true, seasonsCreated: created };
}
if (types.includes('series') && selectResults.some(result => result.dataSource === 'TMDBSeriesAPI')) {
const seriesResults = selectResults.filter(result => result.dataSource === 'TMDBSeriesAPI');
if (seriesResults.length === 1 && types.includes('season')) {
const created = await this.showSeasonSelectAndCreate(seriesResults[0].id, seriesResults[0].title);
return { handled: true, seasonsCreated: created };
}
}
return { handled: false };
}
private async showSeasonSelectAndCreate(seriesId: string, seriesTitle: string): Promise<boolean> {
const tmdbSeasonAPI = this.plugin.apiManager.getApiByName('TMDBSeasonAPI') as TMDBSeasonAPI | undefined;
if (!tmdbSeasonAPI) {
new Notice('TMDBSeasonAPI not available.');
return false;
}
const allSeasonsResult = await tmdbSeasonAPI.getSeasonsForSeries(seriesId);
if (!allSeasonsResult.ok) {
this.reportAppError(allSeasonsResult.error);
new Notice(`Error loading seasons: ${allSeasonsResult.error.userMessage}`);
return false;
}
const allSeasons = allSeasonsResult.value;
if (allSeasons.length === 0) {
new Notice('No seasons found for this series.');
return false;
}
const selectedSeasons = await this.showSeasonSelectModal(allSeasons, seriesTitle);
if (!selectedSeasons || selectedSeasons.length === 0) {
return false;
}
await this.createNotesForSelectedSeasons(selectedSeasons, allSeasons, tmdbSeasonAPI);
new Notice(`Successfully created ${selectedSeasons.length} season ${selectedSeasons.length === 1 ? 'entry' : 'entries'}.`);
return true;
}
private async showSeasonSelectModal(allSeasons: SeasonModel[], seriesTitle: string): Promise<SeasonSelectModalElement[] | undefined> {
const modal = new MediaDbSeasonSelectModal(
this.plugin,
allSeasons.map(season => ({
season_number: season.seasonNumber,
name: season.seasonTitle || season.title,
episode_count: season.episodes || 0,
air_date: season.year,
poster_path: season.image,
})),
true,
seriesTitle,
);
return await new Promise(resolve => {
modal.setSubmitCb(resolve);
modal.open();
});
}
private async createNotesForSelectedSeasons(selectedSeasons: SeasonSelectModalElement[], allSeasons: SeasonModel[], tmdbSeasonAPI: TMDBSeasonAPI): Promise<void> {
await Promise.all(
selectedSeasons.map(async selectedSeason => {
const seasonModel = allSeasons.find(season => season.seasonNumber === selectedSeason.season_number);
if (seasonModel) {
const fullMetadataResult = await tmdbSeasonAPI.getById(seasonModel.id);
if (!fullMetadataResult.ok) {
this.reportAppError(fullMetadataResult.error);
new Notice(`Failed to load season ${selectedSeason.season_number}: ${fullMetadataResult.error.userMessage}`);
return;
}
await this.plugin.fileHelper.createMediaDbNotes([fullMetadataResult.value]);
}
}),
);
}
}

View file

@ -0,0 +1,325 @@
import type { TFile } from 'obsidian';
import { TFolder } from 'obsidian';
import { Notice, normalizePath, parseYaml, requestUrl, stringifyYaml } from 'obsidian';
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { ConfirmOverwriteModal } from 'packages/obsidian/src/modals/ConfirmOverwriteModal';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import { Logger } from 'packages/obsidian/src/utils/Logger';
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Result } from 'packages/obsidian/src/utils/result';
import { err, ok } from 'packages/obsidian/src/utils/result';
import type { CreateNoteOptions } from 'packages/obsidian/src/utils/Utils';
import { hasTemplaterPlugin, replaceIllegalFileNameCharactersInString, useTemplaterPluginInFile } from 'packages/obsidian/src/utils/Utils';
export type Metadata = Record<string, unknown>;
export interface MediaTypeModelObj {
id: string;
type: MediaType;
dataSource: string;
}
export class MediaDbFileHelper {
readonly plugin: MediaDbPlugin;
private readonly frontMatterRegExpPattern = '^(---)\\n[\\s\\S]*?\\n---';
constructor(plugin: MediaDbPlugin) {
this.plugin = plugin;
}
async createMediaDbNotes(models: MediaTypeModel[], attachFile?: TFile): Promise<Result<void, AppError>> {
const results = await Promise.all(models.map(model => this.createMediaDbNoteFromModel(model, { attachTemplate: true, attachFile })));
const failures = results.filter(result => !result.ok);
if (failures.length > 0) {
Logger.warn(
'MDB | Some notes failed to create:',
failures.map(result => result.error),
);
new Notice(`${models.length - failures.length} of ${models.length} notes created successfully.`);
return err(failures[0].error);
}
return ok(undefined);
}
async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<Result<void, AppError>> {
Logger.debug('MDB | creating new note');
options.openNote = this.plugin.settings.openNoteInNewTab;
if (this.plugin.settings.imageDownload) {
const imageResult = await this.downloadImageForMediaModel(mediaTypeModel);
if (!imageResult.ok) {
return imageResult;
}
}
const fileContentResult = await this.attempt(() => this.generateMediaDbNoteContents(mediaTypeModel, options), {
kind: AppErrorKind.Unexpected,
message: 'Failed to generate note contents',
userMessage: 'Failed to generate note contents',
});
if (!fileContentResult.ok) {
return fileContentResult;
}
const folderResult = await this.attempt(() => this.plugin.mediaTypeManager.getFolder(mediaTypeModel, this.plugin.app), {
kind: AppErrorKind.Vault,
message: 'Failed to determine note folder',
userMessage: 'Failed to determine note folder',
});
if (!folderResult.ok) {
return folderResult;
}
options.folder ??= folderResult.value;
const targetFileResult = await this.createNote(this.plugin.mediaTypeManager.getFileName(mediaTypeModel), fileContentResult.value, options);
if (!targetFileResult.ok) {
return targetFileResult;
}
if (this.plugin.settings.enableTemplaterIntegration) {
const templaterResult = await this.attempt(() => useTemplaterPluginInFile(this.plugin.app, targetFileResult.value), {
kind: AppErrorKind.Unexpected,
message: 'Failed to apply templater to the note',
userMessage: 'Failed to apply templater to the note',
});
if (!templaterResult.ok) {
return templaterResult;
}
}
return ok(undefined);
}
async updateActiveNote(onlyMetadata: boolean = false): Promise<void> {
const activeFile = this.plugin.app.workspace.getActiveFile() ?? undefined;
if (!activeFile) {
throw new Error('MDB | there is no active note');
}
let metadata = this.getMetadataFromFileCache(activeFile);
metadata = this.plugin.modelPropertyMapper.convertObjectBack(metadata);
Logger.debug(`MDB | read metadata`, metadata);
if (!metadata?.type || !metadata?.dataSource || !metadata?.id) {
throw new Error('MDB | active note is not a Media DB entry or is missing metadata');
}
const validOldMetadata: MediaTypeModelObj = metadata as unknown as MediaTypeModelObj;
Logger.debug(`MDB | validOldMetadata`, validOldMetadata);
const oldMediaTypeModel = this.plugin.mediaTypeManager.createMediaTypeModelFromMediaType(validOldMetadata, validOldMetadata.type);
Logger.debug(`MDB | oldMediaTypeModel created`, oldMediaTypeModel);
const newMediaTypeModelResult = await this.plugin.apiManager.queryDetailedInfoById(validOldMetadata.id, validOldMetadata.dataSource);
if (!newMediaTypeModelResult.ok) {
this.plugin.errorReporter.report(newMediaTypeModelResult.error);
return;
}
let newMediaTypeModel = newMediaTypeModelResult.value;
if (!newMediaTypeModel) {
return;
}
newMediaTypeModel = Object.assign(oldMediaTypeModel, newMediaTypeModel.getWithOutUserData());
Logger.debug(`MDB | newMediaTypeModel after merge`, newMediaTypeModel);
if (onlyMetadata) {
const result = await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachFile: activeFile, folder: activeFile.parent ?? undefined, openNote: true });
if (!result.ok) {
this.plugin.errorReporter.report(result.error);
}
} else {
const result = await this.createMediaDbNoteFromModel(newMediaTypeModel, { attachTemplate: true, folder: activeFile.parent ?? undefined, openNote: true });
if (!result.ok) {
this.plugin.errorReporter.report(result.error);
}
}
}
generateMediaDbNoteFrontmatterPreview(mediaTypeModel: MediaTypeModel): string {
const fileMetadata = this.plugin.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject());
return stringifyYaml(fileMetadata);
}
async generateMediaDbNoteContents(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<string> {
let template = await this.plugin.mediaTypeManager.getTemplate(mediaTypeModel, this.plugin.app);
let fileMetadata: Record<string, unknown>;
if (this.plugin.settings.useDefaultFrontMatter) {
fileMetadata = this.plugin.modelPropertyMapper.convertObject(mediaTypeModel.toMetaDataObject());
} else {
fileMetadata = {
id: mediaTypeModel.id,
type: mediaTypeModel.type,
dataSource: mediaTypeModel.dataSource,
};
}
let fileContent = '';
template = options.attachTemplate ? template : '';
({ fileMetadata, fileContent } = await this.attachFile(fileMetadata, fileContent, options.attachFile));
({ fileMetadata, fileContent } = await this.attachTemplate(fileMetadata, fileContent, template));
if (this.plugin.settings.enableTemplaterIntegration && hasTemplaterPlugin(this.plugin.app)) {
fileContent = `---\n<%* const media = ${JSON.stringify(mediaTypeModel)} %>\n${stringifyYaml(fileMetadata)}---\n${fileContent}`;
} else {
fileContent = `---\n${stringifyYaml(fileMetadata)}---\n${fileContent}`;
}
return fileContent;
}
async attachFile(fileMetadata: Metadata, fileContent: string, fileToAttach?: TFile): Promise<{ fileMetadata: Metadata; fileContent: string }> {
if (!fileToAttach) {
return { fileMetadata, fileContent };
}
const attachFileMetadata = this.getMetadataFromFileCache(fileToAttach);
fileMetadata = { ...attachFileMetadata, ...fileMetadata };
let attachFileContent = await this.plugin.app.vault.read(fileToAttach);
const regExp = new RegExp(this.frontMatterRegExpPattern);
attachFileContent = attachFileContent.replace(regExp, '');
attachFileContent = attachFileContent.startsWith('\n') ? attachFileContent.substring(1) : attachFileContent;
fileContent += attachFileContent;
return { fileMetadata, fileContent };
}
async attachTemplate(fileMetadata: Metadata, fileContent: string, template: string | undefined): Promise<{ fileMetadata: Metadata; fileContent: string }> {
if (!template) {
return { fileMetadata, fileContent };
}
const templateMetadata = this.getMetaDataFromFileContent(template);
fileMetadata = { ...templateMetadata, ...fileMetadata };
const regExp = new RegExp(this.frontMatterRegExpPattern);
const attachFileContent = template.replace(regExp, '');
fileContent += attachFileContent;
return { fileMetadata, fileContent };
}
getMetaDataFromFileContent(fileContent: string): Metadata {
const regExp = new RegExp(this.frontMatterRegExpPattern);
const frontMatterRegExpResult = regExp.exec(fileContent);
if (!frontMatterRegExpResult) {
return {};
}
let frontMatter = frontMatterRegExpResult[0];
if (!frontMatter) {
return {};
}
frontMatter = frontMatter.substring(4);
frontMatter = frontMatter.substring(0, frontMatter.length - 3);
let metadata = parseYaml(frontMatter) as Metadata;
if (!metadata) {
metadata = {};
}
Logger.debug(`MDB | metadata read from file content`, metadata);
return metadata;
}
getMetadataFromFileCache(file: TFile): Metadata {
const metadata: Metadata | undefined = this.plugin.app.metadataCache.getFileCache(file)?.frontmatter;
return structuredClone(metadata ?? {});
}
async createNote(fileName: string, fileContent: string, options: CreateNoteOptions): Promise<Result<TFile, AppError>> {
const folder = options.folder ?? this.plugin.app.vault.getAbstractFileByPath('/');
if (!folder || !(folder instanceof TFolder)) {
return err({ kind: AppErrorKind.Validation, message: 'MDB | invalid folder', userMessage: 'MDB | invalid folder' });
}
fileName = replaceIllegalFileNameCharactersInString(fileName);
const filePath = `${folder.path}/${fileName}.md`;
const file = this.plugin.app.vault.getAbstractFileByPath(filePath);
if (file) {
const shouldOverwrite = await new Promise<boolean>(resolve => {
new ConfirmOverwriteModal(this.plugin.app, fileName, resolve).open();
});
if (!shouldOverwrite) {
return err({ kind: AppErrorKind.Cancelled, message: 'MDB | file creation cancelled by user', userMessage: 'MDB | file creation cancelled by user' });
}
await this.plugin.app.fileManager.trashFile(file);
}
const targetFile = await this.plugin.app.vault.create(filePath, fileContent);
Logger.debug(`MDB | created new file at ${filePath}`);
if (options.openNote) {
const activeLeaf = this.plugin.app.workspace.getLeaf(false);
if (!activeLeaf) {
Logger.warn('MDB | no active leaf, not opening newly created note');
return ok(targetFile);
}
await activeLeaf.openFile(targetFile, { state: { mode: 'source' } });
}
return ok(targetFile);
}
private async downloadImageForMediaModel(mediaTypeModel: MediaTypeModel): Promise<Result<void, AppError>> {
if (mediaTypeModel.image && typeof mediaTypeModel.image === 'string' && mediaTypeModel.image.startsWith('http')) {
const imageUrl = mediaTypeModel.image;
const imageResult = await this.attempt(
async () => {
const imageExt = imageUrl.split('.').pop()?.split(/#|\?/)[0] ?? 'jpg';
const imageFileName = `${replaceIllegalFileNameCharactersInString(`${mediaTypeModel.type}_${mediaTypeModel.title} (${mediaTypeModel.year})`)}.${imageExt}`;
const imagePath = normalizePath(`${this.plugin.settings.imageFolder}/${imageFileName}`);
if (!this.plugin.app.vault.getAbstractFileByPath(this.plugin.settings.imageFolder)) {
await this.plugin.app.vault.createFolder(this.plugin.settings.imageFolder);
}
if (!this.plugin.app.vault.getAbstractFileByPath(imagePath)) {
const response = await requestUrl({ url: imageUrl, method: 'GET' });
await this.plugin.app.vault.createBinary(imagePath, response.arrayBuffer);
}
mediaTypeModel.image = `[[${imagePath}]]`;
},
{
kind: AppErrorKind.Network,
message: 'MDB | Failed to download image',
userMessage: 'Failed to download image',
},
);
if (!imageResult.ok) {
Logger.warn('MDB | Failed to download image:', imageResult.error);
return imageResult;
}
}
return ok(undefined);
}
private async attempt<T>(operation: () => Promise<T> | T, fallback: Omit<AppError, 'cause'>): Promise<Result<T, AppError>> {
return await Promise.resolve()
.then(operation)
.then(
value => ok(value),
cause => err(toAppError(cause, fallback)),
);
}
}

View file

@ -0,0 +1,11 @@
export enum MediaType {
Movie = 'movie',
Series = 'series',
Season = 'season',
ComicManga = 'comicManga',
Game = 'game',
MusicRelease = 'musicRelease',
Wiki = 'wiki',
BoardGame = 'boardgame',
Book = 'book',
}

View file

@ -0,0 +1,166 @@
import type { App } from 'obsidian';
import { TFile } from 'obsidian';
import { TFolder } from 'obsidian';
import { BoardGameModel } from 'packages/obsidian/src/models/BoardGameModel';
import { BookModel } from 'packages/obsidian/src/models/BookModel';
import { ComicMangaModel } from 'packages/obsidian/src/models/ComicMangaModel';
import { GameModel } from 'packages/obsidian/src/models/GameModel';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import { MovieModel } from 'packages/obsidian/src/models/MovieModel';
import { MusicReleaseModel } from 'packages/obsidian/src/models/MusicReleaseModel';
import { SeasonModel } from 'packages/obsidian/src/models/SeasonModel';
import { SeriesModel } from 'packages/obsidian/src/models/SeriesModel';
import { WikiModel } from 'packages/obsidian/src/models/WikiModel';
import type { MediaDbPluginSettings } from 'packages/obsidian/src/settings/Settings';
import { ILLEGAL_FILENAME_CHARACTERS } from 'packages/obsidian/src/utils/IllegalFilenameCharactersList';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import { replaceTags } from 'packages/obsidian/src/utils/Utils';
// All media types in alphabetical order
export const MEDIA_TYPES: MediaType[] = [
MediaType.BoardGame,
MediaType.Book,
MediaType.ComicManga,
MediaType.Game,
MediaType.Movie,
MediaType.MusicRelease,
MediaType.Series,
MediaType.Season,
MediaType.Wiki,
];
export class MediaTypeManager {
mediaFileNameTemplateMap: Map<MediaType, string>;
mediaTemplateMap: Map<MediaType, string>;
mediaFolderMap: Map<MediaType, string>;
constructor() {
this.mediaFileNameTemplateMap = new Map<MediaType, string>();
this.mediaTemplateMap = new Map<MediaType, string>();
this.mediaFolderMap = new Map<MediaType, string>();
}
updateTemplates(settings: MediaDbPluginSettings): void {
this.mediaFileNameTemplateMap = new Map<MediaType, string>();
this.mediaFileNameTemplateMap.set(MediaType.Movie, settings.movieFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Series, settings.seriesFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Season, settings.seasonFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.ComicManga, settings.mangaFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Game, settings.gameFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Wiki, settings.wikiFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.MusicRelease, settings.musicReleaseFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.BoardGame, settings.boardgameFileNameTemplate);
this.mediaFileNameTemplateMap.set(MediaType.Book, settings.bookFileNameTemplate);
this.mediaTemplateMap = new Map<MediaType, string>();
this.mediaTemplateMap.set(MediaType.Movie, settings.movieTemplate);
this.mediaTemplateMap.set(MediaType.Series, settings.seriesTemplate);
this.mediaTemplateMap.set(MediaType.Season, settings.seasonTemplate);
this.mediaTemplateMap.set(MediaType.ComicManga, settings.mangaTemplate);
this.mediaTemplateMap.set(MediaType.Game, settings.gameTemplate);
this.mediaTemplateMap.set(MediaType.Wiki, settings.wikiTemplate);
this.mediaTemplateMap.set(MediaType.MusicRelease, settings.musicReleaseTemplate);
this.mediaTemplateMap.set(MediaType.BoardGame, settings.boardgameTemplate);
this.mediaTemplateMap.set(MediaType.Book, settings.bookTemplate);
}
updateFolders(settings: MediaDbPluginSettings): void {
this.mediaFolderMap = new Map<MediaType, string>();
this.mediaFolderMap.set(MediaType.Movie, settings.movieFolder);
this.mediaFolderMap.set(MediaType.Series, settings.seriesFolder);
this.mediaFolderMap.set(MediaType.Season, settings.seasonFolder);
this.mediaFolderMap.set(MediaType.ComicManga, settings.mangaFolder);
this.mediaFolderMap.set(MediaType.Game, settings.gameFolder);
this.mediaFolderMap.set(MediaType.Wiki, settings.wikiFolder);
this.mediaFolderMap.set(MediaType.MusicRelease, settings.musicReleaseFolder);
this.mediaFolderMap.set(MediaType.BoardGame, settings.boardgameFolder);
this.mediaFolderMap.set(MediaType.Book, settings.bookFolder);
}
getFileName(mediaTypeModel: MediaTypeModel): string {
// Ignore undefined tags since some search APIs do not return all properties in the model and produce clean file names even if errors occur
const fileName = replaceTags(this.mediaFileNameTemplateMap.get(mediaTypeModel.getMediaType())!, mediaTypeModel, true);
return this.cleanFileName(fileName);
}
cleanFileName(fileName: string): string {
const cleanedFileName = ILLEGAL_FILENAME_CHARACTERS.reduce((str, char) => str.replaceAll(char[0], char[1]), fileName);
// Remove all duplicate whitespace in the file name
return cleanedFileName.replaceAll(/ +/g, ' ');
}
async getTemplate(mediaTypeModel: MediaTypeModel, app: App): Promise<string> {
const templateFilePath = this.mediaTemplateMap.get(mediaTypeModel.getMediaType());
if (!templateFilePath) {
return '';
}
let templateFile = app.vault.getAbstractFileByPath(templateFilePath) ?? undefined;
// WARNING: This was previously selected by filename, but that could lead to collisions and unwanted effects.
// This now falls back to the previous method if no file is found
if (!templateFile || templateFile instanceof TFolder) {
templateFile = app.vault
.getFiles()
.filter((f: TFile) => f.name === templateFilePath)
.first();
if (!templateFile) {
return '';
}
}
if (!(templateFile instanceof TFile)) {
return '';
}
const template = await app.vault.cachedRead(templateFile);
return replaceTags(template, mediaTypeModel);
}
async getFolder(mediaTypeModel: MediaTypeModel, app: App): Promise<TFolder> {
let folderPath = this.mediaFolderMap.get(mediaTypeModel.getMediaType()) ?? '/';
if (!(await app.vault.adapter.exists(folderPath))) {
await app.vault.createFolder(folderPath);
}
const folder = app.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) {
throw Error(`Expected ${folder?.path} to be instance of TFolder`);
}
return folder;
}
/**
* Takes an object and a MediaType and turns the object into an instance of a MediaTypeModel corresponding to the MediaType passed in.
*
* @param obj
* @param mediaType
*/
createMediaTypeModelFromMediaType(obj: object, mediaType: MediaType): MediaTypeModel {
if (mediaType === MediaType.Movie) {
return new MovieModel(obj);
} else if (mediaType === MediaType.Series) {
return new SeriesModel(obj);
} else if (mediaType === MediaType.Season) {
return new SeasonModel(obj);
} else if (mediaType === MediaType.ComicManga) {
return new ComicMangaModel(obj);
} else if (mediaType === MediaType.Game) {
return new GameModel(obj);
} else if (mediaType === MediaType.Wiki) {
return new WikiModel(obj);
} else if (mediaType === MediaType.MusicRelease) {
return new MusicReleaseModel(obj);
} else if (mediaType === MediaType.BoardGame) {
return new BoardGameModel(obj);
} else if (mediaType === MediaType.Book) {
return new BookModel(obj);
}
throw new Error(`Unknown media type: ${mediaType}`);
}
}

View file

@ -0,0 +1,282 @@
import type MediaDbPlugin from 'packages/obsidian/src/main';
import { MediaDbAdvancedSearchModal } from 'packages/obsidian/src/modals/MediaDbAdvancedSearchModal';
import { MediaDbIdSearchModal } from 'packages/obsidian/src/modals/MediaDbIdSearchModal';
import { MediaDbPreviewModal } from 'packages/obsidian/src/modals/MediaDbPreviewModal';
import { MediaDbSearchModal } from 'packages/obsidian/src/modals/MediaDbSearchModal';
import { MediaDbSearchResultModal } from 'packages/obsidian/src/modals/MediaDbSearchResultModal';
import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel';
import type { AppError } from 'packages/obsidian/src/utils/AppError';
import { AppErrorKind, toAppError } from 'packages/obsidian/src/utils/AppError';
import type { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { Outcome } from 'packages/obsidian/src/utils/result';
import { cancelled, failure, OutcomeStatus, skipped, success } from 'packages/obsidian/src/utils/result';
export interface SearchModalData {
query: string;
types: MediaType[];
}
export interface AdvancedSearchModalData {
query: string;
apis: string[];
}
export interface IdSearchModalData {
query: string;
api: string;
}
export interface SelectModalData {
selected: MediaTypeModel[];
}
export interface PreviewModalData {
confirmed: boolean;
}
export interface SearchModalOptions {
modalTitle?: string;
preselectedTypes?: MediaType[];
prefilledSearchString?: string;
}
export interface AdvancedSearchModalOptions {
modalTitle?: string;
preselectedAPIs?: string[];
prefilledSearchString?: string;
}
export interface IdSearchModalOptions {
modalTitle?: string;
preselectedAPI?: string;
prefilledSearchString?: string;
}
export interface SelectModalOptions {
elements?: MediaTypeModel[];
multiSelect?: boolean;
modalTitle?: string;
skipButton?: boolean;
description?: string;
submitButtonText?: string;
}
export interface PreviewModalOptions {
modalTitle?: string;
elements?: MediaTypeModel[];
}
export const SEARCH_MODAL_DEFAULT_OPTIONS: SearchModalOptions = {
modalTitle: 'Media DB Search',
preselectedTypes: [],
prefilledSearchString: '',
};
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: undefined,
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: [],
};
export const SELECTMODALOPTIONSDEFAULT: SelectModalOptions = {
elements: [],
multiSelect: true,
modalTitle: '',
skipButton: false,
description: 'Select one or multiple search results.',
submitButtonText: 'Ok',
};
interface ModalCoreResult<T, TModal> {
modalResult: Outcome<T, AppError>;
modal: TModal;
}
export class ModalHelper {
plugin: MediaDbPlugin;
constructor(plugin: MediaDbPlugin) {
this.plugin = plugin;
}
private async openModalCore<TData, TModal extends { open(): void; close(): void }>(
createModal: () => TModal,
wireHandlers: (modal: TModal, resolve: (result: Outcome<TData, AppError>) => void) => void,
): Promise<ModalCoreResult<TData, TModal>> {
const modal = createModal();
const modalResult = await new Promise<Outcome<TData, AppError>>(resolve => {
wireHandlers(modal, resolve);
modal.open();
});
return { modalResult, modal };
}
private async resolveOutcome<T>(outcomePromise: Promise<Outcome<T, AppError>>): Promise<T | undefined> {
const outcome = await outcomePromise;
if (outcome.status === OutcomeStatus.Ok) {
return outcome.data;
}
if (outcome.status === OutcomeStatus.Error) {
this.plugin.errorReporter.report(outcome.error);
}
return undefined;
}
async createSearchModalOutcome(searchModalOptions: SearchModalOptions): Promise<Outcome<SearchModalData, AppError>> {
const { modalResult, modal } = await this.openModalCore<SearchModalData, MediaDbSearchModal>(
() => new MediaDbSearchModal(this.plugin, searchModalOptions),
(modal, resolve) => {
modal.setSubmitCb(res => resolve(success(res)));
modal.setCloseCb(err => {
if (err) {
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Search modal closed with an error' })));
return;
}
resolve(cancelled());
});
},
);
if (modalResult.status === OutcomeStatus.Ok) {
modal.close();
}
return modalResult;
}
async promptSearchModal(searchModalOptions: SearchModalOptions): Promise<SearchModalData | undefined> {
return await this.resolveOutcome(this.createSearchModalOutcome(searchModalOptions));
}
async createAdvancedSearchModalOutcome(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise<Outcome<AdvancedSearchModalData, AppError>> {
const { modalResult, modal } = await this.openModalCore<AdvancedSearchModalData, MediaDbAdvancedSearchModal>(
() => new MediaDbAdvancedSearchModal(this.plugin, advancedSearchModalOptions),
(modal, resolve) => {
modal.setSubmitCb(res => resolve(success(res)));
modal.setCloseCb(err => {
if (err) {
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Advanced search modal closed with an error' })));
return;
}
resolve(cancelled());
});
},
);
if (modalResult.status === OutcomeStatus.Ok) {
modal.close();
}
return modalResult;
}
async promptAdvancedSearchModal(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise<AdvancedSearchModalData | undefined> {
return await this.resolveOutcome(this.createAdvancedSearchModalOutcome(advancedSearchModalOptions));
}
async createIdSearchModalOutcome(idSearchModalOptions: IdSearchModalOptions): Promise<Outcome<IdSearchModalData, AppError>> {
const { modalResult, modal } = await this.openModalCore<IdSearchModalData, MediaDbIdSearchModal>(
() => new MediaDbIdSearchModal(this.plugin, idSearchModalOptions),
(modal, resolve) => {
modal.setSubmitCb(res => resolve(success(res)));
modal.setCloseCb(err => {
if (err) {
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Id search modal closed with an error' })));
return;
}
resolve(cancelled());
});
},
);
if (modalResult.status === OutcomeStatus.Ok) {
modal.close();
}
return modalResult;
}
async promptIdSearchModal(idSearchModalOptions: IdSearchModalOptions): Promise<IdSearchModalData | undefined> {
return await this.resolveOutcome(this.createIdSearchModalOutcome(idSearchModalOptions));
}
async createSelectModalOutcome(selectModalOptions: SelectModalOptions): Promise<Outcome<SelectModalData, AppError>> {
const { modalResult, modal } = await this.openModalCore<SelectModalData, MediaDbSearchResultModal>(
() => new MediaDbSearchResultModal(this.plugin, selectModalOptions),
(modal, resolve) => {
modal.setSubmitCb(res => resolve(success(res)));
modal.setSkipCallback(() => resolve(skipped()));
modal.setCloseCb(err => {
if (err) {
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Select modal closed with an error' })));
return;
}
resolve(cancelled());
});
},
);
if (modalResult.status === OutcomeStatus.Ok || modalResult.status === OutcomeStatus.Skipped) {
modal.close();
}
return modalResult;
}
async promptSelectModal(selectModalOptions: SelectModalOptions): Promise<SelectModalData | undefined> {
return await this.resolveOutcome(this.createSelectModalOutcome(selectModalOptions));
}
async createPreviewModalOutcome(previewModalOptions: PreviewModalOptions): Promise<Outcome<PreviewModalData, AppError>> {
const { modalResult, modal } = await this.openModalCore<PreviewModalData, MediaDbPreviewModal>(
() => new MediaDbPreviewModal(this.plugin, previewModalOptions),
(modal, resolve) => {
modal.setSubmitCb(res => resolve(success(res)));
modal.setCloseCb(err => {
if (err) {
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Preview modal closed with an error' })));
return;
}
resolve(cancelled());
});
},
);
if (modalResult.status === OutcomeStatus.Ok) {
modal.close();
}
return modalResult;
}
async promptPreviewModal(previewModalOptions: PreviewModalOptions): Promise<PreviewModalData | undefined> {
return await this.resolveOutcome(this.createPreviewModalOutcome(previewModalOptions));
}
}

View file

@ -0,0 +1,315 @@
import { iso6392 } from 'iso-639-2';
import type { TFile, TFolder, App } from 'obsidian';
import { requestUrl } from 'obsidian';
import type { MediaTypeModel } from 'packages/obsidian/src/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.8.0';
export function wrapAround(value: number, size: number): number {
if (size <= 0) {
throw Error('size may not be zero or negative');
}
return mod(value, size);
}
export function containsOnlyLettersAndUnderscores(str: string): boolean {
return /^[\p{Letter}\p{M}_]+$/u.test(str);
}
export function replaceIllegalFileNameCharactersInString(string: string): string {
return string.replace(/[\\,#%&{}/*<>$"@.?]*/g, '').replace(/:+/g, ' -');
}
export function replaceTags(template: string, mediaTypeModel: MediaTypeModel, ignoreUndefined: boolean = false): string {
return template.replace(new RegExp('{{.*?}}', 'g'), (match: string) => replaceTag(match, mediaTypeModel, ignoreUndefined));
}
function replaceTag(match: string, mediaTypeModel: MediaTypeModel, ignoreUndefined: boolean): string {
let tag = match;
tag = tag.substring(2);
tag = tag.substring(0, tag.length - 2);
tag = tag.trim();
const parts = tag.split(':');
if (parts.length === 1) {
const path = parts[0].split('.');
const obj = traverseMetaData(path, mediaTypeModel);
if (obj === undefined) {
return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}';
}
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return obj?.toString() ?? 'null';
} else if (parts.length === 2) {
const operator = parts[0];
const path = parts[1].split('.');
const obj = traverseMetaData(path, mediaTypeModel);
if (obj === undefined) {
return ignoreUndefined ? '' : '{{ INVALID TEMPLATE TAG - object undefined }}';
}
if (operator === 'LIST') {
if (!Array.isArray(obj)) {
return '{{ INVALID TEMPLATE TAG - operator LIST is only applicable on an array }}';
}
return obj.map((e: unknown) => `- ${e}`).join('\n');
} else if (operator === 'ENUM') {
if (!Array.isArray(obj)) {
return '{{ INVALID TEMPLATE TAG - operator ENUM is only applicable on an array }}';
}
return obj.join(', ');
} else if (operator === 'FIRST') {
if (!Array.isArray(obj)) {
return '{{ INVALID TEMPLATE TAG - operator FIRST is only applicable on an array }}';
}
const first = obj[0] as unknown;
return first?.toString() ?? 'null';
} else if (operator === 'LAST') {
if (!Array.isArray(obj)) {
return '{{ INVALID TEMPLATE TAG - operator LAST is only applicable on an array }}';
}
const last = obj[obj.length - 1] as unknown;
return last?.toString() ?? 'null';
}
return `{{ INVALID TEMPLATE TAG - unknown operator ${operator} }}`;
}
return '{{ INVALID TEMPLATE TAG }}';
}
function traverseMetaData(path: string[], mediaTypeModel: MediaTypeModel): unknown {
let o: unknown = mediaTypeModel;
for (const part of path) {
if (o !== undefined) {
o = (o as Record<string, unknown>)[part];
}
}
return o;
}
export function markdownTable(content: string[][]): string {
const rows = content.length;
if (rows === 0) {
return '';
}
const columns = content[0].length;
if (columns === 0) {
return '';
}
for (const row of content) {
if (row.length !== columns) {
return '';
}
}
const 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): string {
return `${date.getMonth() + 1}-${date.getDate()}-${date.getFullYear()}`;
}
export function timeToString(time: Date): string {
return `${time.getHours()}-${time.getMinutes()}-${time.getSeconds()}`;
}
export function dateTimeToString(dateTime: Date): string {
return `${dateToString(dateTime)} ${timeToString(dateTime)}`;
}
// js can't even implement modulo correctly...
export function mod(n: number, m: number): number {
return ((n % m) + m) % m;
}
export function capitalizeFirstLetter(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1);
}
export class PropertyMappingValidationError extends Error {
constructor(message: string) {
super(message);
}
}
export class PropertyMappingNameConflictError extends Error {
constructor(message: string) {
super(message);
}
}
/**
* - attachTemplate: whether to attach the template (DEFAULT: false)
* - attachFie: a file to attach (DEFAULT: undefined)
* - openNote: whether to open the note after creation (DEFAULT: false)
* - folder: folder to put the note in
*/
export interface CreateNoteOptions {
attachTemplate?: boolean;
attachFile?: TFile;
openNote?: boolean;
folder?: TFolder;
}
export function migrateObject<T extends object>(object: T, oldData: Record<string, unknown>, defaultData: T): void {
for (const key in object) {
object[key] = Object.hasOwn(oldData, key) && oldData[key] !== undefined && oldData[key] !== null ? (oldData[key] as T[typeof key]) : defaultData[key];
}
}
export function unCamelCase(str: string): string {
return (
str
// insert a space between lower & upper
.replace(/([a-z])([A-Z])/g, '$1 $2')
// space before last upper in a sequence followed by lower
.replace(/\b([A-Z]+)([A-Z])([a-z])/, '$1 $2$3')
// uppercase the first character
.replace(/^./, function (str) {
return str.toUpperCase();
})
);
}
interface TemplaterPlugin {
settings?: {
trigger_on_file_creation?: boolean;
};
templater?: {
overwrite_file_commands: (file: TFile) => Promise<void>;
};
}
function getTemplaterPlugin(app: App): TemplaterPlugin | null {
const pluginContainer = (app as App & { plugins?: { plugins?: Record<string, unknown> } }).plugins;
const candidate = pluginContainer?.plugins?.['templater-obsidian'];
if (!candidate || typeof candidate !== 'object') {
return null;
}
return candidate;
}
export function hasTemplaterPlugin(app: App): boolean {
const templater = getTemplaterPlugin(app);
return !!templater;
}
// Copied from https://github.com/anpigon/obsidian-book-search-plugin
// Licensed under the MIT license. Copyright (c) 2020 Jake Runzer
export async function useTemplaterPluginInFile(app: App, file: TFile): Promise<void> {
const templater = getTemplaterPlugin(app);
if (templater && !templater.settings?.trigger_on_file_creation && templater.templater) {
await templater.templater.overwrite_file_commands(file);
}
}
export type ModelToData<T> = {
[K in keyof T as T[K] extends (...args: never[]) => unknown ? never : K]?: T[K] | null;
};
// Checks if a given URL points to an existing image (status 200), or returns false for 404/other errors.
export async function imageUrlExists(url: string): Promise<boolean> {
try {
// @ts-ignore
const response = await requestUrl({
url,
method: 'HEAD',
throw: false,
});
return response.status === 200;
} catch {
return false;
}
}
export function isTruthy<T>(value: T): value is Exclude<T, false | 0 | '' | null | undefined> {
return Boolean(value);
}
/**
* Wraps Obsidians `requestUrl` in a fetch like API.
*/
export async function obsidianFetch(input: Request): Promise<Response> {
const obs_headers: Record<string, string> = {};
input.headers.forEach((value, key) => {
obs_headers[key] = value;
});
const res = await requestUrl({
url: input.url,
method: input.method,
headers: obs_headers,
throw: false, // Do not throw on error, handle it manually
});
const responseHeaders: Headers = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
responseHeaders.append(key, value);
}
return {
ok: res.status >= 200 && res.status < 300,
status: res.status,
headers: responseHeaders,
// eslint-disable-next-line
json: async () => res.json,
text: async () => res.text,
} as Response;
}
export function getLanguageName(code: string): string | null {
const language = iso6392.find(lang => lang.iso6392B === code || lang.iso6392T === code);
return language?.name ?? null;
}

View file

@ -0,0 +1,52 @@
export interface Ok<T> {
ok: true;
value: T;
}
export interface Err<E> {
ok: false;
error: E;
}
export type Result<T, E> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
export const isOk = <T, E>(result: Result<T, E>): result is Ok<T> => result.ok;
export const isErr = <T, E>(result: Result<T, E>): result is Err<E> => !result.ok;
export const mapResult = <T, E, U>(result: Result<T, E>, mapper: (value: T) => U): Result<U, E> => (result.ok ? ok(mapper(result.value)) : result);
export const mapError = <T, E, F>(result: Result<T, E>, mapper: (error: E) => F): Result<T, F> => (result.ok ? result : err(mapper(result.error)));
export const andThen = <T, E, U>(result: Result<T, E>, binder: (value: T) => Result<U, E>): Result<U, E> => (result.ok ? binder(result.value) : result);
export const tapError = <T, E>(result: Result<T, E>, sideEffect: (error: E) => void): Result<T, E> => {
if (!result.ok) sideEffect(result.error);
return result;
};
export const fromPromise = async <T, E>(promise: Promise<T>, onError: (cause: unknown) => E): Promise<Result<T, E>> => {
try {
return ok(await promise);
} catch (cause) {
return err(onError(cause));
}
};
export enum OutcomeStatus {
Ok = 'ok',
Cancelled = 'cancelled',
Skipped = 'skipped',
Error = 'error',
}
export type Outcome<T, E> =
| { status: OutcomeStatus.Ok; data: T }
| { status: OutcomeStatus.Cancelled }
| { status: OutcomeStatus.Skipped }
| { status: OutcomeStatus.Error; error: E };
export const cancelled = (): Outcome<never, never> => ({ status: OutcomeStatus.Cancelled });
export const skipped = (): Outcome<never, never> => ({ status: OutcomeStatus.Skipped });
export const success = <T>(data: T): Outcome<T, never> => ({ status: OutcomeStatus.Ok, data });
export const failure = <E>(error: E): Outcome<never, E> => ({ status: OutcomeStatus.Error, error });