mirror of
https://github.com/immich-app/immich.git
synced 2026-03-16 15:18:38 -07:00
Compare commits
4 Commits
refactor/v
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a59f8c68 | ||
|
|
de84e46f62 | ||
|
|
6d3dda7e2e | ||
|
|
4ca76b24e9 |
@@ -91,7 +91,7 @@
|
||||
scrubberWidth = usingMobileDevice ? MOBILE_WIDTH : DESKTOP_WIDTH;
|
||||
});
|
||||
|
||||
const toScrollFromMonthPercentage = (
|
||||
const toScrollFromMonthGroupPercentage = (
|
||||
scrubberMonth: ViewportTopMonth,
|
||||
scrubberMonthPercent: number,
|
||||
scrubOverallPercent: number,
|
||||
@@ -124,7 +124,7 @@
|
||||
}
|
||||
};
|
||||
const scrollY = $derived(
|
||||
toScrollFromMonthPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
||||
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
||||
);
|
||||
const timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight);
|
||||
const relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||
@@ -280,12 +280,12 @@
|
||||
const boundingClientRect = bestElement.boundingClientRect;
|
||||
const sy = boundingClientRect.y;
|
||||
const relativeY = y - sy;
|
||||
const monthPercentY = relativeY / boundingClientRect.height;
|
||||
const monthGroupPercentY = relativeY / boundingClientRect.height;
|
||||
return {
|
||||
isOnPaddingTop: false,
|
||||
isOnPaddingBottom: false,
|
||||
segment,
|
||||
monthPercentY,
|
||||
monthGroupPercentY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
isOnPaddingTop,
|
||||
isOnPaddingBottom,
|
||||
segment: undefined,
|
||||
monthPercentY: 0,
|
||||
monthGroupPercentY: 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -327,7 +327,7 @@
|
||||
const upper = rect?.height - (PADDING_TOP + PADDING_BOTTOM);
|
||||
hoverY = clamp(clientY - rect?.top - PADDING_TOP, lower, upper);
|
||||
const x = rect!.left + rect!.width / 2;
|
||||
const { segment, monthPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
|
||||
const { segment, monthGroupPercentY, isOnPaddingTop, isOnPaddingBottom } = getActive(x, clientY);
|
||||
activeSegment = segment;
|
||||
isHoverOnPaddingTop = isOnPaddingTop;
|
||||
isHoverOnPaddingBottom = isOnPaddingBottom;
|
||||
@@ -335,7 +335,7 @@
|
||||
const scrubData = {
|
||||
scrubberMonth: segmentDate,
|
||||
overallScrollPercent: toTimelineY(hoverY),
|
||||
scrubberMonthScrollPercent: monthPercentY,
|
||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
||||
};
|
||||
if (wasDragging === false && isDragging) {
|
||||
void startScrub?.(scrubData);
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
@@ -62,7 +62,7 @@
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
dayTitle: string,
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => void;
|
||||
@@ -130,10 +130,8 @@
|
||||
timelineManager.scrollableElement = scrollableElement;
|
||||
});
|
||||
|
||||
const getAssetPosition = (assetId: string, month: TimelineMonth) => month.findAssetAbsolutePosition(assetId);
|
||||
|
||||
const scrollToAssetPosition = (assetId: string, month: TimelineMonth) => {
|
||||
const position = getAssetPosition(assetId, month);
|
||||
const position = month.findAssetAbsolutePosition(assetId);
|
||||
|
||||
if (!position) {
|
||||
return;
|
||||
@@ -176,7 +174,7 @@
|
||||
};
|
||||
|
||||
const scrollAndLoadAsset = async (assetId: string) => {
|
||||
const month = await timelineManager.findMonthForAsset(assetId);
|
||||
const month = await timelineManager.search.getMonthForAsset(assetId);
|
||||
if (!month) {
|
||||
return false;
|
||||
}
|
||||
@@ -185,7 +183,7 @@
|
||||
};
|
||||
|
||||
const scrollToAsset = (asset: TimelineAsset) => {
|
||||
const month = timelineManager.getMonthByAssetId(asset.id);
|
||||
const month = timelineManager.search.findMonthForAsset(asset.id)?.month;
|
||||
if (!month) {
|
||||
return false;
|
||||
}
|
||||
@@ -442,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;
|
||||
@@ -508,7 +504,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
|
||||
const assets = assetsSnapshot(await timelineManager.search.retrieveRange(startAsset, endAsset));
|
||||
assetInteraction.setAssetSelectionCandidates(assets);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.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 { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
@@ -38,7 +38,7 @@
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
dayTitle: string,
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => void;
|
||||
@@ -49,7 +49,7 @@
|
||||
singleSelect,
|
||||
withStacked,
|
||||
showArchiveIcon,
|
||||
month = $bindable(),
|
||||
month: monthGroup = $bindable(),
|
||||
assetInteraction,
|
||||
timelineManager,
|
||||
customLayout,
|
||||
@@ -61,18 +61,20 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isMouseOverGroup = $state(false);
|
||||
let hoveredDay = $state();
|
||||
let hoveredDayGroup = $state();
|
||||
|
||||
const transitionDuration = $derived.by(() => (month.scrollManager.suspendTransitions && !$isUploading ? 0 : 150));
|
||||
const transitionDuration = $derived.by(() =>
|
||||
monthGroup.scrollManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||
);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
dayTitle: string,
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, dayTitle);
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
@@ -83,19 +85,21 @@
|
||||
const assetSelectHandler = (
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
assetsInDay: TimelineAsset[],
|
||||
dayTitle: string,
|
||||
assetsInDayGroup: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
) => {
|
||||
onSelectAssets(asset);
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
let selectedAssetsInDayCount = assetsInDay.filter((asset) => assetInteraction.hasSelectedAsset(asset.id)).length;
|
||||
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
||||
assetInteraction.hasSelectedAsset(asset.id),
|
||||
).length;
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
if (selectedAssetsInDayCount == assetsInDay.length) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dayTitle);
|
||||
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
||||
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dayTitle);
|
||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||
}
|
||||
|
||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||
@@ -105,9 +109,9 @@
|
||||
}
|
||||
};
|
||||
|
||||
const assetMouseEventHandler = (dayTitle: string, asset: TimelineAsset | null) => {
|
||||
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
|
||||
// Show multi select icon on hover on date group
|
||||
hoveredDay = dayTitle;
|
||||
hoveredDayGroup = groupTitle;
|
||||
|
||||
if (assetInteraction.selectionActive) {
|
||||
onSelectAssetCandidates(asset);
|
||||
@@ -118,7 +122,7 @@
|
||||
return intersectable.filter((int) => int.intersecting);
|
||||
}
|
||||
|
||||
const getDayFullDate = (day: TimelineDay): string => {
|
||||
const getDayGroupFullDate = (day: TimelineDay): string => {
|
||||
const { month, year } = day.month.yearMonth;
|
||||
const date = fromTimelinePlainDate({
|
||||
year,
|
||||
@@ -129,14 +133,14 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(month.days) as day, groupIndex (day.day)}
|
||||
{#each filterIntersecting(monthGroup.days) as day, groupIndex (day.day)}
|
||||
{@const absoluteWidth = day.left}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class={[
|
||||
{ 'transition-all': !month.scrollManager.suspendTransitions },
|
||||
!month.scrollManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||
{ 'transition-all': !monthGroup.scrollManager.suspendTransitions },
|
||||
!monthGroup.scrollManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||
]}
|
||||
data-group
|
||||
style:position="absolute"
|
||||
@@ -158,7 +162,7 @@
|
||||
{#if !singleSelect}
|
||||
<div
|
||||
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
|
||||
class:w-8={(hoveredDay === day.dayTitle && isMouseOverGroup) ||
|
||||
class:w-8={(hoveredDayGroup === day.dayTitle && isMouseOverGroup) ||
|
||||
assetInteraction.selectedGroup.has(day.dayTitle)}
|
||||
onclick={() => handleSelectGroup(day.dayTitle, assetsSnapshot(day.getAssets()))}
|
||||
onkeydown={() => handleSelectGroup(day.dayTitle, assetsSnapshot(day.getAssets()))}
|
||||
@@ -171,63 +175,55 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize" title={getDayFullDate(day)}>
|
||||
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(day)}>
|
||||
{day.dayTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div data-image-grid class="relative overflow-clip" style:height={day.height + 'px'} style:width={day.width + 'px'}>
|
||||
<div
|
||||
data-image-grid
|
||||
class="relative overflow-clip"
|
||||
style:height={day.height + 'px'}
|
||||
style:width={day.width + 'px'}
|
||||
>
|
||||
{#each filterIntersecting(day.viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
{#each filterIntersecting(day.viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
|
||||
<!-- {#if viewerAsset.intersecting} -->
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
animate:flip={{ duration: transitionDuration }}
|
||||
>
|
||||
<Thumbnail
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, day, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, day.getAssets(), day.dayTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||
day.month.scrollManager.albumAssets.has(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
disabled={day.month.scrollManager.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{#if customLayout}
|
||||
{@render customLayout(asset)}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- {/if} -->
|
||||
{/each}
|
||||
</div>
|
||||
<!-- {#if viewerAsset.intersecting} -->
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
animate:flip={{ duration: transitionDuration }}
|
||||
>
|
||||
<Thumbnail
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, day, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, day.getAssets(), day.dayTitle, asset);
|
||||
}
|
||||
}}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, day.getAssets(), day.dayTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(day.dayTitle, assetSnapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) || day.month.scrollManager.albumAssets.has(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
disabled={day.month.scrollManager.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
{#if customLayout}
|
||||
{@render customLayout(asset)}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- {/if} -->
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TimelineAsset, UpdateGeometryOptions } from '$lib/managers/timeline-manager/types';
|
||||
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type {
|
||||
AssetOperation,
|
||||
VirtualScrollManager,
|
||||
VisibleWindow,
|
||||
} from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
@@ -196,6 +197,36 @@ export abstract class ScrollSegment {
|
||||
}
|
||||
this.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting });
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const unprocessedIds = new Set<string>(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const processedIds = new Set<string>();
|
||||
const moveAssets: TimelineAsset[] = [];
|
||||
let changedGeometry = false;
|
||||
for (const assetId of unprocessedIds) {
|
||||
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
|
||||
if (index === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const asset = this.viewerAssets[index].asset!;
|
||||
const opResult = operation(asset);
|
||||
let remove = false;
|
||||
if (opResult) {
|
||||
remove = (opResult as { remove: boolean }).remove ?? false;
|
||||
}
|
||||
|
||||
unprocessedIds.delete(assetId);
|
||||
processedIds.add(assetId);
|
||||
if (remove || this.scrollManager.isExcluded(asset)) {
|
||||
this.viewerAssets.splice(index, 1);
|
||||
changedGeometry = true;
|
||||
}
|
||||
}
|
||||
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import type { ScrollSegment } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { setDifferenceInPlace } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { ScrollSegment, SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import { updateObject } from '$lib/managers/VirtualScrollManager/utils.svelte';
|
||||
|
||||
import { clamp, debounce } from 'lodash-es';
|
||||
|
||||
export type VisibleWindow = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
export type AssetOperation = (asset: TimelineAsset) => unknown;
|
||||
|
||||
type LayoutOptions = {
|
||||
headerHeight: number;
|
||||
rowHeight: number;
|
||||
gap: number;
|
||||
};
|
||||
|
||||
export type VisibleWindow = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
type ViewportTopSegmentIntersection = {
|
||||
segment: ScrollSegment | null;
|
||||
// Where viewport top intersects segment (0 = segment top, 1 = segment bottom)
|
||||
@@ -269,13 +271,120 @@ export abstract class VirtualScrollManager {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
getSegmentForAssetId(assetId: string) {
|
||||
for (const segment of this.segments) {
|
||||
const asset = segment.assets.find((asset) => asset.id === assetId);
|
||||
if (asset) {
|
||||
return segment;
|
||||
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
|
||||
const { cancelable = true } = options ?? {};
|
||||
const segment = this.segments.find((segment) => identifier.matches(segment));
|
||||
if (!segment || segment.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await segment.load(cancelable);
|
||||
}
|
||||
|
||||
upsertAssets(assets: TimelineAsset[]) {
|
||||
const notExcluded = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.#updateAssets(notExcluded);
|
||||
this.addAssetsToSegments(notUpdated);
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
this.#runAssetOperation(ids, () => ({ remove: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the given operation against every passed in asset id.
|
||||
*
|
||||
* @returns An object with the changed ids, unprocessed ids, and if this resulted
|
||||
* in changes of the timeline geometry.
|
||||
*/
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
return this.#runAssetOperation(ids, operation);
|
||||
}
|
||||
|
||||
isExcluded(_: TimelineAsset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected addAssetsToSegments(assets: TimelineAsset[]) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const context = this.createUpsertContext();
|
||||
const monthCount = this.segments.length;
|
||||
for (const asset of assets) {
|
||||
this.upsertAssetIntoSegment(asset, context);
|
||||
}
|
||||
if (this.segments.length !== monthCount) {
|
||||
this.postCreateSegments();
|
||||
}
|
||||
this.postUpsert(context);
|
||||
this.updateIntersections();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected upsertAssetIntoSegment(asset: TimelineAsset, context: unknown): void {}
|
||||
protected createUpsertContext(): unknown {
|
||||
return undefined;
|
||||
}
|
||||
protected postUpsert(_: unknown): void {}
|
||||
protected postCreateSegments(): void {}
|
||||
|
||||
/**
|
||||
* Looks up the specified asset from the TimelineAsset using its id, and then updates the
|
||||
* existing object to match the rest of the TimelineAsset parameter.
|
||||
|
||||
* @returns list of assets that were updated (not found)
|
||||
*/
|
||||
#updateAssets(updatedAssets: TimelineAsset[]) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const lookup = new Map<string, TimelineAsset>();
|
||||
const ids = [];
|
||||
for (const asset of updatedAssets) {
|
||||
ids.push(asset.id);
|
||||
lookup.set(asset.id, asset);
|
||||
}
|
||||
const { unprocessedIds } = this.#runAssetOperation(ids, (asset) => updateObject(asset, lookup.get(asset.id)));
|
||||
const result: TimelineAsset[] = [];
|
||||
for (const id of unprocessedIds) {
|
||||
result.push(lookup.get(id)!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
#runAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const changedMonths = new Set<ScrollSegment>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsToProcess = new Set(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: TimelineAsset[] = [];
|
||||
for (const month of this.segments) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(...moveAssets);
|
||||
}
|
||||
setDifferenceInPlace(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedMonths.add(month);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
this.addAssetsToSegments(combinedMoveAssets);
|
||||
}
|
||||
const changedGeometry = changedMonths.size > 0;
|
||||
for (const month of changedMonths) {
|
||||
month.updateGeometry({ invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
this.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
web/src/lib/managers/VirtualScrollManager/utils.svelte.ts
Normal file
52
web/src/lib/managers/VirtualScrollManager/utils.svelte.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { onCreateDay } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { onCreateTimelineDay } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
|
||||
import type { AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
@@ -12,7 +12,6 @@ export class TimelineDay {
|
||||
readonly index: number;
|
||||
readonly dayTitle: string;
|
||||
readonly dayTitleFull: string;
|
||||
|
||||
readonly day: number;
|
||||
viewerAssets: ViewerAsset[] = $state([]);
|
||||
|
||||
@@ -32,9 +31,8 @@ export class TimelineDay {
|
||||
this.day = day;
|
||||
this.dayTitle = groupTitle;
|
||||
this.dayTitleFull = groupTitleFull;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
onCreateDay(this);
|
||||
onCreateTimelineDay(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,15 +105,6 @@ export class TimelineDay {
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
if (ids.size === 0) {
|
||||
return {
|
||||
moveAssets: [] as TimelineAsset[],
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
processedIds: new Set<string>(),
|
||||
unprocessedIds: ids,
|
||||
changedGeometry: false,
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const unprocessedIds = new Set<string>(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
|
||||
import { setDifference } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { type TimelineDate } from '$lib/utils/timeline-util';
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
|
||||
export class GroupInsertionCache {
|
||||
@@ -18,14 +19,14 @@ export class GroupInsertionCache {
|
||||
return this.#lookupCache[year]?.[month]?.[day];
|
||||
}
|
||||
|
||||
setDay(day: TimelineDay, { year, month, day: dayNumber }: TimelineDate) {
|
||||
setDay(day: TimelineDay, { year, month, day: dayNum }: TimelineDate) {
|
||||
if (!this.#lookupCache[year]) {
|
||||
this.#lookupCache[year] = {};
|
||||
}
|
||||
if (!this.#lookupCache[year][month]) {
|
||||
this.#lookupCache[year][month] = {};
|
||||
}
|
||||
this.#lookupCache[year][month][dayNumber] = day;
|
||||
this.#lookupCache[year][month][dayNum] = day;
|
||||
}
|
||||
|
||||
get existingDays() {
|
||||
@@ -1,13 +1,12 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { getMonthByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { setTestHooks } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { setTestHooks } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||
import { tick } from 'svelte';
|
||||
import type { MockInstance } from 'vitest';
|
||||
@@ -20,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,
|
||||
@@ -134,10 +137,10 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('loads a month', async () => {
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('ignores invalid months', async () => {
|
||||
@@ -146,13 +149,13 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('cancels month loading', async () => {
|
||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 })!;
|
||||
void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||
month?.cancel();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(3);
|
||||
});
|
||||
|
||||
it('prevents loading months multiple times', async () => {
|
||||
@@ -167,7 +170,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('allows loading a canceled month', async () => {
|
||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 })!;
|
||||
const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
|
||||
month.cancel();
|
||||
@@ -244,7 +247,7 @@ describe('TimelineManager', () => {
|
||||
);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 });
|
||||
expect(month).not.toBeNull();
|
||||
expect(month?.assets.length).toEqual(3);
|
||||
expect(month?.assets[0].id).toEqual(assetOne.id);
|
||||
@@ -322,19 +325,19 @@ describe('TimelineManager', () => {
|
||||
sortDaysFn: MockInstance;
|
||||
};
|
||||
|
||||
const days = new Map<TimelineDay, DayMocks>();
|
||||
const dayGroups = new Map<TimelineDay, DayMocks>();
|
||||
const months = new Map<TimelineMonth, MonthMocks>();
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
setTestHooks({
|
||||
onCreateDay: (day: TimelineDay) => {
|
||||
days.set(day, {
|
||||
onCreateTimelineDay: (day: TimelineDay) => {
|
||||
dayGroups.set(day, {
|
||||
layoutFn: vi.spyOn(day, 'layout'),
|
||||
sortAssetsFn: vi.spyOn(day, 'sortAssets'),
|
||||
});
|
||||
},
|
||||
onCreateMonth: (month: TimelineMonth) => {
|
||||
onCreateTimelineMonth: (month: TimelineMonth) => {
|
||||
months.set(month, {
|
||||
sortDaysFn: vi.spyOn(month, 'sortDays'),
|
||||
});
|
||||
@@ -394,7 +397,7 @@ describe('TimelineManager', () => {
|
||||
timelineManager.updateAssetOperation([month1day2asset1.id], (asset) => {
|
||||
asset.localDateTime.day = asset.localDateTime.day + 1;
|
||||
});
|
||||
for (const [day, mocks] of days) {
|
||||
for (const [day, mocks] of dayGroups) {
|
||||
if (day.day === 15 && day.month.yearMonth.month === 1) {
|
||||
// source - should be layout once
|
||||
expect.soft(mocks.layoutFn).toBeCalledTimes(1);
|
||||
@@ -455,78 +458,15 @@ describe('TimelineManager', () => {
|
||||
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(timelineManager.segments.length).toEqual(1);
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(1);
|
||||
|
||||
timelineManager.upsertAssets([updatedAsset]);
|
||||
expect(timelineManager.segments.length).toEqual(2);
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
|
||||
await timelineManager.updateOptions({
|
||||
visibility: AssetVisibility.Archive,
|
||||
});
|
||||
const fixture = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
visibility: AssetVisibility.Archive,
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.upsertAssets([fixture]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
|
||||
const updated = Object.freeze({ ...fixture, visibility: AssetVisibility.Timeline });
|
||||
timelineManager.upsertAssets([updated]);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
|
||||
timelineManager.upsertAssets([{ ...fixture, visibility: AssetVisibility.Archive }]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('asset is removed during upsert when TimelineManager if isFavorite changes', async () => {
|
||||
await timelineManager.updateOptions({
|
||||
isFavorite: true,
|
||||
});
|
||||
const fixture = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
isFavorite: true,
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.upsertAssets([fixture]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
|
||||
const updated = Object.freeze({ ...fixture, isFavorite: false });
|
||||
timelineManager.upsertAssets([updated]);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
|
||||
timelineManager.upsertAssets([{ ...fixture, isFavorite: true }]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('asset is removed during upsert when TimelineManager if isTrashed changes', async () => {
|
||||
await timelineManager.updateOptions({
|
||||
isTrashed: true,
|
||||
});
|
||||
const fixture = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
isTrashed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.upsertAssets([fixture]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
|
||||
const updated = Object.freeze({ ...fixture, isTrashed: false });
|
||||
timelineManager.upsertAssets([updated]);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
|
||||
timelineManager.upsertAssets([{ ...fixture, isTrashed: true }]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 1 })?.assets.length).toEqual(0);
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })).not.toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })?.assets.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -650,17 +590,17 @@ 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 () => {
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 1 });
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 1 });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -668,23 +608,23 @@ describe('TimelineManager', () => {
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
||||
|
||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
|
||||
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 2 });
|
||||
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);
|
||||
});
|
||||
|
||||
it('loads previous month', async () => {
|
||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
||||
const month = getMonthByDate(timelineManager, { year: 2024, month: 2 });
|
||||
const previousMonth = getMonthByDate(timelineManager, { year: 2024, month: 3 });
|
||||
const month = timelineManager.search.findMonthByDate({ year: 2024, month: 2 });
|
||||
const previousMonth = timelineManager.search.findMonthByDate({ year: 2024, month: 3 });
|
||||
const a = month!.getFirstAsset();
|
||||
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);
|
||||
@@ -697,16 +637,16 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthIndexByAssetId', () => {
|
||||
describe('getmonthIndexByAssetId', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -717,8 +657,8 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('returns null for invalid months', () => {
|
||||
expect(getMonthByDate(timelineManager, { year: -1, month: -1 })).toBeUndefined();
|
||||
expect(getMonthByDate(timelineManager, { year: 2024, month: 3 })).toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: -1, month: -1 })).toBeUndefined();
|
||||
expect(timelineManager.search.findMonthByDate({ year: 2024, month: 3 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the month index', () => {
|
||||
@@ -734,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', () => {
|
||||
@@ -754,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -804,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();
|
||||
|
||||
@@ -1,68 +1,50 @@
|
||||
import type { SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
|
||||
import {
|
||||
findClosestGroupForDate,
|
||||
findMonthForAsset as findMonthForAssetUtil,
|
||||
findMonthForDate,
|
||||
getAssetWithOffset,
|
||||
getMonthByDate,
|
||||
retrieveRange as retrieveRangeUtil,
|
||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { isMismatched, updateObject } from '$lib/managers/timeline-manager/internal/utils.svelte';
|
||||
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
||||
import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte';
|
||||
import { TimelineWebsocketExtension } from '$lib/managers/timeline-manager/TimelineWebsocketExtension';
|
||||
import type {
|
||||
AssetDescriptor,
|
||||
AssetOperation,
|
||||
Direction,
|
||||
ScrubberMonth,
|
||||
TimelineAsset,
|
||||
TimelineManagerOptions,
|
||||
Viewport,
|
||||
} from '$lib/managers/timeline-manager/types';
|
||||
import { isMismatched } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import {
|
||||
getSegmentIdentifier,
|
||||
setDifferenceInPlace,
|
||||
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);
|
||||
|
||||
segments: TimelineMonth[] = $state([]);
|
||||
albumAssets: Set<string> = new SvelteSet();
|
||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
limitedScroll = $derived(this.maxScrollPercent < 0.5);
|
||||
initTask = new CancellableTask(
|
||||
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(
|
||||
() => {
|
||||
this.isInitialized = true;
|
||||
if (this.#options.albumId || this.#options.personId) {
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
this.websocket.connect();
|
||||
},
|
||||
() => {
|
||||
this.disconnect();
|
||||
this.websocket.disconnect();
|
||||
this.isInitialized = false;
|
||||
},
|
||||
() => void 0,
|
||||
);
|
||||
|
||||
static #INIT_OPTIONS = {};
|
||||
#websocketSupport: WebsocketSupport | undefined;
|
||||
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||
segments: TimelineMonth[] = $state([]);
|
||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
#options: TimelineManagerOptions = {};
|
||||
#scrollableElement: HTMLElement | undefined = $state();
|
||||
|
||||
constructor() {
|
||||
@@ -99,7 +81,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
}
|
||||
|
||||
public override destroy() {
|
||||
this.disconnect();
|
||||
this.websocket.disconnect();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
@@ -107,7 +89,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
if (options.deferInit) {
|
||||
return;
|
||||
}
|
||||
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
||||
if (isEqual(this.#options, options)) {
|
||||
return;
|
||||
}
|
||||
await this.initTask.reset();
|
||||
@@ -138,154 +120,12 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
this.onUpdateViewport(oldViewport, viewport);
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.#websocketSupport) {
|
||||
throw new Error('TimelineManager already connected');
|
||||
}
|
||||
this.#websocketSupport = new WebsocketSupport(this);
|
||||
this.#websocketSupport.connectWebsocketEvents();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (!this.#websocketSupport) {
|
||||
return;
|
||||
}
|
||||
this.#websocketSupport.disconnectWebsocketEvents();
|
||||
this.#websocketSupport = undefined;
|
||||
}
|
||||
|
||||
upsertAssets(assets: TimelineAsset[]) {
|
||||
const notUpdated = this.#updateAssets(assets);
|
||||
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
|
||||
this.addAssetsToSegments(notExcluded);
|
||||
}
|
||||
|
||||
async findMonthForAsset(id: string) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilCompletion();
|
||||
}
|
||||
|
||||
let { month } = findMonthForAssetUtil(this, 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 loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
|
||||
const { cancelable = true } = options ?? {};
|
||||
const segment = this.segments.find((segment) => identifier.matches(segment));
|
||||
if (!segment || segment.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await segment.load(cancelable);
|
||||
}
|
||||
|
||||
async #loadMonthAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
||||
await this.loadSegment(getSegmentIdentifier(yearMonth), options);
|
||||
return getMonthByDate(this, yearMonth);
|
||||
}
|
||||
|
||||
getMonthByAssetId(assetId: string) {
|
||||
const monthInfo = findMonthForAssetUtil(this, 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.
|
||||
*
|
||||
* @returns An object with the changed ids, unprocessed ids, and if this resulted
|
||||
* in changes of the timeline geometry.
|
||||
*/
|
||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
return this.#runAssetOperation(ids, operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the specified asset from the TimelineAsset using its id, and then updates the
|
||||
* existing object to match the rest of the TimelineAsset parameter.
|
||||
|
||||
* @returns list of assets that were updated (not found)
|
||||
*/
|
||||
#updateAssets(updatedAssets: TimelineAsset[]) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const lookup = new Map<string, TimelineAsset>();
|
||||
const ids = [];
|
||||
for (const asset of updatedAssets) {
|
||||
ids.push(asset.id);
|
||||
lookup.set(asset.id, asset);
|
||||
}
|
||||
const { unprocessedIds } = this.#runAssetOperation(ids, (asset) => updateObject(asset, lookup.get(asset.id)));
|
||||
const result: TimelineAsset[] = [];
|
||||
for (const id of unprocessedIds) {
|
||||
result.push(lookup.get(id)!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
removeAssets(ids: string[]) {
|
||||
this.#runAssetOperation(ids, () => ({ remove: true }));
|
||||
}
|
||||
|
||||
protected createUpsertContext(): GroupInsertionCache {
|
||||
protected override createUpsertContext(): GroupInsertionCache {
|
||||
return new GroupInsertionCache();
|
||||
}
|
||||
|
||||
protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
|
||||
let month = getMonthByDate(this, asset.localDateTime);
|
||||
protected override upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
|
||||
let month = this.search.findMonthByDate(asset.localDateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
|
||||
@@ -295,64 +135,30 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
month.addTimelineAsset(asset, context);
|
||||
}
|
||||
|
||||
protected addAssetsToSegments(assets: TimelineAsset[]) {
|
||||
if (assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const context = this.createUpsertContext();
|
||||
const monthCount = this.segments.length;
|
||||
for (const asset of assets) {
|
||||
this.upsertAssetIntoSegment(asset, context);
|
||||
}
|
||||
if (this.segments.length !== monthCount) {
|
||||
this.postCreateSegments();
|
||||
}
|
||||
this.postUpsert(context);
|
||||
this.updateIntersections();
|
||||
protected override postCreateSegments(): void {
|
||||
this.segments.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
#runAssetOperation(ids: string[], operation: AssetOperation) {
|
||||
if (ids.length === 0) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
return { processedIds: new Set<string>(), unprocessedIds: new Set<string>(), changedGeometry: false };
|
||||
protected override postUpsert(context: GroupInsertionCache): void {
|
||||
for (const group of context.existingDays) {
|
||||
group.sortAssets(this.#options.order);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const changedMonths = new Set<TimelineMonth>();
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsToProcess = new Set(ids);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const idsProcessed = new Set<string>();
|
||||
const combinedMoveAssets: TimelineAsset[] = [];
|
||||
for (const month of this.segments) {
|
||||
if (idsToProcess.size > 0) {
|
||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(...moveAssets);
|
||||
}
|
||||
setDifferenceInPlace(idsToProcess, processedIds);
|
||||
for (const id of processedIds) {
|
||||
idsProcessed.add(id);
|
||||
}
|
||||
if (changedGeometry) {
|
||||
changedMonths.add(month);
|
||||
}
|
||||
}
|
||||
for (const month of context.monthsWithNewDays) {
|
||||
month.sortDays();
|
||||
}
|
||||
if (combinedMoveAssets.length > 0) {
|
||||
this.addAssetsToSegments(combinedMoveAssets);
|
||||
}
|
||||
const changedGeometry = changedMonths.size > 0;
|
||||
for (const month of changedMonths) {
|
||||
|
||||
for (const month of context.updatedMonths) {
|
||||
month.sortDays();
|
||||
month.updateGeometry({ invalidateHeight: true });
|
||||
}
|
||||
if (changedGeometry) {
|
||||
this.updateIntersections();
|
||||
}
|
||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||
}
|
||||
|
||||
isExcluded(asset: TimelineAsset) {
|
||||
override isExcluded(asset: TimelineAsset) {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
@@ -368,52 +174,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 getAssetWithOffset(this, assetDescriptor, interval, 'later');
|
||||
}
|
||||
|
||||
async getEarlierAsset(
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
||||
let month = findMonthForDate(this, dateTime);
|
||||
if (!month) {
|
||||
month = 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 retrieveRangeUtil(this, start, end);
|
||||
}
|
||||
|
||||
clearDeferredLayout(month: TimelineMonth) {
|
||||
const hasDeferred = month.days.some((group) => group.deferredLayout);
|
||||
if (hasDeferred) {
|
||||
month.updateGeometry({ invalidateHeight: true, noDefer: true });
|
||||
for (const group of month.days) {
|
||||
group.deferredLayout = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async *assetsIterator(options?: {
|
||||
startMonth?: TimelineMonth;
|
||||
startDay?: TimelineDay;
|
||||
@@ -481,27 +241,4 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
}));
|
||||
this.scrubberTimelineHeight = this.totalViewerHeight;
|
||||
}
|
||||
|
||||
protected postCreateSegments(): void {
|
||||
this.segments.sort((a, b) => {
|
||||
return a.yearMonth.year === b.yearMonth.year
|
||||
? b.yearMonth.month - a.yearMonth.month
|
||||
: b.yearMonth.year - a.yearMonth.year;
|
||||
});
|
||||
}
|
||||
|
||||
protected postUpsert(context: GroupInsertionCache): void {
|
||||
for (const group of context.existingDays) {
|
||||
group.sortAssets(this.#options.order);
|
||||
}
|
||||
|
||||
for (const month of context.monthsWithNewDays) {
|
||||
month.sortDays();
|
||||
}
|
||||
|
||||
for (const month of context.updatedMonths) {
|
||||
month.sortDays();
|
||||
month.updateGeometry({ invalidateHeight: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
|
||||
import { onCreateMonth } from '$lib/managers/timeline-manager/internal/TestHooks.svelte';
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { onCreateTimelineMonth } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import { ScrollSegment, type SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import { setDifferenceInPlace } from '$lib/managers/VirtualScrollManager/utils.svelte';
|
||||
import type { AssetOperation } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import {
|
||||
formatDayTitle,
|
||||
formatDayTitleFull,
|
||||
formatMonthTitle,
|
||||
formatGroupTitle,
|
||||
formatGroupTitleFull,
|
||||
formatMonthGroupTitle,
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
getSegmentIdentifier,
|
||||
getTimes,
|
||||
setDifferenceInPlace,
|
||||
toISOYearMonthUTC,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
@@ -45,10 +46,10 @@ export class TimelineMonth extends ScrollSegment {
|
||||
this.#identifier = getSegmentIdentifier(yearMonth);
|
||||
this.#timelineManager = timelineManager;
|
||||
this.#sortOrder = order;
|
||||
this.monthTitle = formatMonthTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
this.monthTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
this.loaded = loaded;
|
||||
if (import.meta.env.DEV) {
|
||||
onCreateMonth(this);
|
||||
onCreateTimelineMonth(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,15 +71,15 @@ export class TimelineMonth extends ScrollSegment {
|
||||
|
||||
override findAssetAbsolutePosition(assetId: string) {
|
||||
this.#clearDeferredLayout();
|
||||
for (const group of this.days) {
|
||||
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||
for (const day of this.days) {
|
||||
const viewerAsset = day.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||
if (viewerAsset) {
|
||||
if (!viewerAsset.position) {
|
||||
console.warn('No position for asset');
|
||||
return;
|
||||
}
|
||||
return {
|
||||
top: this.top + group.top + viewerAsset.position.top + this.scrollManager.headerHeight,
|
||||
top: this.top + day.top + viewerAsset.position.top + this.#timelineManager.headerHeight,
|
||||
height: viewerAsset.position.height,
|
||||
};
|
||||
}
|
||||
@@ -137,41 +138,41 @@ export class TimelineMonth extends ScrollSegment {
|
||||
let cumulativeWidth = 0;
|
||||
let currentRowHeight = 0;
|
||||
|
||||
let dayRow = 0;
|
||||
let dayCol = 0;
|
||||
let dayGroupRow = 0;
|
||||
let dayGroupCol = 0;
|
||||
|
||||
const options = this.scrollManager.justifiedLayoutOptions;
|
||||
for (const day of this.days) {
|
||||
day.layout(options, noDefer);
|
||||
for (const dayGroup of this.days) {
|
||||
dayGroup.layout(options, noDefer);
|
||||
|
||||
// Calculate space needed for this item (including gap if not first in row)
|
||||
const spaceNeeded = day.width + (dayCol > 0 ? this.scrollManager.gap : 0);
|
||||
const spaceNeeded = dayGroup.width + (dayGroupCol > 0 ? this.scrollManager.gap : 0);
|
||||
const fitsInCurrentRow = cumulativeWidth + spaceNeeded <= this.scrollManager.viewportWidth;
|
||||
|
||||
if (fitsInCurrentRow) {
|
||||
day.row = dayRow;
|
||||
day.col = dayCol++;
|
||||
day.left = cumulativeWidth;
|
||||
day.top = cumulativeHeight;
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol++;
|
||||
dayGroup.left = cumulativeWidth;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
cumulativeWidth += day.width + this.scrollManager.gap;
|
||||
cumulativeWidth += dayGroup.width + this.scrollManager.gap;
|
||||
} else {
|
||||
// Move to next row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
cumulativeWidth = 0;
|
||||
dayRow++;
|
||||
dayCol = 0;
|
||||
dayGroupRow++;
|
||||
dayGroupCol = 0;
|
||||
|
||||
// Position at start of new row
|
||||
day.row = dayRow;
|
||||
day.col = dayCol;
|
||||
day.left = 0;
|
||||
day.top = cumulativeHeight;
|
||||
dayGroup.row = dayGroupRow;
|
||||
dayGroup.col = dayGroupCol;
|
||||
dayGroup.left = 0;
|
||||
dayGroup.top = cumulativeHeight;
|
||||
|
||||
dayCol++;
|
||||
cumulativeWidth += day.width + this.scrollManager.gap;
|
||||
dayGroupCol++;
|
||||
cumulativeWidth += dayGroup.width + this.scrollManager.gap;
|
||||
}
|
||||
currentRowHeight = day.height + this.scrollManager.headerHeight;
|
||||
currentRowHeight = dayGroup.height + this.scrollManager.headerHeight;
|
||||
}
|
||||
|
||||
// Add the height of the final row
|
||||
@@ -206,16 +207,7 @@ export class TimelineMonth extends ScrollSegment {
|
||||
return this.days[0]?.getFirstAsset();
|
||||
}
|
||||
|
||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
if (ids.size === 0) {
|
||||
return {
|
||||
moveAssets: [] as TimelineAsset[],
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
processedIds: new Set<string>(),
|
||||
unprocessedIds: ids,
|
||||
changedGeometry: false,
|
||||
};
|
||||
}
|
||||
override runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
||||
const { days } = this;
|
||||
let combinedChangedGeometry = false;
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
@@ -228,6 +220,7 @@ export class TimelineMonth extends ScrollSegment {
|
||||
if (idsToProcess.size > 0) {
|
||||
const group = days[index];
|
||||
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation);
|
||||
|
||||
if (moveAssets.length > 0) {
|
||||
combinedMoveAssets.push(...moveAssets);
|
||||
}
|
||||
@@ -326,9 +319,9 @@ export class TimelineMonth extends ScrollSegment {
|
||||
if (day) {
|
||||
addContext.setDay(day, localDateTime);
|
||||
} else {
|
||||
const dayTitle = formatDayTitle(fromTimelinePlainDate(localDateTime));
|
||||
const dayTitleFull = formatDayTitleFull(fromTimelinePlainDate(localDateTime));
|
||||
day = new TimelineDay(this, this.days.length, localDateTime.day, dayTitle, dayTitleFull);
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||
const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime));
|
||||
day = new TimelineDay(this, this.days.length, localDateTime.day, groupTitle, groupTitleFull);
|
||||
this.days.push(day);
|
||||
addContext.setDay(day, localDateTime);
|
||||
addContext.newDays.add(day);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { findClosestMonthToDate } from '$lib/utils/timeline-util';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function createMockMonthGroup(year: number, month: number): TimelineMonth {
|
||||
return {
|
||||
yearMonth: { year, month },
|
||||
} as TimelineMonth;
|
||||
}
|
||||
|
||||
describe('findClosestMonthToDate', () => {
|
||||
it('should return undefined for empty months array', () => {
|
||||
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 = 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 = 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 = 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 = 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 });
|
||||
});
|
||||
|
||||
it('should correctly calculate distance across years', () => {
|
||||
const months = [createMockMonthGroup(2022, 6), createMockMonthGroup(2024, 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 = 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 = 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 = 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 = 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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
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 {
|
||||
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 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,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, month } = this.findMonthForAsset(assetDescriptor.id) ?? {};
|
||||
if (!month || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return this.#getAssetByAssetOffset(asset, month, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return this.#getAssetByDayOffset(asset, month, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return this.#getAssetByMonthOffset(month, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return this.#getAssetByYearOffset(month, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findMonthForAsset(id: string) {
|
||||
for (const month of this.#timelineManager.segments) {
|
||||
const asset = month.findAssetById({ id });
|
||||
if (asset) {
|
||||
return { month, asset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findMonthByDate(targetYearMonth: TimelineYearMonth): TimelineMonth | undefined {
|
||||
return this.#timelineManager.segments.find(
|
||||
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.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.#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,
|
||||
startDay: day,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (asset.id !== targetAsset.id) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getAssetByDayOffset(asset: TimelineAsset, month: TimelineMonth, direction: Direction) {
|
||||
const day = month.findDayForAsset(asset);
|
||||
for await (const targetAsset of this.#timelineManager.assetsIterator({
|
||||
startMonth: month,
|
||||
startDay: day,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.assetsIterator({ startMonth: targetMonth, direction })
|
||||
.next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.assetsIterator({ startMonth: targetMonth, direction })
|
||||
.next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
||||
let { asset: startAsset, month: startMonth } = this.findMonthForAsset(start.id) ?? {};
|
||||
if (!startMonth || !startAsset) {
|
||||
return [];
|
||||
}
|
||||
let { asset: endAsset, month: endMonth } = this.findMonthForAsset(end.id) ?? {};
|
||||
if (!endMonth || !endAsset) {
|
||||
return [];
|
||||
}
|
||||
const assetOrder: AssetOrder = this.#timelineManager.getAssetOrder();
|
||||
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
[startMonth, endMonth] = [endMonth, startMonth];
|
||||
}
|
||||
|
||||
const range: TimelineAsset[] = [];
|
||||
const startDay = startMonth.findDayForAsset(startAsset);
|
||||
for await (const targetAsset of this.#timelineManager.assetsIterator({
|
||||
startMonth,
|
||||
startDay,
|
||||
startAsset,
|
||||
})) {
|
||||
range.push(targetAsset);
|
||||
if (targetAsset.id === endAsset.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
findMonthForDate(targetYearMonth: TimelineYearMonth) {
|
||||
for (const month of this.#timelineManager.segments) {
|
||||
const { year, month: monthNum } = month.yearMonth;
|
||||
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getClosestAssetToDate(dateTime: TimelineDateTime) {
|
||||
let month = this.findMonthForDate(dateTime);
|
||||
if (!month) {
|
||||
month = findClosestMonthToDate(this.#timelineManager.segments, dateTime);
|
||||
if (!month) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
|
||||
export type TestHooks = {
|
||||
onCreateTimelineMonth(month: TimelineMonth): unknown;
|
||||
onCreateTimelineDay(day: TimelineDay): unknown;
|
||||
};
|
||||
|
||||
let testHooks: TestHooks | undefined = undefined;
|
||||
|
||||
export const setTestHooks = (hooks: TestHooks) => {
|
||||
testHooks = hooks;
|
||||
};
|
||||
|
||||
export const onCreateTimelineMonth = (month: TimelineMonth) => testHooks?.onCreateTimelineMonth(month);
|
||||
export const onCreateTimelineDay = (day: TimelineDay) => testHooks?.onCreateTimelineDay(day);
|
||||
@@ -1,16 +1,13 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { throttle } from 'lodash-es';
|
||||
import type { Unsubscriber } from 'svelte/store';
|
||||
|
||||
export class WebsocketSupport {
|
||||
#pendingChanges: PendingChange[] = [];
|
||||
#unsubscribers: Unsubscriber[] = [];
|
||||
#timelineManager: TimelineManager;
|
||||
|
||||
#processPendingChanges = throttle(() => {
|
||||
export class TimelineWebsocketExtension {
|
||||
readonly #timelineManager: TimelineManager;
|
||||
readonly #processPendingChanges = throttle(() => {
|
||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||
if (add.length > 0) {
|
||||
this.#timelineManager.upsertAssets(add);
|
||||
@@ -24,11 +21,26 @@ export class WebsocketSupport {
|
||||
this.#pendingChanges = [];
|
||||
}, 2500);
|
||||
|
||||
#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 WebsocketSupport {
|
||||
);
|
||||
}
|
||||
|
||||
disconnectWebsocketEvents() {
|
||||
#disconnectWebsocketEvents() {
|
||||
for (const unsubscribe of this.#unsubscribers) {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
|
||||
let testHooks: TestHooks | undefined = undefined;
|
||||
|
||||
export type TestHooks = {
|
||||
onCreateMonth(month: TimelineMonth): unknown;
|
||||
onCreateDay(day: TimelineDay): unknown;
|
||||
};
|
||||
|
||||
export const setTestHooks = (hooks: TestHooks) => {
|
||||
testHooks = hooks;
|
||||
};
|
||||
|
||||
export const onCreateMonth = (month: TimelineMonth) => testHooks?.onCreateMonth(month);
|
||||
export const onCreateDay = (day: TimelineDay) => testHooks?.onCreateDay(day);
|
||||
@@ -1,75 +0,0 @@
|
||||
import { findClosestGroupForDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function createMockMonth(year: number, month: number): TimelineMonth {
|
||||
return {
|
||||
yearMonth: { year, month },
|
||||
} as TimelineMonth;
|
||||
}
|
||||
|
||||
describe('findClosestGroupForDate', () => {
|
||||
it('should return undefined for empty months array', () => {
|
||||
const result = findClosestGroupForDate([], { year: 2024, month: 1 });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the only month when there is only one month', () => {
|
||||
const months = [createMockMonth(2024, 6)];
|
||||
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should return exact match when available', () => {
|
||||
const months = [createMockMonth(2024, 1), createMockMonth(2024, 6), createMockMonth(2024, 12)];
|
||||
const result = findClosestGroupForDate(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 = [createMockMonth(2024, 1), createMockMonth(2024, 6), createMockMonth(2024, 12)];
|
||||
const result = findClosestGroupForDate(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 = [createMockMonth(2023, 12), createMockMonth(2024, 2)];
|
||||
const result = findClosestGroupForDate(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 });
|
||||
});
|
||||
|
||||
it('should correctly calculate distance across years', () => {
|
||||
const months = [createMockMonth(2022, 6), createMockMonth(2024, 6)];
|
||||
const result = findClosestGroupForDate(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 = [createMockMonth(2024, 6), createMockMonth(2024, 12)];
|
||||
const result = findClosestGroupForDate(months, { year: 2024, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle target after all months', () => {
|
||||
const months = [createMockMonth(2024, 1), createMockMonth(2024, 6)];
|
||||
const result = findClosestGroupForDate(months, { year: 2025, month: 1 });
|
||||
expect(result?.yearMonth).toEqual({ year: 2024, month: 6 });
|
||||
});
|
||||
|
||||
it('should handle multiple years correctly', () => {
|
||||
const months = [createMockMonth(2020, 1), createMockMonth(2022, 1), createMockMonth(2024, 1)];
|
||||
const result = findClosestGroupForDate(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 = [createMockMonth(2024, 1), createMockMonth(2024, 10)];
|
||||
const result = findClosestGroupForDate(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,165 +0,0 @@
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import { 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';
|
||||
|
||||
export async function getAssetWithOffset(
|
||||
timelineManager: TimelineManager,
|
||||
assetDescriptor: AssetDescriptor,
|
||||
interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
|
||||
direction: Direction,
|
||||
): Promise<TimelineAsset | undefined> {
|
||||
const { asset, month } = findMonthForAsset(timelineManager, assetDescriptor.id) ?? {};
|
||||
if (!month || !asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (interval) {
|
||||
case 'asset': {
|
||||
return getAssetByAssetOffset(timelineManager, asset, month, direction);
|
||||
}
|
||||
case 'day': {
|
||||
return getAssetByDayOffset(timelineManager, asset, month, direction);
|
||||
}
|
||||
case 'month': {
|
||||
return getAssetByMonthOffset(timelineManager, month, direction);
|
||||
}
|
||||
case 'year': {
|
||||
return getAssetByYearOffset(timelineManager, month, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function findMonthForAsset(timelineManager: TimelineManager, id: string) {
|
||||
for (const month of timelineManager.segments) {
|
||||
const asset = month.findAssetById({ id });
|
||||
if (asset) {
|
||||
return { month, asset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getMonthByDate(
|
||||
timelineManager: TimelineManager,
|
||||
targetYearMonth: TimelineYearMonth,
|
||||
): TimelineMonth | undefined {
|
||||
return timelineManager.segments.find(
|
||||
(month) => month.yearMonth.year === targetYearMonth.year && month.yearMonth.month === targetYearMonth.month,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAssetByAssetOffset(
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
month: TimelineMonth,
|
||||
direction: Direction,
|
||||
) {
|
||||
const day = month.findDayForAsset(asset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonth: month,
|
||||
startDay: day,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (asset.id !== targetAsset.id) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByDayOffset(
|
||||
timelineManager: TimelineManager,
|
||||
asset: TimelineAsset,
|
||||
month: TimelineMonth,
|
||||
direction: Direction,
|
||||
) {
|
||||
const day = month.findDayForAsset(asset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonth: month,
|
||||
startDay: day,
|
||||
startAsset: asset,
|
||||
direction,
|
||||
})) {
|
||||
if (targetAsset.localDateTime.day !== asset.localDateTime.day) {
|
||||
return targetAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByMonthOffset(timelineManager: TimelineManager, month: TimelineMonth, direction: Direction) {
|
||||
for (const targetMonth of timelineManager.monthIterator({ startMonth: month, direction })) {
|
||||
if (targetMonth.yearMonth.month !== month.yearMonth.month) {
|
||||
const { value, done } = await timelineManager.assetsIterator({ startMonth: targetMonth, direction }).next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAssetByYearOffset(timelineManager: TimelineManager, month: TimelineMonth, direction: Direction) {
|
||||
for (const targetMonth of timelineManager.monthIterator({ startMonth: month, direction })) {
|
||||
if (targetMonth.yearMonth.year !== month.yearMonth.year) {
|
||||
const { value, done } = await timelineManager.assetsIterator({ startMonth: targetMonth, direction }).next();
|
||||
return done ? undefined : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function retrieveRange(timelineManager: TimelineManager, start: AssetDescriptor, end: AssetDescriptor) {
|
||||
let { asset: startAsset, month: startMonth } = findMonthForAsset(timelineManager, start.id) ?? {};
|
||||
if (!startMonth || !startAsset) {
|
||||
return [];
|
||||
}
|
||||
let { asset: endAsset, month: endMonth } = findMonthForAsset(timelineManager, end.id) ?? {};
|
||||
if (!endMonth || !endAsset) {
|
||||
return [];
|
||||
}
|
||||
const assetOrder: AssetOrder = timelineManager.getAssetOrder();
|
||||
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
||||
[startAsset, endAsset] = [endAsset, startAsset];
|
||||
[startMonth, endMonth] = [endMonth, startMonth];
|
||||
}
|
||||
|
||||
const range: TimelineAsset[] = [];
|
||||
const startDay = startMonth.findDayForAsset(startAsset);
|
||||
for await (const targetAsset of timelineManager.assetsIterator({
|
||||
startMonth,
|
||||
startDay,
|
||||
startAsset,
|
||||
})) {
|
||||
range.push(targetAsset);
|
||||
if (targetAsset.id === endAsset.id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
export function findMonthForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
|
||||
for (const month of timelineManager.segments) {
|
||||
const { year, month: monthNum } = month.yearMonth;
|
||||
if (monthNum === targetYearMonth.month && year === targetYearMonth.year) {
|
||||
return month;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function 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;
|
||||
}
|
||||
}
|
||||
|
||||
return closestMonth;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||
return option === undefined ? false : option !== value;
|
||||
}
|
||||
@@ -1,4 +1,59 @@
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
|
||||
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function updateObject(target: any, source: any): boolean {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
let updated = false;
|
||||
for (const key in source) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
continue;
|
||||
}
|
||||
if (key === '__proto__' || key === 'constructor') {
|
||||
continue;
|
||||
}
|
||||
const isDate = target[key] instanceof Date;
|
||||
if (typeof target[key] === 'object' && !isDate) {
|
||||
updated = updated || updateObject(target[key], source[key]);
|
||||
} else {
|
||||
if (target[key] !== source[key]) {
|
||||
target[key] = source[key];
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
export function isMismatched<T>(option: T | undefined, value: T): boolean {
|
||||
return option === undefined ? false : option !== value;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { isIntersecting } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
@@ -491,17 +491,17 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
|
||||
isSelectingAllAssets.set(true);
|
||||
|
||||
try {
|
||||
for (const month of timelineManager.segments) {
|
||||
await timelineManager.loadSegment(getSegmentIdentifier(month.yearMonth));
|
||||
for (const monthGroup of timelineManager.segments) {
|
||||
await timelineManager.loadSegment(getSegmentIdentifier(monthGroup.yearMonth));
|
||||
|
||||
if (!get(isSelectingAllAssets)) {
|
||||
assetInteraction.clearMultiselect();
|
||||
break; // Cancelled
|
||||
}
|
||||
assetInteraction.selectAssets(assetsSnapshot([...month.assetsIterator()]));
|
||||
assetInteraction.selectAssets(assetsSnapshot([...monthGroup.assetsIterator()]));
|
||||
|
||||
for (const day of month.days) {
|
||||
assetInteraction.addGroupToMultiselectGroup(day.dayTitle);
|
||||
for (const dateGroup of monthGroup.days) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dateGroup.dayTitle);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
import { formatDayTitle, toISOYearMonthUTC } from '$lib/utils/timeline-util';
|
||||
import { formatGroupTitle, toISOYearMonthUTC } from '$lib/utils/timeline-util';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
describe('formatDayTitle', () => {
|
||||
describe('formatGroupTitle', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
process.env.TZ = 'UTC';
|
||||
@@ -18,63 +18,63 @@ describe('formatDayTitle', () => {
|
||||
it('formats today', () => {
|
||||
const date = parseUtcDate('2024-07-27T01:00:00Z');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(date)).toBe('today');
|
||||
expect(formatGroupTitle(date)).toBe('today');
|
||||
locale.set('es');
|
||||
expect(formatDayTitle(date)).toBe('hoy');
|
||||
expect(formatGroupTitle(date)).toBe('hoy');
|
||||
});
|
||||
|
||||
it('formats yesterday', () => {
|
||||
const date = parseUtcDate('2024-07-26T23:59:59Z');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(date)).toBe('yesterday');
|
||||
expect(formatGroupTitle(date)).toBe('yesterday');
|
||||
locale.set('fr');
|
||||
expect(formatDayTitle(date)).toBe('hier');
|
||||
expect(formatGroupTitle(date)).toBe('hier');
|
||||
});
|
||||
|
||||
it('formats last week', () => {
|
||||
const date = parseUtcDate('2024-07-21T00:00:00Z');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(date)).toBe('Sunday');
|
||||
expect(formatGroupTitle(date)).toBe('Sunday');
|
||||
locale.set('ar-SA');
|
||||
expect(formatDayTitle(date)).toBe('الأحد');
|
||||
expect(formatGroupTitle(date)).toBe('الأحد');
|
||||
});
|
||||
|
||||
it('formats date 7 days ago', () => {
|
||||
const date = parseUtcDate('2024-07-20T00:00:00Z');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(date)).toBe('Sat, Jul 20');
|
||||
expect(formatGroupTitle(date)).toBe('Sat, Jul 20');
|
||||
locale.set('de');
|
||||
expect(formatDayTitle(date)).toBe('Sa., 20. Juli');
|
||||
expect(formatGroupTitle(date)).toBe('Sa., 20. Juli');
|
||||
});
|
||||
|
||||
it('formats date this year', () => {
|
||||
const date = parseUtcDate('2020-01-01T00:00:00Z');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(date)).toBe('Wed, Jan 1, 2020');
|
||||
expect(formatGroupTitle(date)).toBe('Wed, Jan 1, 2020');
|
||||
locale.set('ja');
|
||||
expect(formatDayTitle(date)).toBe('2020年1月1日(水)');
|
||||
expect(formatGroupTitle(date)).toBe('2020年1月1日(水)');
|
||||
});
|
||||
|
||||
it('formats future date', () => {
|
||||
const tomorrow = parseUtcDate('2024-07-28T00:00:00Z');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(tomorrow)).toBe('Sun, Jul 28');
|
||||
expect(formatGroupTitle(tomorrow)).toBe('Sun, Jul 28');
|
||||
|
||||
const nextMonth = parseUtcDate('2024-08-28T00:00:00Z');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(nextMonth)).toBe('Wed, Aug 28');
|
||||
expect(formatGroupTitle(nextMonth)).toBe('Wed, Aug 28');
|
||||
|
||||
const nextYear = parseUtcDate('2025-01-10T12:00:00Z');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(nextYear)).toBe('Fri, Jan 10, 2025');
|
||||
expect(formatGroupTitle(nextYear)).toBe('Fri, Jan 10, 2025');
|
||||
});
|
||||
|
||||
it('returns "Invalid DateTime" when date is invalid', () => {
|
||||
const date = DateTime.invalid('test');
|
||||
locale.set('en');
|
||||
expect(formatDayTitle(date)).toBe('Invalid DateTime');
|
||||
expect(formatGroupTitle(date)).toBe('Invalid DateTime');
|
||||
locale.set('es');
|
||||
expect(formatDayTitle(date)).toBe('Invalid DateTime');
|
||||
expect(formatGroupTitle(date)).toBe('Invalid DateTime');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svelte';
|
||||
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import type { ScrollSegment, SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
@@ -101,7 +101,7 @@ export const toISOYearMonthUTC = ({ year, month }: TimelineYearMonth): string =>
|
||||
return `${yearFull}-${monthFull}-01T00:00:00.000Z`;
|
||||
};
|
||||
|
||||
export function formatMonthTitle(_date: DateTime): string {
|
||||
export function formatMonthGroupTitle(_date: DateTime): string {
|
||||
if (!_date.isValid) {
|
||||
return _date.toString();
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function formatMonthTitle(_date: DateTime): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDayTitle(_date: DateTime): string {
|
||||
export function formatGroupTitle(_date: DateTime): string {
|
||||
if (!_date.isValid) {
|
||||
return _date.toString();
|
||||
}
|
||||
@@ -223,32 +223,7 @@ 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 formatDayTitleFull = (_date: DateTime): string => {
|
||||
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)));
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
||||
@@ -117,7 +117,7 @@
|
||||
onClick: (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
dayTitle: string,
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => void,
|
||||
) => {
|
||||
|
||||
Reference in New Issue
Block a user