Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Beckford e4cf79263b feat: webhook workflow action 2026-06-22 00:01:04 -07:00
22 changed files with 301 additions and 151 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v3
IMMICH_VERSION=v2
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
+28
View File
@@ -289,6 +289,34 @@
"required": ["albumIds"]
}
},
{
"name": "assetDataWebhook",
"title": "Trigger Webhook",
"description": "POST asset data to any URL",
"types": ["AssetV1"],
"hostFunctions": true,
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "URL",
"description": "Asset data will be POSTed to this URL as a JSON object"
},
"headerName": {
"type": "string",
"title": "Header name",
"description": "The name of an additional header to include with the request (e.g. authentication)"
},
"headerValue": {
"type": "string",
"title": "Header value",
"description": "The value of the additional header"
}
},
"required": ["url"]
}
},
{
"name": "noop1",
"title": "DEV: Nested properties",
+2
View File
@@ -5,6 +5,7 @@ declare module 'extism:host' {
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
httpRequest(ptr: PTR): I64;
}
}
@@ -24,4 +25,5 @@ declare module 'main' {
export function assetTimeline(): I32;
export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
export function assetDataWebhook(): I32;
}
+22
View File
@@ -181,3 +181,25 @@ export const assetAddToAlbums = () => {
return {};
});
};
export const assetDataWebhook = () => {
return wrapper<WorkflowType.AssetV1, { url: string; headerName?: string; headerValue?: string }>(
({ config, data, functions }) => {
let headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (config.headerName && config.headerValue) {
headers[config.headerName] = config.headerValue;
}
functions.httpRequest(config.url, {
method: 'POST',
body: JSON.stringify(data.asset),
headers,
});
return {};
},
);
};
+12
View File
@@ -13,6 +13,7 @@ declare module 'extism:host' {
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
httpRequest(ptr: PTR): I64;
}
}
@@ -33,6 +34,11 @@ type HostFunctionResult<T> =
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
type HttpRequestOptions = {
method?: string;
headers?: Record<string, string>;
body?: string;
};
export const hostFunctions = (authToken: string) => {
const host = Host.getFunctions();
@@ -75,5 +81,11 @@ export const hostFunctions = (authToken: string) => {
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
httpRequest: (url: string, options?: HttpRequestOptions) =>
call<[string, HttpRequestOptions | undefined], string>(
'httpRequest',
authToken,
[url, options],
),
};
};
@@ -74,12 +74,26 @@ export class WorkflowExecutionService extends BaseService {
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
const httpRequest = this.wrap<
[
url: string,
options?: {
method?: string;
headers?: Record<string, string>;
body?: string;
},
]
>(async (_, args) => {
const res = await fetch(...args);
return res.text();
});
const functions = {
searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums,
httpRequest,
};
const stubs: typeof functions = {
@@ -87,6 +101,7 @@ export class WorkflowExecutionService extends BaseService {
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy,
httpRequest: dummy,
};
const plugins = await this.pluginRepository.getForLoad();
@@ -20,6 +20,7 @@
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
@@ -68,6 +69,7 @@
onAssetChange?: (asset: AssetResponseDto) => void;
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (assetId: string) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
@@ -83,6 +85,7 @@
onAssetChange,
preAction,
onAction,
onUndoDelete,
onClose,
onRemoveFromAlbum,
onRandom,
@@ -311,6 +314,11 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.DELETE:
case AssetAction.TRASH: {
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
@@ -493,6 +501,7 @@
{stack}
preAction={handlePreAction}
onAction={handleAction}
{onUndoDelete}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
@@ -4,9 +4,11 @@
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/ArchiveAction.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/DeleteAction.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/RatingAction.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
@@ -23,8 +25,9 @@
import { Route } from '$lib/route';
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleTrashOrDelete } from '$lib/services/asset.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
@@ -34,7 +37,7 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, shortcut, Tooltip, type ActionItem } from '@immich/ui';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCompare, mdiDotsVertical, mdiImageSearch, mdiVideoOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -45,6 +48,7 @@
stack?: StackResponseDto | null;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean;
@@ -58,6 +62,7 @@
stack = null,
preAction,
onAction,
onUndoDelete = undefined,
onClose,
onRemoveFromAlbum,
playOriginalVideo = false,
@@ -83,10 +88,6 @@
const sharedLink = getSharedLink();
</script>
<svelte:document
use:shortcut={{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => handleTrashOrDelete(asset, true) }}
/>
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div
@@ -127,8 +128,10 @@
{/if}
<ActionButton action={Actions.Edit} />
<ActionButton action={Actions.Delete} />
<ActionButton action={Actions.PermanentlyDelete} />
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
{#if !sharedLink}
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
@@ -136,7 +139,10 @@
<ActionMenuItem action={Actions.Download} />
<ActionMenuItem action={Actions.DownloadOriginal} />
<ActionMenuItem action={Actions.Restore} />
{#if !isLocked && asset.isTrashed}
<RestoreAction {asset} {onAction} />
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)}
@@ -0,0 +1,48 @@
import type { AssetResponseDto } from '@immich/sdk';
import '@testing-library/jest-dom';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });
});
it('displays a button to move the asset to the trash bin', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
});
describe('but if the asset is already trashed', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: true });
});
it('displays a button to permanently delete the asset', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});
});
});
@@ -0,0 +1,75 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { AssetAction } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
preAction: PreAction;
onUndoDelete?: OnUndoDelete;
}
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
const forceDefault = $derived(asset.isTrashed || !featureFlagsManager.value.trash);
const trashOrDelete = async (forceRequest?: boolean) => {
const timelineAsset = toTimelineAsset(asset);
const force = forceDefault || forceRequest;
if (force) {
if ($showDeleteModal) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
toastManager.primary($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
return;
}
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
await deleteAssetsUtil(
false,
() => onAction({ type: AssetAction.TRASH, asset: timelineAsset }),
[timelineAsset],
onUndoDelete,
);
};
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
]}
/>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline}
aria-label={forceDefault ? $t('permanently_delete') : $t('delete')}
onclick={() => trashOrDelete()}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
}
let { asset = $bindable(), onAction }: Props = $props();
const handleRestoreAsset = async () => {
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
</script>
<MenuOption icon={mdiHistory} onClick={handleRestoreAsset} text={$t('restore')} />
@@ -5,6 +5,9 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.STACK]: { stack: StackResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
@@ -22,7 +22,6 @@
import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
interface Props {
sharedLink: SharedLinkResponseDto;
@@ -64,20 +63,15 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE: {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
await goto(Route.photos());
break;
}
// no default
}
};
const onAssetsDelete = async (assetIds: string[]) => {
// Only used for single asset shared link
if (assetIds.includes(assets[0].id)) {
await goto(Route.photos());
}
};
</script>
{#if sharedLink?.allowUpload || assets.length > 1}
@@ -138,8 +132,6 @@
{/if}
</header>
{:else if assets.length === 1}
<OnEvents {onAssetsDelete} />
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer cursor={{ current: asset }} onAction={handleAction} />
@@ -4,7 +4,6 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
@@ -286,7 +285,9 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE: {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
@@ -304,17 +305,6 @@
}
};
const onAssetsDelete = async (assetIds: string[]) => {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets = assets.filter((asset) => !assetIds.includes(asset.id));
if (assets.length === 0) {
return await goto(Route.photos());
}
if (assetIds.includes(assetCursor.current.id) && nextAsset) {
await navigateToAsset(nextAsset);
}
};
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
@@ -348,8 +338,6 @@
<svelte:document onselectstart={onSelectStart} use:shortcuts={shortcutList} onscroll={() => updateSlidingWindow()} />
<OnEvents {onAssetsDelete} />
{#if assets.length > 0}
<div
style:position="relative"
@@ -1,12 +1,12 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
@@ -78,14 +78,6 @@
};
};
/** Find the next asset to show or close the viewer */
const navigateOrCloseViewer = async (id: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(id));
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
const asset = assetViewerManager.asset;
@@ -117,20 +109,35 @@
const handleRemoveFromAlbum = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
if (!assetIds.includes(assetCursor.current.id)) {
return;
}
// keep the cleanup workflow in viewer by moving to adjacent asset first
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(assetCursor.current.id));
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// must update manager before performing any navigation
timelineManager.removeAssets([action.asset.id]);
await navigateOrCloseViewer(action.asset.id);
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset.id));
break;
}
// no default
@@ -192,19 +199,9 @@
// no default
}
};
const onAssetsDelete = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
}
};
const onAssetsRestore = async (assets: AssetResponseDto[]) => {
timelineManager.upsertAssets(assets.map((a) => toTimelineAsset(a)));
if (assets.length !== 1) {
// don't reopen asset viewer if multiple assets were restored (bulk action)
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets);
if (assets.length === 0) {
return;
}
@@ -237,8 +234,6 @@
});
</script>
<OnEvents {onAssetsDelete} {onAssetsRestore} />
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
@@ -254,6 +249,7 @@
handleAction(action);
assetCacheManager.invalidate();
}}
onUndoDelete={handleUndoDelete}
onRandom={handleRandom}
onRemoveFromAlbum={handleRemoveFromAlbum}
onClose={handleClose}
+3
View File
@@ -3,6 +3,9 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
STACK = 'stack',
UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
@@ -36,7 +36,6 @@ export type Events = {
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetsRestore: [AssetResponseDto[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
@@ -31,12 +31,6 @@ vitest.mock('$lib/utils', async () => {
};
});
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => {
describe('getAssetActions', () => {
beforeEach(() => {
-77
View File
@@ -3,9 +3,7 @@ import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
deleteAssets,
getAssetInfo,
restoreAssets,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -17,15 +15,12 @@ import {
mdiCogRefreshOutline,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDeleteForeverOutline,
mdiDeleteOutline,
mdiDownload,
mdiDownloadBox,
mdiFaceRecognition,
mdiHeadSyncOutline,
mdiHeart,
mdiHeartOutline,
mdiHistory,
mdiImageRefreshOutline,
mdiInformationOutline,
mdiMagnifyMinusOutline,
@@ -39,18 +34,14 @@ import {
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils';
@@ -105,7 +96,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const sharedLink = getSharedLink();
const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId);
const isDeletionPermanent = asset.isTrashed || !featureFlagsManager.value.trash;
const Share: ActionItem = {
title: $t('share'),
@@ -252,29 +242,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'e' }],
};
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDeleteOutline,
$if: () => isOwner && !isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset),
shortcuts: { key: 'Delete' },
};
const PermanentlyDelete: ActionItem = {
title: $t('permanently_delete'),
icon: mdiDeleteForeverOutline,
$if: () => isOwner && isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset, true),
shortcuts: { key: 'Delete', shift: true },
};
const Restore: ActionItem = {
title: $t('restore'),
icon: mdiHistory,
$if: () => asset.visibility !== AssetVisibility.Locked && asset.isTrashed,
onAction: () => handleRestore(asset),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -319,9 +286,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Tag,
TagPeople,
Edit,
Delete,
PermanentlyDelete,
Restore,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
@@ -403,47 +367,6 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
}
};
export const handleTrashOrDelete = async (asset: AssetResponseDto, force?: boolean) => {
const $t = await getFormatter();
if (force && get(showDeleteModal)) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force } });
eventManager.emit('AssetsDelete', [asset.id]);
if (force) {
toastManager.primary($t('permanently_deleted_asset'));
} else {
toastManager.primary(
{
description: $t('moved_to_trash'),
button: { label: $t('undo'), color: 'secondary', onclick: () => handleRestore(asset) },
},
{ timeout: 5000 },
);
}
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
};
const handleRestore = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
eventManager.emit('AssetsRestore', [asset]);
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import LargeAssetData from './LargeAssetData.svelte';
@@ -36,14 +37,16 @@
return asset;
};
const onAssetsDelete = async (assetIds: string[]) => {
if (assetIds.includes(assetCursor.current.id)) {
const preAction = async (payload: Action) => {
if (payload.type == 'trash') {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
assetViewerManager.showAssetViewer(false);
}
};
const onAssetsDelete = (assetIds: string[]) => {
assets = assets.filter(({ id }) => !assetIds.includes(id));
};
@@ -81,6 +84,7 @@
cursor={assetCursor}
showNavigation={assets.length > 1}
{onRandom}
{preAction}
onClose={() => {
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));