add obsidian eslint plugin, solve major issues
This commit is contained in:
parent
84214a1c3c
commit
498e2611ae
105 changed files with 5702 additions and 5308 deletions
24
packages/obsidian/src/utils/AppError.ts
Normal file
24
packages/obsidian/src/utils/AppError.ts
Normal 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 };
|
||||
};
|
||||
144
packages/obsidian/src/utils/BulkImportHelper.ts
Normal file
144
packages/obsidian/src/utils/BulkImportHelper.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
73
packages/obsidian/src/utils/DateFormatter.ts
Normal file
73
packages/obsidian/src/utils/DateFormatter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
23
packages/obsidian/src/utils/ErrorReporter.ts
Normal file
23
packages/obsidian/src/utils/ErrorReporter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1185
packages/obsidian/src/utils/IconList.ts
Normal file
1185
packages/obsidian/src/utils/IconList.ts
Normal file
File diff suppressed because it is too large
Load diff
16
packages/obsidian/src/utils/IllegalFilenameCharactersList.ts
Normal file
16
packages/obsidian/src/utils/IllegalFilenameCharactersList.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Illegal characters in the form `[illegal_character, replacement][]`
|
||||
export const ILLEGAL_FILENAME_CHARACTERS = [
|
||||
['/', '-'],
|
||||
['\\', '-'],
|
||||
['<', ''],
|
||||
['>', ''],
|
||||
[':', ' - '],
|
||||
['"', "'"],
|
||||
['|', ' - '],
|
||||
['?', ''],
|
||||
['*', ''],
|
||||
['[', '('],
|
||||
[']', ')'],
|
||||
['^', ''],
|
||||
['#', ''],
|
||||
];
|
||||
38
packages/obsidian/src/utils/Logger.ts
Normal file
38
packages/obsidian/src/utils/Logger.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
289
packages/obsidian/src/utils/MediaDbEntryHelper.ts
Normal file
289
packages/obsidian/src/utils/MediaDbEntryHelper.ts
Normal 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]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
325
packages/obsidian/src/utils/MediaDbFileHelper.ts
Normal file
325
packages/obsidian/src/utils/MediaDbFileHelper.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
11
packages/obsidian/src/utils/MediaType.ts
Normal file
11
packages/obsidian/src/utils/MediaType.ts
Normal 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',
|
||||
}
|
||||
166
packages/obsidian/src/utils/MediaTypeManager.ts
Normal file
166
packages/obsidian/src/utils/MediaTypeManager.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
282
packages/obsidian/src/utils/ModalHelper.ts
Normal file
282
packages/obsidian/src/utils/ModalHelper.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
315
packages/obsidian/src/utils/Utils.ts
Normal file
315
packages/obsidian/src/utils/Utils.ts
Normal 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;
|
||||
}
|
||||
52
packages/obsidian/src/utils/result.ts
Normal file
52
packages/obsidian/src/utils/result.ts
Normal 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 });
|
||||
Loading…
Add table
Add a link
Reference in a new issue