Files
Immich/web/test/common/data.ts
T
Jason Rasmussen cca2dd7ece chore: add regression tests for tags (#33051)
(cherry picked from commit 7b7b5b8c5a1c13f65bb5696e7e06908b41a00e59)
2026-11-07 21:31:44 +03:00

1000 lines
31 KiB
TypeScript

import { adminUserId, factory, type TimelineConfig } from '#test/common/factories.ts';
import { SeededRandom } from '#test/common/helpers.ts';
import { payload } from '#test/common/payloads.ts';
import type { ResponseOf } from '#test/common/types.ts';
import {
AssetOrder,
AssetTypeEnum,
AssetVisibility,
QueueCommand,
ReactionLevel,
ReactionType,
SearchSuggestionType,
type ActivityCreateDto,
type ActivityResponseDto,
type AdminConfigDto,
type AlbumResponseDto,
type AssetBulkUpdateDto,
type AssetOcrResponseDto,
type AssetResponseDto,
type CreateAlbumDto,
type DownloadInfoDto,
type DuplicateResolveDto,
type DuplicateResponseDto,
type ExifResponseDto,
type MapMarkerResponseDto,
type MemoryResponseDto,
type MemoryUpdateDto,
type MetadataSearchDto,
type PersonResponseDto,
type PersonUpdateDto,
type PlacesResponseDto,
type PluginMethodResponseDto,
type QueueCommandDto,
type QueueDeleteDto,
type QueueName,
type QueueResponseDto,
type QueueUpdateDto,
type SearchExploreResponseDto,
type SearchResponseDto,
type SharedLinkResponseDto,
type StackCreateDto,
type StackResponseDto,
type StackUpdateDto,
type TagResponseDto,
type TagUpdateDto,
type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto,
type UpdateAlbumDto,
type UpdateAssetDto,
type UserAdminCreateDto,
type UserAdminResponseDto,
type UserAdminUpdateDto,
type UserPreferencesResponseDto,
type WorkflowCreateDto,
type WorkflowResponseDto,
type WorkflowStepDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
export type TimelineFilter = {
albumId?: string;
isFavorite?: boolean;
isTrashed?: boolean;
visibility?: AssetVisibility;
order?: AssetOrder;
withStacked?: boolean;
};
export type ActivityFilter = {
assetId?: string;
userId?: string;
$type?: ReactionType;
level?: ReactionLevel;
};
export type SuggestionFilter = {
$type: SearchSuggestionType;
country?: string;
state?: string;
make?: string;
model?: string;
includeNull?: boolean;
};
export type MemoryFilter = {
id?: string;
page?: number;
size?: number;
};
const monthKey = (iso: string) => {
const date = new Date(iso);
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
};
type MaybeArray<T> = T | T[];
const toList = <T>(items: MaybeArray<T>) => (Array.isArray(items) ? items : [items]);
const toWorkflowSteps = (steps: WorkflowStepDto[]) =>
steps.map(({ method, config, enabled }) => ({ method, config, enabled }));
const hiddenVisibilities = new Set<AssetVisibility>([AssetVisibility.Hidden, AssetVisibility.Locked]);
const suggestionFields: Record<SearchSuggestionType, keyof ExifResponseDto> = {
[SearchSuggestionType.Country]: 'country',
[SearchSuggestionType.State]: 'state',
[SearchSuggestionType.City]: 'city',
[SearchSuggestionType.CameraMake]: 'make',
[SearchSuggestionType.CameraModel]: 'model',
[SearchSuggestionType.CameraLensModel]: 'lensModel',
};
const exploreRecentsLimit = 12;
export class MockData {
readonly user = factory.user({ id: adminUserId });
readonly serverConfig = payload.serverConfig();
readonly publicConfig = payload.publicConfig();
readonly userConfig = payload.userConfig();
readonly serverFeatures = payload.serverFeatures();
readonly aboutInfo = payload.aboutInfo();
readonly storage = payload.storage();
readonly versionHistory = payload.versionHistory();
readonly mediaTypes = payload.mediaTypes();
adminConfig = payload.adminConfig();
readonly serverVersion = payload.serverVersion();
readonly queues = payload.queues();
readonly notifications = payload.notifications();
readonly backups = payload.backups();
private preferences = factory.preferences();
private integrityReport = payload.integrityReport();
private readonly users = new Map<string, UserAdminResponseDto>([[this.user.id, this.user]]);
private readonly assets = new Map<string, AssetResponseDto>();
private readonly albums = new Map<string, AlbumResponseDto>();
private readonly albumAssets = new Map<string, Set<string>>();
private readonly memories = new Map<string, MemoryResponseDto>();
private readonly people = new Map<string, PersonResponseDto>();
private readonly places: PlacesResponseDto[] = [];
private readonly stacks = new Map<string, StackResponseDto>();
private readonly ocr = new Map<string, AssetOcrResponseDto[]>();
private readonly brokenAssetIds = new Set<string>();
private readonly sharedLinks = new Map<string, SharedLinkResponseDto>();
private readonly tags = new Map<string, TagResponseDto>();
private readonly activities = new Map<string, ActivityResponseDto>();
private readonly workflows = new Map<string, WorkflowResponseDto>();
private readonly pluginMethods = new Map<string, PluginMethodResponseDto>();
private readonly queueStates = new Map<QueueName, QueueResponseDto>();
getPreferences() {
return this.preferences;
}
getIntegrityReport() {
return this.integrityReport;
}
updateIntegrityReport(report: Partial<ResponseOf<'getIntegrityReportSummary'>>) {
this.integrityReport = { ...this.integrityReport, ...report };
}
setPreferences(preferences: UserPreferencesResponseDto) {
this.preferences = preferences;
return this;
}
getAsset(id: string) {
return this.assets.get(id);
}
getStack(id: string) {
return this.stacks.get(id);
}
getMemory(id: string) {
return this.memories.get(id);
}
getPeople() {
return [...this.people.values()];
}
getPerson(id: string) {
return this.people.get(id);
}
personStatistics(id: string) {
return { assets: this.timelineAssets().filter((asset) => asset.people?.some((person) => person.id === id)).length };
}
peoplePage({ withHidden, isHidden }: { withHidden?: boolean; isHidden?: boolean }) {
const all = this.getPeople().filter((person) => isHidden === undefined || person.isHidden === isHidden);
const people = withHidden || isHidden !== undefined ? all : all.filter((person) => !person.isHidden);
return {
people,
hasNextPage: false,
total: all.length,
hidden: all.filter((person) => person.isHidden).length,
};
}
searchPeople({ name, withHidden }: { name: string; withHidden?: boolean }) {
const query = name.toLowerCase();
return this.getPeople().filter(
(person) =>
(withHidden || !person.isHidden) &&
(person.name.toLowerCase().startsWith(query) || person.name.toLowerCase().includes(` ${query}`)),
);
}
updatePerson(id: string, dto: PersonUpdateDto) {
const person = this.people.get(id);
if (!person) {
return;
}
const updated: PersonResponseDto = {
...person,
name: dto.name ?? person.name,
isHidden: dto.isHidden ?? person.isHidden,
isFavorite: dto.isFavorite ?? person.isFavorite,
birthDate: dto.birthDate === undefined ? person.birthDate : dto.birthDate,
};
this.people.set(id, updated);
return updated;
}
updatePeople(people: { id: string; isHidden?: boolean }[]) {
return people.map(({ id, isHidden }) => {
const person = this.people.get(id);
if (!person) {
return { id, success: false };
}
this.people.set(id, { ...person, isHidden: isHidden ?? person.isHidden });
return { id, success: true };
});
}
updateAlbum(id: string, dto: UpdateAlbumDto) {
const album = this.albums.get(id);
if (!album) {
return;
}
this.albums.set(id, { ...album, ...dto, description: dto.description ?? album.description });
return this.getAlbum(id);
}
getAlbum(albumId: string): AlbumResponseDto | undefined {
const album = this.albums.get(albumId);
if (!album) {
return;
}
const assets = this.timelineAssets({ albumId });
return {
...album,
assetCount: assets.length,
albumThumbnailAssetId: album.albumThumbnailAssetId ?? assets[0]?.id ?? null,
startDate: assets.at(-1)?.fileCreatedAt,
endDate: assets[0]?.fileCreatedAt,
lastModifiedAssetTimestamp: assets[0]?.fileCreatedAt,
};
}
timelineAssets(filter: TimelineFilter = {}) {
const members = filter.albumId === undefined ? undefined : (this.albumAssets.get(filter.albumId) ?? new Set());
const isTrashed = filter.isTrashed ?? false;
const direction = filter.order === AssetOrder.Asc ? 1 : -1;
return [...this.assets.values()]
.filter(
(asset) =>
(members === undefined || members.has(asset.id)) &&
(filter.visibility === undefined
? !hiddenVisibilities.has(asset.visibility)
: asset.visibility === filter.visibility) &&
asset.isTrashed === isTrashed &&
(filter.isFavorite === undefined || asset.isFavorite === filter.isFavorite) &&
(!filter.withStacked || !asset.stack || asset.stack.primaryAssetId === asset.id),
)
.sort((a, b) => direction * a.fileCreatedAt.localeCompare(b.fileCreatedAt));
}
getOcr(assetId: string) {
return this.ocr.get(assetId) ?? [];
}
isBroken(assetId: string) {
return this.brokenAssetIds.has(assetId);
}
markBroken(assets: MaybeArray<Pick<AssetResponseDto, 'id'>>) {
for (const { id } of toList(assets)) {
this.brokenAssetIds.add(id);
}
return this;
}
addSharedLinks(links: MaybeArray<SharedLinkResponseDto>) {
for (const link of toList(links)) {
this.sharedLinks.set(link.id, link);
this.addAssets(link.assets);
}
return this;
}
addAssets(assets: MaybeArray<AssetResponseDto>) {
for (const asset of toList(assets)) {
this.assets.set(asset.id, asset);
}
return this;
}
sharedLinkList() {
return [...this.sharedLinks.keys()].map((id) => this.getSharedLink({ id })!);
}
getSharedLink({ id, key, slug }: { id?: string; key?: string; slug?: string }) {
for (const link of this.sharedLinks.values()) {
if (
(id !== undefined && link.id === id) ||
(key !== undefined && link.key === key) ||
(slug !== undefined && link.slug === slug)
) {
return { ...link, album: link.album ? this.getAlbum(link.album.id) : undefined };
}
}
}
removeSharedLinkAssets(id: string, assetIds: string[]) {
const link = this.sharedLinks.get(id);
if (!link) {
return;
}
const updated = { ...link, assets: link.assets.filter((asset) => !assetIds.includes(asset.id)) };
this.sharedLinks.set(id, updated);
return updated;
}
assetForSharedLink(asset: AssetResponseDto, key?: string) {
const link = key === undefined ? undefined : this.getSharedLink({ key });
if (!link || link.showMetadata) {
return asset;
}
return { ...asset, hasMetadata: false, exifInfo: undefined, people: [] };
}
downloadInfo({ assetIds, albumId }: DownloadInfoDto) {
const ids = assetIds ?? (albumId ? this.timelineAssets({ albumId }).map(({ id }) => id) : []);
const size = ids.reduce((total, id) => total + (this.assets.get(id)?.exifInfo?.fileSizeInByte ?? 0), 0);
return { archives: [{ assetIds: ids, size }], totalSize: size };
}
getUser(id: string) {
return this.users.get(id);
}
clusterGroupUsers(clusterGroupId: string) {
return this.userList().filter((user) => user.clusterGroupId === clusterGroupId);
}
userList({ withDeleted = false }: { withDeleted?: boolean } = {}) {
return [...this.users.values()].filter((user) => withDeleted || !user.deletedAt);
}
createUser(dto: UserAdminCreateDto) {
const user = factory.user({
email: dto.email,
name: dto.name,
isAdmin: dto.isAdmin ?? false,
shouldChangePassword: dto.shouldChangePassword ?? true,
quotaSizeInBytes: dto.quotaSizeInBytes ?? null,
storageLabel: dto.storageLabel ?? null,
});
this.addUsers(user);
return user;
}
addUsers(users: MaybeArray<UserAdminResponseDto>) {
for (const user of toList(users)) {
this.users.set(user.id, user);
}
return this;
}
updateUser(id: string, dto: UserAdminUpdateDto) {
const user = this.users.get(id);
if (!user) {
return;
}
const updated: UserAdminResponseDto = {
...user,
email: dto.email ?? user.email,
name: dto.name ?? user.name,
isAdmin: dto.isAdmin ?? user.isAdmin,
avatarColor: dto.avatarColor ?? user.avatarColor,
quotaSizeInBytes: dto.quotaSizeInBytes === undefined ? user.quotaSizeInBytes : dto.quotaSizeInBytes,
storageLabel: dto.storageLabel === undefined ? user.storageLabel : dto.storageLabel,
shouldChangePassword: dto.shouldChangePassword ?? user.shouldChangePassword,
updatedAt: new Date().toISOString(),
};
this.users.set(id, updated);
return updated;
}
addTimeline(config?: TimelineConfig) {
const assets = factory.timeline(config);
this.addAssets(assets);
return assets;
}
createAlbum(dto: CreateAlbumDto) {
const album = factory.album({ albumName: dto.albumName, description: dto.description ?? '' });
this.addAlbums(album).addToAlbum(
album.id,
(dto.assetIds ?? []).map((id) => ({ id })),
);
return this.getAlbum(album.id)!;
}
addAlbums(albums: MaybeArray<AlbumResponseDto>) {
for (const album of toList(albums)) {
this.albums.set(album.id, album);
this.albumAssets.set(album.id, new Set());
}
return this;
}
deleteAlbum(id: string) {
this.albums.delete(id);
this.albumAssets.delete(id);
return this;
}
addDuplicates(assets: AssetResponseDto[]) {
const duplicateId = crypto.randomUUID();
for (const asset of assets) {
asset.duplicateId = duplicateId;
}
return this.addAssets(assets);
}
duplicateList(): DuplicateResponseDto[] {
const groups = new Map<string, AssetResponseDto[]>();
for (const asset of this.assets.values()) {
if (asset.duplicateId) {
groups.set(asset.duplicateId, [...(groups.get(asset.duplicateId) ?? []), asset]);
}
}
return [...groups].map(([duplicateId, assets]) => ({ duplicateId, assets, suggestedKeepAssetIds: [assets[0].id] }));
}
resolveDuplicates({ groups }: DuplicateResolveDto) {
for (const { duplicateId, trashAssetIds } of groups) {
this.deleteDuplicates([duplicateId]);
this.deleteAssets(trashAssetIds);
}
return groups.map(({ duplicateId }) => ({ id: duplicateId, success: true }));
}
deleteDuplicates(duplicateIds: string[]) {
const ids = new Set(duplicateIds);
const duplicates = [...this.assets.values()].filter(({ duplicateId }) => duplicateId && ids.has(duplicateId));
for (const asset of duplicates) {
this.assets.set(asset.id, { ...asset, duplicateId: null });
}
return this;
}
removeFromAlbum(albumId: string, assetIds: string[]) {
const members = this.albumAssets.get(albumId);
const removed = [...this.activities.values()].filter(({ assetId }) => assetId && assetIds.includes(assetId));
for (const { id } of removed) {
this.activities.delete(id);
}
return assetIds.map((id) => ({ id, success: members?.delete(id) ?? false }));
}
addToAlbum(albumId: string, assets: MaybeArray<Pick<AssetResponseDto, 'id'>>) {
const members = this.albumAssets.get(albumId);
if (!members) {
throw new Error(`Unknown album: ${albumId}`);
}
for (const { id } of toList(assets)) {
members.add(id);
}
return this;
}
addMemories(memories: MaybeArray<MemoryResponseDto>) {
for (const memory of toList(memories)) {
this.memories.set(memory.id, memory);
}
return this;
}
addTags(tags: MaybeArray<TagResponseDto>) {
for (const tag of toList(tags)) {
this.tags.set(tag.id, tag);
}
return this;
}
getTag(id: string) {
return this.tags.get(id);
}
tagList() {
return [...this.tags.values()];
}
upsertTags(values: string[]) {
return values.map((value) => this.upsertTag(value));
}
updateTag(id: string, dto: TagUpdateDto) {
const tag = this.tags.get(id);
if (!tag) {
return;
}
const name = dto.name ?? tag.name;
const value = tag.value.slice(0, tag.value.length - tag.name.length) + name;
for (const descendant of this.tagList()) {
if (descendant.value.startsWith(`${tag.value}/`)) {
this.tags.set(descendant.id, { ...descendant, value: value + descendant.value.slice(tag.value.length) });
}
}
const updated = { ...tag, name, value, color: dto.color === undefined ? tag.color : (dto.color ?? undefined) };
this.tags.set(id, updated);
return updated;
}
deleteTag(id: string) {
const tag = this.tags.get(id);
if (!tag) {
return false;
}
for (const descendant of this.tagList()) {
if (descendant.id === id || descendant.value.startsWith(`${tag.value}/`)) {
this.tags.delete(descendant.id);
}
}
return true;
}
tagAssets(assetIds: string[], tagIds: string[]) {
const tags = tagIds.map((id) => this.tags.get(id)).filter((tag) => tag !== undefined);
let count = 0;
for (const assetId of assetIds) {
const asset = this.assets.get(assetId);
if (!asset) {
continue;
}
const existing = asset.tags ?? [];
const added = tags.filter((tag) => existing.every(({ id }) => id !== tag.id));
count += added.length;
this.assets.set(assetId, { ...asset, tags: [...existing, ...added] });
}
return count;
}
untagAssets(tagId: string, assetIds: string[]) {
return assetIds.map((id) => {
const asset = this.assets.get(id);
if (!asset?.tags?.some((tag) => tag.id === tagId)) {
return { id, success: false };
}
this.assets.set(id, { ...asset, tags: asset.tags.filter((tag) => tag.id !== tagId) });
return { id, success: true };
});
}
private upsertTag(value: string): TagResponseDto {
const existing = this.tagList().find((tag) => tag.value === value);
if (existing) {
return existing;
}
const separator = value.lastIndexOf('/');
const parent = separator === -1 ? undefined : this.upsertTag(value.slice(0, separator));
const tag = factory.tag({ name: value.slice(separator + 1), value, parentId: parent?.id });
this.tags.set(tag.id, tag);
return tag;
}
addActivities(activities: MaybeArray<ActivityResponseDto>) {
for (const activity of toList(activities)) {
this.activities.set(activity.id, activity);
}
return this;
}
createActivity({ assetId, type, comment }: ActivityCreateDto) {
const activity = factory.activity({ user: this.user, assetId: assetId ?? null, type, comment: comment ?? null });
this.addActivities(activity);
return activity;
}
activityStatistics(filter: { assetId?: string } = {}) {
const activities = this.activityList(filter);
return {
comments: activities.filter(({ type }) => type === ReactionType.Comment).length,
likes: activities.filter(({ type }) => type === ReactionType.Like).length,
};
}
activityList({ assetId, userId, $type, level }: ActivityFilter = {}) {
return [...this.activities.values()].filter(
(activity) =>
(!assetId || activity.assetId === assetId) &&
(!userId || activity.user.id === userId) &&
(!$type || activity.type === $type) &&
(level !== ReactionLevel.Album || activity.assetId === null) &&
(level !== ReactionLevel.Asset || activity.assetId !== null) &&
(activity.assetId === null || this.assets.get(activity.assetId)?.isTrashed === false),
);
}
deleteActivity(id: string) {
return this.activities.delete(id);
}
addWorkflows(workflows: MaybeArray<WorkflowResponseDto>) {
for (const workflow of toList(workflows)) {
this.workflows.set(workflow.id, workflow);
}
return this;
}
workflowList() {
return [...this.workflows.values()];
}
createWorkflow(dto: WorkflowCreateDto) {
const workflow = factory.workflow({ ...dto, steps: toWorkflowSteps(dto.steps ?? []) });
this.workflows.set(workflow.id, workflow);
return workflow;
}
updateWorkflow(id: string, dto: WorkflowUpdateDto) {
const workflow = this.workflows.get(id);
if (!workflow) {
return;
}
const steps = dto.steps ? toWorkflowSteps(dto.steps) : workflow.steps;
const updated = { ...workflow, ...dto, steps, updatedAt: new Date().toISOString() };
this.workflows.set(id, updated);
return updated;
}
addPluginMethods(methods: MaybeArray<PluginMethodResponseDto>) {
for (const method of toList(methods)) {
this.pluginMethods.set(method.key, method);
}
return this;
}
pluginMethodList() {
return [...this.pluginMethods.values()];
}
updateAdminConfig(config: AdminConfigDto) {
this.adminConfig = config;
return config;
}
addQueues(queues: MaybeArray<QueueResponseDto>) {
for (const queue of toList(queues)) {
this.queueStates.set(queue.name, queue);
}
return this;
}
queueList() {
return [...this.queueStates.values()];
}
getQueue(name: QueueName) {
return this.queueStates.get(name);
}
updateQueue(name: QueueName, { isPaused }: QueueUpdateDto) {
const queue = this.queueStates.get(name);
if (queue && isPaused !== undefined) {
queue.isPaused = isPaused;
}
return queue;
}
emptyQueue(name: QueueName, { failed = false }: QueueDeleteDto) {
const queue = this.queueStates.get(name);
if (queue) {
queue.statistics.waiting = 0;
queue.statistics.delayed = 0;
queue.statistics.paused = 0;
if (failed) {
queue.statistics.failed = 0;
}
}
return queue;
}
runQueueCommand(name: QueueName, { command }: QueueCommandDto) {
switch (command) {
case QueueCommand.Pause:
case QueueCommand.Resume: {
return this.updateQueue(name, { isPaused: command === QueueCommand.Pause });
}
case QueueCommand.Empty: {
return this.emptyQueue(name, { failed: false });
}
case QueueCommand.ClearFailed: {
const queue = this.queueStates.get(name);
if (queue) {
queue.statistics.failed = 0;
}
return queue;
}
case QueueCommand.Start: {
return this.queueStates.get(name);
}
}
}
addPlaces(places: MaybeArray<PlacesResponseDto>) {
this.places.push(...toList(places));
return this;
}
searchPlaces(name: string) {
const query = name.toLowerCase();
return this.places.filter((place) => place.name.toLowerCase().startsWith(query));
}
addPeople(people: MaybeArray<PersonResponseDto>) {
for (const person of toList(people)) {
this.people.set(person.id, person);
}
return this;
}
addStacks(stacks: MaybeArray<StackResponseDto>) {
for (const stack of toList(stacks)) {
this.stacks.set(stack.id, stack);
for (const asset of stack.assets) {
asset.stack = { id: stack.id, assetCount: stack.assets.length, primaryAssetId: stack.primaryAssetId };
this.assets.set(asset.id, asset);
}
}
return this;
}
createStack({ assetIds }: StackCreateDto) {
const stack = factory.stack({ assets: assetIds.map((id) => this.assets.get(id)!) });
this.addStacks(stack);
return stack;
}
updateStack(id: string, { primaryAssetId }: StackUpdateDto) {
const stack = this.stacks.get(id);
if (!stack) {
return;
}
const updated = { ...stack, primaryAssetId: primaryAssetId ?? stack.primaryAssetId };
this.stacks.set(id, updated);
return updated;
}
removeFromStack(id: string, assetId: string) {
const stack = this.stacks.get(id);
const asset = this.assets.get(assetId);
if (!stack || !asset) {
return;
}
this.assets.set(assetId, { ...asset, stack: null });
this.addStacks({ ...stack, assets: stack.assets.filter((asset) => asset.id !== assetId) });
return this;
}
deleteStacks(ids: string[]) {
for (const id of ids) {
for (const asset of this.stacks.get(id)?.assets ?? []) {
this.assets.set(asset.id, { ...asset, stack: null });
}
this.stacks.delete(id);
}
return this;
}
addOcr(results: MaybeArray<AssetOcrResponseDto>) {
for (const result of toList(results)) {
const existing = this.ocr.get(result.assetId) ?? [];
this.ocr.set(result.assetId, [...existing, result]);
}
return this;
}
mapMarkers(albumId?: string): MapMarkerResponseDto[] {
return this.timelineAssets({ albumId })
.filter((asset) => typeof asset.exifInfo?.latitude === 'number' && typeof asset.exifInfo.longitude === 'number')
.map((asset) => ({
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
}));
}
albumList(assetId?: string) {
return [...this.albums.keys()]
.filter((albumId) => assetId === undefined || this.albumAssets.get(albumId)?.has(assetId))
.map((albumId) => this.getAlbum(albumId)!);
}
sampleAssets(count: number, seed = 1) {
return new SeededRandom(seed)
.pickMany(this.timelineAssets(), count)
.sort((a, b) => b.fileCreatedAt.localeCompare(a.fileCreatedAt));
}
timeBuckets(filter: TimelineFilter = {}): TimeBucketsResponseDto[] {
const counts = new Map<string, number>();
for (const asset of this.timelineAssets(filter)) {
const key = monthKey(asset.fileCreatedAt);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return Array.from(counts, ([key, count]) => ({ timeBucket: `${key}-01T00:00:00.000Z`, count }));
}
timeBucket(timeBucket: string, filter: TimelineFilter = {}): TimeBucketAssetResponseDto {
const key = monthKey(timeBucket);
const bucket = this.timelineAssets(filter).filter((asset) => monthKey(asset.fileCreatedAt) === key);
return {
id: bucket.map((asset) => asset.id),
ownerId: bucket.map((asset) => asset.ownerId),
ratio: bucket.map((asset) => (asset.width && asset.height ? asset.width / asset.height : 1)),
thumbhash: bucket.map((asset) => asset.thumbhash),
createdAt: bucket.map((asset) => asset.createdAt),
fileCreatedAt: bucket.map((asset) => asset.fileCreatedAt),
localOffsetHours: bucket.map(() => 0),
isFavorite: bucket.map((asset) => asset.isFavorite),
isTrashed: bucket.map((asset) => asset.isTrashed),
isImage: bucket.map((asset) => asset.type === AssetTypeEnum.Image),
duration: bucket.map((asset) => asset.duration),
projectionType: bucket.map((asset) => asset.exifInfo?.projectionType ?? null),
livePhotoVideoId: bucket.map((asset) => asset.livePhotoVideoId ?? null),
city: bucket.map((asset) => asset.exifInfo?.city ?? null),
country: bucket.map((asset) => asset.exifInfo?.country ?? null),
visibility: bucket.map((asset) => asset.visibility),
stack: bucket.map((asset) => (asset.stack ? [asset.stack.id, String(asset.stack.assetCount)] : null)),
};
}
updateAssets({ ids, ...dto }: AssetBulkUpdateDto) {
for (const id of ids) {
this.updateAsset(id, dto);
}
return this;
}
updateAsset(id: string, dto: UpdateAssetDto) {
const asset = this.assets.get(id);
if (!asset) {
return;
}
const visibility = dto.visibility ?? asset.visibility;
const updated: AssetResponseDto = {
...asset,
isFavorite: dto.isFavorite ?? asset.isFavorite,
visibility,
isArchived: visibility === AssetVisibility.Archive,
livePhotoVideoId: dto.livePhotoVideoId === undefined ? asset.livePhotoVideoId : dto.livePhotoVideoId,
updatedAt: new Date().toISOString(),
exifInfo: {
...asset.exifInfo,
description: dto.description ?? asset.exifInfo?.description,
rating: dto.rating === undefined ? asset.exifInfo?.rating : dto.rating,
latitude: dto.latitude ?? asset.exifInfo?.latitude,
longitude: dto.longitude ?? asset.exifInfo?.longitude,
dateTimeOriginal: dto.dateTimeOriginal ?? asset.exifInfo?.dateTimeOriginal,
},
};
this.assets.set(id, updated);
return updated;
}
deleteAssets(ids: string[], force = false) {
for (const id of ids) {
const asset = this.assets.get(id);
if (!asset) {
continue;
}
if (force) {
this.assets.delete(id);
} else {
this.assets.set(id, { ...asset, isTrashed: true });
}
}
return this;
}
restoreAssets(ids: string[]) {
let count = 0;
for (const id of ids) {
const asset = this.assets.get(id);
if (!asset?.isTrashed) {
continue;
}
this.assets.set(id, { ...asset, isTrashed: false });
count++;
}
return count;
}
memoryList({ id, page = 1, size = 250 }: MemoryFilter = {}) {
const memories = [...this.memories.values()]
.filter((memory) => id === undefined || memory.id === id)
.sort((a, b) => b.memoryAt.localeCompare(a.memoryAt));
return memories.slice((page - 1) * size, page * size);
}
deleteMemory(id: string) {
this.memories.delete(id);
return this;
}
updateMemory(id: string, dto: MemoryUpdateDto) {
const memory = this.memories.get(id);
if (!memory) {
return;
}
const updated: MemoryResponseDto = {
...memory,
isSaved: dto.isSaved ?? memory.isSaved,
memoryAt: dto.memoryAt ?? memory.memoryAt,
seenAt: dto.seenAt ?? memory.seenAt,
updatedAt: new Date().toISOString(),
};
this.memories.set(id, updated);
return updated;
}
searchAssets(dto: Pick<MetadataSearchDto, 'visibility' | 'isFavorite' | 'size'>): SearchResponseDto {
const items = this.timelineAssets({ visibility: dto.visibility, isFavorite: dto.isFavorite ?? undefined }).slice(
0,
dto.size ?? 250,
);
return {
albums: { total: 0, count: 0, items: [], facets: [] },
assets: { total: items.length, count: items.length, items, facets: [], nextPage: null, nextCursor: null },
};
}
searchSuggestions({ $type, country, state, make, model, includeNull }: SuggestionFilter) {
const matches = (value: string | null | undefined, filter?: string) => filter === undefined || value === filter;
const values = this.timelineAssets()
.map((asset) => asset.exifInfo)
.filter(
(exif) =>
exif !== undefined &&
matches(exif.country, country) &&
matches(exif.state, state) &&
matches(exif.make, make) &&
matches(exif.model, model),
)
.map((exif) => exif![suggestionFields[$type]])
.filter((value): value is string => typeof value === 'string');
const suggestions: Array<string | null> = [...new Set(values)].sort();
return includeNull ? [...suggestions, null] : suggestions;
}
exploreData(): SearchExploreResponseDto[] {
const recents = this.timelineAssets()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, exploreRecentsLimit);
return [
{ fieldName: 'exifInfo.city', items: this.assetsByCity().map((data) => ({ value: data.exifInfo!.city!, data })) },
{ fieldName: 'createdAt', items: recents.map((data) => ({ value: data.createdAt, data })) },
];
}
assetsByCity() {
const byCity = new Map<string, AssetResponseDto>();
for (const asset of this.timelineAssets()) {
const city = asset.exifInfo?.city;
if (city && !byCity.has(city)) {
byCity.set(city, asset);
}
}
return [...byCity.values()];
}
}