From 57f4db9e6a0c32b57519120e147aba35a6ab55b1 Mon Sep 17 00:00:00 2001 From: ildyria Date: Mon, 23 Jun 2025 19:07:54 +0200 Subject: [PATCH 1/3] feature: refactoring the tag system --- app/Actions/Album/CreateTagAlbum.php | 3 +- app/Actions/Album/PositionData.php | 1 + app/Actions/Albums/PositionData.php | 1 + .../Photo/Pipes/Shared/HydrateMetadata.php | 3 +- app/Actions/Search/PhotoSearch.php | 4 +- app/Casts/{ArrayCast.php => TagArrayCast.php} | 27 ++-- app/Factories/AlbumFactory.php | 4 +- .../Controllers/Gallery/AlbumController.php | 3 +- .../Controllers/Gallery/FrameController.php | 4 +- .../Controllers/Gallery/PhotoController.php | 29 ++-- .../Requests/Album/AddTagAlbumRequest.php | 1 + .../Editable/EditableBaseAlbumResource.php | 2 +- app/Http/Resources/Models/PhotoResource.php | 2 +- .../Resources/Models/TagAlbumResource.php | 2 +- app/Models/Builders/TagBuilder.php | 20 +++ app/Models/Photo.php | 20 ++- app/Models/Tag.php | 102 ++++++++++++ app/Models/TagAlbum.php | 19 ++- app/Relations/BaseHasManyPhotos.php | 2 +- app/Relations/HasManyPhotosByTag.php | 24 +-- app/SmartAlbums/BaseSmartAlbum.php | 2 +- app/SmartAlbums/UnsortedAlbum.php | 2 +- database/factories/PhotoFactory.php | 24 ++- database/factories/TagAlbumFactory.php | 8 +- database/factories/TagFactory.php | 39 +++++ .../2025_06_07_142046_create_tags_table.php | 52 +++++++ .../2025_06_07_144157_photo_tags_to_table.php | 146 ++++++++++++++++++ ...7_150253_remove_tag_column_from_photos.php | 35 +++++ tests/Feature_v2/Album/AlbumTest.php | 1 + tests/Feature_v2/Album/AlbumUpdateTest.php | 1 + tests/Feature_v2/Base/BaseApiWithDataTest.php | 14 +- tests/Traits/RequiresEmptyGroups.php | 2 + tests/Traits/RequiresEmptyTags.php | 38 +++++ 33 files changed, 569 insertions(+), 68 deletions(-) rename app/Casts/{ArrayCast.php => TagArrayCast.php} (57%) create mode 100644 app/Models/Builders/TagBuilder.php create mode 100644 app/Models/Tag.php create mode 100644 database/factories/TagFactory.php create mode 100644 database/migrations/2025_06_07_142046_create_tags_table.php create mode 100644 database/migrations/2025_06_07_144157_photo_tags_to_table.php create mode 100644 database/migrations/2025_06_07_150253_remove_tag_column_from_photos.php create mode 100644 tests/Traits/RequiresEmptyTags.php diff --git a/app/Actions/Album/CreateTagAlbum.php b/app/Actions/Album/CreateTagAlbum.php index a06ceed7176..6e1e62cc47f 100644 --- a/app/Actions/Album/CreateTagAlbum.php +++ b/app/Actions/Album/CreateTagAlbum.php @@ -10,6 +10,7 @@ use App\Exceptions\ModelDBException; use App\Exceptions\UnauthenticatedException; +use App\Models\Tag; use App\Models\TagAlbum; use Illuminate\Support\Facades\Auth; @@ -33,7 +34,7 @@ public function create(string $title, array $show_tags): TagAlbum $album = new TagAlbum(); $album->title = $title; - $album->show_tags = $show_tags; + $album->show_tags = Tag::from($show_tags)->all(); $album->owner_id = $user_id; $album->save(); $this->setStatistics($album); diff --git a/app/Actions/Album/PositionData.php b/app/Actions/Album/PositionData.php index 3ea1ab778c9..9e67f84b5d3 100644 --- a/app/Actions/Album/PositionData.php +++ b/app/Actions/Album/PositionData.php @@ -35,6 +35,7 @@ public function get(AbstractAlbum $album, bool $include_sub_albums = false): Pos $r->whereBetween('type', [SizeVariantType::SMALL2X, SizeVariantType::THUMB]); }, 'palette', + 'tags', ]) ->whereNotNull('latitude') ->whereNotNull('longitude'); diff --git a/app/Actions/Albums/PositionData.php b/app/Actions/Albums/PositionData.php index 512fae5fd52..f71c0083a5f 100644 --- a/app/Actions/Albums/PositionData.php +++ b/app/Actions/Albums/PositionData.php @@ -49,6 +49,7 @@ public function do(): PositionDataResource $r->whereBetween('type', [SizeVariantType::SMALL2X, SizeVariantType::THUMB]); }, 'palette', + 'tags', ]) ->whereNotNull('latitude') ->whereNotNull('longitude'), diff --git a/app/Actions/Photo/Pipes/Shared/HydrateMetadata.php b/app/Actions/Photo/Pipes/Shared/HydrateMetadata.php index 499e4c1c005..f26f1afe128 100644 --- a/app/Actions/Photo/Pipes/Shared/HydrateMetadata.php +++ b/app/Actions/Photo/Pipes/Shared/HydrateMetadata.php @@ -38,7 +38,8 @@ public function handle(DuplicateDTO|StandaloneDTO $state, \Closure $next): Dupli $state->photo->description = $state->exif_info->description; } if (count($state->photo->tags) === 0) { - $state->photo->tags = $state->exif_info->tags; + // TODO FIX ME. + // $state->photo->tags = $state->exif_info->tags; } if ($state->photo->type === null) { $state->photo->type = $state->exif_info->type; diff --git a/app/Actions/Search/PhotoSearch.php b/app/Actions/Search/PhotoSearch.php index b0eb9aab3da..b07e6e6c054 100644 --- a/app/Actions/Search/PhotoSearch.php +++ b/app/Actions/Search/PhotoSearch.php @@ -57,7 +57,7 @@ public function query(array $terms): Collection public function sqlQuery(array $terms, ?Album $album = null): Builder { $query = $this->photoQueryPolicy->applySearchabilityFilter( - query: Photo::query()->with(['albums', 'statistics', 'size_variants', 'palette']), + query: Photo::query()->with(['albums', 'statistics', 'size_variants', 'palette', 'tags']), origin: $album, include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_search') ); @@ -67,7 +67,7 @@ public function sqlQuery(array $terms, ?Album $album = null): Builder fn (FixedQueryBuilder $query) => $query ->where('title', 'like', '%' . $term . '%') ->orWhere('description', 'like', '%' . $term . '%') - ->orWhere('tags', 'like', '%' . $term . '%') + // ->orWhere('tags', 'like', '%' . $term . '%') ->orWhere('location', 'like', '%' . $term . '%') ->orWhere('model', 'like', '%' . $term . '%') ->orWhere('taken_at', 'like', '%' . $term . '%') diff --git a/app/Casts/ArrayCast.php b/app/Casts/TagArrayCast.php similarity index 57% rename from app/Casts/ArrayCast.php rename to app/Casts/TagArrayCast.php index 56e1b801c87..46ab6639d95 100644 --- a/app/Casts/ArrayCast.php +++ b/app/Casts/TagArrayCast.php @@ -8,13 +8,14 @@ namespace App\Casts; +use App\Models\Tag; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; /** - * @implements CastsAttributes + * @implements CastsAttributes */ -class ArrayCast implements CastsAttributes +class TagArrayCast implements CastsAttributes { /** * @param Model $model the associated model class @@ -22,34 +23,38 @@ class ArrayCast implements CastsAttributes * @param mixed $value the stringified array * @param array $attributes all SQL attributes of the entity * - * @return array the array + * @return array the array */ public function get(Model $model, string $key, mixed $value, array $attributes): array { - return ($value === null || $value === '') ? [] : explode(',', strval($value)); + if ($value === null || $value === '') { + return []; + } + + // Split the string by ' OR ' and return an array of Tag objects + return Tag::whereIn('id', explode(' OR ', strval($value)))->get()->all(); } /** - * @param Model $model the associated model class - * @param string $key the name of the SQL column holding the stringified array - * @param (string|null)[]|null $value the array - * @param array $attributes + * @param Model $model the associated model class + * @param string $key the name of the SQL column holding the stringified array + * @param (Tag|null)[]|null $value the array + * @param array $attributes * * @return array An associative map of SQL columns and their values */ public function set(Model $model, string $key, mixed $value, array $attributes): array { // Normalize the input value - // The array must not contain empty tags and tags which contain a comma // TODO: Either use a separate table to store the tags or another encoding (e.g. JSON) which also allows commas in tags $arr = !is_array($value) ? [] : array_values(array_filter( $value, - fn ($elem) => ($elem !== null && $elem !== '' && !str_contains($elem, ',')), + fn ($elem) => ($elem !== null && $elem !== '' && $elem instanceof Tag && $elem->name !== ''), )); return [ - $key => count($arr) === 0 ? null : implode(',', $arr), + $key => count($arr) === 0 ? null : implode(' OR ', array_map(fn ($t) => $t->id, $arr)), ]; } } diff --git a/app/Factories/AlbumFactory.php b/app/Factories/AlbumFactory.php index 292ec381f3d..d535b2ef1a7 100644 --- a/app/Factories/AlbumFactory.php +++ b/app/Factories/AlbumFactory.php @@ -96,8 +96,8 @@ public function findBaseAlbumOrFail(string $album_id, bool $with_relations = tru $tag_album_query = TagAlbum::query(); if ($with_relations) { - $album_query->with(['access_permissions', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette']); - $tag_album_query->with(['photos', 'photos.size_variants', 'photos.statistics', 'photos.palette']); + $album_query->with(['access_permissions', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']); + $tag_album_query->with(['photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']); } $ret = $album_query->find($album_id) ?? $tag_album_query->find($album_id); diff --git a/app/Http/Controllers/Gallery/AlbumController.php b/app/Http/Controllers/Gallery/AlbumController.php index a201a198947..d9d460d2006 100644 --- a/app/Http/Controllers/Gallery/AlbumController.php +++ b/app/Http/Controllers/Gallery/AlbumController.php @@ -57,6 +57,7 @@ use App\Http\Resources\Models\Utils\AlbumProtectionPolicy; use App\Models\Album; use App\Models\Extensions\BaseAlbum; +use App\Models\Tag; use App\Models\TagAlbum; use App\SmartAlbums\BaseSmartAlbum; use Illuminate\Routing\Controller; @@ -165,7 +166,7 @@ public function updateTagAlbum(UpdateTagAlbumRequest $request): EditableBaseAlbu } $album->title = $request->title(); $album->description = $request->description(); - $album->show_tags = $request->tags(); + $album->show_tags = Tag::from($request->tags())->all(); $album->copyright = $request->copyright(); $album->photo_sorting = $request->photoSortingCriterion(); $album->photo_layout = $request->photoLayout(); diff --git a/app/Http/Controllers/Gallery/FrameController.php b/app/Http/Controllers/Gallery/FrameController.php index b5a5eb41036..805d0e82808 100644 --- a/app/Http/Controllers/Gallery/FrameController.php +++ b/app/Http/Controllers/Gallery/FrameController.php @@ -86,12 +86,12 @@ private function loadPhoto(AbstractAlbum|null $album, int $retries = 5): ?Photo // default query if ($album === null) { $query = $this->photo_query_policy->applySearchabilityFilter( - query: Photo::query()->with(['albums', 'size_variants', 'palette']), + query: Photo::query()->with(['albums', 'size_variants', 'palette', 'tags']), origin: null, include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_frame') ); } else { - $query = $album->photos()->with(['albums', 'size_variants', 'palette']); + $query = $album->photos()->with(['albums', 'size_variants', 'palette', 'tags']); } /** @var ?Photo $photo */ diff --git a/app/Http/Controllers/Gallery/PhotoController.php b/app/Http/Controllers/Gallery/PhotoController.php index 3af23139c37..6029c9ce440 100644 --- a/app/Http/Controllers/Gallery/PhotoController.php +++ b/app/Http/Controllers/Gallery/PhotoController.php @@ -35,9 +35,11 @@ use App\Jobs\ProcessImageJob; use App\Models\Configs; use App\Models\Photo; +use App\Models\Tag; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; /** @@ -122,7 +124,10 @@ public function update(EditPhotoRequest $request): PhotoResource $photo->title = $request->title(); $photo->description = $request->description(); $photo->created_at = $request->uploadDate(); - $photo->tags = $request->tags(); + + $existing_tags = Tag::from($request->tags()); + $photo->tags()->sync($existing_tags->pluck('id')); + $photo->load('tags'); $photo->license = $request->license()->value; // if the request takenAt is null, then we set the initial value back. @@ -205,15 +210,21 @@ public function rename(RenamePhotoRequest $request): void public function tags(SetPhotosTagsRequest $request): void { $tags = $request->tags(); + $photos = $request->photos(); - /** @var Photo $photo */ - foreach ($request->photos() as $photo) { - if ($request->shall_override) { - $photo->tags = $tags; - } else { - $photo->tags = array_unique(array_merge($photo->tags, $tags)); - } - $photo->save(); + // Fetch existing tags + $existing_tags = Tag::from($tags); + + if ($request->shall_override) { + // Delete existing associations for those photos ids if we override the tags + DB::table('photos_tags') + ->whereIn('photo_id', $photos->pluck('id')) + ->delete(); } + + // Associate the existing tags with the photos + $existing_tags->each(function (Tag $tag) use ($photos): void { + $tag->photos()->syncWithoutDetaching($photos->pluck('id')); + }); } } \ No newline at end of file diff --git a/app/Http/Requests/Album/AddTagAlbumRequest.php b/app/Http/Requests/Album/AddTagAlbumRequest.php index 6bf7697b370..d11088c9a03 100644 --- a/app/Http/Requests/Album/AddTagAlbumRequest.php +++ b/app/Http/Requests/Album/AddTagAlbumRequest.php @@ -15,6 +15,7 @@ use App\Http\Requests\BaseApiRequest; use App\Http\Requests\Traits\HasTagsTrait; use App\Http\Requests\Traits\HasTitleTrait; +use App\Models\Tag; use App\Policies\AlbumPolicy; use App\Rules\TitleRule; use Illuminate\Support\Facades\Gate; diff --git a/app/Http/Resources/Editable/EditableBaseAlbumResource.php b/app/Http/Resources/Editable/EditableBaseAlbumResource.php index 872519ef515..b5f817f8238 100644 --- a/app/Http/Resources/Editable/EditableBaseAlbumResource.php +++ b/app/Http/Resources/Editable/EditableBaseAlbumResource.php @@ -67,7 +67,7 @@ public function __construct(Album|TagAlbum $album) } if ($album instanceof TagAlbum) { - $this->tags = $album->show_tags; + $this->tags = array_map(fn ($t) => $t->name, $album->show_tags); } } diff --git a/app/Http/Resources/Models/PhotoResource.php b/app/Http/Resources/Models/PhotoResource.php index c7485b8b346..f9ca4ce0ba4 100644 --- a/app/Http/Resources/Models/PhotoResource.php +++ b/app/Http/Resources/Models/PhotoResource.php @@ -93,7 +93,7 @@ public function __construct(Photo $photo, ?AbstractAlbum $album) $this->original_checksum = $photo->original_checksum; $this->shutter = $photo->shutter; $this->size_variants = new SizeVariantsResouce($photo, $album); - $this->tags = $photo->tags; + $this->tags = $photo->tags->pluck('name')->all(); $this->taken_at = $photo->taken_at?->toIso8601String(); $this->taken_at_orig_tz = $photo->taken_at_orig_tz; $this->title = (Configs::getValueAsBool('file_name_hidden') && Auth::guest()) ? '' : $photo->title; diff --git a/app/Http/Resources/Models/TagAlbumResource.php b/app/Http/Resources/Models/TagAlbumResource.php index 559da172c3a..261aa0e2b59 100644 --- a/app/Http/Resources/Models/TagAlbumResource.php +++ b/app/Http/Resources/Models/TagAlbumResource.php @@ -64,7 +64,7 @@ public function __construct(TagAlbum $tag_album) $this->title = $tag_album->title; $this->owner_name = Auth::check() ? $tag_album->owner->name : null; $this->is_tag_album = true; - $this->show_tags = $tag_album->show_tags; + $this->show_tags = array_map(fn ($t) => $t->name, $tag_album->show_tags); $this->copyright = $tag_album->copyright; // children diff --git a/app/Models/Builders/TagBuilder.php b/app/Models/Builders/TagBuilder.php new file mode 100644 index 00000000000..2222b2290eb --- /dev/null +++ b/app/Models/Builders/TagBuilder.php @@ -0,0 +1,20 @@ + + */ +class TagBuilder extends FixedQueryBuilder +{ +} \ No newline at end of file diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 9ca940addcf..0ba8c7555a6 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -9,7 +9,6 @@ namespace App\Models; use App\Actions\Photo\Delete; -use App\Casts\ArrayCast; use App\Casts\DateTimeWithTimezoneCast; use App\Casts\MustNotSetCast; use App\Constants\PhotoAlbum as PA; @@ -49,7 +48,7 @@ * @property string $id * @property string $title * @property string|null $description - * @property string[] $tags + * @property Collection $tags * @property int $owner_id * @property string|null $type * @property string|null $iso @@ -162,7 +161,6 @@ class Photo extends Model implements HasUTCBasedTimes 'taken_at_mod' => 'datetime', 'owner_id' => 'integer', 'is_starred' => 'boolean', - 'tags' => ArrayCast::class, 'latitude' => 'float', 'longitude' => 'float', 'altitude' => 'float', @@ -234,6 +232,22 @@ public function palette(): HasOne return $this->hasOne(Palette::class, 'photo_id', 'id'); } + /** + * Returns the relationship between a tag and all photos with whom + * this tag is attached. + * + * @return BelongsToMany + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany( + Tag::class, + 'photos_tags', + 'photo_id', + 'tag_id', + ); + } + /** * Accessor for attribute {@link Photo::$shutter}. * diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 00000000000..f4c7fb67bdd --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,102 @@ + */ + use HasFactory; + use ToArrayThrowsNotImplemented; + use ThrowsConsistentExceptions { + delete as parentDelete; + } + + // Disable the default timestamps handling + public $timestamps = false; + + /** + * @var list the attributes that are mass assignable + */ + protected $fillable = [ + 'name', + 'description', + ]; + + protected $hidden = []; + + /** + * Create a new Eloquent query builder for the model. + * + * @param BaseBuilder $query + * + * @return TagBuilder + */ + public function newEloquentBuilder($query): TagBuilder + { + return new TagBuilder($query); + } + + /** + * Returns the relationship between a tag and all photos with whom + * this tag is attached. + * + * @return BelongsToMany + */ + public function photos(): BelongsToMany + { + return $this->belongsToMany( + Photo::class, + 'photos_tags', + 'tag_id', + 'photo_id', + ); + } + + /** + * Fetches the tags from the database, creating them if they do not exist. + * + * @param array $tags + * + * @return Collection + */ + public static function from(array $tags): Collection + { + // Trim whitespace from each tag + $tags = array_map(fn ($tag) => $tag = trim($tag), $tags); + + // Fetch existing tags + $existing_tags = self::whereIn('name', $tags)->get(); + + // figure out the missing ones and create them. + $missing_tags = array_diff($tags, $existing_tags->pluck('name')->all()); + if (count($missing_tags) > 0) { + // Create missing tags + self::insert(array_map(fn ($name) => ['name' => $name], $missing_tags)); + $existing_tags = $existing_tags->merge(self::whereIn('name', $missing_tags)->get()); + } + + return $existing_tags; + } +} diff --git a/app/Models/TagAlbum.php b/app/Models/TagAlbum.php index 8fb47e9441e..e3d4dc490cf 100644 --- a/app/Models/TagAlbum.php +++ b/app/Models/TagAlbum.php @@ -8,7 +8,7 @@ namespace App\Models; -use App\Casts\ArrayCast; +use App\Casts\TagArrayCast; use App\Exceptions\InvalidPropertyException; use App\ModelFunctions\HasAbstractAlbumProperties; use App\Models\Builders\TagAlbumBuilder; @@ -23,7 +23,7 @@ /** * App\Models\TagAlbum. * - * @property string[] $show_tags + * @property Tag[] $show_tags * * @method static TagAlbumBuilder|TagAlbum query() Begin querying the model. * @method static TagAlbumBuilder|TagAlbum with(array|string $relations) Begin querying the model with eager loading. @@ -78,13 +78,16 @@ class TagAlbum extends BaseAlbum ]; /** - * @var array + * @return array */ - protected $casts = [ - 'min_taken_at' => 'datetime', - 'max_taken_at' => 'datetime', - 'show_tags' => ArrayCast::class, - ]; + protected function casts(): array + { + return [ + 'min_taken_at' => 'datetime', + 'max_taken_at' => 'datetime', + 'show_tags' => TagArrayCast::class, + ]; + } /** * @var list The list of attributes which exist as columns of the DB diff --git a/app/Relations/BaseHasManyPhotos.php b/app/Relations/BaseHasManyPhotos.php index 505b45fd4f8..18aa9382bda 100644 --- a/app/Relations/BaseHasManyPhotos.php +++ b/app/Relations/BaseHasManyPhotos.php @@ -65,7 +65,7 @@ public function __construct(TagAlbum|Album $owning_album) // indirect condition. // Hence, the actually owning albums of the photos are not // necessarily loaded. - Photo::query()->with(['albums', 'size_variants', 'palette']), + Photo::query()->with(['albums', 'size_variants', 'palette', 'tags']), $owning_album ); } diff --git a/app/Relations/HasManyPhotosByTag.php b/app/Relations/HasManyPhotosByTag.php index c006e80fe57..7220e078169 100644 --- a/app/Relations/HasManyPhotosByTag.php +++ b/app/Relations/HasManyPhotosByTag.php @@ -16,6 +16,7 @@ use App\Models\TagAlbum; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Query\Builder as BaseBuilder; /** * @disregard @@ -76,12 +77,7 @@ public function addEagerConstraints(array $albums): void origin: null, include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_smart_albums') ) - ->where(function (Builder $q) use ($tags): void { - // Filter for requested tags - foreach ($tags as $tag) { - $q->where('tags', 'like', '%' . trim($tag) . '%'); - } - }); + ->where(fn (Builder $q) => $this->getPhotoIdsWithTags($q, $tags)); } else { $this->photo_query_policy ->applySearchabilityFilter( @@ -89,15 +85,19 @@ public function addEagerConstraints(array $albums): void origin: null, include_nsfw: !Configs::getValueAsBool('hide_nsfw_in_smart_albums') ) - ->where(function (Builder $q) use ($tags): void { - // Filter for requested tags - foreach ($tags as $tag) { - $q->where('tags', 'like', '%' . trim($tag) . '%'); - } - }); + ->where(fn (Builder $q) => $this->getPhotoIdsWithTags($q, $tags)); } } + private function getPhotoIdsWithTags(Builder &$query, array $tags): void + { + $query->whereExists(fn (BaseBuilder $q) => $q->select('photos_tags.photo_id') + ->from('photos_tags') + ->whereIn('photos_tags.tag_id', array_map(fn ($t) => $t->id, $tags)) + ->whereColumn('photos_tags.photo_id', 'photos.id') + ); + } + /** * Maps a collection of eagerly fetched photos to the given owning albums. * diff --git a/app/SmartAlbums/BaseSmartAlbum.php b/app/SmartAlbums/BaseSmartAlbum.php index 4e4342c781e..33a93eb3a2b 100644 --- a/app/SmartAlbums/BaseSmartAlbum.php +++ b/app/SmartAlbums/BaseSmartAlbum.php @@ -102,7 +102,7 @@ public function get_photos(): Collection */ public function photos(): Builder { - $base_query = Photo::query()->leftJoin(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID)->with(['size_variants', 'statistics', 'palette']); + $base_query = Photo::query()->leftJoin(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID)->with(['size_variants', 'statistics', 'palette', 'tags']); if (!Configs::getValueAsBool('SA_override_visibility')) { return $this->photo_query_policy diff --git a/app/SmartAlbums/UnsortedAlbum.php b/app/SmartAlbums/UnsortedAlbum.php index 978e31387a0..6cd4cc65fbb 100644 --- a/app/SmartAlbums/UnsortedAlbum.php +++ b/app/SmartAlbums/UnsortedAlbum.php @@ -46,7 +46,7 @@ public static function getInstance(): self public function photos(): Builder { if ($this->public_permissions !== null) { - return Photo::query()->leftJoin(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID)->with(['size_variants', 'statistics', 'palette']) + return Photo::query()->leftJoin(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID)->with(['size_variants', 'statistics', 'palette', 'tags']) ->where($this->smart_photo_condition); } diff --git a/database/factories/PhotoFactory.php b/database/factories/PhotoFactory.php index b70271806da..b6443979c8d 100644 --- a/database/factories/PhotoFactory.php +++ b/database/factories/PhotoFactory.php @@ -13,6 +13,7 @@ use App\Models\Photo; use App\Models\SizeVariant; use App\Models\Statistics; +use App\Models\Tag; use Database\Factories\Traits\OwnedBy; use Illuminate\Database\Eloquent\Factories\Factory; @@ -44,7 +45,6 @@ public function definition(): array return [ 'title' => 'CR_' . fake()->numerify('####'), 'description' => null, - 'tags' => '', 'owner_id' => 1, 'type' => 'image/jpeg', 'iso' => '100', @@ -125,13 +125,22 @@ public function with_subGPS_coordinates(): self }); } - /** define tags for that picture */ - public function with_tags(string $tags): self + /** + * Define tags for that picture. + * + * @param array $tags + * + * @return PhotoFactory + */ + public function with_tags(array $tags): self { - return $this->state(function (array $attributes) use ($tags) { - return [ - 'tags' => $tags, - ]; + return $this->afterCreating(function (Photo $photo) use ($tags) { + foreach ($tags as $tag) { + if (!$tag instanceof Tag) { + throw new \TypeError('Expected Tag instance, got ' . gettype($tag)); + } + $photo->tags()->attach($tag); + } }); } @@ -194,6 +203,7 @@ public function configure(): static } $photo->load('palette'); + $photo->load('tags'); $photo->load('statistics'); // Reset the value if it was disabled. diff --git a/database/factories/TagAlbumFactory.php b/database/factories/TagAlbumFactory.php index 6ff0297b788..70d0f07e8f3 100644 --- a/database/factories/TagAlbumFactory.php +++ b/database/factories/TagAlbumFactory.php @@ -9,6 +9,7 @@ namespace Database\Factories; use App\Models\Statistics; +use App\Models\Tag; use App\Models\TagAlbum; use Database\Factories\Traits\OwnedBy; use Illuminate\Database\Eloquent\Factories\Factory; @@ -40,7 +41,12 @@ public function definition(): array ]; } - public function of_tags(string $tags): self + /** + * @param array $tags + * + * @return TagAlbumFactory + */ + public function of_tags(array $tags): self { return $this->state(function (array $attributes) use ($tags) { return [ diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php new file mode 100644 index 00000000000..da66ab946e5 --- /dev/null +++ b/database/factories/TagFactory.php @@ -0,0 +1,39 @@ + $this->faker->unique()->word(), + 'description' => $this->faker->sentence(), + ]; + } + + /** + * define name for that tag. + * + * @return self + */ + public function with_name(string $name): self + { + return $this->state(function (array $attributes) use ($name) { + return [ + 'name' => $name, + ]; + }); + } +} diff --git a/database/migrations/2025_06_07_142046_create_tags_table.php b/database/migrations/2025_06_07_142046_create_tags_table.php new file mode 100644 index 00000000000..5b1e8415896 --- /dev/null +++ b/database/migrations/2025_06_07_142046_create_tags_table.php @@ -0,0 +1,52 @@ +bigIncrements('id'); + $table->string('name', 100)->nullable(false)->unique(); + $table->string('description', 255)->nullable(true); + $table->index('name'); + }); + + Schema::create('photos_tags', function (Blueprint $table) { + $table->bigIncrements('id'); + $table->unsignedBigInteger(self::TAG_ID)->nullable(false); + $table->char(self::PHOTO_ID, self::RANDOM_ID_LENGTH)->nullable(false); + + $table->index([self::TAG_ID]); + $table->index([self::PHOTO_ID]); + $table->index([self::TAG_ID, self::PHOTO_ID]); + $table->unique([self::TAG_ID, self::PHOTO_ID]); + $table->foreign(self::TAG_ID)->references('id')->on('tags')->cascadeOnUpdate()->cascadeOnDelete(); + $table->foreign(self::PHOTO_ID)->references('id')->on('photos')->cascadeOnUpdate()->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('photos_tags'); + Schema::dropIfExists('tags'); + } +}; diff --git a/database/migrations/2025_06_07_144157_photo_tags_to_table.php b/database/migrations/2025_06_07_144157_photo_tags_to_table.php new file mode 100644 index 00000000000..09198989c97 --- /dev/null +++ b/database/migrations/2025_06_07_144157_photo_tags_to_table.php @@ -0,0 +1,146 @@ + $this->applyUp()); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::transaction(fn () => $this->applyDown()); + } + + /** + * We itergate over all photos with tags and create a new tag for each + * unique tag name. Then we create a link between the tag and the photo. + * + * @return void + * + * @throws InvalidArgumentException + */ + private function applyUp(): void + { + $tags_to_create = []; + $tag_photo_links = []; + + $tag_idx = 0; + DB::table('photos')->select(['id', 'tags']) + ->whereNotNull('tags') + ->where('tags', '!=', '') + ->orderBy('id') + ->chunk(100, function ($photos) use (&$tags_to_create, &$tag_photo_links, &$tag_idx) { + foreach ($photos as $photo) { + $tags = explode(',', $photo->tags); + foreach ($tags as $tag) { + $tag = trim($tag); + // Add the tag to the tags_to_create array if it doesn't exist + if (!array_key_exists($tag, $tags_to_create)) { + $tags_to_create[$tag] = [ + 'id' => ++$tag_idx, + 'name' => $tag, + 'description' => null, // No description provided + ]; + } + + // Create a link between the tag and the photo + $tag_photo_links[] = [ + 'tag_id' => $tags_to_create[$tag]['id'], + 'photo_id' => $photo->id, + ]; + } + } + }); + + DB::table('tags')->insert(array_values($tags_to_create)); + DB::table('photos_tags')->insert($tag_photo_links); + + DB::table('tag_albums')->orderBy('id')->chunk(100, function ($tag_albums) use (&$tags_to_create) { + foreach ($tag_albums as $tag_album) { + $new_show_tag = ''; + $tags = explode(',', $tag_album->show_tags); + foreach ($tags as $tag) { + $tag = trim($tag); + if (!array_key_exists($tag, $tags_to_create)) { + // skip tags that do not exist in the new tags + // this can happen if the tag was removed from the photo + // but still exists in the tag_album's show_tags field + continue; + } + $new_show_tag .= ($new_show_tag !== '' ? ' OR ' : '') . $tags_to_create[$tag]['id']; + } + DB::table('tag_albums') + ->where('id', $tag_album->id) + ->update(['show_tags' => $new_show_tag]); + } + }); + } + + /** + * Reversely, we iterate over all photos with tags and merge the tags in + * a comma-separated string before updating the photo's tags field. + * + * @return void + * + * @throws InvalidArgumentException + */ + private function applyDown(): void + { + DB::table('photos_tags')->distinct()->select('photo_id') + ->orderBy('photo_id') + ->chunk(100, function ($photo_ids) { + foreach ($photo_ids as $photo_id) { + $tags = DB::table('tags') + ->select(['tags.name']) + ->join('photos_tags', 'tags.id', '=', 'photos_tags.tag_id') + ->where('photos_tags.photo_id', '=', $photo_id)->pluck('name'); + $tags = implode(',', $tags->toArray()); + DB::table('photos')->where('id', $photo_id)->update(['tags' => $tags]); + } + }); + + // In theory this should create the mapping name => id for the tags. + $id_to_tag = DB::table('tags')->select(['id', 'name'])->pluck('id', 'name')->toArray(); + DB::table('tag_albums')->orderBy('id')->chunk(100, function ($tag_albums) use (&$id_to_tag) { + foreach ($tag_albums as $tag_album) { + if (str_contains($tag_album->show_tags, ' AND ')) { + // We skip, this is not supported. + continue; + } + + $new_show_tag = ''; + $tags_ids = explode(' OR ', $tag_album->show_tags); + foreach ($tags_ids as $tag_id) { + $tag_id = trim($tag_id); + if (!array_key_exists($tag_id, $id_to_tag)) { + // skip tags that do not exist in the new tags + // this can happen if the tag was removed from the photo + // but still exists in the tag_album's show_tags field + continue; + } + $new_show_tag .= ($new_show_tag !== '' ? ',' : '') . $id_to_tag[$tag_id]; + } + DB::table('tag_albums') + ->where('id', $tag_album->id) + ->update(['show_tags' => $new_show_tag]); + } + }); + + DB::table('photos_tags')->delete(); + DB::table('tags')->delete(); + } +}; diff --git a/database/migrations/2025_06_07_150253_remove_tag_column_from_photos.php b/database/migrations/2025_06_07_150253_remove_tag_column_from_photos.php new file mode 100644 index 00000000000..cadb469ed54 --- /dev/null +++ b/database/migrations/2025_06_07_150253_remove_tag_column_from_photos.php @@ -0,0 +1,35 @@ +dropColumn(self::TAGS); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + $table->text('tags')->nullable()->after('description'); + }); + } +}; diff --git a/tests/Feature_v2/Album/AlbumTest.php b/tests/Feature_v2/Album/AlbumTest.php index 5344c4f8e6d..04a37ccd901 100644 --- a/tests/Feature_v2/Album/AlbumTest.php +++ b/tests/Feature_v2/Album/AlbumTest.php @@ -111,6 +111,7 @@ public function testGetAsOwner(): void 'resource' => [ 'id' => $this->tagAlbum1->id, 'title' => $this->tagAlbum1->title, + 'show_tags' => [$this->tag_test->name], 'photos' => [ [ 'id' => $this->photo1->id, diff --git a/tests/Feature_v2/Album/AlbumUpdateTest.php b/tests/Feature_v2/Album/AlbumUpdateTest.php index 06f5059db78..5e274ad01c3 100644 --- a/tests/Feature_v2/Album/AlbumUpdateTest.php +++ b/tests/Feature_v2/Album/AlbumUpdateTest.php @@ -142,6 +142,7 @@ public function testUpdateTagAlbumAuthorized(): void 'resource' => [ 'id' => $this->tagAlbum1->id, 'title' => 'title', // from modified above. + 'show_tags' => ['tag1', 'tag2'], 'photos' => [], ], ]); diff --git a/tests/Feature_v2/Base/BaseApiWithDataTest.php b/tests/Feature_v2/Base/BaseApiWithDataTest.php index edfb6b5fed2..7c39ec682fd 100644 --- a/tests/Feature_v2/Base/BaseApiWithDataTest.php +++ b/tests/Feature_v2/Base/BaseApiWithDataTest.php @@ -24,6 +24,7 @@ use App\Models\Configs; use App\Models\Palette; use App\Models\Photo; +use App\Models\Tag; use App\Models\TagAlbum; use App\Models\User; use App\Models\UserGroup; @@ -33,6 +34,7 @@ use Tests\Traits\RequiresEmptyGroups; use Tests\Traits\RequiresEmptyLiveMetrics; use Tests\Traits\RequiresEmptyPhotos; +use Tests\Traits\RequiresEmptyTags; use Tests\Traits\RequiresEmptyUsers; use Tests\Traits\RequiresEmptyWebAuthnCredentials; @@ -46,6 +48,7 @@ abstract class BaseApiWithDataTest extends BaseApiTest use RequiresEmptyWebAuthnCredentials; use InteractWithSmartAlbums; use RequiresEmptyGroups; + use RequiresEmptyTags; protected User $admin; protected User $userMayUpload1; @@ -74,6 +77,9 @@ abstract class BaseApiWithDataTest extends BaseApiTest protected Album $album3; protected Photo $photo3; + // Tags + protected Tag $tag_test; + // album 4 belongs to userLocked // album 4 is visible without being logged in protected Album $album4; @@ -103,6 +109,7 @@ public function setUp(): void $this->setUpRequiresEmptyColourPalettes(); $this->setUpRequiresEmptyLiveMetrics(); $this->setUpRequiresEmptyGroups(); + $this->setUpRequiresEmptyTags(); $this->admin = User::factory()->may_administrate()->create(); $this->userMayUpload1 = User::factory()->may_upload()->create(); @@ -115,13 +122,15 @@ public function setUp(): void $this->userWithGroup1 = User::factory()->with_group($this->group1)->create(); $this->userWithGroupAdmin = User::factory()->with_group($this->group1, UserGroupRole::ADMIN)->create(); + $this->tag_test = Tag::factory()->with_name('test')->create(); + $this->album1 = Album::factory()->as_root()->owned_by($this->userMayUpload1)->create(); - $this->photo1 = Photo::factory()->owned_by($this->userMayUpload1)->with_GPS_coordinates()->with_tags('test')->with_palette()->in($this->album1)->create(); + $this->photo1 = Photo::factory()->owned_by($this->userMayUpload1)->with_GPS_coordinates()->with_tags([$this->tag_test])->with_palette()->in($this->album1)->create(); $this->palette1 = $this->photo1->palette; $this->photo1b = Photo::factory()->owned_by($this->userMayUpload1)->with_subGPS_coordinates()->in($this->album1)->create(); $this->subAlbum1 = Album::factory()->children_of($this->album1)->owned_by($this->userMayUpload1)->create(); $this->subPhoto1 = Photo::factory()->owned_by($this->userMayUpload1)->with_GPS_coordinates()->in($this->subAlbum1)->create(); - $this->tagAlbum1 = TagAlbum::factory()->owned_by($this->userMayUpload1)->of_tags('test')->create(); + $this->tagAlbum1 = TagAlbum::factory()->owned_by($this->userMayUpload1)->of_tags([$this->tag_test])->create(); $this->album2 = Album::factory()->as_root()->owned_by($this->userMayUpload2)->create(); $this->photo2 = Photo::factory()->owned_by($this->userMayUpload2)->with_GPS_coordinates()->in($this->album2)->create(); @@ -174,6 +183,7 @@ public function setUp(): void public function tearDown(): void { + $this->tearDownRequiresEmptyTags(); $this->tearDownRequiresEmptyLiveMetrics(); $this->tearDownRequiresEmptyColourPalettes(); $this->tearDownRequiresEmptyPhotos(); diff --git a/tests/Traits/RequiresEmptyGroups.php b/tests/Traits/RequiresEmptyGroups.php index b88dbefea85..2b2581b5507 100644 --- a/tests/Traits/RequiresEmptyGroups.php +++ b/tests/Traits/RequiresEmptyGroups.php @@ -16,11 +16,13 @@ abstract protected function assertDatabaseCount($table, int $count, $connection protected function setUpRequiresEmptyGroups(): void { + $this->assertDatabaseCount('users_user_groups', 0); $this->assertDatabaseCount('user_groups', 0); } protected function tearDownRequiresEmptyGroups(): void { + DB::table('users_user_groups')->delete(); DB::table('user_groups')->delete(); } } diff --git a/tests/Traits/RequiresEmptyTags.php b/tests/Traits/RequiresEmptyTags.php new file mode 100644 index 00000000000..a000bf4abf5 --- /dev/null +++ b/tests/Traits/RequiresEmptyTags.php @@ -0,0 +1,38 @@ +assertDatabaseCount('photos_tags', 0); + $this->assertDatabaseCount('tags', 0); + } + + protected function tearDownRequiresEmptyTags(): void + { + DB::table('tags')->delete(); + DB::table('photos_tags')->delete(); + } +} From cf5db29ef667c8535b148d87f6e87a987d43d63b Mon Sep 17 00:00:00 2001 From: ildyria Date: Sun, 29 Jun 2025 17:10:30 +0200 Subject: [PATCH 2/3] Fix tests --- tests/Unit/Actions/Db/OptimizeDbTest.php | 2 +- tests/Unit/Actions/Db/OptimizeTablesTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Actions/Db/OptimizeDbTest.php b/tests/Unit/Actions/Db/OptimizeDbTest.php index a70f18245be..c2ffe77fd47 100644 --- a/tests/Unit/Actions/Db/OptimizeDbTest.php +++ b/tests/Unit/Actions/Db/OptimizeDbTest.php @@ -32,6 +32,6 @@ public function testOptimizeDb(): void { $optimize = new OptimizeDb(); $output = count($optimize->do()); - self::assertTrue(in_array($output, [3, 26], true), 'OptimizeDb should return either 3 or 26: ' . $output); + self::assertTrue(in_array($output, [3, 28], true), 'OptimizeDb should return either 3 or 28: ' . $output); } } diff --git a/tests/Unit/Actions/Db/OptimizeTablesTest.php b/tests/Unit/Actions/Db/OptimizeTablesTest.php index b6974e1f6d2..7edf44b9295 100644 --- a/tests/Unit/Actions/Db/OptimizeTablesTest.php +++ b/tests/Unit/Actions/Db/OptimizeTablesTest.php @@ -32,6 +32,6 @@ public function testOptimizeTables(): void { $optimize = new OptimizeTables(); $output = count($optimize->do()); - self::assertTrue(in_array($output, [3, 26], true), 'OptimizeTables should return either 3 or 26: ' . $output); + self::assertTrue(in_array($output, [3, 28], true), 'OptimizeTables should return either 3 or 28: ' . $output); } } From e5c67c88e79eb5c817ab9a1e917fe71ffcb51861 Mon Sep 17 00:00:00 2001 From: ildyria Date: Wed, 30 Jul 2025 20:56:12 +0200 Subject: [PATCH 3/3] add tags to hydrated data --- app/Actions/Albums/Flow.php | 2 +- app/Actions/Photo/Pipes/Shared/HydrateMetadata.php | 4 ++-- app/Actions/Photo/Pipes/Shared/Save.php | 1 + app/Contracts/PhotoCreate/PhotoDTO.php | 7 +++++++ app/DTO/PhotoCreate/DuplicateDTO.php | 9 +++++++++ app/DTO/PhotoCreate/PhotoPartnerDTO.php | 6 ++++++ app/DTO/PhotoCreate/StandaloneDTO.php | 8 ++++++++ app/DTO/PhotoCreate/VideoPartnerDTO.php | 6 ++++++ app/Image/Handlers/GoogleMotionPictureHandler.php | 4 +++- 9 files changed, 43 insertions(+), 4 deletions(-) diff --git a/app/Actions/Albums/Flow.php b/app/Actions/Albums/Flow.php index 2f68197a168..50ee1518c56 100644 --- a/app/Actions/Albums/Flow.php +++ b/app/Actions/Albums/Flow.php @@ -110,7 +110,7 @@ private function getQuery(Album|null $base, bool $with_relations): AlbumBuilder $base_query = Album::query(); if ($with_relations) { - $base_query->with(['cover', 'cover.size_variants', 'statistics', 'photos', 'photos.statistics', 'photos.size_variants', 'photos.palette']); + $base_query->with(['cover', 'cover.size_variants', 'statistics', 'photos', 'photos.statistics', 'photos.size_variants', 'photos.palette', 'photos.tags']); } // Only join what we need for ordering. diff --git a/app/Actions/Photo/Pipes/Shared/HydrateMetadata.php b/app/Actions/Photo/Pipes/Shared/HydrateMetadata.php index f26f1afe128..0a097036fc0 100644 --- a/app/Actions/Photo/Pipes/Shared/HydrateMetadata.php +++ b/app/Actions/Photo/Pipes/Shared/HydrateMetadata.php @@ -11,6 +11,7 @@ use App\Contracts\PhotoCreate\SharedPipe; use App\DTO\PhotoCreate\DuplicateDTO; use App\DTO\PhotoCreate\StandaloneDTO; +use App\Models\Tag; /** * Hydrates meta-info of the media file from the @@ -38,8 +39,7 @@ public function handle(DuplicateDTO|StandaloneDTO $state, \Closure $next): Dupli $state->photo->description = $state->exif_info->description; } if (count($state->photo->tags) === 0) { - // TODO FIX ME. - // $state->photo->tags = $state->exif_info->tags; + $state->tags = Tag::from($state->exif_info->tags); } if ($state->photo->type === null) { $state->photo->type = $state->exif_info->type; diff --git a/app/Actions/Photo/Pipes/Shared/Save.php b/app/Actions/Photo/Pipes/Shared/Save.php index d504468cea8..b5f65175b61 100644 --- a/app/Actions/Photo/Pipes/Shared/Save.php +++ b/app/Actions/Photo/Pipes/Shared/Save.php @@ -19,6 +19,7 @@ class Save implements PhotoPipe public function handle(PhotoDTO $state, \Closure $next): PhotoDTO { $state->getPhoto()->save(); + $state->getPhoto()->tags()->sync($state->getTags()->pluck('id')->all()); return $next($state); } diff --git a/app/Contracts/PhotoCreate/PhotoDTO.php b/app/Contracts/PhotoCreate/PhotoDTO.php index a724318dbf6..31134cb9719 100644 --- a/app/Contracts/PhotoCreate/PhotoDTO.php +++ b/app/Contracts/PhotoCreate/PhotoDTO.php @@ -9,8 +9,15 @@ namespace App\Contracts\PhotoCreate; use App\Models\Photo; +use App\Models\Tag; +use Illuminate\Support\Collection; interface PhotoDTO { public function getPhoto(): Photo; + + /** + * @return Collection + */ + public function getTags(): Collection; } diff --git a/app/DTO/PhotoCreate/DuplicateDTO.php b/app/DTO/PhotoCreate/DuplicateDTO.php index 67adb32726f..0c9e0e692cb 100644 --- a/app/DTO/PhotoCreate/DuplicateDTO.php +++ b/app/DTO/PhotoCreate/DuplicateDTO.php @@ -12,6 +12,7 @@ use App\Contracts\PhotoCreate\PhotoDTO; use App\Metadata\Extractor; use App\Models\Photo; +use Illuminate\Support\Collection; /** * DTO used when dealing with duplicates. @@ -21,6 +22,8 @@ class DuplicateDTO implements PhotoDTO { public bool $has_been_resynced; + public Collection $tags; + public function __construct( public readonly bool $shall_resync_metadata, public readonly bool $shall_skip_duplicates, @@ -39,6 +42,7 @@ public function __construct( // During initial steps if duplicate is found, it will be placed here. public Photo $photo, ) { + $this->tags = $this->photo->tags; } public static function ofInit(InitDTO $init_dto): DuplicateDTO @@ -59,6 +63,11 @@ public function getPhoto(): Photo return $this->photo; } + public function getTags(): Collection + { + return $this->tags; + } + public function setHasBeenResync(bool $val): void { $this->has_been_resynced = $val; diff --git a/app/DTO/PhotoCreate/PhotoPartnerDTO.php b/app/DTO/PhotoCreate/PhotoPartnerDTO.php index cd576e290da..eba7992e945 100644 --- a/app/DTO/PhotoCreate/PhotoPartnerDTO.php +++ b/app/DTO/PhotoCreate/PhotoPartnerDTO.php @@ -11,6 +11,7 @@ use App\Contracts\Image\StreamStats; use App\Contracts\PhotoCreate\PhotoDTO; use App\Models\Photo; +use Illuminate\Support\Collection; class PhotoPartnerDTO implements PhotoDTO { @@ -27,4 +28,9 @@ public function getPhoto(): Photo { return $this->photo; } + + public function getTags(): Collection + { + return $this->photo->tags; + } } diff --git a/app/DTO/PhotoCreate/StandaloneDTO.php b/app/DTO/PhotoCreate/StandaloneDTO.php index 3f231cbea09..ace50c27f8b 100644 --- a/app/DTO/PhotoCreate/StandaloneDTO.php +++ b/app/DTO/PhotoCreate/StandaloneDTO.php @@ -18,6 +18,7 @@ use App\Image\Files\TemporaryLocalFile; use App\Metadata\Extractor; use App\Models\Photo; +use Illuminate\Support\Collection; class StandaloneDTO implements PhotoDTO { @@ -27,6 +28,7 @@ class StandaloneDTO implements PhotoDTO public FlysystemFile $target_file; public StreamStats|null $stream_stat; public FlysystemFile|null $backup_file = null; + public Collection $tags; public function __construct( // The resulting photo @@ -44,6 +46,7 @@ public function __construct( public readonly bool $shall_import_via_symlink, public readonly bool $shall_delete_imported, ) { + $this->tags = new Collection(); } public static function ofInit(InitDTO $init_dto): StandaloneDTO @@ -64,4 +67,9 @@ public function getPhoto(): Photo { return $this->photo; } + + public function getTags(): Collection + { + return $this->tags; + } } diff --git a/app/DTO/PhotoCreate/VideoPartnerDTO.php b/app/DTO/PhotoCreate/VideoPartnerDTO.php index 7449cf19286..03a4afb4e1f 100644 --- a/app/DTO/PhotoCreate/VideoPartnerDTO.php +++ b/app/DTO/PhotoCreate/VideoPartnerDTO.php @@ -12,6 +12,7 @@ use App\Contracts\PhotoCreate\PhotoDTO; use App\Image\Files\BaseMediaFile; use App\Models\Photo; +use Illuminate\Support\Collection; class VideoPartnerDTO implements PhotoDTO { @@ -32,6 +33,11 @@ public function getPhoto(): Photo return $this->photo; } + public function getTags(): Collection + { + return $this->photo->tags; + } + public static function ofInit(InitDTO $init_dto): VideoPartnerDTO { return new VideoPartnerDTO( diff --git a/app/Image/Handlers/GoogleMotionPictureHandler.php b/app/Image/Handlers/GoogleMotionPictureHandler.php index c597f3f7052..5ffff5f42bd 100644 --- a/app/Image/Handlers/GoogleMotionPictureHandler.php +++ b/app/Image/Handlers/GoogleMotionPictureHandler.php @@ -51,7 +51,9 @@ class GoogleMotionPictureHandler extends VideoHandler */ public function __destruct() { - $this->working_copy->delete(); + if (isset($this->working_copy)) { + $this->working_copy->delete(); + } } /**