mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 17:25:35 +03:00
fix: regression: sort day by fileCreatedAt again (#18732)
* fix: regression: sort day by fileCreatedAt again * lint * e2e test * inline function * e2e * Address comments. Drop dayGroup and timezone in favor of localOffsetMinutes * lint and some api-doc * lint, more api-doc * format * Move minutes to fractional hours * make sql * merge/conflict * merge fallout, review comments * spelling * drop offset from returned date * move description into decorator where possible, regen api
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import { fromDateTimeOriginal, fromLocalDateTime } from '$lib/utils/timeline-util';
|
||||
import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAssetInfo,
|
||||
@@ -112,8 +112,8 @@
|
||||
let timeZone = $derived(asset.exifInfo?.timeZone);
|
||||
let dateTime = $derived(
|
||||
timeZone && asset.exifInfo?.dateTimeOriginal
|
||||
? fromDateTimeOriginal(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromLocalDateTime(asset.localDateTime),
|
||||
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
|
||||
: fromISODateTimeUTC(asset.localDateTime),
|
||||
);
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import {
|
||||
@@ -576,7 +576,7 @@
|
||||
|
||||
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
||||
<p>
|
||||
{fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||
locale: $locale,
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
formatBucketTitle,
|
||||
formatGroupTitle,
|
||||
fromLocalDateTimeToObject,
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
getTimes,
|
||||
type TimelinePlainDateTime,
|
||||
type TimelinePlainYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
@@ -153,8 +153,12 @@ export class AssetBucket {
|
||||
|
||||
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
||||
const addContext = new AddContext();
|
||||
const people: string[] = [];
|
||||
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||
const { localDateTime, fileCreatedAt } = getTimes(
|
||||
bucketAssets.fileCreatedAt[i],
|
||||
bucketAssets.localOffsetHours[i],
|
||||
);
|
||||
|
||||
const timelineAsset: TimelineAsset = {
|
||||
city: bucketAssets.city[i],
|
||||
country: bucketAssets.country[i],
|
||||
@@ -166,9 +170,9 @@ export class AssetBucket {
|
||||
isTrashed: bucketAssets.isTrashed[i],
|
||||
isVideo: !bucketAssets.isImage[i],
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||
localDateTime: fromLocalDateTimeToObject(bucketAssets.localDateTime[i]),
|
||||
localDateTime,
|
||||
fileCreatedAt,
|
||||
ownerId: bucketAssets.ownerId[i],
|
||||
people,
|
||||
projectionType: bucketAssets.projectionType[i],
|
||||
ratio: bucketAssets.ratio[i],
|
||||
stack: bucketAssets.stack?.[i]
|
||||
@@ -179,6 +183,7 @@ export class AssetBucket {
|
||||
}
|
||||
: null,
|
||||
thumbhash: bucketAssets.thumbhash[i],
|
||||
people: null, // People are not included in the bucket assets
|
||||
};
|
||||
this.addTimelineAsset(timelineAsset, addContext);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export class AssetDateGroup {
|
||||
|
||||
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||
const sortFn = plainDateTimeCompare.bind(undefined, sortOrder === AssetOrder.Asc);
|
||||
this.intersectingAssets.sort((a, b) => sortFn(a.asset.localDateTime, b.asset.localDateTime));
|
||||
this.intersectingAssets.sort((a, b) => sortFn(a.asset.fileCreatedAt, b.asset.fileCreatedAt));
|
||||
}
|
||||
|
||||
getFirstAsset() {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import {
|
||||
plainDateTimeCompare,
|
||||
toISOLocalDateTime,
|
||||
toISOYearMonthUTC,
|
||||
toTimelineAsset,
|
||||
type TimelinePlainDate,
|
||||
type TimelinePlainDateTime,
|
||||
@@ -573,7 +573,7 @@ export class AssetStore {
|
||||
if (bucket.getFirstAsset()) {
|
||||
return;
|
||||
}
|
||||
const timeBucket = toISOLocalDateTime(bucket.yearMonth);
|
||||
const timeBucket = toISOYearMonthUTC(bucket.yearMonth);
|
||||
const key = authManager.key;
|
||||
const bucketResponse = await getTimeBucket(
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ export type TimelineAsset = {
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: TimelinePlainDateTime;
|
||||
fileCreatedAt: TimelinePlainDateTime;
|
||||
visibility: AssetVisibility;
|
||||
isFavorite: boolean;
|
||||
isTrashed: boolean;
|
||||
@@ -29,7 +30,7 @@ export type TimelineAsset = {
|
||||
livePhotoVideoId: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
people: string[];
|
||||
people: string[] | null;
|
||||
};
|
||||
|
||||
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromLocalDateTimeToObject } from '$lib/utils/timeline-util';
|
||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||
|
||||
@@ -14,6 +14,13 @@ async function getAssets(store: AssetStore) {
|
||||
return assets;
|
||||
}
|
||||
|
||||
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
|
||||
return {
|
||||
...arg,
|
||||
localDateTime: arg.fileCreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AssetStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -22,15 +29,24 @@ describe('AssetStore', () => {
|
||||
describe('init', () => {
|
||||
let assetStore: AssetStore;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(1)
|
||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(100)
|
||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(3)
|
||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(100).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
@@ -40,9 +56,9 @@ describe('AssetStore', () => {
|
||||
beforeEach(async () => {
|
||||
assetStore = new AssetStore();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||
{ count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
|
||||
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||
{ count: 1, timeBucket: '2024-03-01' },
|
||||
{ count: 100, timeBucket: '2024-02-01' },
|
||||
{ count: 3, timeBucket: '2024-01-01' },
|
||||
]);
|
||||
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
@@ -78,12 +94,18 @@ describe('AssetStore', () => {
|
||||
describe('loadBucket', () => {
|
||||
let assetStore: AssetStore;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(1)
|
||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(3)
|
||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
|
||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
@@ -166,9 +188,11 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('adds assets to new bucket', () => {
|
||||
const asset = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
assetStore.addAssets([asset]);
|
||||
|
||||
expect(assetStore.buckets.length).toEqual(1);
|
||||
@@ -180,9 +204,11 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('adds assets to existing bucket', () => {
|
||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const [assetOne, assetTwo] = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
assetStore.addAssets([assetOne]);
|
||||
assetStore.addAssets([assetTwo]);
|
||||
|
||||
@@ -194,15 +220,21 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('orders assets in buckets by descending date', () => {
|
||||
const assetOne = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const assetTwo = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'),
|
||||
});
|
||||
const assetThree = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-16T12:00:00.000Z'),
|
||||
});
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
const bucket = assetStore.getBucketByDate({ year: 2024, month: 1 });
|
||||
@@ -214,15 +246,21 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('orders buckets by descending date', () => {
|
||||
const assetOne = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const assetTwo = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-04-20T12:00:00.000Z'),
|
||||
});
|
||||
const assetThree = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2023-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-04-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetThree = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
assetStore.addAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
expect(assetStore.buckets.length).toEqual(3);
|
||||
@@ -238,7 +276,7 @@ describe('AssetStore', () => {
|
||||
|
||||
it('updates existing asset', () => {
|
||||
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
|
||||
const asset = timelineAssetFactory.build();
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
assetStore.addAssets([asset]);
|
||||
|
||||
assetStore.addAssets([asset]);
|
||||
@@ -248,8 +286,8 @@ describe('AssetStore', () => {
|
||||
|
||||
// disabled due to the wasm Justified Layout import
|
||||
it('ignores trashed assets when isTrashed is true', async () => {
|
||||
const asset = timelineAssetFactory.build({ isTrashed: false });
|
||||
const trashedAsset = timelineAssetFactory.build({ isTrashed: true });
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
|
||||
const trashedAsset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: true }));
|
||||
|
||||
const assetStore = new AssetStore();
|
||||
await assetStore.updateOptions({ isTrashed: true });
|
||||
@@ -269,14 +307,14 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('ignores non-existing assets', () => {
|
||||
assetStore.updateAssets([timelineAssetFactory.build()]);
|
||||
assetStore.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||
|
||||
expect(assetStore.buckets.length).toEqual(0);
|
||||
expect(assetStore.count).toEqual(0);
|
||||
});
|
||||
|
||||
it('updates an asset', () => {
|
||||
const asset = timelineAssetFactory.build({ isFavorite: false });
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
||||
const updatedAsset = { ...asset, isFavorite: true };
|
||||
|
||||
assetStore.addAssets([asset]);
|
||||
@@ -289,10 +327,15 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('asset moves buckets when asset date changes', () => {
|
||||
const asset = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const updatedAsset = deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||
});
|
||||
const updatedAsset = { ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-20T12:00:00.000Z') };
|
||||
|
||||
assetStore.addAssets([asset]);
|
||||
expect(assetStore.buckets.length).toEqual(1);
|
||||
@@ -320,7 +363,11 @@ describe('AssetStore', () => {
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
assetStore.addAssets(
|
||||
timelineAssetFactory.buildList(2, { localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z') }),
|
||||
timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset)),
|
||||
);
|
||||
assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
|
||||
|
||||
@@ -330,9 +377,11 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('removes asset from bucket', () => {
|
||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const [assetOne, assetTwo] = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
assetStore.removeAssets([assetOne.id]);
|
||||
|
||||
@@ -342,9 +391,11 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('does not remove bucket when empty', () => {
|
||||
const assets = timelineAssetFactory.buildList(2, {
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const assets = timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
assetStore.addAssets(assets);
|
||||
assetStore.removeAssets(assets.map((asset) => asset.id));
|
||||
|
||||
@@ -367,12 +418,16 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('populated store returns first asset', () => {
|
||||
const assetOne = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const assetTwo = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-15T12:00:00.000Z'),
|
||||
});
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
expect(assetStore.getFirstAsset()).toEqual(assetOne);
|
||||
});
|
||||
@@ -381,15 +436,24 @@ describe('AssetStore', () => {
|
||||
describe('getLaterAsset', () => {
|
||||
let assetStore: AssetStore;
|
||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(1)
|
||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-03-01T00:00:00.000Z') })),
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(6)
|
||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-02-01T00:00:00.000Z') })),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||
.buildList(3)
|
||||
.map((asset) => ({ ...asset, localDateTime: fromLocalDateTimeToObject('2024-01-01T00:00:00.000Z') })),
|
||||
'2024-03-01T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-02-01T00:00:00.000Z': timelineAssetFactory.buildList(6).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
'2024-01-01T00:00:00.000Z': timelineAssetFactory.buildList(3).map((asset) =>
|
||||
deriveLocalDateTimeFromFileCreatedAt({
|
||||
...asset,
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
),
|
||||
};
|
||||
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||
@@ -479,12 +543,16 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('returns the bucket index', () => {
|
||||
const assetOne = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const assetTwo = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'),
|
||||
});
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
@@ -494,12 +562,16 @@ describe('AssetStore', () => {
|
||||
});
|
||||
|
||||
it('ignores removed buckets', () => {
|
||||
const assetOne = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-01-20T12:00:00.000Z'),
|
||||
});
|
||||
const assetTwo = timelineAssetFactory.build({
|
||||
localDateTime: fromLocalDateTimeToObject('2024-02-15T12:00:00.000Z'),
|
||||
});
|
||||
const assetOne = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const assetTwo = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
assetStore.addAssets([assetOne, assetTwo]);
|
||||
|
||||
assetStore.removeAssets([assetTwo.id]);
|
||||
|
||||
@@ -62,6 +62,15 @@ describe('getAltText', () => {
|
||||
ownerId: 'test-owner',
|
||||
ratio: 1,
|
||||
thumbhash: null,
|
||||
fileCreatedAt: {
|
||||
year: testDate.getUTCFullYear(),
|
||||
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
||||
day: testDate.getUTCDate(),
|
||||
hour: testDate.getUTCHours(),
|
||||
minute: testDate.getUTCMinutes(),
|
||||
second: testDate.getUTCSeconds(),
|
||||
millisecond: testDate.getUTCMilliseconds(),
|
||||
},
|
||||
localDateTime: {
|
||||
year: testDate.getUTCFullYear(),
|
||||
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
||||
@@ -71,6 +80,7 @@ describe('getAltText', () => {
|
||||
second: testDate.getUTCSeconds(),
|
||||
millisecond: testDate.getUTCMilliseconds(),
|
||||
},
|
||||
|
||||
visibility: AssetVisibility.Timeline,
|
||||
isFavorite: false,
|
||||
isTrashed: false,
|
||||
|
||||
@@ -46,16 +46,16 @@ export const getAltText = derived(t, ($t) => {
|
||||
});
|
||||
const hasPlace = asset.city && asset.country;
|
||||
|
||||
const peopleCount = asset.people.length;
|
||||
const peopleCount = asset.people?.length ?? 0;
|
||||
const isVideo = asset.isVideo;
|
||||
|
||||
const values = {
|
||||
date,
|
||||
city: asset.city,
|
||||
country: asset.country,
|
||||
person1: asset.people[0],
|
||||
person2: asset.people[1],
|
||||
person3: asset.people[2],
|
||||
person1: asset.people?.[0],
|
||||
person2: asset.people?.[1],
|
||||
person3: asset.people?.[2],
|
||||
isVideo,
|
||||
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
|
||||
};
|
||||
|
||||
@@ -5,17 +5,81 @@ import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { DateTime, type LocaleOptions } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
// Move type definitions to the top
|
||||
export type TimelinePlainYearMonth = {
|
||||
year: number;
|
||||
month: number;
|
||||
};
|
||||
|
||||
export type TimelinePlainDate = TimelinePlainYearMonth & {
|
||||
day: number;
|
||||
};
|
||||
|
||||
export type TimelinePlainDateTime = TimelinePlainDate & {
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
millisecond: number;
|
||||
};
|
||||
|
||||
export type ScrubberListener = (
|
||||
bucketDate: { year: number; month: number },
|
||||
overallScrollPercent: number,
|
||||
bucketScrollPercent: number,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export const fromLocalDateTime = (localDateTime: string) =>
|
||||
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
|
||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||
DateTime.fromISO(isoDateTime, { zone: timeZone, locale: get(locale) }) as DateTime<true>;
|
||||
|
||||
export const fromLocalDateTimeToObject = (localDateTime: string): TimelinePlainDateTime =>
|
||||
(fromLocalDateTime(localDateTime) as DateTime<true>).toObject();
|
||||
export const fromISODateTimeToObject = (isoDateTime: string, timeZone: string): TimelinePlainDateTime =>
|
||||
(fromISODateTime(isoDateTime, timeZone) as DateTime<true>).toObject();
|
||||
|
||||
// used for AssetResponseDto.localDateTime, amongst others
|
||||
export const fromISODateTimeUTC = (isoDateTimeUtc: string) => fromISODateTime(isoDateTimeUtc, 'UTC');
|
||||
|
||||
export const fromISODateTimeUTCToObject = (isoDateTimeUtc: string): TimelinePlainDateTime =>
|
||||
(fromISODateTimeUTC(isoDateTimeUtc) as DateTime<true>).toObject();
|
||||
|
||||
// used to create equivalent of AssetResponseDto.localDateTime in UTC, but without timezone information
|
||||
export const fromISODateTimeTruncateTZToObject = (
|
||||
isoDateTimeUtc: string,
|
||||
timeZone: string | undefined,
|
||||
): TimelinePlainDateTime =>
|
||||
(
|
||||
fromISODateTime(isoDateTimeUtc, timeZone ?? 'UTC').setZone('UTC', { keepLocalTime: true }) as DateTime<true>
|
||||
).toObject();
|
||||
|
||||
// Used to derive a local date time from an ISO string and a UTC offset in hours
|
||||
export const fromISODateTimeWithOffsetToObject = (
|
||||
isoDateTimeUtc: string,
|
||||
utcOffsetHours: number,
|
||||
): TimelinePlainDateTime => {
|
||||
const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc);
|
||||
|
||||
// Apply the offset to get the local time
|
||||
// Note: offset is in hours (may be fractional), positive for east of UTC, negative for west
|
||||
const localDateTime = utcDateTime.plus({ hours: utcOffsetHours });
|
||||
|
||||
// Return as plain object (keeping the local time but in UTC zone context)
|
||||
return (localDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toObject();
|
||||
};
|
||||
|
||||
export const getTimes = (isoDateTimeUtc: string, localUtcOffsetHours: number) => {
|
||||
const utcDateTime = fromISODateTimeUTC(isoDateTimeUtc);
|
||||
const fileCreatedAt = (utcDateTime as DateTime<true>).toObject();
|
||||
|
||||
// Apply the offset to get the local time
|
||||
// Note: offset is in hours (may be fractional), positive for east of UTC, negative for west
|
||||
const luxonLocalDateTime = utcDateTime.plus({ hours: localUtcOffsetHours });
|
||||
// Return as plain object (keeping the local time but in UTC zone context)
|
||||
const localDateTime = (luxonLocalDateTime.setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toObject();
|
||||
|
||||
return {
|
||||
fileCreatedAt,
|
||||
localDateTime,
|
||||
};
|
||||
};
|
||||
|
||||
export const fromTimelinePlainDateTime = (timelineDateTime: TimelinePlainDateTime): DateTime<true> =>
|
||||
DateTime.fromObject(timelineDateTime, { zone: 'local', locale: get(locale) }) as DateTime<true>;
|
||||
@@ -32,10 +96,7 @@ export const fromTimelinePlainYearMonth = (timelineYearMonth: TimelinePlainYearM
|
||||
{ zone: 'local', locale: get(locale) },
|
||||
) as DateTime<true>;
|
||||
|
||||
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
|
||||
DateTime.fromISO(dateTimeOriginal, { zone: timeZone, locale: get(locale) });
|
||||
|
||||
export const toISOLocalDateTime = (timelineYearMonth: TimelinePlainYearMonth): string =>
|
||||
export const toISOYearMonthUTC = (timelineYearMonth: TimelinePlainYearMonth): string =>
|
||||
(fromTimelinePlainYearMonth(timelineYearMonth).setZone('UTC', { keepLocalTime: true }) as DateTime<true>).toISO();
|
||||
|
||||
export function formatBucketTitle(_date: DateTime): string {
|
||||
@@ -104,12 +165,16 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
const country = assetResponse.exifInfo?.country;
|
||||
const people = assetResponse.people?.map((person) => person.name) || [];
|
||||
|
||||
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
|
||||
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
|
||||
|
||||
return {
|
||||
id: assetResponse.id,
|
||||
ownerId: assetResponse.ownerId,
|
||||
ratio,
|
||||
thumbhash: assetResponse.thumbhash,
|
||||
localDateTime: fromLocalDateTimeToObject(assetResponse.localDateTime),
|
||||
localDateTime,
|
||||
fileCreatedAt,
|
||||
isFavorite: assetResponse.isFavorite,
|
||||
visibility: assetResponse.visibility,
|
||||
isTrashed: assetResponse.isTrashed,
|
||||
@@ -151,19 +216,3 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelinePlainDateTim
|
||||
}
|
||||
return aDateTime.millisecond - bDateTime.millisecond;
|
||||
};
|
||||
|
||||
export type TimelinePlainDateTime = TimelinePlainDate & {
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
millisecond: number;
|
||||
};
|
||||
|
||||
export type TimelinePlainDate = TimelinePlainYearMonth & {
|
||||
day: number;
|
||||
};
|
||||
|
||||
export type TimelinePlainYearMonth = {
|
||||
year: number;
|
||||
month: number;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { fromLocalDateTimeToObject, fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
|
||||
import { fromISODateTimeUTCToObject, fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { Sync } from 'factory.ts';
|
||||
@@ -34,7 +34,8 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
||||
ratio: Sync.each(() => faker.number.int()),
|
||||
ownerId: Sync.each(() => faker.string.uuid()),
|
||||
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
|
||||
localDateTime: Sync.each(() => fromLocalDateTimeToObject(faker.date.past().toISOString())),
|
||||
localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
|
||||
fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
|
||||
isFavorite: Sync.each(() => faker.datatype.boolean()),
|
||||
visibility: AssetVisibility.Timeline,
|
||||
isTrashed: false,
|
||||
@@ -60,7 +61,8 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
||||
isImage: [],
|
||||
isTrashed: [],
|
||||
livePhotoVideoId: [],
|
||||
localDateTime: [],
|
||||
fileCreatedAt: [],
|
||||
localOffsetHours: [],
|
||||
ownerId: [],
|
||||
projectionType: [],
|
||||
ratio: [],
|
||||
@@ -68,6 +70,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
||||
thumbhash: [],
|
||||
};
|
||||
for (const asset of timelineAsset) {
|
||||
const fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt).toISO();
|
||||
bucketAssets.city.push(asset.city);
|
||||
bucketAssets.country.push(asset.country);
|
||||
bucketAssets.duration.push(asset.duration!);
|
||||
@@ -77,7 +80,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
||||
bucketAssets.isImage.push(asset.isImage);
|
||||
bucketAssets.isTrashed.push(asset.isTrashed);
|
||||
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
|
||||
bucketAssets.localDateTime.push(fromTimelinePlainDateTime(asset.localDateTime).toISO());
|
||||
bucketAssets.fileCreatedAt.push(fileCreatedAt);
|
||||
bucketAssets.ownerId.push(asset.ownerId);
|
||||
bucketAssets.projectionType.push(asset.projectionType!);
|
||||
bucketAssets.ratio.push(asset.ratio);
|
||||
|
||||
Reference in New Issue
Block a user