mirror of
https://github.com/immich-app/immich.git
synced 2026-01-09 07:41:22 +08:00
push top level functions from timelinemanager into extensions
This commit is contained in:
parent
6d3dda7e2e
commit
de84e46f62
@ -440,10 +440,8 @@
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
|
||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||
let startBucket = timelineManager.getSegmentForAssetId(assetInteraction.assetSelectionStart.id) as
|
||||
| TimelineMonth
|
||||
| undefined;
|
||||
let endBucket = timelineManager.getSegmentForAssetId(asset.id) as TimelineMonth | undefined;
|
||||
let startBucket = await timelineManager.search.getMonthForAsset(assetInteraction.assetSelectionStart.id);
|
||||
let endBucket = await timelineManager.search.getMonthForAsset(asset.id);
|
||||
|
||||
if (!startBucket || !endBucket) {
|
||||
return;
|
||||
|
||||
@ -40,10 +40,10 @@
|
||||
|
||||
const handlePrevious = async () => {
|
||||
const release = await mutex.acquire();
|
||||
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
|
||||
const laterAsset = await timelineManager.search.getLaterAsset($viewingAsset);
|
||||
|
||||
if (laterAsset) {
|
||||
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
|
||||
const preloadAsset = await timelineManager.search.getLaterAsset(laterAsset);
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
|
||||
@ -55,10 +55,10 @@
|
||||
|
||||
const handleNext = async () => {
|
||||
const release = await mutex.acquire();
|
||||
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
|
||||
const earlierAsset = await timelineManager.search.getEarlierAsset($viewingAsset);
|
||||
|
||||
if (earlierAsset) {
|
||||
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
|
||||
const preloadAsset = await timelineManager.search.getEarlierAsset(earlierAsset);
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
|
||||
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
|
||||
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
|
||||
@ -69,7 +69,7 @@
|
||||
};
|
||||
|
||||
const handleRandom = async () => {
|
||||
const randomAsset = await timelineManager.getRandomAsset();
|
||||
const randomAsset = await timelineManager.search.getRandomAsset();
|
||||
|
||||
if (randomAsset) {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
|
||||
|
||||
@ -31,7 +31,7 @@ export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean
|
||||
|
||||
export const setFocusTo = async (
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||
store: TimelineManager,
|
||||
timelineManager: TimelineManager,
|
||||
direction: 'earlier' | 'later',
|
||||
interval: 'day' | 'month' | 'year' | 'asset',
|
||||
) => {
|
||||
@ -53,8 +53,8 @@ export const setFocusTo = async (
|
||||
|
||||
const asset =
|
||||
direction === 'earlier'
|
||||
? await store.getEarlierAsset({ id }, interval)
|
||||
: await store.getLaterAsset({ id }, interval);
|
||||
? await timelineManager.search.getEarlierAsset({ id }, interval)
|
||||
: await timelineManager.search.getLaterAsset({ id }, interval);
|
||||
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
|
||||
@ -278,15 +278,6 @@ export abstract class VirtualScrollManager {
|
||||
|
||||
await segment.load(cancelable);
|
||||
}
|
||||
|
||||
getSegmentForAssetId(assetId: string) {
|
||||
for (const segment of this.segments) {
|
||||
const asset = segment.assets.find((asset) => asset.id === assetId);
|
||||
if (asset) {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isEmptyViewport = (viewport: Viewport) => viewport.width === 0 || viewport.height === 0;
|
||||
|
||||
@ -19,6 +19,10 @@ async function getAssets(timelineManager: TimelineManager) {
|
||||
return assets;
|
||||
}
|
||||
|
||||
function getMonthForAssetId(timelineManager: TimelineManager, id: string) {
|
||||
return timelineManager.search.findMonthForAsset(id)?.month;
|
||||
}
|
||||
|
||||
function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset {
|
||||
return {
|
||||
...arg,
|
||||
@ -586,8 +590,8 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('returns null for invalid assetId', async () => {
|
||||
expect(() => timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
||||
expect(await timelineManager.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
||||
expect(() => timelineManager.search.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).not.toThrow();
|
||||
expect(await timelineManager.search.getLaterAsset({ id: 'invalid' } as AssetResponseDto)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns previous assetId', async () => {
|
||||
@ -596,7 +600,7 @@ describe('TimelineManager', () => {
|
||||
|
||||
const a = month!.assets[0];
|
||||
const b = month!.assets[1];
|
||||
const previous = await timelineManager.getLaterAsset(b);
|
||||
const previous = await timelineManager.search.getLaterAsset(b);
|
||||
expect(previous).toEqual(a);
|
||||
});
|
||||
|
||||
@ -608,7 +612,7 @@ describe('TimelineManager', () => {
|
||||
const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 });
|
||||
const a = month!.assets[0];
|
||||
const b = previousMonth!.assets[0];
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
const previous = await timelineManager.search.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
});
|
||||
|
||||
@ -620,7 +624,7 @@ describe('TimelineManager', () => {
|
||||
const b = previousMonth!.getFirstAsset();
|
||||
const loadmonthSpy = vi.spyOn(month!.loader!, 'execute');
|
||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
const previous = await timelineManager.search.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
expect(loadmonthSpy).toBeCalledTimes(0);
|
||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||
@ -633,12 +637,12 @@ describe('TimelineManager', () => {
|
||||
|
||||
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(await timelineManager.getLaterAsset(assetThree)).toEqual(assetOne);
|
||||
expect(await timelineManager.search.getLaterAsset(assetThree)).toEqual(assetOne);
|
||||
});
|
||||
|
||||
it('returns null when no more assets', async () => {
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||
expect(await timelineManager.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined();
|
||||
expect(await timelineManager.search.getLaterAsset(timelineManager.segments[0].getFirstAsset())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -670,10 +674,10 @@ describe('TimelineManager', () => {
|
||||
);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
expect((timelineManager.getSegmentForAssetId(assetTwo.id) as TimelineMonth)?.yearMonth.year).toEqual(2024);
|
||||
expect((timelineManager.getSegmentForAssetId(assetTwo.id) as TimelineMonth)?.yearMonth.month).toEqual(2);
|
||||
expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.year).toEqual(2024);
|
||||
expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.month).toEqual(1);
|
||||
expect(getMonthForAssetId(timelineManager, assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(getMonthForAssetId(timelineManager, assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
|
||||
it('ignores removed months', () => {
|
||||
@ -690,8 +694,8 @@ describe('TimelineManager', () => {
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.year).toEqual(2024);
|
||||
expect((timelineManager.getSegmentForAssetId(assetOne.id) as TimelineMonth)?.yearMonth.month).toEqual(1);
|
||||
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(getMonthForAssetId(timelineManager, assetOne.id)?.yearMonth.month).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -740,7 +744,7 @@ describe('TimelineManager', () => {
|
||||
expect(assetCount).toBe(14);
|
||||
const discoveredAssets: Set<string> = new Set();
|
||||
for (let idx = 0; idx < assetCount; idx++) {
|
||||
const asset = await timelineManager.getRandomAsset(idx);
|
||||
const asset = await timelineManager.search.getRandomAsset(idx);
|
||||
expect(asset).toBeDefined();
|
||||
const id = asset!.id;
|
||||
expect(discoveredAssets.has(id)).toBeFalsy();
|
||||
|
||||
@ -6,7 +6,6 @@ import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svel
|
||||
import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte';
|
||||
import { TimelineWebsocketExtension } from '$lib/managers/timeline-manager/TimelineWebsocketExtension';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
Direction,
|
||||
ScrubberMonth,
|
||||
@ -16,19 +15,15 @@ import type {
|
||||
} from '$lib/managers/timeline-manager/types';
|
||||
import { isMismatched, setDifferenceInPlace, updateObject } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import {
|
||||
getSegmentIdentifier,
|
||||
toTimelineAsset,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
||||
import { getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, getTimeBuckets } from '@immich/sdk';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export class TimelineManager extends VirtualScrollManager {
|
||||
override bottomSectionHeight = $state(60);
|
||||
readonly search = new TimelineSearchExtension(this);
|
||||
readonly websocket = new TimelineWebsocketExtension(this);
|
||||
readonly albumAssets: Set<string> = new SvelteSet();
|
||||
readonly limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
||||
readonly initTask = new CancellableTask(
|
||||
@ -37,10 +32,10 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
this.websocket.connect();
|
||||
},
|
||||
() => {
|
||||
this.disconnect();
|
||||
this.websocket.disconnect();
|
||||
this.isInitialized = false;
|
||||
},
|
||||
() => void 0,
|
||||
@ -50,7 +45,6 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
#websocketSupport: TimelineWebsocketExtension | undefined;
|
||||
#options: TimelineManagerOptions = {};
|
||||
#scrollableElement: HTMLElement | undefined = $state();
|
||||
|
||||
@ -88,7 +82,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
}
|
||||
|
||||
public override destroy() {
|
||||
this.disconnect();
|
||||
this.websocket.disconnect();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
@ -127,102 +121,12 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
this.onUpdateViewport(oldViewport, viewport);
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.#websocketSupport) {
|
||||
throw new Error('TimelineManager already connected');
|
||||
}
|
||||
this.#websocketSupport = new TimelineWebsocketExtension(this);
|
||||
this.#websocketSupport.connectWebsocketEvents();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (!this.#websocketSupport) {
|
||||
return;
|
||||
}
|
||||
this.#websocketSupport.disconnectWebsocketEvents();
|
||||
this.#websocketSupport = undefined;
|
||||
}
|
||||
|
||||
upsertAssets(assets: TimelineAsset[]) {
|
||||
const notExcluded = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.#updateAssets(notExcluded);
|
||||
this.addAssetsToSegments(notUpdated);
|
||||
}
|
||||
|
||||
async findMonthForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
|
||||
let { month } = this.search.findMonthForAsset(id) ?? {};
|
||||
if (month) {
|
||||
return month;
|
||||
}
|
||||
|
||||
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = toTimelineAsset(response);
|
||||
if (!asset || this.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
month = await this.#loadMonthAtTime(asset.localDateTime, { cancelable: false });
|
||||
if (month?.findAssetById({ id })) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.loadSegment(getSegmentIdentifier(yearMonth), options);
|
||||
return this.search.findMonthByDate(yearMonth);
|
||||
}
|
||||
|
||||
getMonthByAssetId(assetId: string) {
|
||||
const monthInfo = this.search.findMonthForAsset(assetId);
|
||||
return monthInfo?.month;
|
||||
}
|
||||
|
||||
// note: the `index` input is expected to be in the range [0, assetCount). This
|
||||
// value can be passed to make the method deterministic, which is mainly useful
|
||||
// for testing.
|
||||
async getRandomAsset(index?: number): Promise<TimelineAsset | undefined> {
|
||||
const randomAssetIndex = index ?? Math.floor(Math.random() * this.assetCount);
|
||||
|
||||
let accumulatedCount = 0;
|
||||
|
||||
let randomMonth: TimelineMonth | undefined = undefined;
|
||||
for (const month of this.segments) {
|
||||
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
||||
randomMonth = month;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedCount += month.assetsCount;
|
||||
}
|
||||
if (!randomMonth) {
|
||||
return;
|
||||
}
|
||||
await this.loadSegment(getSegmentIdentifier(randomMonth.yearMonth), { cancelable: false });
|
||||
|
||||
let randomDay: TimelineDay | undefined = undefined;
|
||||
for (const day of randomMonth.days) {
|
||||
if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) {
|
||||
randomDay = day;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedCount += day.viewerAssets.length;
|
||||
}
|
||||
if (!randomDay) {
|
||||
return;
|
||||
}
|
||||
|
||||
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given operation against every passed in asset id.
|
||||
*
|
||||
@ -347,42 +251,6 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return this.segments[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.search.getAssetWithOffset(assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.search.getAssetWithOffset(assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
||||
let month = this.search.findMonthForDate(dateTime);
|
||||
if (!month) {
|
||||
month = this.search.findClosestGroupForDate(this.segments, dateTime);
|
||||
if (!month) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
|
||||
const asset = month.findClosest(dateTime);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
for await (const asset of this.assetsIterator({ startMonth: month })) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||
return this.search.retrieveRange(start, end);
|
||||
}
|
||||
|
||||
async *assetsIterator(options?: {
|
||||
startMonth?: TimelineMonth;
|
||||
startDay?: TimelineDay;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte';
|
||||
import { findClosestMonthToDate } from '$lib/utils/timeline-util';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function createMockMonthGroup(year: number, month: number): TimelineMonth {
|
||||
@ -9,37 +8,33 @@ function createMockMonthGroup(year: number, month: number): TimelineMonth {
|
||||
} as TimelineMonth;
|
||||
}
|
||||
|
||||
describe('findClosestGroupForDate', () => {
|
||||
let search: TimelineSearchExtension;
|
||||
beforeEach(() => {
|
||||
search = new TimelineSearchExtension(new TimelineManager());
|
||||
});
|
||||
describe('findClosestMonthToDate', () => {
|
||||
it('should return undefined for empty months array', () => {
|
||||
const result = search.findClosestGroupForDate([], { year: 2024, month: 1 });
|
||||
const result = findClosestMonthToDate([], { year: 2024, month: 1 });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the only month when there is only one month', () => {
|
||||
const months = [createMockMonthGroup(2024, 6)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2025, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should return exact match when available', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2024, month: 6 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 6 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should find closest month when target is between two months', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2024, month: 4 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 4 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle year boundaries correctly (2023-12 vs 2024-01)', () => {
|
||||
const months = [createMockMonthGroup(2023, 12), createMockMonthGroup(2024, 2)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 1 });
|
||||
// 2024-01 is 1 month from 2023-12 and 1 month from 2024-02
|
||||
// Should return first encountered with min distance (2023-12)
|
||||
expect(result?.yearMonth).toEqual({ year: 2023, month: 12 });
|
||||
@ -47,33 +42,33 @@ describe('findClosestGroupForDate', () => {
|
||||
|
||||
it('should correctly calculate distance across years', () => {
|
||||
const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 6)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2023, month: 6 });
|
||||
const result = findClosestMonthToDate(months, { year: 2023, month: 6 });
|
||||
// Both are exactly 12 months away, should return first encountered
|
||||
expect(result?.yearMonth).toEqual({ year: 2022, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle target before all months', () => {
|
||||
const months = [createMockMonthGroup(2024, 6), createMockMonthGroup(2024, 12)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle target after all months', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 6)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2025, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle multiple years correctly', () => {
|
||||
const months = [createMockMonthGroup(2020, 1), createMockMonthGroup(2022, 1), createMockMonthGroup(2024, 1)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2023, month: 1 });
|
||||
const result = findClosestMonthToDate(months, { year: 2023, month: 1 });
|
||||
// 2023-01 is 12 months from 2022-01 and 12 months from 2024-01
|
||||
expect(result?.yearMonth).toEqual({ year: 2022, month: 1 });
|
||||
});
|
||||
|
||||
it('should prefer closer month when one is clearly closer', () => {
|
||||
const months = [createMockMonthGroup(2024, 1), createMockMonthGroup(2024, 10)];
|
||||
const result = search.findClosestGroupForDate(months, { year: 2024, month: 11 });
|
||||
const result = findClosestMonthToDate(months, { year: 2024, month: 11 });
|
||||
// 2024-11 is 1 month from 2024-10 and 10 months from 2024-01
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 10 });
|
||||
});
|
||||
|
||||
@ -1,16 +1,70 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
findClosestMonthToDate,
|
||||
getSegmentIdentifier,
|
||||
plainDateTimeCompare,
|
||||
toTimelineAsset,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { AssetOrder, getAssetInfo } from '@immich/sdk';
|
||||
|
||||
export class TimelineSearchExtension {
|
||||
#timelineManager: TimelineManager;
|
||||
constructor(timelineManager: TimelineManager) {
|
||||
this.#timelineManager = timelineManager;
|
||||
}
|
||||
async getAssetWithOffset(
|
||||
|
||||
async getLaterAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await this.#getAssetWithOffset(assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getMonthForAsset(id: string) {
|
||||
if (!this.#timelineManager.isInitialized) {
|
||||
await this.#timelineManager.initTask.waitUntilCompletion();
|
||||
}
|
||||
|
||||
let { month } = this.findMonthForAsset(id) ?? {};
|
||||
if (month) {
|
||||
return month;
|
||||
}
|
||||
|
||||
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = toTimelineAsset(response);
|
||||
if (!asset || this.#timelineManager.isExcluded(asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
month = await this.#loadMonthAtTime(asset.localDateTime, { cancelable: false });
|
||||
if (month?.findAssetById({ id })) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.#timelineManager.loadSegment(getSegmentIdentifier(yearMonth), options);
|
||||
return this.findMonthByDate(yearMonth);
|
||||
}
|
||||
|
||||
async #getAssetWithOffset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
@ -22,16 +76,16 @@ export class TimelineSearchExtension {
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return this.getAssetByAssetOffset(asset, month, direction);
|
||||
return this.#getAssetByAssetOffset(asset, month, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return this.getAssetByDayOffset(asset, month, direction);
|
||||
return this.#getAssetByDayOffset(asset, month, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return this.getAssetByMonthOffset(month, direction);
|
||||
return this.#getAssetByMonthOffset(month, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return this.getAssetByYearOffset(month, direction);
|
||||
return this.#getAssetByYearOffset(month, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -51,7 +105,45 @@ export class TimelineSearchExtension {
|
||||
);
|
||||
}
|
||||
|
||||
async getAssetByAssetOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) {
|
||||
// note: the `index` input is expected to be in the range [0, assetCount). This
|
||||
// value can be passed to make the method deterministic, which is mainly useful
|
||||
// for testing.
|
||||
async getRandomAsset(index?: number): Promise<TimelineAsset | undefined> {
|
||||
const randomAssetIndex = index ?? Math.floor(Math.random() * this.#timelineManager.assetCount);
|
||||
|
||||
let accumulatedCount = 0;
|
||||
|
||||
let randomMonth: TimelineMonth | undefined = undefined;
|
||||
for (const month of this.#timelineManager.segments) {
|
||||
if (randomAssetIndex < accumulatedCount + month.assetsCount) {
|
||||
randomMonth = month;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedCount += month.assetsCount;
|
||||
}
|
||||
if (!randomMonth) {
|
||||
return;
|
||||
}
|
||||
await this.#timelineManager.loadSegment(getSegmentIdentifier(randomMonth.yearMonth), { cancelable: false });
|
||||
|
||||
let randomDay: TimelineDay | undefined = undefined;
|
||||
for (const day of randomMonth.days) {
|
||||
if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) {
|
||||
randomDay = day;
|
||||
break;
|
||||
}
|
||||
|
||||
accumulatedCount += day.viewerAssets.length;
|
||||
}
|
||||
if (!randomDay) {
|
||||
return;
|
||||
}
|
||||
|
||||
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
|
||||
}
|
||||
|
||||
async #getAssetByAssetOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) {
|
||||
const day = month.findDayForAsset(asset);
|
||||
for await (const targetAsset of this.#timelineManager.assetsIterator({
|
||||
startMonth: month,
|
||||
@ -65,7 +157,7 @@ export class TimelineSearchExtension {
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetByDayOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) {
|
||||
async #getAssetByDayOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) {
|
||||
const day = month.findDayForAsset(asset);
|
||||
for await (const targetAsset of this.#timelineManager.assetsIterator({
|
||||
startMonth: month,
|
||||
@ -79,7 +171,7 @@ export class TimelineSearchExtension {
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetByMonthOffset(month: TimelineMonth, direction: Direction) {
|
||||
async #getAssetByMonthOffset(month: TimelineMonth, direction: Direction) {
|
||||
for (const targetMonth of this.#timelineManager.monthIterator({ startMonth: month, direction })) {
|
||||
if (targetMonth.yearMonth.month !== month.yearMonth.month) {
|
||||
const { value, done } = await this.#timelineManager
|
||||
@ -90,7 +182,7 @@ export class TimelineSearchExtension {
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetByYearOffset(month: TimelineMonth, direction: Direction) {
|
||||
async #getAssetByYearOffset(month: TimelineMonth, direction: Direction) {
|
||||
for (const targetMonth of this.#timelineManager.monthIterator({ startMonth: month, direction })) {
|
||||
if (targetMonth.yearMonth.year !== month.yearMonth.year) {
|
||||
const { value, done } = await this.#timelineManager
|
||||
@ -140,22 +232,21 @@ export class TimelineSearchExtension {
|
||||
}
|
||||
}
|
||||
|
||||
findClosestGroupForDate(months: TimelineMonth[], targetYearMonth: TimelineYearMonth) {
|
||||
const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month });
|
||||
|
||||
let closestMonth: TimelineMonth | undefined;
|
||||
let minDifference = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const month of months) {
|
||||
const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month });
|
||||
const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months);
|
||||
|
||||
if (totalDiff < minDifference) {
|
||||
minDifference = totalDiff;
|
||||
closestMonth = month;
|
||||
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
||||
let month = this.findMonthForDate(dateTime);
|
||||
if (!month) {
|
||||
month = findClosestMonthToDate(this.#timelineManager.segments, dateTime);
|
||||
if (!month) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return closestMonth;
|
||||
await this.#timelineManager.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
|
||||
const asset = month.findClosest(dateTime);
|
||||
if (asset) {
|
||||
return asset;
|
||||
}
|
||||
for await (const asset of this.#timelineManager.assetsIterator({ startMonth: month })) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,12 +23,24 @@ export class TimelineWebsocketExtension {
|
||||
|
||||
#pendingChanges: PendingChange[] = [];
|
||||
#unsubscribers: Unsubscriber[] = [];
|
||||
#connected = false;
|
||||
|
||||
constructor(timeineManager: TimelineManager) {
|
||||
this.#timelineManager = timeineManager;
|
||||
}
|
||||
|
||||
connectWebsocketEvents() {
|
||||
connect() {
|
||||
if (this.#connected) {
|
||||
throw new Error('TimelineManager already connected');
|
||||
}
|
||||
this.#connectWebsocketEvents();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#disconnectWebsocketEvents();
|
||||
}
|
||||
|
||||
#connectWebsocketEvents() {
|
||||
this.#unsubscribers.push(
|
||||
websocketEvents.on('on_upload_success', (asset) =>
|
||||
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
|
||||
@ -41,7 +53,7 @@ export class TimelineWebsocketExtension {
|
||||
);
|
||||
}
|
||||
|
||||
disconnectWebsocketEvents() {
|
||||
#disconnectWebsocketEvents() {
|
||||
for (const unsubscribe of this.#unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
|
||||
// Get the local date/time components from the selected string using neutral timezone
|
||||
const dateTime = toDatetime(selectedDate, selectedOption) as DateTime<true>;
|
||||
const asset = await timelineManager.getClosestAssetToDate(dateTime.toObject());
|
||||
const asset = await timelineManager.search.getClosestAssetToDate(dateTime.toObject());
|
||||
onClose(asset);
|
||||
};
|
||||
|
||||
|
||||
@ -223,31 +223,6 @@ export const plainDateTimeCompare = (ascending: boolean, a: TimelineDateTime, b:
|
||||
return aDateTime.millisecond - bDateTime.millisecond;
|
||||
};
|
||||
|
||||
export function setDifference<T>(setA: Set<T>, setB: Set<T>): Set<T> {
|
||||
// Check if native Set.prototype.difference is available (ES2025)
|
||||
const setWithDifference = setA as unknown as Set<T> & { difference?: (other: Set<T>) => Set<T> };
|
||||
if (setWithDifference.difference && typeof setWithDifference.difference === 'function') {
|
||||
return setWithDifference.difference(setB);
|
||||
}
|
||||
const result = new Set<T>();
|
||||
for (const value of setA) {
|
||||
if (!setB.has(value)) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all elements of setB from setA in-place (mutates setA).
|
||||
*/
|
||||
export function setDifferenceInPlace<T>(setA: Set<T>, setB: Set<T>): Set<T> {
|
||||
for (const value of setB) {
|
||||
setA.delete(value);
|
||||
}
|
||||
return setA;
|
||||
}
|
||||
|
||||
export const formatGroupTitleFull = (_date: DateTime): string => {
|
||||
if (!_date.isValid) {
|
||||
return _date.toString();
|
||||
@ -273,3 +248,22 @@ export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDate
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const findClosestMonthToDate = (months: TimelineMonth[], targetYearMonth: TimelineYearMonth) => {
|
||||
const targetDate = DateTime.fromObject({ year: targetYearMonth.year, month: targetYearMonth.month });
|
||||
|
||||
let closestMonth: TimelineMonth | undefined;
|
||||
let minDifference = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const month of months) {
|
||||
const monthDate = DateTime.fromObject({ year: month.yearMonth.year, month: month.yearMonth.month });
|
||||
const totalDiff = Math.abs(monthDate.diff(targetDate, 'months').months);
|
||||
|
||||
if (totalDiff < minDifference) {
|
||||
minDifference = totalDiff;
|
||||
closestMonth = month;
|
||||
}
|
||||
}
|
||||
|
||||
return closestMonth;
|
||||
};
|
||||
|
||||
@ -140,7 +140,7 @@
|
||||
const handleStartSlideshow = async () => {
|
||||
const asset =
|
||||
$slideshowNavigation === SlideshowNavigation.Shuffle
|
||||
? await timelineManager.getRandomAsset()
|
||||
? await timelineManager.search.getRandomAsset()
|
||||
: timelineManager.getFirstAsset();
|
||||
if (asset) {
|
||||
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user