Compare commits

...

4 commits

Author SHA1 Message Date
Alex Phillips
08f9674f4f Merge branch 'main' into feature/ui-tags 2023-07-15 08:07:55 -04:00
Alex Phillips
7422319d9c Added tags to asset 2023-07-09 12:00:51 -04:00
Alex Phillips
659a754b98 read and save tags from exif data 2023-07-08 14:38:12 -04:00
Alex Phillips
1dd56acf8a initial add of ui in details pane and tag-specific page for viewing assets 2023-07-08 11:24:50 -04:00
11 changed files with 197 additions and 10 deletions

View file

@ -30,7 +30,8 @@ class Asset {
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite,
isArchived = remote.isArchived;
isArchived = remote.isArchived,
tags = remote.tags;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
@ -45,7 +46,8 @@ class Asset {
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
isArchived = false,
fileCreatedAt = local.createDateTime {
fileCreatedAt = local.createDateTime,
tags = [] {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
}
@ -74,6 +76,7 @@ class Asset {
this.exifInfo,
required this.isFavorite,
required this.isArchived,
required this.tags,
});
@ignore
@ -139,6 +142,8 @@ class Asset {
bool isArchived;
List<dynamic> tags;
@ignore
ExifInfo? exifInfo;
@ -216,7 +221,8 @@ class Asset {
livePhotoVideoId.hashCode ^
isFavorite.hashCode ^
isLocal.hashCode ^
isArchived.hashCode;
isArchived.hashCode ^
tags.hashCode;
/// Returns `true` if this [Asset] can updated with values from parameter [a]
bool canUpdate(Asset a) {
@ -306,6 +312,7 @@ class Asset {
String? livePhotoVideoId,
bool? isFavorite,
bool? isArchived,
List<dynamic>? tags,
ExifInfo? exifInfo,
}) =>
Asset(
@ -326,6 +333,7 @@ class Asset {
isFavorite: isFavorite ?? this.isFavorite,
isArchived: isArchived ?? this.isArchived,
exifInfo: exifInfo ?? this.exifInfo,
tags: tags ?? [],
);
Future<void> put(Isar db) async {
@ -365,15 +373,15 @@ class Asset {
"remoteId": "${remoteId ?? "N/A"}",
"localId": "${localId ?? "N/A"}",
"checksum": "$checksum",
"ownerId": $ownerId,
"ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",
"durationInSeconds": $durationInSeconds,
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",
"durationInSeconds": $durationInSeconds,
"type": "$type",
"fileName": "$fileName",
"isFavorite": $isFavorite,
"fileName": "$fileName",
"isFavorite": $isFavorite,
"isRemote: $isRemote,
"storage": "$storage",
"width": ${width ?? "N/A"},

View file

@ -203,6 +203,7 @@ void _assetSerialize(
writer.writeByte(offsets[12], object.type.index);
writer.writeDateTime(offsets[13], object.updatedAt);
writer.writeInt(offsets[14], object.width);
writer.writeString(offsets[15], jsonEncode(object.tags));
}
Asset _assetDeserialize(
@ -229,6 +230,7 @@ Asset _assetDeserialize(
AssetType.other,
updatedAt: reader.readDateTime(offsets[13]),
width: reader.readIntOrNull(offsets[14]),
tags: jsonDecode(reader.readString(offsets[15])),
);
return object;
}

View file

@ -5,6 +5,7 @@ export const ITagRepository = 'ITagRepository';
export interface ITagRepository {
getById(userId: string, tagId: string): Promise<TagEntity | null>;
getAll(userId: string): Promise<TagEntity[]>;
getByName(userId: string, name: string): Promise<TagEntity>;
create(tag: Partial<TagEntity>): Promise<TagEntity>;
update(tag: Partial<TagEntity>): Promise<TagEntity>;
remove(tag: TagEntity): Promise<void>;

View file

@ -27,6 +27,10 @@ export class TagRepository implements ITagRepository {
return this.repository.find({ where: { userId } });
}
getByName(userId: string, name: string): Promise<TagEntity> {
return this.repository.findOneOrFail({ where: { userId, name }});
}
create(tag: Partial<TagEntity>): Promise<TagEntity> {
return this.save(tag);
}

View file

@ -4,13 +4,14 @@ import {
IEntityJob,
IGeocodingRepository,
IJobRepository,
ITagRepository,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
QueueName,
usePagination,
WithoutProperty,
} from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { AssetEntity, AssetType, ExifEntity, TagEntity, TagType } from '@app/infra/entities';
import { Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
@ -41,6 +42,7 @@ export class MetadataExtractionProcessor {
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository,
@Inject(ITagRepository) private tagRepository: ITagRepository,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
configService: ConfigService,
@ -200,6 +202,23 @@ export class MetadataExtractionProcessor {
}
}
// Retrieve tags from exif and create / associate them to the asset
const tags = getExifProperty('TagsList') ?? []
if (tags.length > 0) {
for (const name of tags) {
let tag;
try {
tag = await this.tagRepository.getByName(asset.ownerId, name);
} catch (error: any) {
tag = await this.tagRepository.create({ name, userId: asset.ownerId, type: TagType.CUSTOM })
}
asset.tags.push({ id: tag.id } as TagEntity);
}
await this.assetRepository.save(asset)
}
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
await this.assetRepository.save({ id: asset.id, fileCreatedAt: fileCreatedAt || undefined });

View file

@ -17,6 +17,7 @@ import {
SystemConfigApi,
UserApi,
UserApiFp,
TagApi,
} from './open-api';
import { BASE_PATH } from './open-api/base';
import { DUMMY_BASE_URL, toPathString } from './open-api/common';
@ -36,6 +37,7 @@ export class ImmichApi {
public personApi: PersonApi;
public systemConfigApi: SystemConfigApi;
public userApi: UserApi;
public tagApi: TagApi;
private config: Configuration;
@ -55,6 +57,7 @@ export class ImmichApi {
this.personApi = new PersonApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.userApi = new UserApi(this.config);
this.tagApi = new TagApi(this.config);
}
private createUrl(path: string, params?: Record<string, unknown>) {

View file

@ -8,6 +8,7 @@
import Close from 'svelte-material-icons/Close.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
import TagOutline from 'svelte-material-icons/TagOutline.svelte';
import { createEventDispatcher } from 'svelte';
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { asByteUnitString } from '../../utils/byte-units';
@ -236,6 +237,21 @@
</div>
</div>
{/if}
{#if asset.tags.length > 0}
<div class="flex gap-4 py-4">
<div><TagOutline size="24" /></div>
<div class="container mx-auto flex flex-row">
{#each asset.tags as tag}
<a href="/tags/{tag.id}" on:click={() => dispatch('close-viewer')}>
<div class="flex justify-center items-center m-1 px-2 py-1 border border-gray-300 rounded-full text-base dark:text-immich-dark-fg font-medium">
<div class="flex-initial max-w-full leading-none text-xs font-normal text-center">{tag.name}</div>
</div>
</a>
{/each}
</div>
</div>
{/if}
</div>
</section>

View file

@ -0,0 +1,22 @@
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals, parent, params }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
const { data: tag } = await locals.api.tagApi.getTagById({ id: params.tagId });
const { data: assets } = await locals.api.tagApi.getTagAssets({ id: params.tagId });
return {
user,
assets,
tag,
meta: {
title: tag.name || 'Tag',
},
};
}) satisfies PageServerLoad;

View file

@ -0,0 +1,98 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { AssetResponseDto, TagResponseDto, api } from '@api';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
import type { PageData } from './$types';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import SelectAll from 'svelte-material-icons/SelectAll.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import FaceThumbnailSelector from '$lib/components/faces-page/face-thumbnail-selector.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import TagOutline from 'svelte-material-icons/TagOutline.svelte';
export let data: PageData;
export let tag: TagResponseDto;
let isSelectingFace = false;
let previousRoute: string = AppRoute.EXPLORE;
let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0;
$: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
$: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
afterNavigate(({ from }) => {
// Prevent setting previousRoute to the current page.
if (from && from.route.id !== $page.route.id) {
previousRoute = from.url.href;
}
});
const onAssetDelete = (assetId: string) => {
data.assets = data.assets.filter((asset: AssetResponseDto) => asset.id !== assetId);
};
const handleSelectAll = () => {
selectedAssets = new Set(data.assets);
};
</script>
{#if isMultiSelectionMode}
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<CreateSharedLink />
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
<AssetSelectContextMenu icon={Plus} title="Add">
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
<DeleteAssets {onAssetDelete} />
<AssetSelectContextMenu icon={DotsVertical} title="Add">
<DownloadAction menuItem filename="{data.tag.name || 'immich'}.zip" />
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<ArchiveAction menuItem unarchive={isAllArchive} onAssetArchive={(asset) => onAssetDelete(asset.id)} />
</AssetSelectContextMenu>
</AssetSelectControlBar>
{:else}
<ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)} />
{/if}
<!-- Tag information block -->
<section class="pt-24 px-4 sm:px-6 flex place-items-center">
<div class="flex gap-4 py-2">
<div>
<TagOutline class="text-6xl text-immich-primary dark:text-immich-dark-primary w-[99%] border-b-2 border-transparent outline-none bg-immich-bg dark:bg-immich-dark-bg"/>
</div>
<div class="text-6xl text-immich-primary dark:text-immich-dark-primary w-[99%] border-b-2 border-transparent outline-none bg-immich-bg dark:bg-immich-dark-bg">
{data.tag.name}
</div>
</div>
</section>
<!-- Gallery Block -->
{#if !isSelectingFace}
<section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg">
<section class="overflow-y-auto relative immich-scrollbar">
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
<GalleryViewer assets={data.assets} viewFrom="search-page" showArchiveIcon={true} bind:selectedAssets />
</section>
</section>
</section>
{/if}

View file

@ -0,0 +1,14 @@
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const prerender = false;
export const load: PageLoad = async ({ params, parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
}
const tagId = params['tagId'];
throw redirect(302, `${AppRoute.TAG}/${tagId}`);
};