cleanup of new result code

This commit is contained in:
Moritz Jung 2026-05-06 17:03:31 +02:00
parent 498e2611ae
commit 5a032a87ae
23 changed files with 323 additions and 303 deletions

View file

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

View file

@ -1,4 +1,4 @@
export enum AppErrorKind {
export enum MDBErrorKind {
Validation = 'Validation',
Api = 'Api',
Network = 'Network',
@ -8,17 +8,15 @@ export enum AppErrorKind {
Unexpected = 'Unexpected',
}
export interface AppError {
kind: AppErrorKind;
export interface MDBError {
kind: MDBErrorKind;
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 => {
export function toMdbError(cause: unknown, fallback: Omit<MDBError, 'cause'>): MDBError {
const message = cause instanceof Error ? cause.message : String(cause);
return { ...fallback, message: fallback.message || message, cause };
};
}

View file

@ -5,8 +5,8 @@ import type { SeasonSelectModalElement } from 'packages/obsidian/src/modals/Medi
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 type { MDBError } from 'packages/obsidian/src/utils/MDBError';
import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError';
import { MediaType } from 'packages/obsidian/src/utils/MediaType';
import type { SearchModalOptions } from 'packages/obsidian/src/utils/ModalHelper';
@ -17,7 +17,7 @@ export class MediaDbEntryHelper {
this.plugin = plugin;
}
private reportAppError(error: AppError): void {
private reportMdbError(error: MDBError): void {
this.plugin.errorReporter.report(error);
}
@ -29,12 +29,12 @@ export class MediaDbEntryHelper {
const apiSearchResults = await this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis);
if (!apiSearchResults.ok) {
this.reportAppError(apiSearchResults.error);
this.reportMdbError(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.' });
this.reportMdbError({ kind: MDBErrorKind.Validation, message: 'No results found.', userMessage: 'No results found.' });
return;
}
@ -66,18 +66,18 @@ export class MediaDbEntryHelper {
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);
this.reportMdbError(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.' });
this.reportMdbError({ kind: MDBErrorKind.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.' });
this.reportMdbError({ kind: MDBErrorKind.Validation, message: 'No results found for the selected types.', userMessage: 'No results found for the selected types.' });
return;
}
@ -121,12 +121,12 @@ export class MediaDbEntryHelper {
const apiSearchResults = await this.plugin.apiManager.query(advancedSearch.query, advancedSearch.apis);
if (!apiSearchResults.ok) {
this.reportAppError(apiSearchResults.error);
this.reportMdbError(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.' });
this.reportMdbError({ kind: MDBErrorKind.Validation, message: 'No results found.', userMessage: 'No results found.' });
return;
}
@ -160,7 +160,7 @@ export class MediaDbEntryHelper {
const queriedIdResult = await this.plugin.apiManager.queryDetailedInfoById(idSearchData.query, idSearchData.api);
if (!queriedIdResult.ok) {
this.reportAppError(queriedIdResult.error);
this.reportMdbError(queriedIdResult.error);
return;
}
@ -183,7 +183,7 @@ export class MediaDbEntryHelper {
const createNoteResult = await this.plugin.fileHelper.createMediaDbNoteFromModel(idSearchResult, { attachTemplate: true, openNote: true });
if (!createNoteResult.ok) {
this.reportAppError(createNoteResult.error);
this.reportMdbError(createNoteResult.error);
}
}
@ -195,7 +195,7 @@ export class MediaDbEntryHelper {
if (result.ok && result.value) {
detailModels.push(result.value);
} else if (!result.ok) {
this.reportAppError(result.error);
this.reportMdbError(result.error);
}
}
@ -228,7 +228,7 @@ export class MediaDbEntryHelper {
const allSeasonsResult = await tmdbSeasonAPI.getSeasonsForSeries(seriesId);
if (!allSeasonsResult.ok) {
this.reportAppError(allSeasonsResult.error);
this.reportMdbError(allSeasonsResult.error);
new Notice(`Error loading seasons: ${allSeasonsResult.error.userMessage}`);
return false;
}
@ -276,7 +276,7 @@ export class MediaDbEntryHelper {
if (seasonModel) {
const fullMetadataResult = await tmdbSeasonAPI.getById(seasonModel.id);
if (!fullMetadataResult.ok) {
this.reportAppError(fullMetadataResult.error);
this.reportMdbError(fullMetadataResult.error);
new Notice(`Failed to load season ${selectedSeason.season_number}: ${fullMetadataResult.error.userMessage}`);
return;
}

View file

@ -4,9 +4,9 @@ import { Notice, normalizePath, parseYaml, requestUrl, stringifyYaml } from 'obs
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 { MDBError } from 'packages/obsidian/src/utils/MDBError';
import { MDBErrorKind, toMdbError } from 'packages/obsidian/src/utils/MDBError';
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';
@ -29,7 +29,7 @@ export class MediaDbFileHelper {
this.plugin = plugin;
}
async createMediaDbNotes(models: MediaTypeModel[], attachFile?: TFile): Promise<Result<void, AppError>> {
async createMediaDbNotes(models: MediaTypeModel[], attachFile?: TFile): Promise<Result<void, MDBError>> {
const results = await Promise.all(models.map(model => this.createMediaDbNoteFromModel(model, { attachTemplate: true, attachFile })));
const failures = results.filter(result => !result.ok);
@ -45,7 +45,7 @@ export class MediaDbFileHelper {
return ok(undefined);
}
async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<Result<void, AppError>> {
async createMediaDbNoteFromModel(mediaTypeModel: MediaTypeModel, options: CreateNoteOptions): Promise<Result<void, MDBError>> {
Logger.debug('MDB | creating new note');
options.openNote = this.plugin.settings.openNoteInNewTab;
@ -58,7 +58,7 @@ export class MediaDbFileHelper {
}
const fileContentResult = await this.attempt(() => this.generateMediaDbNoteContents(mediaTypeModel, options), {
kind: AppErrorKind.Unexpected,
kind: MDBErrorKind.Unexpected,
message: 'Failed to generate note contents',
userMessage: 'Failed to generate note contents',
});
@ -67,7 +67,7 @@ export class MediaDbFileHelper {
}
const folderResult = await this.attempt(() => this.plugin.mediaTypeManager.getFolder(mediaTypeModel, this.plugin.app), {
kind: AppErrorKind.Vault,
kind: MDBErrorKind.Vault,
message: 'Failed to determine note folder',
userMessage: 'Failed to determine note folder',
});
@ -84,7 +84,7 @@ export class MediaDbFileHelper {
if (this.plugin.settings.enableTemplaterIntegration) {
const templaterResult = await this.attempt(() => useTemplaterPluginInFile(this.plugin.app, targetFileResult.value), {
kind: AppErrorKind.Unexpected,
kind: MDBErrorKind.Unexpected,
message: 'Failed to apply templater to the note',
userMessage: 'Failed to apply templater to the note',
});
@ -240,11 +240,11 @@ export class MediaDbFileHelper {
return structuredClone(metadata ?? {});
}
async createNote(fileName: string, fileContent: string, options: CreateNoteOptions): Promise<Result<TFile, AppError>> {
async createNote(fileName: string, fileContent: string, options: CreateNoteOptions): Promise<Result<TFile, MDBError>> {
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' });
return err({ kind: MDBErrorKind.Validation, message: 'MDB | invalid folder', userMessage: 'MDB | invalid folder' });
}
fileName = replaceIllegalFileNameCharactersInString(fileName);
@ -257,7 +257,7 @@ export class MediaDbFileHelper {
});
if (!shouldOverwrite) {
return err({ kind: AppErrorKind.Cancelled, message: 'MDB | file creation cancelled by user', userMessage: 'MDB | file creation cancelled by user' });
return err({ kind: MDBErrorKind.Cancelled, message: 'MDB | file creation cancelled by user', userMessage: 'MDB | file creation cancelled by user' });
}
await this.plugin.app.fileManager.trashFile(file);
@ -278,7 +278,7 @@ export class MediaDbFileHelper {
return ok(targetFile);
}
private async downloadImageForMediaModel(mediaTypeModel: MediaTypeModel): Promise<Result<void, AppError>> {
private async downloadImageForMediaModel(mediaTypeModel: MediaTypeModel): Promise<Result<void, MDBError>> {
if (mediaTypeModel.image && typeof mediaTypeModel.image === 'string' && mediaTypeModel.image.startsWith('http')) {
const imageUrl = mediaTypeModel.image;
const imageResult = await this.attempt(
@ -299,7 +299,7 @@ export class MediaDbFileHelper {
mediaTypeModel.image = `[[${imagePath}]]`;
},
{
kind: AppErrorKind.Network,
kind: MDBErrorKind.Network,
message: 'MDB | Failed to download image',
userMessage: 'Failed to download image',
},
@ -314,12 +314,12 @@ export class MediaDbFileHelper {
return ok(undefined);
}
private async attempt<T>(operation: () => Promise<T> | T, fallback: Omit<AppError, 'cause'>): Promise<Result<T, AppError>> {
private async attempt<T>(operation: () => Promise<T> | T, fallback: Omit<MDBError, 'cause'>): Promise<Result<T, MDBError>> {
return await Promise.resolve()
.then(operation)
.then(
value => ok(value),
cause => err(toAppError(cause, fallback)),
cause => err(toMdbError(cause, fallback)),
);
}
}

View file

@ -5,8 +5,8 @@ import { MediaDbPreviewModal } from 'packages/obsidian/src/modals/MediaDbPreview
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 { MDBError } from 'packages/obsidian/src/utils/MDBError';
import { MDBErrorKind, toMdbError } from 'packages/obsidian/src/utils/MDBError';
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';
@ -106,7 +106,7 @@ export const SELECTMODALOPTIONSDEFAULT: SelectModalOptions = {
};
interface ModalCoreResult<T, TModal> {
modalResult: Outcome<T, AppError>;
modalResult: Outcome<T, MDBError>;
modal: TModal;
}
@ -119,10 +119,10 @@ export class ModalHelper {
private async openModalCore<TData, TModal extends { open(): void; close(): void }>(
createModal: () => TModal,
wireHandlers: (modal: TModal, resolve: (result: Outcome<TData, AppError>) => void) => void,
wireHandlers: (modal: TModal, resolve: (result: Outcome<TData, MDBError>) => void) => void,
): Promise<ModalCoreResult<TData, TModal>> {
const modal = createModal();
const modalResult = await new Promise<Outcome<TData, AppError>>(resolve => {
const modalResult = await new Promise<Outcome<TData, MDBError>>(resolve => {
wireHandlers(modal, resolve);
modal.open();
});
@ -130,7 +130,7 @@ export class ModalHelper {
return { modalResult, modal };
}
private async resolveOutcome<T>(outcomePromise: Promise<Outcome<T, AppError>>): Promise<T | undefined> {
private async resolveOutcome<T>(outcomePromise: Promise<Outcome<T, MDBError>>): Promise<T | undefined> {
const outcome = await outcomePromise;
if (outcome.status === OutcomeStatus.Ok) {
@ -144,14 +144,14 @@ export class ModalHelper {
return undefined;
}
async createSearchModalOutcome(searchModalOptions: SearchModalOptions): Promise<Outcome<SearchModalData, AppError>> {
async createSearchModalOutcome(searchModalOptions: SearchModalOptions): Promise<Outcome<SearchModalData, MDBError>> {
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' })));
resolve(failure(toMdbError(err, { kind: MDBErrorKind.Modal, message: 'Search modal closed with an error' })));
return;
}
@ -171,14 +171,14 @@ export class ModalHelper {
return await this.resolveOutcome(this.createSearchModalOutcome(searchModalOptions));
}
async createAdvancedSearchModalOutcome(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise<Outcome<AdvancedSearchModalData, AppError>> {
async createAdvancedSearchModalOutcome(advancedSearchModalOptions: AdvancedSearchModalOptions): Promise<Outcome<AdvancedSearchModalData, MDBError>> {
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' })));
resolve(failure(toMdbError(err, { kind: MDBErrorKind.Modal, message: 'Advanced search modal closed with an error' })));
return;
}
@ -198,14 +198,14 @@ export class ModalHelper {
return await this.resolveOutcome(this.createAdvancedSearchModalOutcome(advancedSearchModalOptions));
}
async createIdSearchModalOutcome(idSearchModalOptions: IdSearchModalOptions): Promise<Outcome<IdSearchModalData, AppError>> {
async createIdSearchModalOutcome(idSearchModalOptions: IdSearchModalOptions): Promise<Outcome<IdSearchModalData, MDBError>> {
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' })));
resolve(failure(toMdbError(err, { kind: MDBErrorKind.Modal, message: 'Id search modal closed with an error' })));
return;
}
@ -225,7 +225,7 @@ export class ModalHelper {
return await this.resolveOutcome(this.createIdSearchModalOutcome(idSearchModalOptions));
}
async createSelectModalOutcome(selectModalOptions: SelectModalOptions): Promise<Outcome<SelectModalData, AppError>> {
async createSelectModalOutcome(selectModalOptions: SelectModalOptions): Promise<Outcome<SelectModalData, MDBError>> {
const { modalResult, modal } = await this.openModalCore<SelectModalData, MediaDbSearchResultModal>(
() => new MediaDbSearchResultModal(this.plugin, selectModalOptions),
(modal, resolve) => {
@ -233,7 +233,7 @@ export class ModalHelper {
modal.setSkipCallback(() => resolve(skipped()));
modal.setCloseCb(err => {
if (err) {
resolve(failure(toAppError(err, { kind: AppErrorKind.Modal, message: 'Select modal closed with an error' })));
resolve(failure(toMdbError(err, { kind: MDBErrorKind.Modal, message: 'Select modal closed with an error' })));
return;
}
@ -253,14 +253,14 @@ export class ModalHelper {
return await this.resolveOutcome(this.createSelectModalOutcome(selectModalOptions));
}
async createPreviewModalOutcome(previewModalOptions: PreviewModalOptions): Promise<Outcome<PreviewModalData, AppError>> {
async createPreviewModalOutcome(previewModalOptions: PreviewModalOptions): Promise<Outcome<PreviewModalData, MDBError>> {
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' })));
resolve(failure(toMdbError(err, { kind: MDBErrorKind.Modal, message: 'Preview modal closed with an error' })));
return;
}

View file

@ -8,30 +8,44 @@ export interface Err<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 function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
export function err<E>(error: E): Err<E> {
return { 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 function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.ok;
}
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return !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 function mapResult<T, E, U>(result: Result<T, E>, mapper: (value: T) => U): Result<U, E> {
return 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 function mapError<T, E, F>(result: Result<T, E>, mapper: (error: E) => F): Result<T, F> {
return 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 function andThen<T, E, U>(result: Result<T, E>, binder: (value: T) => Result<U, E>): Result<U, E> {
return result.ok ? binder(result.value) : result;
}
export const tapError = <T, E>(result: Result<T, E>, sideEffect: (error: E) => void): Result<T, E> => {
export function 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>> => {
export async function fromPromise<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',
@ -46,7 +60,15 @@ export type Outcome<T, E> =
| { 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 });
export function cancelled(): Outcome<never, never> {
return { status: OutcomeStatus.Cancelled };
}
export function skipped(): Outcome<never, never> {
return { status: OutcomeStatus.Skipped };
}
export function success<T>(data: T): Outcome<T, never> {
return { status: OutcomeStatus.Ok, data };
}
export function failure<E>(error: E): Outcome<never, E> {
return { status: OutcomeStatus.Error, error };
}