Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions app/Actions/Tag/MergeTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public function do(Tag $source, Tag $into): void
}

$this->handlePhotos($source, $into, $user);
$this->handleAlbums($source, $into, $user);
$this->handleTagAlbums($source, $into, $user);

// Cleanup unused tags after merging
Expand Down Expand Up @@ -109,6 +110,48 @@ private function handlePhotos(Tag $source, Tag $into, User $user): void
});
}

private function handleAlbums(Tag $source, Tag $into, User $user): void
{
$source_album_ids = DB::table('albums_tags')
->where('tag_id', $source->id)
->when(
$user->may_administrate === false,
fn ($q) => $q
->whereExists(fn (Builder $query) => $query->select(DB::raw(1))
->from('base_albums')
->whereColumn('base_albums.id', 'albums_tags.album_id')
->where('base_albums.owner_id', $user->id)
)
)
->select('album_id')
->pluck('album_id')
->toArray();

$existing_album_ids = DB::table('albums_tags')
->where('tag_id', $into->id)
->whereIn('album_id', $source_album_ids)
->select('album_id')
->pluck('album_id')
->toArray();

$album_ids_to_add = array_diff($source_album_ids, $existing_album_ids);

DB::transaction(function () use ($album_ids_to_add, $into, $source, $source_album_ids): void {
if (count($album_ids_to_add) > 0) {
$insert_data = array_map(fn ($album_id) => [
'album_id' => $album_id,
'tag_id' => $into->id,
], $album_ids_to_add);
DB::table('albums_tags')->insert($insert_data);
}

DB::table('albums_tags')
->where('tag_id', $source->id)
->whereIn('album_id', $source_album_ids)
->delete();
});
}

