1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-09 21:36:46 +01:00
immich/web/src/lib/utils.ts

295 lines
9 KiB
TypeScript
Raw Normal View History

import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
import { locales } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import {
AssetJobName,
JobName,
ThumbnailFormat,
finishOAuth,
2024-05-17 22:48:29 +02:00
getBaseUrl,
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
type SharedLinkResponseDto,
} from '@immich/sdk';
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiImageRefreshOutline } from '@mdi/js';
interface DownloadRequestOptions<T = unknown> {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
url: string;
data?: T;
signal?: AbortSignal;
onDownloadProgress?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
}
interface UploadRequestOptions {
url: string;
method?: 'POST' | 'PUT';
data: FormData;
onUploadProgress?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
}
export class AbortError extends Error {
name = 'AbortError';
}
class ApiError extends Error {
name = 'ApiError';
constructor(
public message: string,
public statusCode: number,
public details: string,
) {
super(message);
}
}
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
const { onUploadProgress: onProgress, data, url } = options;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener('error', (error) => reject(error));
xhr.addEventListener('load', () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
resolve({ data: xhr.response as T, status: xhr.status });
} else {
reject(new ApiError(xhr.statusText, xhr.status, xhr.response));
}
});
if (onProgress) {
xhr.upload.addEventListener('progress', (event) => onProgress(event));
}
xhr.open(options.method || 'POST', url);
xhr.responseType = 'json';
xhr.send(data);
});
};
export const downloadRequest = <TBody = unknown>(options: DownloadRequestOptions<TBody> | string) => {
if (typeof options === 'string') {
options = { url: options };
}
const { signal, method, url, data: body, onDownloadProgress: onProgress } = options;
return new Promise<{ data: Blob; status: number }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener('error', (error) => reject(error));
xhr.addEventListener('abort', () => reject(new AbortError()));
xhr.addEventListener('load', () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
resolve({ data: xhr.response as Blob, status: xhr.status });
} else {
reject(new ApiError(xhr.statusText, xhr.status, xhr.responseText));
}
});
if (onProgress) {
xhr.addEventListener('progress', (event) => onProgress(event));
}
if (signal) {
signal.addEventListener('abort', () => xhr.abort());
}
xhr.open(method || 'GET', url);
xhr.responseType = 'blob';
if (body) {
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify(body));
} else {
xhr.send();
}
});
};
export const getJobName = (jobName: JobName) => {
const names: Record<JobName, string> = {
[JobName.ThumbnailGeneration]: 'Generate Thumbnails',
[JobName.MetadataExtraction]: 'Extract Metadata',
[JobName.Sidecar]: 'Sidecar Metadata',
[JobName.SmartSearch]: 'Smart Search',
feat(server): near-duplicate detection (#8228) * duplicate detection job, entity, config * queueing * job panel, update api * use embedding in db instead of fetching * disable concurrency * only queue visible assets * handle multiple duplicateIds * update concurrent queue check * add provider * add web placeholder, server endpoint, migration, various fixes * update sql * select embedding by default * rename variable * simplify * remove separate entity, handle re-running with different threshold, set default back to 0.02 * fix tests * add tests * add index to entity * formatting * update asset mock * fix `upsertJobStatus` signature * update sql * formatting * default to 0.03 * optimize clustering * use asset's `duplicateId` if present * update sql * update tests * expose admin setting * refactor * formatting * skip if ml is disabled * debug trash e2e * remove from web * remove from sidebar * test if ml is disabled * update sql * separate duplicate detection from clip in config, disable by default for now * fix doc * lower minimum `maxDistance` * update api * Add and Use Duplicate Detection Feature Flag (#9364) * Add Duplicate Detection Flag * Use Duplicate Detection Flag * Attempt Fixes for Failing Checks * lower minimum `maxDistance` * fix tests --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> * chore: fixes and additions after rebase * chore: update api (remove new Role enum) * fix: left join smart search so getAll works without machine learning * test: trash e2e go back to checking length of assets is zero * chore: regen api after rebase * test: fix tests after rebase * redundant join --------- Co-authored-by: Nicholas Flamy <30300649+NicholasFlamy@users.noreply.github.com> Co-authored-by: Zack Pollard <zackpollard@ymail.com> Co-authored-by: Zack Pollard <zack@futo.org>
2024-05-16 19:08:37 +02:00
[JobName.DuplicateDetection]: 'Duplicate Detection',
[JobName.FaceDetection]: 'Face Detection',
[JobName.FacialRecognition]: 'Facial Recognition',
[JobName.VideoConversion]: 'Transcode Videos',
[JobName.StorageTemplateMigration]: 'Storage Template Migration',
[JobName.Migration]: 'Migration',
[JobName.BackgroundTask]: 'Background Tasks',
[JobName.Search]: 'Search',
[JobName.Library]: 'Library',
feat(server): email notifications (#8447) * feat(server): add `react-mail` as mail template engine and `nodemailer` * feat(server): add `smtp` related configs to `SystemConfig` * feat(web): add page for SMTP settings * feat(server): add `react-email.adapter` This adapter render the React-Email into HTML and plain/text email. The output is set as the body of the email. * feat(server): add `MailRepository` and `MailService` Allow to use the NestJS-modules-mailer module to send SMTP emails. This is the base transport for the `NotificationRepository` * feat(server): register the job dispatcher and Job for async email This allows to queue email sending jobs for the `EmailService`. * feat(server): add `NotificationRepository` and `NotificationService` This act as a middleware to properly route the notification to the right transport. As POC I've only implemented a simple SMTP transport. * feat(server): add `welcome` email template * feat(server): add the first notification on `createUser` in `UserService` This trigger an event for the `NotificationRepository` that once processes by using the global config and per-user config will carry the payload to the right notification transport. * chore: clean up * chore: clean up web * fix: type errors" * fix package lock * fix mail sending, option to ignore certs * chore: open api * chore: clean up * remove unused import * feat: email feature flag * chore: remove unused interface * small styling --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Daniel Dietzler <mail@ddietzler.dev> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-05-02 16:43:18 +02:00
[JobName.Notifications]: 'Notifications',
};
return names[jobName];
};
let _key: string | undefined;
let _sharedLink: SharedLinkResponseDto | undefined;
export const setKey = (key: string) => (_key = key);
export const getKey = (): string | undefined => _key;
export const setSharedLink = (sharedLink: SharedLinkResponseDto) => (_sharedLink = sharedLink);
export const getSharedLink = (): SharedLinkResponseDto | undefined => _sharedLink;
export const isSharedLink = () => {
return !!_key;
};
const createUrl = (path: string, parameters?: Record<string, unknown>) => {
const searchParameters = new URLSearchParams();
for (const key in parameters) {
const value = parameters[key];
if (value !== undefined && value !== null) {
searchParameters.set(key, value.toString());
}
}
const url = new URL(path, 'https://example.com');
url.search = searchParameters.toString();
2024-05-17 22:48:29 +02:00
return getBaseUrl() + url.pathname + url.search + url.hash;
};
export const getAssetFileUrl = (
...[assetId, isWeb, isThumb, checksum]:
| [assetId: string, isWeb: boolean, isThumb: boolean]
| [assetId: string, isWeb: boolean, isThumb: boolean, checksum: string]
) => {
const path = `/asset/file/${assetId}`;
return createUrl(path, { isThumb, isWeb, key: getKey(), c: checksum });
};
export const getAssetThumbnailUrl = (
...[assetId, format, checksum]:
| [assetId: string, format: ThumbnailFormat | undefined]
| [assetId: string, format: ThumbnailFormat | undefined, checksum: string]
) => {
// checksum (optional) is used as a cache-buster param, since thumbs are
// served with static resource cache headers
const path = `/asset/thumbnail/${assetId}`;
return createUrl(path, { format, key: getKey(), c: checksum });
};
2024-05-25 17:13:03 +02:00
export const getProfileImageUrl = (userId: string) => {
const path = `/users/${userId}/profile-image`;
return createUrl(path);
};
export const getPeopleThumbnailUrl = (personId: string) => {
const path = `/people/${personId}/thumbnail`;
return createUrl(path);
};
export const getAssetJobName = (job: AssetJobName) => {
const names: Record<AssetJobName, string> = {
[AssetJobName.RefreshMetadata]: 'Refresh metadata',
[AssetJobName.RegenerateThumbnail]: 'Refresh thumbnails',
[AssetJobName.TranscodeVideo]: 'Refresh encoded videos',
};
return names[job];
};
export const getAssetJobMessage = (job: AssetJobName) => {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshMetadata]: 'Refreshing metadata',
[AssetJobName.RegenerateThumbnail]: `Regenerating thumbnails`,
[AssetJobName.TranscodeVideo]: `Refreshing encoded video`,
};
return messages[job];
};
export const getAssetJobIcon = (job: AssetJobName) => {
const names: Record<AssetJobName, string> = {
[AssetJobName.RefreshMetadata]: mdiDatabaseRefreshOutline,
[AssetJobName.RegenerateThumbnail]: mdiImageRefreshOutline,
[AssetJobName.TranscodeVideo]: mdiCogRefreshOutline,
};
return names[job];
};
export const copyToClipboard = async (secret: string) => {
try {
await navigator.clipboard.writeText(secret);
notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Cannot copy to clipboard, make sure you are accessing the page through https');
}
};
export const makeSharedLinkUrl = (externalDomain: string, key: string) => {
let url = externalDomain || window.location.origin;
if (!url.endsWith('/')) {
url += '/';
}
return `${url}share/${key}`;
};
export const oauth = {
isCallback: (location: Location) => {
const search = location.search;
return search.includes('code=') || search.includes('error=');
},
isAutoLaunchDisabled: (location: Location) => {
const values = ['autoLaunch=0', 'password=1', 'password=true'];
for (const value of values) {
if (location.search.includes(value)) {
return true;
}
}
return false;
},
authorize: async (location: Location) => {
try {
const redirectUri = location.href.split('?')[0];
const { url } = await startOAuth({ oAuthConfigDto: { redirectUri } });
window.location.href = url;
return true;
} catch (error) {
handleError(error, 'Unable to login with OAuth');
return false;
}
},
login: (location: Location) => {
return finishOAuth({ oAuthCallbackDto: { url: location.href } });
},
link: (location: Location) => {
return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } });
},
unlink: () => {
return unlinkOAuthAccount();
},
};
export const findLocale = (code: string | undefined) => {
const language = locales.find((lang) => lang.code === code);
return {
code: language?.code,
name: language?.name,
};
};
export const asyncTimeout = (ms: number) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
export const handlePromiseError = <T>(promise: Promise<T>): void => {
promise.catch((error) => console.error(`[utils.ts]:handlePromiseError ${error}`, error));
};
2024-05-22 15:33:37 +02:00
export const s = (count: number) => (count === 1 ? '' : 's');
export const memoryLaneTitle = (yearsAgo: number) => `${yearsAgo} year${s(yearsAgo)} ago`;