chore(deps): update dependency eslint-plugin-unicorn to v78 (#32588)

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 72e2bc8b0bc4a7e0a84ac16c0bde7f5e538729fb)
This commit is contained in:
renovate[bot]
2026-10-28 20:41:28 +03:00
committed by Codex
parent 3127dbec71
commit 7fb7699c06
141 changed files with 654 additions and 761 deletions
+1 -1
View File
@@ -40,7 +40,7 @@
"eslint": "^10.0.0", "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^75.0.0", "eslint-plugin-unicorn": "^78.0.0",
"exiftool-vendored": "^39.0.0", "exiftool-vendored": "^39.0.0",
"globals": "^17.0.0", "globals": "^17.0.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
@@ -14,9 +14,7 @@ type DayDistributionStrategy = (
rng: SeededRandom, rng: SeededRandom,
) => MockTimelineAsset[]; ) => MockTimelineAsset[];
/** /** Strategies for determining total asset count per month */
* Strategies for determining total asset count per month
*/
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = { export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
empty: null, // Special case - handled separately empty: null, // Special case - handled separately
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
@@ -25,9 +23,7 @@ export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStra
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets 'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
}; };
/** /** Strategies for distributing assets across days within a month */
* Strategies for distributing assets across days within a month
*/
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = { export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => { 'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// All assets on one day in the middle of the month // All assets on one day in the middle of the month
@@ -1,6 +1,4 @@
/** /** Generator functions for timeline model objects */
* Generator functions for timeline model objects
*/
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { AssetVisibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
@@ -12,9 +10,7 @@ import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config'; import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config'; import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
/** /** Generate a random aspect ratio based on weighted probabilities */
* Generate a random aspect ratio based on weighted probabilities
*/
export function generateAspectRatio(rng: SeededRandom): string { export function generateAspectRatio(rng: SeededRandom): string {
const random = rng.next(); const random = rng.next();
let cumulative = 0; let cumulative = 0;
@@ -89,9 +85,7 @@ export function generateAsset(
return asset; return asset;
} }
/** /** Generate assets for a specific day */
* Generate assets for a specific day
*/
export function generateDayAssets( export function generateDayAssets(
year: number, year: number,
month: number, month: number,
@@ -104,10 +98,10 @@ export function generateDayAssets(
} }
/** /**
* Distribute assets evenly across consecutive days Distribute assets evenly across consecutive days
*
* @returns Array of generated timeline assets @returns Array of generated timeline assets
*/ */
export function generateConsecutiveDays( export function generateConsecutiveDays(
year: number, year: number,
month: number, month: number,
@@ -133,9 +127,7 @@ export function generateConsecutiveDays(
return assets; return assets;
} }
/** /** Generate assets for a month with specified distribution pattern */
* Generate assets for a month with specified distribution pattern
*/
export function generateMonthAssets( export function generateMonthAssets(
year: number, year: number,
month: number, month: number,
@@ -173,9 +165,7 @@ export function generateMonthAssets(
return assets; return assets;
} }
/** /** Main generator function for timeline data */
* Main generator function for timeline data
*/
export function generateTimelineData(config: TimelineConfig): MockTimelineData { export function generateTimelineData(config: TimelineConfig): MockTimelineData {
validateTimelineConfig(config); validateTimelineConfig(config);
+30 -34
View File
@@ -1,6 +1,4 @@
/** /** REST API output functions for converting timeline data to API response formats */
* REST API output functions for converting timeline data to API response formats
*/
import { import {
AlbumUserRole, AlbumUserRole,
@@ -19,9 +17,7 @@ import { signupDto } from 'src/fixtures.js';
import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js'; import { parseTimeBucketKey } from 'src/ui/generators/timeline/utils.js';
import type { MockTimelineAsset, MockTimelineData } from './timeline-config'; import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
/** /** Convert timeline/asset models to columnar format (parallel arrays) */
* Convert timeline/asset models to columnar format (parallel arrays)
*/
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto { export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
const result: TimeBucketAssetResponseDto = { const result: TimeBucketAssetResponseDto = {
id: [], id: [],
@@ -70,11 +66,11 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
} }
/** /**
* Extract a single bucket from timeline data (mimics getTimeBucket API) Extract a single bucket from timeline data (mimics getTimeBucket API)
* Automatically handles both ISO timestamp and simple month formats Automatically handles both ISO timestamp and simple month formats
* Returns data in columnar format matching the actual API Returns data in columnar format matching the actual API
* When albumId is provided, only returns assets from that album When albumId is provided, only returns assets from that album
*/ */
export function getTimeBucket( export function getTimeBucket(
timelineData: MockTimelineData, timelineData: MockTimelineData,
timeBucket: string, timeBucket: string,
@@ -141,16 +137,16 @@ export type Changes = {
}; };
/** /**
* Helper function to determine if an asset should be included based on filter criteria Helper function to determine if an asset should be included based on filter criteria
* @param asset - The asset to check @param asset - The asset to check
* @param isTrashed - Filter for trashed status (undefined means no filter) @param isTrashed - Filter for trashed status (undefined means no filter)
* @param isArchived - Filter for archived status (undefined means no filter) @param isArchived - Filter for archived status (undefined means no filter)
* @param isFavorite - Filter for favorite status (undefined means no filter) @param isFavorite - Filter for favorite status (undefined means no filter)
* @param deletedAssetIds - Set of IDs for assets that have been deleted @param deletedAssetIds - Set of IDs for assets that have been deleted
* @param archivedAssetIds - Set of IDs for assets that have been archived @param archivedAssetIds - Set of IDs for assets that have been archived
* @param favoritedAssetIds - Set of IDs for assets that have been favorited @param favoritedAssetIds - Set of IDs for assets that have been favorited
* @returns true if the asset matches all filter criteria @returns true if the asset matches all filter criteria
*/ */
function shouldIncludeAsset( function shouldIncludeAsset(
asset: MockTimelineAsset, asset: MockTimelineAsset,
isTrashed: boolean | undefined, isTrashed: boolean | undefined,
@@ -175,9 +171,9 @@ function shouldIncludeAsset(
return isFavorite === undefined || actuallyFavorited === isFavorite; return isFavorite === undefined || actuallyFavorited === isFavorite;
} }
/** /**
* Get summary for all buckets (mimics getTimeBuckets API) Get summary for all buckets (mimics getTimeBuckets API)
* When albumId is provided, only includes buckets that contain assets from that album When albumId is provided, only includes buckets that contain assets from that album
*/ */
export function getTimeBuckets( export function getTimeBuckets(
timelineData: MockTimelineData, timelineData: MockTimelineData,
isTrashed: boolean | undefined, isTrashed: boolean | undefined,
@@ -279,9 +275,9 @@ const createDefaultOwner = (ownerId: string) => {
}; };
/** /**
* Convert a TimelineAssetConfig to a full AssetResponseDto Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id This matches the response from GET /api/assets/:id
*/ */
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto { export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -292,7 +288,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
make: null, make: null,
model: null, model: null,
exifImageWidth: asset.ratio > 1 ? 4000 : 3000, exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio), exifImageHeight: Math.round(asset.ratio > 1 ? 4000 : 3000 / asset.ratio),
fileSizeInByte: asset.fileSizeInByte, fileSizeInByte: asset.fileSizeInByte,
orientation: '1', orientation: '1',
dateTimeOriginal: asset.fileCreatedAt, dateTimeOriginal: asset.fileCreatedAt,
@@ -348,9 +344,9 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
} }
/** /**
* Get a single asset by ID from timeline data Get a single asset by ID from timeline data
* This matches the response from GET /api/assets/:id This matches the response from GET /api/assets/:id
*/ */
export function getAsset( export function getAsset(
timelineData: MockTimelineData, timelineData: MockTimelineData,
assetId: string, assetId: string,
@@ -368,9 +364,9 @@ export function getAsset(
} }
/** /**
* Get a mock album from timeline data Get a mock album from timeline data
* This matches the response from GET /api/albums/:id This matches the response from GET /api/albums/:id
*/ */
export function getAlbum( export function getAlbum(
timelineData: MockTimelineData, timelineData: MockTimelineData,
ownerId: string, ownerId: string,
@@ -57,9 +57,7 @@ export type MonthSpec = {
pattern: DayPattern; pattern: DayPattern;
}; };
/** /** Configuration for timeline data generation */
* Configuration for timeline data generation
*/
export type TimelineConfig = { export type TimelineConfig = {
ownerId?: string; ownerId?: string;
months: MonthSpec[]; months: MonthSpec[];
@@ -88,9 +86,7 @@ export type SerializedTimelineData = {
album: MockAlbum; album: MockAlbum;
}; };
/** /** Validates a TimelineConfig object to ensure all values are within expected ranges */
* Validates a TimelineConfig object to ensure all values are within expected ranges
*/
export function validateTimelineConfig(config: TimelineConfig): void { export function validateTimelineConfig(config: TimelineConfig): void {
if (!config.months || config.months.length === 0) { if (!config.months || config.months.length === 0) {
throw new Error('TimelineConfig must contain at least one month'); throw new Error('TimelineConfig must contain at least one month');
@@ -146,9 +142,7 @@ export function validateTimelineConfig(config: TimelineConfig): void {
} }
} }
/** /** Create a default timeline configuration */
* Create a default timeline configuration
*/
export function createDefaultTimelineConfig(): TimelineConfig { export function createDefaultTimelineConfig(): TimelineConfig {
const months: MonthSpec[] = [ const months: MonthSpec[] = [
// 2024 - Mix of patterns // 2024 - Mix of patterns
+19 -30
View File
@@ -1,9 +1,7 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js'; import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/ui/generators/timeline/timeline-config.js';
/** /** Linear Congruential Generator for deterministic pseudo-random numbers */
* Linear Congruential Generator for deterministic pseudo-random numbers
*/
export class SeededRandom { export class SeededRandom {
private seed: number; private seed: number;
@@ -11,38 +9,32 @@ export class SeededRandom {
this.seed = seed; this.seed = seed;
} }
/** /** Generate next random number in range [0, 1) */
* Generate next random number in range [0, 1)
*/
next(): number { next(): number {
// LCG parameters from Numerical Recipes // LCG parameters from Numerical Recipes
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647; this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
return this.seed / 2_147_483_647; return this.seed / 2_147_483_647;
} }
/** /** Generate random integer in range [min, max) */
* Generate random integer in range [min, max)
*/
nextInt(min: number, max: number): number { nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min; return Math.floor(this.next() * (max - min)) + min;
} }
/** /** Generate random boolean with given probability */
* Generate random boolean with given probability
*/
nextBoolean(probability = 0.5): boolean { nextBoolean(probability = 0.5): boolean {
return this.next() < probability; return this.next() < probability;
} }
} }
/** /**
* Select random days using seed variation to avoid collisions. Select random days using seed variation to avoid collisions.
*
* @param daysInMonth - Total number of days in the month @param daysInMonth - Total number of days in the month
* @param numDays - Number of days to select @param numDays - Number of days to select
* @param rng - Random number generator instance @param rng - Random number generator instance
* @returns Array of selected day numbers, sorted in descending order @returns Array of selected day numbers, sorted in descending order
*/ */
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] { export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
const selectedDays = new Set<number>(); const selectedDays = new Set<number>();
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
@@ -64,9 +56,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
return [...selectedDays].toSorted((a, b) => b - a); return [...selectedDays].toSorted((a, b) => b - a);
} }
/** /** Select item from array using seeded random */
* Select item from array using seeded random
*/
export function selectRandom<T>(arr: T[], rng: SeededRandom): T { export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
if (arr.length === 0) { if (arr.length === 0) {
throw new Error('Cannot select from empty array'); throw new Error('Cannot select from empty array');
@@ -75,9 +65,7 @@ export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
return arr[index]; return arr[index];
} }
/** /** Select multiple random items from array using seeded random without duplicates */
* Select multiple random items from array using seeded random without duplicates
*/
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] { export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
if (arr.length === 0) { if (arr.length === 0) {
throw new Error('Cannot select from empty array'); throw new Error('Cannot select from empty array');
@@ -104,11 +92,11 @@ export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRand
} }
/** /**
* Parse timeBucket parameter to extract year-month key Parse timeBucket parameter to extract year-month key
* Handles both formats: Handles both formats:
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01" - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
* - Simple format: "2024-12-01" -> "2024-12-01" - Simple format: "2024-12-01" -> "2024-12-01"
*/ */
export function parseTimeBucketKey(timeBucket: string): string { export function parseTimeBucketKey(timeBucket: string): string {
if (!timeBucket) { if (!timeBucket) {
throw new Error('timeBucket parameter cannot be empty'); throw new Error('timeBucket parameter cannot be empty');
@@ -150,6 +138,7 @@ export function getMockAsset(
return null; return null;
} }
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => { const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') { if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day')); return !date1.startOf('day').equals(date2.startOf('day'));
@@ -242,6 +242,7 @@ test.describe('OCR text interaction', () => {
return; return;
} }
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const createTouch = (id: number, x: number, y: number) => { const createTouch = (id: number, x: number, y: number) => {
return new Touch({ return new Touch({
identifier: id, identifier: id,
@@ -150,10 +150,10 @@ test.describe('Timeline', () => {
}); });
test.describe('keyboard', () => { test.describe('keyboard', () => {
/** /**
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
* as necessary downwards), then the asset should always be at the lowest row of the grid. as necessary downwards), then the asset should always be at the lowest row of the grid.
*/ */
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => { test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
await pageUtils.openPhotosPage(page); await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus(); await thumbnailUtils.withAssetId(page, assets[0].id).focus();
+5 -5
View File
@@ -270,13 +270,13 @@ export const utils = {
} }
}, },
waitForWebsocketEvent: ({ event, id, total: count, timeout: ms }: WaitOptions): Promise<void> => { waitForWebsocketEvent: ({ event, id, total: count, timeout: ms = 10_000 }: WaitOptions): Promise<void> => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (!id && !count) { if (!id && !count) {
reject(new Error('id or count must be provided for waitForWebsocketEvent')); reject(new Error('id or count must be provided for waitForWebsocketEvent'));
} }
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000); const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms);
const type = id ? `id=${id}` : `count=${count}`; const type = id ? `id=${id}` : `count=${count}`;
console.log(`Waiting for ${event} [${type}]`); console.log(`Waiting for ${event} [${type}]`);
const set = events[event]; const set = events[event];
@@ -550,7 +550,7 @@ export const utils = {
{ {
headers: asBearerAuth(accessToken), headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) => fetch: (...args: Parameters<typeof fetch>) =>
// eslint-disable-next-line unicorn/no-invalid-argument-count, unicorn/prefer-await // eslint-disable-next-line unicorn/prefer-await
fetch(...args).then((response) => { fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie(); setCookie = response.headers.getSetCookie();
return response; return response;
@@ -660,10 +660,10 @@ export const utils = {
return !jobCounts.active && (!jobCounts.waiting || queues[queue].queueStatus.isPaused); return !jobCounts.active && (!jobCounts.waiting || queues[queue].queueStatus.isPaused);
}, },
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => { waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms: number = 15_000) => {
// eslint-disable-next-line no-async-promise-executor // eslint-disable-next-line no-async-promise-executor
return new Promise<void>(async (resolve, reject) => { return new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 15_000); const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms);
while (true) { while (true) {
const done = await utils.isQueueEmpty(accessToken, queue); const done = await utils.isQueueEmpty(accessToken, queue);
+1 -1
View File
@@ -33,7 +33,7 @@
"eslint": "^10.0.0", "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^75.0.0", "eslint-plugin-unicorn": "^78.0.0",
"globals": "^17.0.0", "globals": "^17.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.7.4", "prettier": "^3.7.4",
+19 -19
View File
@@ -28,11 +28,11 @@ export const defaultQueueOptions = {
}; };
/** /**
* An in-memory queue that processes tasks in parallel with a given concurrency. An in-memory queue that processes tasks in parallel with a given concurrency.
* @see {@link https://www.npmjs.com/package/fastq} @see {@link https://www.npmjs.com/package/fastq}
* @template T - The type of the worker task data. @template T - The type of the worker task data.
* @template R - The type of the worker output data. @template R - The type of the worker output data.
*/ */
export class Queue<T, R> { export class Queue<T, R> {
private readonly queue: fastq.queueAsPromised<string, Task<T, R>>; private readonly queue: fastq.queueAsPromised<string, Task<T, R>>;
private readonly store = new Map<string, Task<T, R>>(); private readonly store = new Map<string, Task<T, R>>();
@@ -40,10 +40,10 @@ export class Queue<T, R> {
readonly worker: (data: T) => Promise<R>; readonly worker: (data: T) => Promise<R>;
/** /**
* Create a new queue. Create a new queue.
* @param worker - The worker function that processes the task. @param worker - The worker function that processes the task.
* @param options - The queue options. @param options - The queue options.
*/ */
constructor(worker: (data: T) => Promise<R>, options?: QueueOptions) { constructor(worker: (data: T) => Promise<R>, options?: QueueOptions) {
this.options = { ...defaultQueueOptions, ...options }; this.options = { ...defaultQueueOptions, ...options };
this.worker = worker; this.worker = worker;
@@ -65,21 +65,21 @@ export class Queue<T, R> {
} }
/** /**
* Wait for the queue to be empty. Wait for the queue to be empty.
* @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker. @returns Promise<void> - The returned Promise will be resolved when all tasks in the queue have been processed by a worker.
* This promise could be ignored as it will not lead to a `unhandledRejection`. This promise could be ignored as it will not lead to a `unhandledRejection`.
*/ */
drained(): Promise<void> { drained(): Promise<void> {
return this.queue.drained(); return this.queue.drained();
} }
/** /**
* Add a task at the end of the queue. Add a task at the end of the queue.
* @see {@link https://www.npmjs.com/package/fastq} @see {@link https://www.npmjs.com/package/fastq}
* @param data @param data
* @returns Promise<void> - A Promise that will be fulfilled (rejected) when the task is completed successfully (unsuccessfully). @returns Promise<void> - A Promise that will be fulfilled (rejected) when the task is completed successfully (unsuccessfully).
* This promise could be ignored as it will not lead to a `unhandledRejection`. This promise could be ignored as it will not lead to a `unhandledRejection`.
*/ */
async push(data: T): Promise<Task<T, R>> { async push(data: T): Promise<Task<T, R>> {
const id = uniqueId(); const id = uniqueId();
const task: Task<T, R> = { id, status: 'idle', error: undefined, count: 0, data, result: undefined }; const task: Task<T, R> = { id, status: 'idle', error: undefined, count: 0, data, result: undefined };
+3 -3
View File
@@ -218,9 +218,9 @@ export const sha1 = (filepath: string) => {
}; };
/** /**
* Batches items and calls onBatch to process them Batches items and calls onBatch to process them
* when the batch size is reached or the debounce time has passed. when the batch size is reached or the debounce time has passed.
*/ */
export class Batcher<T = unknown> { export class Batcher<T = unknown> {
private items: T[] = []; private items: T[] = [];
private readonly batchSize: number; private readonly batchSize: number;
+1 -1
View File
@@ -156,7 +156,7 @@
"eslint-import-resolver-typescript": "^4.4.5", "eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import-x": "^4.17.1", "eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^75.0.0", "eslint-plugin-unicorn": "^78.0.0",
"globals": "^17.0.0", "globals": "^17.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
+5 -7
View File
@@ -28,14 +28,12 @@ export async function configureExpress(
ssr, ssr,
}: { }: {
/** /**
* Whether to allow swagger module to write to the specs.json Whether to allow swagger module to write to the specs.json
* This is not desirable when the API is not available This is not desirable when the API is not available
* @default true @default true
*/ */
permitSwaggerWrite?: boolean; permitSwaggerWrite?: boolean;
/** /** Service to use for server-side rendering */
* Service to use for server-side rendering
*/
ssr: typeof ApiService | typeof MaintenanceWorkerService; ssr: typeof ApiService | typeof MaintenanceWorkerService;
}, },
) { ) {
@@ -102,7 +102,7 @@ export class PromptConfirmMoveQuestions {
message: 'Do you want to proceed? [Y/n]', message: 'Do you want to proceed? [Y/n]',
name: 'value', name: 'value',
}) })
value(value: string): boolean { value(value: string = 'y'): boolean {
return ['yes', 'y'].includes((value || 'y').toLowerCase()); return ['yes', 'y'].includes(value.toLowerCase());
} }
} }
+11 -11
View File
@@ -32,11 +32,11 @@ export const UpdatedAtTrigger = (name: string) =>
export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500; export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500;
/** /**
* Chunks an array or set into smaller collections of the same type and specified size. Chunks an array or set into smaller collections of the same type and specified size.
*
* @param collection The collection to chunk. @param collection The collection to chunk.
* @param size The size of each chunk. @param size The size of each chunk.
*/ */
function chunks<T>(collection: Array<T>, size: number): Array<Array<T>>; function chunks<T>(collection: Array<T>, size: number): Array<Array<T>>;
function chunks<T>(collection: Set<T>, size: number): Array<Set<T>>; function chunks<T>(collection: Set<T>, size: number): Array<Set<T>>;
function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>> | Array<Set<T>> { function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>> | Array<Set<T>> {
@@ -60,12 +60,12 @@ function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>>
} }
/** /**
* Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection, Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection,
* to overcome the maximum number of parameters allowed by the database driver. to overcome the maximum number of parameters allowed by the database driver.
*
* @param options.paramIndex The index of the function parameter to chunk. Defaults to 0. @param options.paramIndex The index of the function parameter to chunk. Defaults to 0.
* @param options.flatten Whether to flatten the results. Defaults to false. @param options.flatten Whether to flatten the results. Defaults to false.
*/ */
export function Chunked( export function Chunked(
options: { paramIndex?: number; chunkSize?: number; mergeFn?: (results: any) => any } = {}, options: { paramIndex?: number; chunkSize?: number; mergeFn?: (results: any) => any } = {},
): MethodDecorator { ): MethodDecorator {
+6 -6
View File
@@ -8,9 +8,9 @@ import { JsonParsed, isoDatetimeToDate, stringToBool } from 'src/validation.js';
export enum AssetMediaSize { export enum AssetMediaSize {
Original = 'original', Original = 'original',
/** /**
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF. An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
* or otherwise the original image itself. or otherwise the original image itself.
*/ */
FULLSIZE = 'fullsize', FULLSIZE = 'fullsize',
PREVIEW = 'preview', PREVIEW = 'preview',
THUMBNAIL = 'thumbnail', THUMBNAIL = 'thumbnail',
@@ -41,9 +41,9 @@ const AssetMediaBaseSchema = z.object({
duration: z.coerce.number().int().min(0).optional().describe('Duration in milliseconds (for videos)'), duration: z.coerce.number().int().min(0).optional().describe('Duration in milliseconds (for videos)'),
filename: z.string().optional().describe('Filename'), filename: z.string().optional().describe('Filename'),
/** /**
* The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled
* in the controller. File parts never reach the request body, so they must be optional here in the controller. File parts never reach the request body, so they must be optional here
*/ */
[UploadFieldName.ASSET_DATA]: z [UploadFieldName.ASSET_DATA]: z
.any() .any()
.optional() .optional()
+4 -4
View File
@@ -14,10 +14,10 @@ enum DatabaseSslMode {
const DatabaseSslModeSchema = z.enum(DatabaseSslMode).describe('Database SSL mode').meta({ id: 'DatabaseSslMode' }); const DatabaseSslModeSchema = z.enum(DatabaseSslMode).describe('Database SSL mode').meta({ id: 'DatabaseSslMode' });
const absolutePath = z.string().regex(/^\//, 'Must be an absolute path').optional(); const absolutePath = z.string().regex(/^\//, 'Must be an absolute path').optional();
/** /**
* Treat certain strings as booleans and coerce them to boolean Treat certain strings as booleans and coerce them to boolean
* Ideal for environment variables that are strings but should be treated as booleans Ideal for environment variables that are strings but should be treated as booleans
* @docs https://zod.dev/api?id=stringbool @docs https://zod.dev/api?id=stringbool
*/ */
const stringBool = z.stringbool(); const stringBool = z.stringbool();
const trustedProxiesSchema = z const trustedProxiesSchema = z
+3 -3
View File
@@ -323,9 +323,9 @@ export const isFullyAlbumConfined = (filter: SearchFilter): boolean =>
isAlbumConfined(filter) || (!!filter.or?.length && filter.or.every((branch) => isAlbumConfined(branch))); isAlbumConfined(filter) || (!!filter.or?.length && filter.or.every((branch) => isAlbumConfined(branch)));
/** /**
* The structured shape and the deprecated flat search fields are mutually exclusive The structured shape and the deprecated flat search fields are mutually exclusive
* TODO(v4): remove together with the deprecated flat fields. TODO(v4): remove together with the deprecated flat fields.
*/ */
const withShapeExclusivity = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) => { const withShapeExclusivity = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) => {
const deprecatedFields = Object.keys(schema.shape).filter( const deprecatedFields = Object.keys(schema.shape).filter(
(field) => (schema.shape[field] as z.ZodType).meta()?.deprecated, (field) => (schema.shape[field] as z.ZodType).meta()?.deprecated,
+8 -12
View File
@@ -59,9 +59,7 @@ export enum ChecksumAlgorithm {
} }
export enum AssetFileType { export enum AssetFileType {
/** /** An full/large-size image extracted/converted from RAW photos */
* An full/large-size image extracted/converted from RAW photos
*/
FullSize = 'fullsize', FullSize = 'fullsize',
Preview = 'preview', Preview = 'preview',
Thumbnail = 'thumbnail', Thumbnail = 'thumbnail',
@@ -362,9 +360,9 @@ export enum SharedLinkType {
Album = 'ALBUM', Album = 'ALBUM',
/** /**
* Individual asset Individual asset
* or group of assets that are not in an album or group of assets that are not in an album
*/ */
Individual = 'INDIVIDUAL', Individual = 'INDIVIDUAL',
} }
@@ -818,9 +816,9 @@ export enum DvProfile {
} }
/** /**
* Dolby Vision base-layer signal-compatibility ID from the DOVI configuration record. Dolby Vision base-layer signal-compatibility ID from the DOVI configuration record.
* Identifies what the base HEVC/AVC layer renders as on a non-DV decoder. Identifies what the base HEVC/AVC layer renders as on a non-DV decoder.
*/ */
export enum DvSignalCompatibility { export enum DvSignalCompatibility {
None = 0, None = 0,
Hdr10 = 1, Hdr10 = 1,
@@ -1227,9 +1225,7 @@ export enum AssetVisibility {
Archive = 'archive', Archive = 'archive',
Timeline = 'timeline', Timeline = 'timeline',
/** /** Video part of the LivePhotos and MotionPhotos */
* Video part of the LivePhotos and MotionPhotos
*/
Hidden = 'hidden', Hidden = 'hidden',
Locked = 'locked', Locked = 'locked',
} }
+7 -15
View File
@@ -10,23 +10,15 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
import { type DB } from 'src/schema/index.js'; import { type DB } from 'src/schema/index.js';
import { getKyselyConfig } from 'src/utils/database.js'; import { getKyselyConfig } from 'src/utils/database.js';
/** /** Manages worker lifecycle */
* Manages worker lifecycle
*/
class Workers { class Workers {
/** /** Currently running workers */
* Currently running workers
*/
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {}; workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
/** /** Fail-safe in case anything dies during restart */
* Fail-safe in case anything dies during restart
*/
restarting = false; restarting = false;
/** /** Boot all enabled workers */
* Boot all enabled workers
*/
async bootstrap() { async bootstrap() {
const isMaintenanceMode = await this.isMaintenanceMode(); const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv(); const { workers } = new ConfigRepository().getEnv();
@@ -92,9 +84,9 @@ class Workers {
} }
/** /**
* Start an individual worker Start an individual worker
* @param name Worker @param name Worker
*/ */
private startWorker(name: ImmichWorker) { private startWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`); console.log(`Starting ${name} worker`);
@@ -43,9 +43,7 @@ export class MaintenanceWorkerController {
private databaseBackupService: DatabaseBackupService, private databaseBackupService: DatabaseBackupService,
) {} ) {}
/** /** {@link _ServerController.getServerConfig } */
* {@link _ServerController.getServerConfig }
*/
@Get('server/config') @Get('server/config')
getServerConfig(): ServerConfigDto { getServerConfig(): ServerConfigDto {
return this.service.getSystemConfig(); return this.service.getSystemConfig();
@@ -61,18 +59,14 @@ export class MaintenanceWorkerController {
return this.service.getVersion(); return this.service.getVersion();
} }
/** /** {@link _DatabaseBackupController.listDatabaseBackups} */
* {@link _DatabaseBackupController.listDatabaseBackups}
*/
@Get('admin/database-backups') @Get('admin/database-backups')
@MaintenanceRoute() @MaintenanceRoute()
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> { listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
return this.databaseBackupService.listBackups(); return this.databaseBackupService.listBackups();
} }
/** /** {@link _DatabaseBackupController.downloadDatabaseBackup} */
* {@link _DatabaseBackupController.downloadDatabaseBackup}
*/
@Get('admin/database-backups/:filename') @Get('admin/database-backups/:filename')
@MaintenanceRoute() @MaintenanceRoute()
async downloadDatabaseBackup( async downloadDatabaseBackup(
@@ -83,18 +77,14 @@ export class MaintenanceWorkerController {
await sendFile(res, next, () => this.databaseBackupService.downloadBackup(filename), this.logger); await sendFile(res, next, () => this.databaseBackupService.downloadBackup(filename), this.logger);
} }
/** /** {@link _DatabaseBackupController.deleteDatabaseBackup} */
* {@link _DatabaseBackupController.deleteDatabaseBackup}
*/
@Delete('admin/database-backups') @Delete('admin/database-backups')
@MaintenanceRoute() @MaintenanceRoute()
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> { async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
return this.databaseBackupService.deleteBackup(dto.backups); return this.databaseBackupService.deleteBackup(dto.backups);
} }
/** /** {@link _DatabaseBackupController.uploadDatabaseBackup} */
* {@link _DatabaseBackupController.uploadDatabaseBackup}
*/
@Post('admin/database-backups/upload') @Post('admin/database-backups/upload')
@MaintenanceRoute() @MaintenanceRoute()
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@@ -282,9 +282,7 @@ describe(MaintenanceWorkerService.name, () => {
describe.skip('setAction'); // just calls setStatus+runAction describe.skip('setAction'); // just calls setStatus+runAction
/** /** Actions */
* Actions
*/
describe('action: start', () => { describe('action: start', () => {
it('should not do anything', async () => { it('should not do anything', async () => {
@@ -33,9 +33,7 @@ import { getConfig } from 'src/utils/config.js';
import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance.js'; import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance.js';
import { getExternalDomain } from 'src/utils/misc.js'; import { getExternalDomain } from 'src/utils/misc.js';
/** /** This service is available inside of maintenance mode to manage maintenance mode */
* This service is available inside of maintenance mode to manage maintenance mode
*/
@Injectable() @Injectable()
export class MaintenanceWorkerService { export class MaintenanceWorkerService {
#secret: string | null = null; #secret: string | null = null;
@@ -87,9 +85,7 @@ export class MaintenanceWorkerService {
} }
} }
/** /** {@link _BaseService.configRepos} */
* {@link _BaseService.configRepos}
*/
private get configRepos() { private get configRepos() {
return { return {
configRepo: this.configRepository, configRepo: this.configRepository,
@@ -98,25 +94,19 @@ export class MaintenanceWorkerService {
}; };
} }
/** /** {@link _BaseService.prototype.getConfig} */
* {@link _BaseService.prototype.getConfig}
*/
private getConfig(options: { withCache: boolean }) { private getConfig(options: { withCache: boolean }) {
return getConfig(this.configRepos, options); return getConfig(this.configRepos, options);
} }
/** /** {@link _ServerService.getSystemConfig} */
* {@link _ServerService.getSystemConfig}
*/
getSystemConfig() { getSystemConfig() {
return { return {
maintenanceMode: true, maintenanceMode: true,
} as ServerConfigDto; } as ServerConfigDto;
} }
/** /** {@link _VersionService.getVersion} */
* {@link _VersionService.getVersion}
*/
getVersion() { getVersion() {
return ServerVersionResponseDto.fromSemVer(serverVersion); return ServerVersionResponseDto.fromSemVer(serverVersion);
} }
@@ -125,9 +115,7 @@ export class MaintenanceWorkerService {
return { res: 'pong' }; return { res: 'pong' };
} }
/** /** {@link _ApiService.ssr} */
* {@link _ApiService.ssr}
*/
ssr(excludePaths: string[]) { ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv(); const { resourcePaths } = this.configRepository.getEnv();
@@ -158,9 +146,7 @@ export class MaintenanceWorkerService {
}; };
} }
/** /** {@link _StorageService.detectMediaLocation} */
* {@link _StorageService.detectMediaLocation}
*/
detectMediaLocation(): string { detectMediaLocation(): string {
const envData = this.configRepository.getEnv(); const envData = this.configRepository.getEnv();
if (envData.storage.mediaLocation) { if (envData.storage.mediaLocation) {
+16 -16
View File
@@ -227,12 +227,12 @@ export class AlbumRepository {
} }
/** /**
* Get asset IDs for the given album ID. Get asset IDs for the given album ID.
*
* @param albumId Album ID to get asset IDs for. @param albumId Album ID to get asset IDs for.
* @param assetIds Optional list of asset IDs to filter on. @param assetIds Optional list of asset IDs to filter on.
* @returns Set of Asset IDs for the given album ID. @returns Set of Asset IDs for the given album ID.
*/ */
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
async getAssetIds(albumId: string, assetIds: string[]): Promise<Set<string>> { async getAssetIds(albumId: string, assetIds: string[]): Promise<Set<string>> {
@@ -348,13 +348,13 @@ export class AlbumRepository {
} }
/** /**
* Makes sure all thumbnails for albums are updated by: Makes sure all thumbnails for albums are updated by:
* - Removing thumbnails from albums without assets - Removing thumbnails from albums without assets
* - Removing references of thumbnails to assets outside the album - Removing references of thumbnails to assets outside the album
* - Setting a thumbnail when none is set and the album contains assets - Setting a thumbnail when none is set and the album contains assets
*
* @returns Amount of updated album thumbnails or undefined when unknown @returns Amount of updated album thumbnails or undefined when unknown
*/ */
async updateThumbnails(): Promise<number | undefined> { async updateThumbnails(): Promise<number | undefined> {
// Subquery for getting a new thumbnail. // Subquery for getting a new thumbnail.
@@ -399,9 +399,9 @@ export class AlbumRepository {
} }
/** /**
* Get per-user asset contribution counts for a single album. Get per-user asset contribution counts for a single album.
* Excludes deleted assets, orders by count desc. Excludes deleted assets, orders by count desc.
*/ */
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getContributorCounts(id: string) { getContributorCounts(id: string) {
return this.db return this.db
+7 -7
View File
@@ -73,10 +73,10 @@ export class MediaRepository {
} }
/** /**
*
* @param input file path to the input image @param input file path to the input image
* @returns ExtractResult if succeeded, or null if failed @returns ExtractResult if succeeded, or null if failed
*/ */
async extract(input: string): Promise<ExtractResult | null> { async extract(input: string): Promise<ExtractResult | null> {
for (const { tag, format } of [ for (const { tag, format } of [
{ tag: 'JpgFromRaw2', format: RawExtractedFormat.Jpeg }, { tag: 'JpgFromRaw2', format: RawExtractedFormat.Jpeg },
@@ -305,9 +305,9 @@ export class MediaRepository {
} }
/** /**
* Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved. Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
* Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video. Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
*/ */
probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> { probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
const ffprobe = spawn( const ffprobe = spawn(
'ffprobe', 'ffprobe',
+22 -22
View File
@@ -38,28 +38,28 @@ import { ImmichFileResponse } from 'src/utils/file.js';
import { batched, handlePromiseError } from 'src/utils/misc.js'; import { batched, handlePromiseError } from 'src/utils/misc.js';
/** /**
* Untracked Files: Untracked Files:
* Files are detected in /data/encoded-video, /data/library, /data/upload Files are detected in /data/encoded-video, /data/library, /data/upload
* Checked against the asset table Checked against the asset table
* Files are detected in /data/thumbs Files are detected in /data/thumbs
* Checked against the asset_file table Checked against the asset_file table
*
* * Can perform download or delete of files Can perform download or delete of files
*
* Missing Files: Missing Files:
* Paths are queried from asset(originalPath, encodedVideoPath), asset_file(path) Paths are queried from asset(originalPath, encodedVideoPath), asset_file(path)
* Check whether files exist on disk Check whether files exist on disk
*
* * Reports must include origin (asset or asset_file) & ID for further action Reports must include origin (asset or asset_file) & ID for further action
* * Can perform trash (asset) or delete (asset_file) Can perform trash (asset) or delete (asset_file)
*
* Checksum Mismatch: Checksum Mismatch:
* Paths & checksums are queried from asset(originalPath, checksum) Paths & checksums are queried from asset(originalPath, checksum)
* Check whether files match checksum, missing files ignored Check whether files match checksum, missing files ignored
*
* * Reports must include origin (as above) for further action Reports must include origin (as above) for further action
* * Can perform download or trash (asset) Can perform download or trash (asset)
*/ */
@Injectable() @Injectable()
export class IntegrityService extends BaseService { export class IntegrityService extends BaseService {
+1 -3
View File
@@ -98,9 +98,7 @@ export class JobService extends BaseService {
} }
} }
/** /** Queue follow up jobs */
* Queue follow up jobs
*/
private async onDone(item: JobItem) { private async onDone(item: JobItem) {
switch (item.name) { switch (item.name) {
case JobName.SidecarCheck: { case JobName.SidecarCheck: {
+1 -3
View File
@@ -18,9 +18,7 @@ import {
} from 'src/utils/maintenance.js'; } from 'src/utils/maintenance.js';
import { getExternalDomain } from 'src/utils/misc.js'; import { getExternalDomain } from 'src/utils/misc.js';
/** /** This service is available outside of maintenance mode to manage maintenance mode */
* This service is available outside of maintenance mode to manage maintenance mode
*/
@Injectable() @Injectable()
export class MaintenanceService extends BaseService { export class MaintenanceService extends BaseService {
getMaintenanceMode(): Promise<MaintenanceModeState> { getMaintenanceMode(): Promise<MaintenanceModeState> {
+3 -4
View File
@@ -169,8 +169,7 @@ export class MediaService extends BaseService {
let thumbhash: Buffer | undefined = generated?.thumbhash; let thumbhash: Buffer | undefined = generated?.thumbhash;
if (!thumbhash) { if (!thumbhash) {
const extractedImage = await this.extractOriginalImage(asset, config.image); const { info, data, colorspace } = await this.extractOriginalImage(asset, config.image);
const { info, data, colorspace } = extractedImage;
thumbhash = await this.mediaRepository.generateThumbhash( thumbhash = await this.mediaRepository.generateThumbhash(
{ data, info }, { data, info },
@@ -290,8 +289,8 @@ export class MediaService extends BaseService {
private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) { private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) {
// Handle embedded preview extraction for RAW files // Handle embedded preview extraction for RAW files
const extractedImage = await this.extractOriginalImage(asset, image, useEdits); const { info, data, colorspace, generateFullsize, convertFullsize, extracted, isTransparent } =
const { info, data, colorspace, generateFullsize, convertFullsize, extracted, isTransparent } = extractedImage; await this.extractOriginalImage(asset, image, useEdits);
const previewFormat = image.preview.format; const previewFormat = image.preview.format;
this.warnOnTransparencyLoss(isTransparent, previewFormat, asset.id); this.warnOnTransparencyLoss(isTransparent, previewFormat, asset.id);
+15 -15
View File
@@ -268,7 +268,7 @@ export class StorageTemplateService extends BaseService {
private async getTemplatePath( private async getTemplatePath(
asset: StorageAsset, asset: StorageAsset,
metadata: MoveAssetMetadata, metadata: MoveAssetMetadata,
stillPhoto?: StorageAsset, stillPhoto: StorageAsset = asset,
): Promise<string> { ): Promise<string> {
const { storageLabel, filename } = metadata; const { storageLabel, filename } = metadata;
@@ -310,7 +310,7 @@ export class StorageTemplateService extends BaseService {
let albumName = null; let albumName = null;
let albumStartDate = null; let albumStartDate = null;
let albumEndDate = null; let albumEndDate = null;
const assetForMetadata = stillPhoto || asset; const assetForMetadata = stillPhoto;
if (this.template.needsAlbum) { if (this.template.needsAlbum) {
// For motion videos, use the still photo's album information since motion videos // For motion videos, use the still photo's album information since motion videos
@@ -360,19 +360,19 @@ export class StorageTemplateService extends BaseService {
} }
/** /**
* In case of migrating duplicate filename to a new path, we need to check if it is already migrated In case of migrating duplicate filename to a new path, we need to check if it is already migrated
* Due to the mechanism of appending +1, +2, +3, etc to the filename Due to the mechanism of appending +1, +2, +3, etc to the filename
*
* Example: Example:
* Source = upload/abc/def/FullSizeRender+7.heic Source = upload/abc/def/FullSizeRender+7.heic
* Expected Destination = upload/abc/def/FullSizeRender.heic Expected Destination = upload/abc/def/FullSizeRender.heic
*
* The file is already at the correct location, but since there are other FullSizeRender.heic files in the The file is already at the correct location, but since there are other FullSizeRender.heic files in the
* destination, it was renamed to FullSizeRender+7.heic. destination, it was renamed to FullSizeRender+7.heic.
*
* The lines below will be used to check if the differences between the source and destination is only the The lines below will be used to check if the differences between the source and destination is only the
* +7 suffix, and if so, it will be considered as already migrated. +7 suffix, and if so, it will be considered as already migrated.
*/ */
if (source.startsWith(fullPath) && source.endsWith(`.${extension}`)) { if (source.startsWith(fullPath) && source.endsWith(`.${extension}`)) {
const diff = source.replace(fullPath, '').replace(`.${extension}`, ''); const diff = source.replace(fullPath, '').replace(`.${extension}`, '');
const hasDuplicationAnnotation = /^\+\d+$/.test(diff); const hasDuplicationAnnotation = /^\+\d+$/.test(diff);
@@ -420,14 +420,14 @@ describe(SystemConfigService.name, () => {
}, },
]; ];
for (const { should, externalDomain, result } of externalDomainTests) { for (const { should, externalDomain, result = 'https://demo.immich.app' } of externalDomainTests) {
it(`should normalize an external domain ${should}`, async () => { it(`should normalize an external domain ${should}`, async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { server: { externalDomain } }; const partialConfig = { server: { externalDomain } };
mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getAdminConfig(); const config = await sut.getAdminConfig();
expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); expect(config.server.externalDomain).toEqual(result);
}); });
} }
@@ -98,7 +98,6 @@ export class WorkflowExecutionService extends BaseService {
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest'); throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
} }
// eslint-disable-next-line unicorn/no-invalid-argument-count
const res = await fetch(...args); const res = await fetch(...args);
return { return {
+4 -4
View File
@@ -355,8 +355,8 @@ export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
).as('tags'); ).as('tags');
} }
export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt, size?: 'DAY' | 'MONTH') { export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt, size: 'DAY' | 'MONTH' = 'MONTH') {
return sql<O>`date_trunc(${sql.lit(size ?? 'MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; return sql<O>`date_trunc(${sql.lit(size)}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
} }
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) { export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
@@ -1106,14 +1106,14 @@ export type ReindexVectorIndexOptions = { indexName: string; lists?: number };
type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions; type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions;
export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: VectorIndexQueryOptions): string { export function vectorIndexQuery({ vectorExtension, table, indexName, lists = 1 }: VectorIndexQueryOptions): string {
switch (vectorExtension) { switch (vectorExtension) {
case DatabaseExtension.VectorChord: { case DatabaseExtension.VectorChord: {
return ` return `
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vchordrq (embedding vector_cosine_ops) WITH (options = $$ CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vchordrq (embedding vector_cosine_ops) WITH (options = $$
residual_quantization = false residual_quantization = false
[build.internal] [build.internal]
lists = [${lists ?? 1}] lists = [${lists}]
spherical_centroids = true spherical_centroids = true
build_threads = 4 build_threads = 4
sampling_factor = 1024 sampling_factor = 1024
+3 -9
View File
@@ -1,23 +1,17 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { isoDateToDate, isoDatetimeToDate } from 'src/validation.js'; import { isoDateToDate, isoDatetimeToDate } from 'src/validation.js';
/** /** Convert a date to a ISO 8601 datetime string. */
* Convert a date to a ISO 8601 datetime string.
*/
export const asDateTimeString = <T extends Date | string | undefined | null>(x: T) => { export const asDateTimeString = <T extends Date | string | undefined | null>(x: T) => {
return x instanceof Date ? isoDatetimeToDate.encode(x) : (x as Exclude<T, Date>); return x instanceof Date ? isoDatetimeToDate.encode(x) : (x as Exclude<T, Date>);
}; };
/** /** Convert a date to a date string (yyyy-mm-dd). */
* Convert a date to a date string (yyyy-mm-dd).
*/
export const asDateString = (x: Date | string | null): string | null => { export const asDateString = (x: Date | string | null): string | null => {
return x instanceof Date ? isoDateToDate.encode(x) : x; return x instanceof Date ? isoDateToDate.encode(x) : x;
}; };
/** /** People born on February 29th are celebrated on February 28th in non-leap years. */
* People born on February 29th are celebrated on February 28th in non-leap years.
*/
export const isLeapDayObserved = ({ year, month, day }: { year: number; month: number; day: number }) => { export const isLeapDayObserved = ({ year, month, day }: { year: number; month: number; day: number }) => {
return month === 2 && day === 28 && !DateTime.local(year).isInLeapYear; return month === 2 && day === 28 && !DateTime.local(year).isInLeapYear;
}; };
+22 -22
View File
@@ -1,27 +1,27 @@
import { AssetResponseDto } from 'src/dtos/asset-response.dto.js'; import { AssetResponseDto } from 'src/dtos/asset-response.dto.js';
/** /**
* Counts all truthy values in the exifInfo object. Counts all truthy values in the exifInfo object.
* This matches the client implementation in web/src/lib/utils/exif-utils.ts This matches the client implementation in web/src/lib/utils/exif-utils.ts
*
* @param asset Asset with optional exifInfo @param asset Asset with optional exifInfo
* @returns Count of truthy EXIF values @returns Count of truthy EXIF values
*/ */
export const getExifCount = (asset: AssetResponseDto): number => { export const getExifCount = (asset: AssetResponseDto): number => {
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length; return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
}; };
/** /**
* Suggests the best duplicate asset to keep from a list of duplicates. Suggests the best duplicate asset to keep from a list of duplicates.
* This is a direct port of the client logic from web/src/lib/utils/duplicate-utils.ts This is a direct port of the client logic from web/src/lib/utils/duplicate-utils.ts
*
* The best asset is determined by the following criteria: The best asset is determined by the following criteria:
* 1. Largest image file size in bytes 1. Largest image file size in bytes
* 2. Largest count of EXIF data (as tie-breaker) 2. Largest count of EXIF data (as tie-breaker)
*
* @param assets List of duplicate assets @param assets List of duplicate assets
* @returns The best asset to keep, or undefined if empty list @returns The best asset to keep, or undefined if empty list
*/ */
export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => { export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto | undefined => {
if (assets.length === 0) { if (assets.length === 0) {
return undefined; return undefined;
@@ -48,12 +48,12 @@ export const suggestDuplicate = (assets: AssetResponseDto[]): AssetResponseDto |
}; };
/** /**
* Suggests the best duplicate asset IDs to keep from a list of duplicates. Suggests the best duplicate asset IDs to keep from a list of duplicates.
* Returns an array with a single asset ID (the best candidate), or empty if no assets. Returns an array with a single asset ID (the best candidate), or empty if no assets.
*
* @param assets List of duplicate assets @param assets List of duplicate assets
* @returns Array of suggested asset IDs to keep (0 or 1 element) @returns Array of suggested asset IDs to keep (0 or 1 element)
*/ */
export const suggestDuplicateKeepAssetIds = (assets: AssetResponseDto[]): string[] => { export const suggestDuplicateKeepAssetIds = (assets: AssetResponseDto[]): string[] => {
const suggested = suggestDuplicate(assets); const suggested = suggestDuplicate(assets);
return suggested ? [suggested.id] : []; return suggested ? [suggested.id] : [];
+1
View File
@@ -71,6 +71,7 @@ export class BaseConfig implements VideoCodecSWConfig {
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
protected constructor( protected constructor(
protected config: ConfigFFmpegDto, protected config: ConfigFFmpegDto,
// eslint-disable-next-line unicorn/no-object-as-default-parameter
protected tune: VideoTuning = { strictGop: false, lowLatency: false }, protected tune: VideoTuning = { strictGop: false, lowLatency: false },
) {} ) {}
+4 -4
View File
@@ -35,10 +35,10 @@ const raw = {
}; };
/** /**
* list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg
* @TODO share with the client @TODO share with the client
* @see {@link web/src/lib/utils/asset-utils.ts#L329} @see {@link web/src/lib/utils/asset-utils.ts#L329}
**/ */
const webSupportedImage = { const webSupportedImage = {
'.avif': ['image/avif'], '.avif': ['image/avif'],
'.bmp': ['image/bmp'], '.bmp': ['image/bmp'],
+1 -3
View File
@@ -52,9 +52,7 @@ export const getMethodNames = (instance: any) => {
export const getExternalDomain = (server: SystemConfig['server'], defaultDomain = 'https://my.immich.app') => export const getExternalDomain = (server: SystemConfig['server'], defaultDomain = 'https://my.immich.app') =>
server.externalDomain || defaultDomain; server.externalDomain || defaultDomain;
/** /** @returns a list of strings representing the keys of the object in dot notation */
* @returns a list of strings representing the keys of the object in dot notation
*/
export const getKeysDeep = (target: unknown, path: string[] = []) => { export const getKeysDeep = (target: unknown, path: string[] = []) => {
if (!target || typeof target !== 'object') { if (!target || typeof target !== 'object') {
return []; return [];
+2 -6
View File
@@ -1,15 +1,11 @@
import { isEqual, isPlainObject } from 'lodash-es'; import { isEqual, isPlainObject } from 'lodash-es';
/** /** Deeply clones and converts a class instance to a plain object. */
* Deeply clones and converts a class instance to a plain object.
*/
export function toPlainObject<T extends object>(obj: T): T { export function toPlainObject<T extends object>(obj: T): T {
return isPlainObject(obj) ? obj : structuredClone(obj); return isPlainObject(obj) ? obj : structuredClone(obj);
} }
/** /** Performs a deep comparison between objects, converting them to plain objects first if needed. */
* Performs a deep comparison between objects, converting them to plain objects first if needed.
*/
export function isEqualObject(value: object, other: object): boolean { export function isEqualObject(value: object, other: object): boolean {
return isEqual(toPlainObject(value), toPlainObject(other)); return isEqual(toPlainObject(value), toPlainObject(other));
} }
+4 -6
View File
@@ -25,9 +25,7 @@ const canMatch = <T>(condition: EnumCondition<T>, value: T): boolean => {
); );
}; };
/** /** The conditions that decide which `field` values the filter can return. A top-level condition decides alone, otherwise each branch itself. */
* The conditions that decide which `field` values the filter can return. A top-level condition decides alone, otherwise each branch itself.
*/
const decidingConditions = <F extends EnumField>(filter: SearchFilter, field: F) => { const decidingConditions = <F extends EnumField>(filter: SearchFilter, field: F) => {
if (filter[field] !== undefined) { if (filter[field] !== undefined) {
return [filter[field]]; return [filter[field]];
@@ -39,9 +37,9 @@ const decidingConditions = <F extends EnumField>(filter: SearchFilter, field: F)
}; };
/** /**
* Keeps locked assets out of search results unless the session is elevated: a filter that asks for Keeps locked assets out of search results unless the session is elevated: a filter that asks for
* them is rejected with 401, and any other filter gets `visibility != locked` ANDed in. them is rejected with 401, and any other filter gets `visibility != locked` ANDed in.
*/ */
export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter): SearchFilter => { export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter): SearchFilter => {
if (auth.session?.hasElevatedPermission) { if (auth.session?.hasElevatedPermission) {
return filter; return filter;
+2 -2
View File
@@ -29,6 +29,6 @@ export const serialize = <T extends keyof SyncItem, D extends SyncItem[T]>({
type, type,
data, data,
ids, ids,
ackType, ackType = type,
}: SerializeOptions<T, D>) => }: SerializeOptions<T, D>) =>
mapJsonLine({ type, data, ack: toAck({ type: ackType ?? type, updateId: ids[0], extraId: ids[1] }) }); mapJsonLine({ type, data, ack: toAck({ type: ackType, updateId: ids[0], extraId: ids[1] }) });
+3 -3
View File
@@ -72,9 +72,9 @@ type TransformState = {
}; };
/** /**
* Transforms an array of points through a series of edit operations (crop, rotate, mirror). Transforms an array of points through a series of edit operations (crop, rotate, mirror).
* Points should be in absolute pixel coordinates relative to the starting dimensions. Points should be in absolute pixel coordinates relative to the starting dimensions.
*/ */
export const transformPoints = ( export const transformPoints = (
points: Point[], points: Point[],
edits: AssetEditActionItem[], edits: AssetEditActionItem[],
+71 -73
View File
@@ -18,14 +18,14 @@ function isIPOrRange(value: string, options?: IsIPRangeOptions): boolean {
} }
/** /**
* Zod schema that validates an array of strings as IP addresses or IP/CIDR ranges. Zod schema that validates an array of strings as IP addresses or IP/CIDR ranges.
* When requireCIDR is true (default), plain IPs are rejected; only CIDR ranges are allowed. When requireCIDR is true (default), plain IPs are rejected; only CIDR ranges are allowed.
*
* @example @example
* z.string().optional().transform(...).pipe(IsIPRange()) z.string().optional().transform(...).pipe(IsIPRange())
* @example @example
* z.string().optional().transform(...).pipe(IsIPRange({ requireCIDR: false })) z.string().optional().transform(...).pipe(IsIPRange({ requireCIDR: false }))
*/ */
export function IsIPRange(options?: IsIPRangeOptions) { export function IsIPRange(options?: IsIPRangeOptions) {
return z return z
.array(z.string()) .array(z.string())
@@ -33,12 +33,12 @@ export function IsIPRange(options?: IsIPRangeOptions) {
} }
/** /**
* Like z.object().partial(), but rejects objects where every field is undefined. Like z.object().partial(), but rejects objects where every field is undefined.
* Use for update/patch DTOs where at least one field must be provided. Use for update/patch DTOs where at least one field must be provided.
*
* @example @example
* nonEmptyPartial({ name: z.string(), bio: z.string() }).meta({ id: 'UpdateDto' }); nonEmptyPartial({ name: z.string(), bio: z.string() }).meta({ id: 'UpdateDto' });
*/ */
export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) { export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) {
return z return z
.object(shape) .object(shape)
@@ -49,14 +49,14 @@ export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) {
} }
/** /**
* Zod schema that validates sibling-exclusion for object schemas. Zod schema that validates sibling-exclusion for object schemas.
* Validation passes when the target property is missing, or when none of the sibling properties are present. Validation passes when the target property is missing, or when none of the sibling properties are present.
* Use with .pipe() like IsIPRange. Use with .pipe() like IsIPRange.
*
* @example @example
* const Schema = z.object({ a: z.string().optional(), b: z.string().optional() }); const Schema = z.object({ a: z.string().optional(), b: z.string().optional() });
* Schema.pipe(IsNotSiblingOf(Schema, 'a', ['b'])); Schema.pipe(IsNotSiblingOf(Schema, 'a', ['b']));
*/ */
export function IsNotSiblingOf< export function IsNotSiblingOf<
TSchema extends z.ZodObject<z.ZodRawShape>, TSchema extends z.ZodObject<z.ZodRawShape>,
TKey extends z.infer<ReturnType<TSchema['keyof']>> & keyof z.infer<TSchema>, TKey extends z.infer<ReturnType<TSchema['keyof']>> & keyof z.infer<TSchema>,
@@ -122,18 +122,18 @@ const FilenameParamSchema = z.object({
export class FilenameParamDto extends createZodDto(FilenameParamSchema) {} export class FilenameParamDto extends createZodDto(FilenameParamSchema) {}
/** /**
* The HTML5 email regex, but with unicode support, so that international email addresses The HTML5 email regex, but with unicode support, so that international email addresses
* (unicode local parts and internationalized domain names) are accepted. (unicode local parts and internationalized domain names) are accepted.
* @see {@link z.regexes.html5Email} @see {@link z.regexes.html5Email}
*/ */
const unicodeEmail = const unicodeEmail =
/^[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; /^[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u;
/** /**
* Unified email validation Unified email validation
* Converts email strings to lowercase and validates against a unicode aware email regex Converts email strings to lowercase and validates against a unicode aware email regex
* @docs https://zod.dev/api?id=email @docs https://zod.dev/api?id=email
*/ */
export const toEmail = z export const toEmail = z
.email({ .email({
pattern: unicodeEmail, pattern: unicodeEmail,
@@ -142,9 +142,9 @@ export const toEmail = z
.transform((val) => val.toLowerCase()); .transform((val) => val.toLowerCase());
/** /**
* Parse ISO 8601 datetime strings to Date objects Parse ISO 8601 datetime strings to Date objects
* @docs https://zod.dev/api?id=codec @docs https://zod.dev/api?id=codec
*/ */
export const isoDatetimeToDate = z export const isoDatetimeToDate = z
.codec( .codec(
z.iso.datetime({ z.iso.datetime({
@@ -160,9 +160,9 @@ export const isoDatetimeToDate = z
.meta({ example: '2024-01-01T00:00:00.000Z' }); .meta({ example: '2024-01-01T00:00:00.000Z' });
/** /**
* Parse ISO date strings to Date objects Parse ISO date strings to Date objects
* @docs https://zod.dev/api?id=codec @docs https://zod.dev/api?id=codec
*/ */
export const isoDateToDate = z export const isoDateToDate = z
.codec( .codec(
z.iso.date({ z.iso.date({
@@ -177,46 +177,44 @@ export const isoDateToDate = z
.meta({ example: '2024-01-01' }); .meta({ example: '2024-01-01' });
/** /**
* Latitude in range [-90, 90]. Reuse for body or query params. Latitude in range [-90, 90]. Reuse for body or query params.
*
* @example @example
* // Regular (body): optional coordinates // Regular (body): optional coordinates
* latitudeSchema.optional().describe('Latitude coordinate') latitudeSchema.optional().describe('Latitude coordinate')
*
* @example @example
* // Pipe (query): coerce string to number then validate range // Pipe (query): coerce string to number then validate range
* z.coerce.number().pipe(latitudeSchema).describe('Latitude (-90 to 90)') z.coerce.number().pipe(latitudeSchema).describe('Latitude (-90 to 90)')
*/ */
export const latitudeSchema = z.number().meta({ format: 'double' }).min(-90).max(90); export const latitudeSchema = z.number().meta({ format: 'double' }).min(-90).max(90);
/** /**
* Longitude in range [-180, 180]. Reuse for body or query params. Longitude in range [-180, 180]. Reuse for body or query params.
*
* @example @example
* // Regular (body): optional coordinates // Regular (body): optional coordinates
* longitudeSchema.optional().describe('Longitude coordinate') longitudeSchema.optional().describe('Longitude coordinate')
*
* @example @example
* // Pipe (query): coerce string to number then validate range // Pipe (query): coerce string to number then validate range
* z.coerce.number().pipe(longitudeSchema).describe('Longitude (-180 to 180)') z.coerce.number().pipe(longitudeSchema).describe('Longitude (-180 to 180)')
*/ */
export const longitudeSchema = z.number().meta({ format: 'double' }).min(-180).max(180); export const longitudeSchema = z.number().meta({ format: 'double' }).min(-180).max(180);
/** /**
* Parse string to boolean Parse string to boolean
* This should be used for boolean query parameters and path parameters, but not for boolean request body parameters, as the first are always string. This should be used for boolean query parameters and path parameters, but not for boolean request body parameters, as the first are always string.
* We don't use z.coerce.boolean() as any truthy value is considered true We don't use z.coerce.boolean() as any truthy value is considered true
* z.stringbool() is a more robust way to parse strings to booleans as it lets you specify the truthy and falsy values and the case sensitivity. z.stringbool() is a more robust way to parse strings to booleans as it lets you specify the truthy and falsy values and the case sensitivity.
* @docs https://zod.dev/api?id=coercion @docs https://zod.dev/api?id=coercion
* @docs https://zod.dev/api?id=stringbool @docs https://zod.dev/api?id=stringbool
*/ */
export const stringToBool = z export const stringToBool = z
.stringbool({ truthy: ['true'], falsy: ['false'], case: 'sensitive' }) .stringbool({ truthy: ['true'], falsy: ['false'], case: 'sensitive' })
.meta({ type: 'boolean' }); .meta({ type: 'boolean' });
/** /** Parse JSON strings from multipart/form-data */
* Parse JSON strings from multipart/form-data
*/
export const JsonParsed = z.transform((val, ctx) => { export const JsonParsed = z.transform((val, ctx) => {
if (typeof val === 'string') { if (typeof val === 'string') {
try { try {
@@ -234,13 +232,13 @@ export const JsonParsed = z.transform((val, ctx) => {
}); });
/** /**
* Hex color validation and normalization. Hex color validation and normalization.
* Accepts formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (with or without # prefix). Accepts formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (with or without # prefix).
* Normalizes output to always include the # prefix. Normalizes output to always include the # prefix.
*
* @example @example
* hexColor.optional() hexColor.optional()
*/ */
const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
export const hexColor = z export const hexColor = z
.string() .string()
@@ -9,6 +9,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, { const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -15,6 +15,7 @@ const consume = async <T>(generator: AsyncIterableIterator<T>) => {
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, { const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -10,6 +10,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, { const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -9,6 +9,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, { const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -13,6 +13,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, { const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -8,6 +8,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, { const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -15,6 +15,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(ActivityService, { return newMediumService(ActivityService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -12,6 +12,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(AlbumService, { return newMediumService(AlbumService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -11,6 +11,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(ApiKeyService, { return newMediumService(ApiKeyService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -22,6 +22,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetMediaService, { return newMediumService(AssetMediaService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -23,6 +23,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(AssetService, { return newMediumService(AssetService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -9,6 +9,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(AuthAdminService, { return newMediumService(AuthAdminService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -22,6 +22,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(AuthService, { return newMediumService(AuthService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -20,6 +20,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
const { sut, ctx } = newMediumService(DuplicateService, { const { sut, ctx } = newMediumService(DuplicateService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -20,6 +20,7 @@ import { getKyselyDB, makeStream } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(IntegrityService, { return newMediumService(IntegrityService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -30,10 +30,10 @@ let defaultDatabase: Kysely<DB>;
const fileModifiedAt = new Date(1_700_000_000_000); const fileModifiedAt = new Date(1_700_000_000_000);
const createFile = async (filePath: string, modifiedAt: Date = fileModifiedAt) => { const createFile = async (filePath: string) => {
await mkdir(dirname(filePath), { recursive: true }); await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, 'test'); await writeFile(filePath, 'test');
await utimes(filePath, modifiedAt, modifiedAt); await utimes(filePath, fileModifiedAt, fileModifiedAt);
return filePath; return filePath;
}; };
@@ -103,6 +103,7 @@ class LibraryTestContext extends MediumTestContext<typeof LibraryService> {
} }
} }
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
const ctx = new LibraryTestContext(db || defaultDatabase); const ctx = new LibraryTestContext(db || defaultDatabase);
return { sut: ctx.sut, ctx }; return { sut: ctx.sut, ctx };
@@ -19,6 +19,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(MemoryService, { return newMediumService(MemoryService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -47,10 +48,14 @@ const create = async (ctx: ReturnType<typeof setup>['ctx']) => {
const newPersonAsset = async ( const newPersonAsset = async (
ctx: ReturnType<typeof setup>['ctx'], ctx: ReturnType<typeof setup>['ctx'],
{ ownerId, personGroupId, localDateTime }: { ownerId: string; personGroupId: string; localDateTime?: string }, {
ownerId,
personGroupId,
localDateTime = '2024-06-13T12:00:00.000Z',
}: { ownerId: string; personGroupId: string; localDateTime?: string },
) => { ) => {
const assetRepo = ctx.get(AssetRepository); const assetRepo = ctx.get(AssetRepository);
const { asset } = await ctx.newAsset({ ownerId, localDateTime: localDateTime ?? '2024-06-13T12:00:00.000Z' }); const { asset } = await ctx.newAsset({ ownerId, localDateTime });
await Promise.all([ await Promise.all([
ctx.newExif({ assetId: asset.id, make: 'Canon' }), ctx.newExif({ assetId: asset.id, make: 'Canon' }),
ctx.newJobStatus({ assetId: asset.id }), ctx.newJobStatus({ assetId: asset.id }),
@@ -15,6 +15,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(OcrService, { return newMediumService(OcrService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -12,6 +12,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(PartnerService, { return newMediumService(PartnerService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -49,6 +49,7 @@ const authenticationResponse = {
}, },
}; };
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(PasskeyService, { return newMediumService(PasskeyService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -13,6 +13,7 @@ let defaultDatabase: Kysely<DB>;
const wasmBytes = Buffer.from('some-wasm-binary-data'); const wasmBytes = Buffer.from('some-wasm-binary-data');
const sha256hash = Buffer.from('some-manifest-hash'); const sha256hash = Buffer.from('some-manifest-hash');
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(PluginService, { return newMediumService(PluginService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -18,6 +18,7 @@ let defaultDatabase: Kysely<DB>;
const unitVector = (index: number) => JSON.stringify(Array.from({ length: 512 }, (_, i) => (i === index ? 1 : 0))); const unitVector = (index: number) => JSON.stringify(Array.from({ length: 512 }, (_, i) => (i === index ? 1 : 0)));
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(SearchService, { return newMediumService(SearchService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -16,6 +16,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(SharedLinkService, { return newMediumService(SharedLinkService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -13,6 +13,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(SyncService, { return newMediumService(SyncService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -16,6 +16,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(TagService, { return newMediumService(TagService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -14,6 +14,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(TimelineService, { return newMediumService(TimelineService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -13,6 +13,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(VersionService, { return newMediumService(VersionService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -13,6 +13,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(WorkflowService, { return newMediumService(WorkflowService, {
database: db || defaultDatabase, database: db || defaultDatabase,
@@ -9,6 +9,7 @@ import { getKyselyDB, wait } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -8,6 +8,7 @@ import { getKyselyDB, wait } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -8,6 +8,7 @@ import { getKyselyDB, wait } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB, wait } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -8,6 +8,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -9,6 +9,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -8,6 +8,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -8,6 +8,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -8,6 +8,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -22,6 +22,7 @@ class LineAutoClosingStream extends Writable {
private count: number; private count: number;
lines: string[] = []; lines: string[] = [];
// eslint-disable-next-line unicorn/no-unnecessary-parameters
constructor({ after }: { after: number }) { constructor({ after }: { after: number }) {
super({ highWaterMark: 1 }); super({ highWaterMark: 1 });
this.count = after; this.count = after;
@@ -10,6 +10,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB, wait } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -10,6 +10,7 @@ import { getKyselyDB, wait } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -10,6 +10,7 @@ import { getKyselyDB, wait } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -8,6 +8,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -8,6 +8,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
@@ -7,6 +7,7 @@ import { getKyselyDB } from 'test/utils.js';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
// eslint-disable-next-line unicorn/no-unnecessary-parameters
const setup = async (db?: Kysely<DB>) => { const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase); const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser(); const { auth, user, session } = await ctx.newSyncAuthUser();
+1 -1
View File
@@ -110,7 +110,7 @@
"eslint-plugin-compat": "^7.0.0", "eslint-plugin-compat": "^7.0.0",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-svelte": "^3.23.1", "eslint-plugin-svelte": "^3.23.1",
"eslint-plugin-unicorn": "^75.0.0", "eslint-plugin-unicorn": "^78.0.0",
"factory.ts": "^1.4.1", "factory.ts": "^1.4.1",
"globals": "^17.0.0", "globals": "^17.0.0",
"happy-dom": "^21.0.0", "happy-dom": "^21.0.0",
+5 -5
View File
@@ -7,11 +7,11 @@ interface Options {
} }
/** /**
* Calls a function when a click occurs outside of the element, or when the escape key is pressed. Calls a function when a click occurs outside of the element, or when the escape key is pressed.
* @param node @param node
* @param options Object containing onOutclick and onEscape functions @param options Object containing onOutclick and onEscape functions
* @returns @returns
*/ */
export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn<Options> { export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn<Options> {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
const { onOutclick } = options; const { onOutclick } = options;
+7 -21
View File
@@ -3,33 +3,19 @@ import { tick } from 'svelte';
import type { Action } from 'svelte/action'; import type { Action } from 'svelte/action';
interface Options { interface Options {
/** /** A function that is called when the dropdown should be closed. */
* A function that is called when the dropdown should be closed.
*/
closeDropdown: () => void; closeDropdown: () => void;
/** /** The container element that with direct children that should be navigated. */
* The container element that with direct children that should be navigated.
*/
container?: HTMLElement; container?: HTMLElement;
/** /** Indicates if the dropdown is open. */
* Indicates if the dropdown is open.
*/
isOpen: boolean; isOpen: boolean;
/** /** Override the default behavior for the escape key. */
* Override the default behavior for the escape key.
*/
onEscape?: (event: KeyboardEvent) => void; onEscape?: (event: KeyboardEvent) => void;
/** /** A function that is called when the dropdown should be opened. */
* A function that is called when the dropdown should be opened.
*/
openDropdown?: (event: KeyboardEvent) => void; openDropdown?: (event: KeyboardEvent) => void;
/** /** The id of the currently selected element. */
* The id of the currently selected element.
*/
selectedId: string | undefined; selectedId: string | undefined;
/** /** A function that is called when the selection changes, to notify consumers of the new selected id. */
* A function that is called when the selection changes, to notify consumers of the new selected id.
*/
selectionChanged: (id: string | undefined) => void; selectionChanged: (id: string | undefined) => void;
} }
+4 -4
View File
@@ -5,10 +5,10 @@ interface Options {
} }
/** /**
* Calls a function when focus leaves the element. Calls a function when focus leaves the element.
* @param node @param node
* @param options Object containing onFocusOut function @param options Object containing onFocusOut function
*/ */
export function focusOutside(node: HTMLElement, options: Options = {}) { export function focusOutside(node: HTMLElement, options: Options = {}) {
const { onFocusOut } = options; const { onFocusOut } = options;
+1 -3
View File
@@ -2,9 +2,7 @@ import { getTabbable } from '#lib/utils/focus-util.ts';
import { tick } from 'svelte'; import { tick } from 'svelte';
interface Options { interface Options {
/** /** Set whether the trap is active or not. */
* Set whether the trap is active or not.
*/
active?: boolean; active?: boolean;
} }

Some files were not shown because too many files have changed in this diff Show More