private function handleTagAlbums(Tag $source, Tag $into, User $user): void
{
// Select all the albums impacted by this tag.
Expand Down
21 changes: 19 additions & 2 deletions app/Actions/Tag/TagCleanupTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
trait TagCleanupTrait
{
/**
* Cleans up tags that are not linked to any photos.
* Cleans up tags that are not linked to photos or albums.
*/
protected function cleanupUnusedTags(): void
{
Expand All @@ -27,6 +27,20 @@ function (Builder $query): void {
->whereColumn('photos_tags.tag_id', 'tags.id');
}
)
->whereNotExists(
function (Builder $query): void {
$query->select(DB::raw(1))
->from('tag_albums_tags')
->whereColumn('tag_albums_tags.tag_id', 'tags.id');
}
)
->whereNotExists(
function (Builder $query): void {
$query->select(DB::raw(1))
->from('albums_tags')
->whereColumn('albums_tags.tag_id', 'tags.id');
}
)
->pluck('id')
->all();

Expand All @@ -39,7 +53,10 @@ function (Builder $query): void {
->whereIn('tag_id', $ids)
->delete();

// Remove any links to the tags in tag albums.
// Remove any links to the tags in albums and tag albums.
DB::table('albums_tags')
->whereIn('tag_id', $ids)
->delete();
DB::table('tag_albums_tags')
->whereIn('tag_id', $ids)
->delete();
Expand Down
4 changes: 2 additions & 2 deletions app/Factories/AlbumFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public function findBaseAlbumOrFail(string $album_id, bool $with_relations = tru
$tag_album_query = TagAlbum::query()->with(['access_permissions']);

if ($with_relations) {
$album_query->with(['photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
$album_query->with(['tags', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
$tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
}

Expand Down Expand Up @@ -192,7 +192,7 @@ public function findBaseAlbumsOrFail(array $album_ids, bool $with_relations = tr

if ($with_relations) {
$tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
$album_query->with(['photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
$album_query->with(['tags', 'photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
}

/** @var ($albums_only is true ? array<int,Album> : array<int,TagAlbum>)&array */
Expand Down
15 changes: 14 additions & 1 deletion app/Http/Controllers/Gallery/AlbumController.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use App\Http\Requests\Album\MoveAlbumsRequest;
use App\Http\Requests\Album\RenameAlbumRequest;
use App\Http\Requests\Album\SetAlbumProtectionPolicyRequest;
use App\Http\Requests\Album\SetAlbumTagsRequest;
use App\Http\Requests\Album\SetAlbumTrackRequest;
use App\Http\Requests\Album\SetAsCoverRequest;
use App\Http\Requests\Album\SetAsHeaderRequest;
Expand Down Expand Up @@ -120,7 +121,19 @@ public function updateAlbum(UpdateAlbumRequest $request, SetHeader $set_header):
shall_override: true
);

return EditableBaseAlbumResource::fromModel($album);
return EditableBaseAlbumResource::fromModel($album->loadMissing('tags'));
}

/**
* Set the tags of an album.
*/
public function setAlbumTags(SetAlbumTagsRequest $request): void
{
$tags = $request->tags();
$album = $request->album();

$existing_tags = Tag::from($tags);
$album->tags()->sync($existing_tags->pluck('id')->all());
}

/**
Expand Down
49 changes: 49 additions & 0 deletions app/Http/Requests/Album/SetAlbumTagsRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2026 LycheeOrg.
*/

namespace App\Http\Requests\Album;

use App\Contracts\Http\Requests\HasAlbum;
use App\Contracts\Http\Requests\HasTags;
use App\Contracts\Http\Requests\RequestAttribute;
use App\Http\Requests\BaseApiRequest;
use App\Http\Requests\Traits\Authorize\AuthorizeCanEditAlbumTrait;
use App\Http\Requests\Traits\HasAlbumTrait;
use App\Http\Requests\Traits\HasTagsTrait;
use App\Models\Album;
use App\Rules\RandomIDRule;

class SetAlbumTagsRequest extends BaseApiRequest implements HasAlbum, HasTags
{
use HasAlbumTrait;
use HasTagsTrait;
use AuthorizeCanEditAlbumTrait;

/**
* {@inheritDoc}
*/
public function rules(): array
{
return [
RequestAttribute::ALBUM_ID_ATTRIBUTE => ['required', new RandomIDRule(false)],
RequestAttribute::TAGS_ATTRIBUTE => 'present|array',
RequestAttribute::TAGS_ATTRIBUTE . '.*' => 'required|string|min:1',
];
}

/**
* {@inheritDoc}
*/
protected function processValidatedValues(array $values, array $files): void
{
$this->album = Album::query()
->with('tags')
->findOrFail($values[RequestAttribute::ALBUM_ID_ATTRIBUTE]);
$this->tags = $values[RequestAttribute::TAGS_ATTRIBUTE];
}
}
3 changes: 2 additions & 1 deletion app/Http/Resources/Editable/EditableBaseAlbumResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ public function __construct(Album|TagAlbum $album)
$this->cover_id = $album->cover_id;
$this->aspect_ratio = $album->album_thumb_aspect_ratio;
$this->album_timeline = $album->album_timeline;
$this->tags = $album->tags->map(fn ($tag) => $tag->name)->all();
}

if ($album instanceof TagAlbum) {
$this->tags = $album->tags->map(fn ($t) => $t->name)->all();
$this->tags = $album->tags->map(fn ($tag) => $tag->name)->all();
$this->is_and = $album->is_and;
}
}
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Resources/Models/HeadAlbumResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public function __construct(Album $album)
$this->is_pinned = $album->is_pinned;

if ($this->rights->can_edit) {
$this->editable = EditableBaseAlbumResource::fromModel($album);
$this->editable = EditableBaseAlbumResource::fromModel($album->loadMissing('tags'));
}

if (request()->configs()->getValueAsBool('metrics_enabled') && Gate::check(AlbumPolicy::CAN_READ_METRICS, [Album::class, $album])) {
Expand Down
17 changes: 17 additions & 0 deletions app/Models/Album.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Query\Builder as BaseBuilder;
Expand All @@ -52,6 +53,7 @@
* @property Collection<int,Album> $children
* @property int $num_children The number of children.
* @property Collection<int,Photo> $all_photos
* @property Collection<int,Tag> $tags
* @property int $num_photos The number of photos in this album (excluding photos in subalbums).
* @property Carbon|null $max_taken_at Maximum taken_at timestamp of all photos in album and descendants.
* @property Carbon|null $min_taken_at Minimum taken_at timestamp of all photos in album and descendants.
Expand Down Expand Up @@ -230,6 +232,21 @@ public function photos(): HasManyChildPhotos
return new HasManyChildPhotos($this);
}

/**
* Returns the relationship between an album and all tags associated with it.
*
* @return BelongsToMany<Tag,$this>
*/
public function tags(): BelongsToMany
{
return $this->belongsToMany(
Tag::class,
'albums_tags',
'album_id',
'tag_id',
);
}

/**
* Returns the relationship between this album and all photos incl.
* photos which are recursive children of this album.
Expand Down
44 changes: 44 additions & 0 deletions database/migrations/2026_05_31_000001_add_albums_tags_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2026 LycheeOrg.
*/

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class() extends Migration {
private const TAG_ID = 'tag_id';
private const ALBUM_ID = 'album_id';
private const RANDOM_ID_LENGTH = 24;

/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('albums_tags', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedBigInteger(self::TAG_ID)->nullable(false);
$table->char(self::ALBUM_ID, self::RANDOM_ID_LENGTH)->nullable(false);

$table->index([self::TAG_ID]);
$table->index([self::ALBUM_ID]);
$table->index([self::TAG_ID, self::ALBUM_ID]);
$table->unique([self::TAG_ID, self::ALBUM_ID]);
$table->foreign(self::TAG_ID)->references('id')->on('tags')->cascadeOnUpdate()->cascadeOnDelete();
$table->foreign(self::ALBUM_ID)->references('id')->on('base_albums')->cascadeOnUpdate()->cascadeOnDelete();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('albums_tags');
}
};
17 changes: 17 additions & 0 deletions docs/specs/4-architecture/features/041-album-tags/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Feature 041 – Album Tags Plan

## Scope
Implement direct tag assignment for regular albums across persistence, API, editable album resources, and the album properties UI.

## Increments
1. Backend schema and model support (`albums_tags`, `Album::tags()`, resource/eager-loading updates).
2. Mutation API (`SetAlbumTagsRequest`, controller action, route).
3. Frontend wiring (`album-service.ts`, `AlbumProperties.vue`, translation key).
4. Verification (feature tests, formatting, TypeScript, PHPStan).

## Verification
- `vendor/bin/php-cs-fixer fix`
- `npm run format`
- `npm run check`
- `php artisan test --filter AlbumTags`
- `make phpstan`
24 changes: 24 additions & 0 deletions docs/specs/4-architecture/features/041-album-tags/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Feature 041 – Album Tags

| Field | Value |
|-------|-------|
| Status | Complete |
| Last updated | 2026-05-31 |
| Owners | LycheeOrg |
| Linked plan | [plan.md](plan.md) |
| Linked tasks | [tasks.md](tasks.md) |
| Roadmap entry | #041 |

## Overview
Allow regular albums to have tags associated with them directly, enabling organizational tagging and search by tag. Related issue: [#42](https://github.com/LycheeOrg/Lychee/issues/42).

## Goals
1. Add a pivot table `albums_tags` for album-to-tag many-to-many relationship.
2. Expose `tags` on the `Album` model via `tags()` BelongsToMany.
3. Provide a `PATCH /api/Album::albumTags` endpoint to set tags on a regular album.
4. Include tags in `EditableBaseAlbumResource` for regular albums.
5. Allow users to edit album tags in the `AlbumProperties` panel.

## Non-Goals
- Tag-based album search (search UI remains as-is).
- Inheriting tags from parent to child albums.
7 changes: 7 additions & 0 deletions docs/specs/4-architecture/features/041-album-tags/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Feature 041 – Album Tags Tasks

- [x] Add backend album-tag persistence and eager loading.
- [x] Add album-tag mutation request, controller action, and API route.
- [x] Update album properties UI and service wiring for regular album tags.
- [x] Add feature coverage for album tag editing and editable resource output.
- [x] Run formatting, targeted tests, frontend checks, and PHPStan.
Loading