feat(web): lighter timeline buckets (#17719)
* feat(web): lighter timeline buckets * GalleryViewer * weird ssr * Remove generics from AssetInteraction * ensure keys on getAssetInfo, alt-text * empty - trigger ci * re-add alt-text * test fix * update tests * tests * missing import * fix: flappy e2e test * lint * revert settings * unneeded cast * fix after merge * missing import * lint * review * lint * avoid abbreviations * review comment - type safety in test * merge conflicts * lint * lint/abbreviations * fix: left-over migration --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { Visibility } from '@immich/sdk';
|
||||
import { timelineAssetFactory } from '@test-data/factories/asset-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
|
||||
describe('AssetInteraction', () => {
|
||||
@@ -11,8 +12,12 @@ describe('AssetInteraction', () => {
|
||||
});
|
||||
|
||||
it('calculates derived values from selection', () => {
|
||||
assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: true, isTrashed: true }));
|
||||
assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: false, isTrashed: false }));
|
||||
assetInteraction.selectAsset(
|
||||
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Archive, isTrashed: true }),
|
||||
);
|
||||
assetInteraction.selectAsset(
|
||||
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Timeline, isTrashed: false }),
|
||||
);
|
||||
|
||||
expect(assetInteraction.selectionActive).toBe(true);
|
||||
expect(assetInteraction.isAllTrashed).toBe(false);
|
||||
@@ -22,7 +27,7 @@ describe('AssetInteraction', () => {
|
||||
|
||||
it('updates isAllUserOwned when the active user changes', () => {
|
||||
const [user1, user2] = userAdminFactory.buildList(2);
|
||||
assetInteraction.selectAsset(assetFactory.build({ ownerId: user1.id }));
|
||||
assetInteraction.selectAsset(timelineAssetFactory.build({ ownerId: user1.id }));
|
||||
|
||||
const cleanup = $effect.root(() => {
|
||||
expect(assetInteraction.isAllUserOwned).toBe(false);
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import type { AssetResponseDto, UserAdminResponseDto } from '@immich/sdk';
|
||||
import { Visibility, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { fromStore } from 'svelte/store';
|
||||
|
||||
export class AssetInteraction {
|
||||
selectedAssets = $state<AssetResponseDto[]>([]);
|
||||
selectedAssets = $state<TimelineAsset[]>([]);
|
||||
hasSelectedAsset(assetId: string) {
|
||||
return this.selectedAssets.some((asset) => asset.id === assetId);
|
||||
}
|
||||
selectedGroup = new SvelteSet<string>();
|
||||
assetSelectionCandidates = $state<AssetResponseDto[]>([]);
|
||||
assetSelectionCandidates = $state<TimelineAsset[]>([]);
|
||||
hasSelectionCandidate(assetId: string) {
|
||||
return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
|
||||
}
|
||||
assetSelectionStart = $state<AssetResponseDto | null>(null);
|
||||
assetSelectionStart = $state<TimelineAsset | null>(null);
|
||||
selectionActive = $derived(this.selectedAssets.length > 0);
|
||||
|
||||
private user = fromStore<UserAdminResponseDto | undefined>(user);
|
||||
private userId = $derived(this.user.current?.id);
|
||||
|
||||
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
|
||||
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.isArchived));
|
||||
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === Visibility.Archive));
|
||||
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
|
||||
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
|
||||
|
||||
selectAsset(asset: AssetResponseDto) {
|
||||
selectAsset(asset: TimelineAsset) {
|
||||
if (!this.hasSelectedAsset(asset.id)) {
|
||||
this.selectedAssets.push(asset);
|
||||
}
|
||||
}
|
||||
|
||||
selectAssets(assets: AssetResponseDto[]) {
|
||||
selectAssets(assets: TimelineAsset[]) {
|
||||
for (const asset of assets) {
|
||||
this.selectAsset(asset);
|
||||
}
|
||||
@@ -51,11 +52,11 @@ export class AssetInteraction {
|
||||
this.selectedGroup.delete(group);
|
||||
}
|
||||
|
||||
setAssetSelectionStart(asset: AssetResponseDto | null) {
|
||||
setAssetSelectionStart(asset: TimelineAsset | null) {
|
||||
this.assetSelectionStart = asset;
|
||||
}
|
||||
|
||||
setAssetSelectionCandidates(assets: AssetResponseDto[]) {
|
||||
setAssetSelectionCandidates(assets: TimelineAsset[]) {
|
||||
this.assetSelectionCandidates = assets;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
const preloadAssets = writable<AssetResponseDto[]>([]);
|
||||
const preloadAssets = writable<TimelineAsset[]>([]);
|
||||
const viewState = writable<boolean>(false);
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => {
|
||||
const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
|
||||
preloadAssets.set(assetsToPreload);
|
||||
viewingAssetStoreState.set(asset);
|
||||
viewState.set(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory';
|
||||
import { AssetStore } from './assets-store.svelte';
|
||||
|
||||
describe('AssetStore', () => {
|
||||
@@ -149,9 +149,8 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('adds assets to new bucket', () => {
|
||||
const asset = assetFactory.build({
|
||||
const asset = timelineAssetFactory.build({
|
||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||
fileCreatedAt: '2024-01-20T12:00:00.000Z',
|
||||
});
|
||||
assetStore.addAssets([asset]);
|
||||
|
||||
@@ -163,9 +162,8 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('adds assets to existing bucket', () => {
|
||||
const [assetOne, assetTwo] = assetFactory.buildList(2, {
|
||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
|
||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||
fileCreatedAt: '2024-01-20T12:00:00.000Z',
|
||||
});
|
||||
assetStore.addAssets([assetOne]);
|
||||
assetStore.addAssets([assetTwo]);
|
||||
@@ -177,16 +175,13 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('orders assets in buckets by descending date', () => {
|
||||
const assetOne = assetFactory.build({
|
||||
fileCreatedAt: '2024-01-20T12:00:00.000Z',
|
||||
const assetOne = timelineAssetFactory.build({
|
||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||
});
|
||||
const assetTwo = assetFactory.build({
|
||||
fileCreatedAt: '2024-01-15T12:00:00.000Z',
|
||||
const assetTwo = timelineAssetFactory.build({
|
||||
localDateTime: '2024-01-15T12:00:00.000Z',
|
||||
});
|
||||
const assetThree = assetFactory.build({
|
||||
fileCreatedAt: '2024-01-16T12:00:00.000Z',
|
||||
const assetThree = timelineAssetFactory.build({
|
||||
localDateTime: '2024-01-16T12:00:00.000Z',
|
||||
});
|
||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||
@@ -200,9 +195,9 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('orders buckets by descending date', () => {
|
||||
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const assetTwo = assetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' });
|
||||
const assetThree = assetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' });
|
||||
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' });
|
||||
const assetThree = timelineAssetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' });
|
||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
expect(assetStore.buckets.length).toEqual(3);
|
||||
@@ -213,7 +208,7 @@ describe('AssetStore', () => {
|
||||
|
||||
it('updates existing asset', () => {
|
||||
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
|
||||
const asset = assetFactory.build();
|
||||
const asset = timelineAssetFactory.build();
|
||||
assetStore.addAssets([asset]);
|
||||
|
||||
assetStore.addAssets([asset]);
|
||||
@@ -223,8 +218,8 @@ describe('AssetStore', () => {
|
||||
|
||||
// disabled due to the wasm Justified Layout import
|
||||
it('ignores trashed assets when isTrashed is true', async () => {
|
||||
const asset = assetFactory.build({ isTrashed: false });
|
||||
const trashedAsset = assetFactory.build({ isTrashed: true });
|
||||
const asset = timelineAssetFactory.build({ isTrashed: false });
|
||||
const trashedAsset = timelineAssetFactory.build({ isTrashed: true });
|
||||
|
||||
const assetStore = new AssetStore();
|
||||
await assetStore.updateOptions({ isTrashed: true });
|
||||
@@ -244,14 +239,14 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('ignores non-existing assets', () => {
|
||||
assetStore.updateAssets([assetFactory.build()]);
|
||||
assetStore.updateAssets([timelineAssetFactory.build()]);
|
||||
|
||||
expect(assetStore.buckets.length).toEqual(0);
|
||||
expect(assetStore.getAssets().length).toEqual(0);
|
||||
});
|
||||
|
||||
it('updates an asset', () => {
|
||||
const asset = assetFactory.build({ isFavorite: false });
|
||||
const asset = timelineAssetFactory.build({ isFavorite: false });
|
||||
const updatedAsset = { ...asset, isFavorite: true };
|
||||
|
||||
assetStore.addAssets([asset]);
|
||||
@@ -264,7 +259,7 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('asset moves buckets when asset date changes', () => {
|
||||
const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const asset = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
|
||||
|
||||
assetStore.addAssets([asset]);
|
||||
@@ -292,7 +287,7 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
|
||||
assetStore.addAssets(timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
|
||||
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||
|
||||
expect(assetStore.getAssets().length).toEqual(2);
|
||||
@@ -301,7 +296,7 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('removes asset from bucket', () => {
|
||||
const [assetOne, assetTwo] = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
assetStore.removeAssets([assetOne.id]);
|
||||
|
||||
@@ -311,7 +306,7 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('does not remove bucket when empty', () => {
|
||||
const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const assets = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
assetStore.addAssets(assets);
|
||||
assetStore.removeAssets(assets.map((asset) => asset.id));
|
||||
|
||||
@@ -334,12 +329,10 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('populated store returns first asset', () => {
|
||||
const assetOne = assetFactory.build({
|
||||
fileCreatedAt: '2024-01-20T12:00:00.000Z',
|
||||
const assetOne = timelineAssetFactory.build({
|
||||
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||
});
|
||||
const assetTwo = assetFactory.build({
|
||||
fileCreatedAt: '2024-01-15T12:00:00.000Z',
|
||||
const assetTwo = timelineAssetFactory.build({
|
||||
localDateTime: '2024-01-15T12:00:00.000Z',
|
||||
});
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
@@ -445,8 +438,8 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('returns the bucket index', () => {
|
||||
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
||||
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
|
||||
@@ -454,8 +447,8 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('ignores removed buckets', () => {
|
||||
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
||||
const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
|
||||
const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
|
||||
assetStore.removeAssets([assetTwo.id]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import {
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
type CommonLayoutOptions,
|
||||
type CommonPosition,
|
||||
} from '$lib/utils/layout-utils';
|
||||
import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util';
|
||||
import { formatDateGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import {
|
||||
AssetOrder,
|
||||
@@ -15,18 +16,17 @@ import {
|
||||
getTimeBucket,
|
||||
getTimeBuckets,
|
||||
TimeBucketSize,
|
||||
Visibility,
|
||||
type AssetResponseDto,
|
||||
type AssetStackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { get, writable, type Unsubscriber } from 'svelte/store';
|
||||
import { handleError } from '../utils/handle-error';
|
||||
import { websocketEvents } from './websocket';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
} = TUNABLES;
|
||||
@@ -61,13 +61,35 @@ function updateObject(target: any, source: any): boolean {
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function assetSnapshot(asset: AssetResponseDto) {
|
||||
return $state.snapshot(asset);
|
||||
export function assetSnapshot(asset: TimelineAsset): TimelineAsset {
|
||||
return $state.snapshot(asset) as TimelineAsset;
|
||||
}
|
||||
|
||||
export function assetsSnapshot(assets: AssetResponseDto[]) {
|
||||
return assets.map((a) => $state.snapshot(a));
|
||||
export function assetsSnapshot(assets: TimelineAsset[]): TimelineAsset[] {
|
||||
return assets.map((a) => $state.snapshot(a)) as TimelineAsset[];
|
||||
}
|
||||
|
||||
export type TimelineAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: string;
|
||||
visibility: Visibility;
|
||||
isFavorite: boolean;
|
||||
isTrashed: boolean;
|
||||
isVideo: boolean;
|
||||
isImage: boolean;
|
||||
stack: AssetStackResponseDto | null;
|
||||
duration: string | null;
|
||||
projectionType: string | null;
|
||||
livePhotoVideoId: string | null;
|
||||
text: {
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
people: string[];
|
||||
};
|
||||
};
|
||||
class IntersectingAsset {
|
||||
// --- public ---
|
||||
readonly #group: AssetDateGroup;
|
||||
@@ -91,17 +113,17 @@ class IntersectingAsset {
|
||||
});
|
||||
|
||||
position: CommonPosition | undefined = $state();
|
||||
asset: AssetResponseDto | undefined = $state();
|
||||
asset: TimelineAsset | undefined = $state();
|
||||
id: string | undefined = $derived(this.asset?.id);
|
||||
|
||||
constructor(group: AssetDateGroup, asset: AssetResponseDto) {
|
||||
constructor(group: AssetDateGroup, asset: TimelineAsset) {
|
||||
this.#group = group;
|
||||
this.asset = asset;
|
||||
}
|
||||
}
|
||||
type AssetOperation = (asset: AssetResponseDto) => { remove: boolean };
|
||||
type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||
|
||||
type MoveAsset = { asset: AssetResponseDto; year: number; month: number };
|
||||
type MoveAsset = { asset: TimelineAsset; year: number; month: number };
|
||||
export class AssetDateGroup {
|
||||
// --- public
|
||||
readonly bucket: AssetBucket;
|
||||
@@ -130,8 +152,8 @@ export class AssetDateGroup {
|
||||
|
||||
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
this.intersetingAssets.sort((a, b) => {
|
||||
const aDate = DateTime.fromISO(a.asset!.fileCreatedAt).toUTC();
|
||||
const bDate = DateTime.fromISO(b.asset!.fileCreatedAt).toUTC();
|
||||
const aDate = DateTime.fromISO(a.asset!.localDateTime).toUTC();
|
||||
const bDate = DateTime.fromISO(b.asset!.localDateTime).toUTC();
|
||||
|
||||
if (sortOrder === AssetOrder.Asc) {
|
||||
return aDate.diff(bDate).milliseconds;
|
||||
@@ -226,6 +248,25 @@ export type ViewportXY = Viewport & {
|
||||
y: number;
|
||||
};
|
||||
|
||||
class AddContext {
|
||||
lookupCache: {
|
||||
[dayOfMonth: number]: AssetDateGroup;
|
||||
} = {};
|
||||
unprocessedAssets: TimelineAsset[] = [];
|
||||
changedDateGroups = new Set<AssetDateGroup>();
|
||||
newDateGroups = new Set<AssetDateGroup>();
|
||||
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
for (const group of this.changedDateGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
for (const group of this.newDateGroups) {
|
||||
group.sortAssets(sortOrder);
|
||||
}
|
||||
if (this.newDateGroups.size > 0) {
|
||||
bucket.sortDateGroups();
|
||||
}
|
||||
}
|
||||
}
|
||||
export class AssetBucket {
|
||||
// --- public ---
|
||||
#intersecting: boolean = $state(false);
|
||||
@@ -317,7 +358,7 @@ export class AssetBucket {
|
||||
getAssets() {
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
return this.dateGroups.reduce(
|
||||
(accumulator: AssetResponseDto[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
|
||||
(accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -382,56 +423,56 @@ export class AssetBucket {
|
||||
}
|
||||
|
||||
// note - if the assets are not part of this bucket, they will not be added
|
||||
addAssets(assets: AssetResponseDto[]) {
|
||||
const lookupCache: {
|
||||
[dayOfMonth: number]: AssetDateGroup;
|
||||
} = {};
|
||||
const unprocessedAssets: AssetResponseDto[] = [];
|
||||
const changedDateGroups = new Set<AssetDateGroup>();
|
||||
const newDateGroups = new Set<AssetDateGroup>();
|
||||
for (const asset of assets) {
|
||||
const date = DateTime.fromISO(asset.localDateTime).toUTC();
|
||||
const month = date.get('month');
|
||||
const year = date.get('year');
|
||||
if (this.month === month && this.year === year) {
|
||||
const day = date.get('day');
|
||||
let dateGroup: AssetDateGroup | undefined = lookupCache[day];
|
||||
if (!dateGroup) {
|
||||
dateGroup = this.findDateGroupByDay(day);
|
||||
if (dateGroup) {
|
||||
lookupCache[day] = dateGroup;
|
||||
}
|
||||
}
|
||||
if (dateGroup) {
|
||||
const intersectingAsset = new IntersectingAsset(dateGroup, asset);
|
||||
if (dateGroup.intersetingAssets.some((a) => a.id === asset.id)) {
|
||||
console.error(`Ignoring attempt to add duplicate asset ${asset.id} to ${dateGroup.groupTitle}`);
|
||||
} else {
|
||||
dateGroup.intersetingAssets.push(intersectingAsset);
|
||||
changedDateGroups.add(dateGroup);
|
||||
}
|
||||
} else {
|
||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
||||
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset));
|
||||
this.dateGroups.push(dateGroup);
|
||||
lookupCache[day] = dateGroup;
|
||||
newDateGroups.add(dateGroup);
|
||||
}
|
||||
} else {
|
||||
unprocessedAssets.push(asset);
|
||||
}
|
||||
addAssets(bucketResponse: AssetResponseDto[]) {
|
||||
const addContext = new AddContext();
|
||||
for (const asset of bucketResponse) {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
this.addTimelineAsset(timelineAsset, addContext);
|
||||
}
|
||||
for (const group of changedDateGroups) {
|
||||
group.sortAssets(this.#sortOrder);
|
||||
}
|
||||
for (const group of newDateGroups) {
|
||||
group.sortAssets(this.#sortOrder);
|
||||
}
|
||||
if (newDateGroups.size > 0) {
|
||||
this.sortDateGroups();
|
||||
}
|
||||
return unprocessedAssets;
|
||||
|
||||
addContext.sort(this, this.#sortOrder);
|
||||
return addContext.unprocessedAssets;
|
||||
}
|
||||
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
|
||||
const { id, localDateTime } = timelineAsset;
|
||||
const date = DateTime.fromISO(localDateTime).toUTC();
|
||||
|
||||
const month = date.get('month');
|
||||
const year = date.get('year');
|
||||
|
||||
// If the timeline asset does not belong to the current bucket, mark it as unprocessed
|
||||
if (this.month !== month || this.year !== year) {
|
||||
addContext.unprocessedAssets.push(timelineAsset);
|
||||
return;
|
||||
}
|
||||
|
||||
const day = date.get('day');
|
||||
let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day] || this.findDateGroupByDay(day);
|
||||
|
||||
if (dateGroup) {
|
||||
// Cache the found date group for future lookups
|
||||
addContext.lookupCache[day] = dateGroup;
|
||||
} else {
|
||||
// Create a new date group if none exists for the given day
|
||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
||||
this.dateGroups.push(dateGroup);
|
||||
addContext.lookupCache[day] = dateGroup;
|
||||
addContext.newDateGroups.add(dateGroup);
|
||||
}
|
||||
|
||||
// Check for duplicate assets in the date group
|
||||
if (dateGroup.intersetingAssets.some((a) => a.id === id)) {
|
||||
console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the timeline asset to the date group
|
||||
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
|
||||
dateGroup.intersetingAssets.push(intersectingAsset);
|
||||
addContext.changedDateGroups.add(dateGroup);
|
||||
}
|
||||
|
||||
getRandomDateGroup() {
|
||||
const random = Math.floor(Math.random() * this.dateGroups.length);
|
||||
return this.dateGroups[random];
|
||||
@@ -512,17 +553,16 @@ export class AssetBucket {
|
||||
}
|
||||
}
|
||||
|
||||
const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
|
||||
option === undefined ? false : option !== value;
|
||||
const isMismatched = <T>(option: T | undefined, value: T): boolean => (option === undefined ? false : option !== value);
|
||||
|
||||
interface AddAsset {
|
||||
type: 'add';
|
||||
values: AssetResponseDto[];
|
||||
values: TimelineAsset[];
|
||||
}
|
||||
|
||||
interface UpdateAsset {
|
||||
type: 'update';
|
||||
values: AssetResponseDto[];
|
||||
values: TimelineAsset[];
|
||||
}
|
||||
|
||||
interface DeleteAsset {
|
||||
@@ -719,9 +759,13 @@ export class AssetStore {
|
||||
|
||||
connect() {
|
||||
this.#unsubscribers.push(
|
||||
websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [asset] })),
|
||||
websocketEvents.on('on_upload_success', (asset) =>
|
||||
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
|
||||
),
|
||||
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
|
||||
websocketEvents.on('on_asset_update', (asset) => this.#addPendingChanges({ type: 'update', values: [asset] })),
|
||||
websocketEvents.on('on_asset_update', (asset) =>
|
||||
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
|
||||
),
|
||||
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
|
||||
);
|
||||
}
|
||||
@@ -735,8 +779,8 @@ export class AssetStore {
|
||||
|
||||
#getPendingChangeBatches() {
|
||||
const batch: {
|
||||
add: AssetResponseDto[];
|
||||
update: AssetResponseDto[];
|
||||
add: TimelineAsset[];
|
||||
update: TimelineAsset[];
|
||||
remove: string[];
|
||||
} = {
|
||||
add: [],
|
||||
@@ -1069,7 +1113,7 @@ export class AssetStore {
|
||||
// so no need to load the bucket, it already has assets
|
||||
return;
|
||||
}
|
||||
const assets = await getTimeBucket(
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
...this.#options,
|
||||
timeBucket: bucketDate,
|
||||
@@ -1078,7 +1122,7 @@ export class AssetStore {
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (assets) {
|
||||
if (bucketResponse) {
|
||||
if (this.#options.timelineAlbumId) {
|
||||
const albumAssets = await getTimeBucket(
|
||||
{
|
||||
@@ -1089,12 +1133,11 @@ export class AssetStore {
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
for (const asset of albumAssets) {
|
||||
this.albumAssets.add(asset.id);
|
||||
for (const { id } of albumAssets) {
|
||||
this.albumAssets.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const unprocessed = bucket.addAssets(assets);
|
||||
const unprocessed = bucket.addAssets(bucketResponse);
|
||||
if (unprocessed.length > 0) {
|
||||
console.error(
|
||||
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`,
|
||||
@@ -1108,8 +1151,8 @@ export class AssetStore {
|
||||
}
|
||||
}
|
||||
|
||||
addAssets(assets: AssetResponseDto[]) {
|
||||
const assetsToUpdate: AssetResponseDto[] = [];
|
||||
addAssets(assets: TimelineAsset[]) {
|
||||
const assetsToUpdate: TimelineAsset[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
if (this.isExcluded(asset)) {
|
||||
@@ -1122,7 +1165,7 @@ export class AssetStore {
|
||||
this.#addAssetsToBuckets([...notUpdated]);
|
||||
}
|
||||
|
||||
#addAssetsToBuckets(assets: AssetResponseDto[]) {
|
||||
#addAssetsToBuckets(assets: TimelineAsset[]) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1139,7 +1182,9 @@ export class AssetStore {
|
||||
bucket = new AssetBucket(this, utc, 1, this.#options.order);
|
||||
this.buckets.push(bucket);
|
||||
}
|
||||
bucket.addAssets([asset]);
|
||||
const addContext = new AddContext();
|
||||
bucket.addTimelineAsset(asset, addContext);
|
||||
addContext.sort(bucket, this.#options.order);
|
||||
updatedBuckets.add(bucket);
|
||||
}
|
||||
|
||||
@@ -1165,7 +1210,7 @@ export class AssetStore {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
let bucket = this.#findBucketForAsset(id);
|
||||
if (!bucket) {
|
||||
const asset = await getAssetInfo({ id });
|
||||
const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
@@ -1178,7 +1223,7 @@ export class AssetStore {
|
||||
}
|
||||
|
||||
async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) {
|
||||
let date = fromLocalDateTime(localDateTime);
|
||||
let date = DateTime.fromISO(localDateTime).toUTC();
|
||||
// Only support TimeBucketSize.Month
|
||||
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
|
||||
const iso = date.toISO()!;
|
||||
@@ -1188,7 +1233,7 @@ export class AssetStore {
|
||||
return this.getBucketByDate(year, month);
|
||||
}
|
||||
|
||||
async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) {
|
||||
async #getBucketInfoForAsset(asset: { id: string; localDateTime: string }, options?: { cancelable: boolean }) {
|
||||
const bucketInfo = this.#findBucketForAsset(asset.id);
|
||||
if (bucketInfo) {
|
||||
return bucketInfo;
|
||||
@@ -1222,7 +1267,7 @@ export class AssetStore {
|
||||
const changedBuckets = new Set<AssetBucket>();
|
||||
let idsToProcess = new Set(ids);
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: { asset: AssetResponseDto; year: number; month: number }[][] = [];
|
||||
const combinedMoveAssets: { asset: TimelineAsset; year: number; month: number }[][] = [];
|
||||
for (const bucket of this.buckets) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
|
||||
@@ -1265,8 +1310,8 @@ export class AssetStore {
|
||||
this.#runAssetOperation(new Set(ids), operation);
|
||||
}
|
||||
|
||||
updateAssets(assets: AssetResponseDto[]) {
|
||||
const lookup = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, asset]));
|
||||
updateAssets(assets: TimelineAsset[]) {
|
||||
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
|
||||
updateObject(asset, lookup.get(asset.id));
|
||||
return { remove: false };
|
||||
@@ -1288,11 +1333,11 @@ export class AssetStore {
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
getFirstAsset(): AssetResponseDto | undefined {
|
||||
getFirstAsset(): TimelineAsset | undefined {
|
||||
return this.buckets[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
|
||||
async getPreviousAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> {
|
||||
let bucket = await this.#getBucketInfoForAsset(asset);
|
||||
if (!bucket) {
|
||||
return;
|
||||
@@ -1335,7 +1380,7 @@ export class AssetStore {
|
||||
}
|
||||
}
|
||||
|
||||
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> {
|
||||
async getNextAsset(asset: { id: string; localDateTime: string }): Promise<TimelineAsset | undefined> {
|
||||
let bucket = await this.#getBucketInfoForAsset(asset);
|
||||
if (!bucket) {
|
||||
return;
|
||||
@@ -1374,9 +1419,9 @@ export class AssetStore {
|
||||
}
|
||||
}
|
||||
|
||||
isExcluded(asset: AssetResponseDto) {
|
||||
isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility === AssetVisibility.Archive, asset.isArchived) ||
|
||||
isMismatched(this.#options.visibility, asset.visibility as unknown as AssetVisibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import {
|
||||
type AssetResponseDto,
|
||||
deleteMemory,
|
||||
type MemoryResponseDto,
|
||||
removeMemoryAssets,
|
||||
searchMemories,
|
||||
updateMemory,
|
||||
} from '@immich/sdk';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
type MemoryIndex = {
|
||||
@@ -17,7 +12,7 @@ type MemoryIndex = {
|
||||
|
||||
export type MemoryAsset = MemoryIndex & {
|
||||
memory: MemoryResponseDto;
|
||||
asset: AssetResponseDto;
|
||||
asset: TimelineAsset;
|
||||
previousMemory?: MemoryResponseDto;
|
||||
previous?: MemoryAsset;
|
||||
next?: MemoryAsset;
|
||||
@@ -41,7 +36,7 @@ class MemoryStoreSvelte {
|
||||
memoryIndex,
|
||||
previousMemory: this.memories[memoryIndex - 1],
|
||||
nextMemory: this.memories[memoryIndex + 1],
|
||||
asset,
|
||||
asset: toTimelineAsset(asset),
|
||||
assetIndex,
|
||||
previous,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user