Skip to content

Commit 808efd0

Browse files
authored
feat(34): add bulk album edit (#4296)
1 parent bda7afb commit 808efd0

61 files changed

Lines changed: 6201 additions & 13 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Actions\Admin;
10+
11+
use App\Actions\Album\SetProtectionPolicy;
12+
use App\DTO\BulkAlbumPatchData;
13+
use App\Http\Resources\Models\Utils\AlbumProtectionPolicy;
14+
use App\Models\Album;
15+
use App\Models\BaseAlbumImpl;
16+
17+
/**
18+
* Applies a partial set of metadata and/or visibility changes to a batch of albums.
19+
*
20+
* Changes are applied within the caller's DB transaction.
21+
* Fields absent from the payload are left unchanged.
22+
*
23+
* Three groups are processed separately:
24+
* 1. base_albums columns → chunked mass UPDATE via BaseAlbumImpl
25+
* 2. albums columns → chunked mass UPDATE via Album
26+
* 3. Visibility fields → per-album via SetProtectionPolicy::do()
27+
*/
28+
class BulkEditAlbumsAction
29+
{
30+
private SetProtectionPolicy $set_protection_policy;
31+
32+
public function __construct()
33+
{
34+
$this->set_protection_policy = new SetProtectionPolicy();
35+
}
36+
37+
/**
38+
* Apply the partial payload to all specified album IDs.
39+
*
40+
* Only fields that were present in the original request (tracked via
41+
* {@see BulkAlbumPatchData::has()}) are updated; absent fields are left
42+
* unchanged.
43+
*
44+
* @param BulkAlbumPatchData $data validated, typed patch payload
45+
*/
46+
public function do(BulkAlbumPatchData $data): void
47+
{
48+
$album_ids = $data->album_ids;
49+
50+
// ── Group 1: base_albums columns ─────────────────────────────────────
51+
$base_data = [];
52+
53+
if ($data->has('description')) {
54+
$base_data['description'] = $data->description;
55+
}
56+
if ($data->has('copyright')) {
57+
$base_data['copyright'] = $data->copyright;
58+
}
59+
if ($data->has('photo_layout')) {
60+
$base_data['photo_layout'] = $data->photo_layout?->value;
61+
}
62+
if ($data->has('photo_sorting_col')) {
63+
$base_data['sorting_col'] = $data->photo_sorting_col?->value;
64+
}
65+
if ($data->has('photo_sorting_order')) {
66+
$base_data['sorting_order'] = $data->photo_sorting_order?->value;
67+
}
68+
if ($data->has('photo_timeline')) {
69+
$base_data['photo_timeline'] = $data->photo_timeline?->value;
70+
}
71+
if ($data->has('is_nsfw')) {
72+
$base_data['is_nsfw'] = $data->is_nsfw;
73+
}
74+
75+
if ($base_data !== []) {
76+
BaseAlbumImpl::query()
77+
->whereIn('id', $album_ids)
78+
->update($base_data);
79+
}
80+
81+
// ── Group 2: albums columns ───────────────────────────────────────────
82+
$album_data = [];
83+
84+
if ($data->has('license')) {
85+
$album_data['license'] = $data->license?->value;
86+
}
87+
if ($data->has('album_thumb_aspect_ratio')) {
88+
$album_data['album_thumb_aspect_ratio'] = $data->album_thumb_aspect_ratio?->value;
89+
}
90+
if ($data->has('album_timeline')) {
91+
$album_data['album_timeline'] = $data->album_timeline?->value;
92+
}
93+
if ($data->has('album_sorting_col')) {
94+
$album_data['album_sorting_col'] = $data->album_sorting_col?->value;
95+
}
96+
if ($data->has('album_sorting_order')) {
97+
$album_data['album_sorting_order'] = $data->album_sorting_order?->value;
98+
}
99+
100+
if ($album_data !== []) {
101+
Album::query()
102+
->whereIn('id', $album_ids)
103+
->update($album_data);
104+
}
105+
106+
// ── Group 3: Visibility fields ────────────────────────────────────────
107+
$has_visibility = $data->has('is_public') ||
108+
$data->has('is_link_required') ||
109+
$data->has('grants_full_photo_access') ||
110+
$data->has('grants_download') ||
111+
$data->has('grants_upload');
112+
113+
if ($has_visibility) {
114+
/** @var Album[] $albums */
115+
$albums = Album::query()
116+
->with('base_class.access_permissions')
117+
->whereIn('id', $album_ids)
118+
->get()
119+
->all();
120+
121+
foreach ($albums as $album) {
122+
$existing = $album->public_permissions();
123+
124+
// Derive current values as defaults, then overlay payload
125+
$is_public = $data->has('is_public')
126+
? ($data->is_public === true)
127+
: ($existing !== null);
128+
$is_link_required = $data->has('is_link_required')
129+
? ($data->is_link_required === true)
130+
: ($existing?->is_link_required === true);
131+
$grants_full_photo_access = $data->has('grants_full_photo_access')
132+
? ($data->grants_full_photo_access === true)
133+
: ($existing?->grants_full_photo_access === true);
134+
$grants_download = $data->has('grants_download')
135+
? ($data->grants_download === true)
136+
: ($existing?->grants_download === true);
137+
$grants_upload = $data->has('grants_upload')
138+
? ($data->grants_upload === true)
139+
: ($existing?->grants_upload === true);
140+
141+
// is_nsfw may have been updated in group 1 via mass-update;
142+
// use the payload value if present, else the model value.
143+
$is_nsfw = $data->has('is_nsfw')
144+
? ($data->is_nsfw === true)
145+
: ($album->is_nsfw === true);
146+
147+
$protection_policy = new AlbumProtectionPolicy(
148+
is_public: $is_public,
149+
is_link_required: $is_link_required,
150+
is_nsfw: $is_nsfw,
151+
grants_full_photo_access: $grants_full_photo_access,
152+
grants_download: $grants_download,
153+
grants_upload: $grants_upload,
154+
);
155+
156+
$this->set_protection_policy->do($album, $protection_policy, false, null);
157+
}
158+
}
159+
}
160+
}

app/Actions/Search/Strategies/Album/AlbumFieldLikeStrategy.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use App\Contracts\Search\AlbumSearchTokenStrategy;
1212
use App\DTO\Search\SearchToken;
1313
use Illuminate\Database\Eloquent\Builder;
14+
use Illuminate\Database\Query\Builder as QueryBuilder;
1415

1516
/**
1617
* Handles `title:` and `description:` tokens in album searches.
@@ -30,7 +31,7 @@ public function __construct(private readonly ?string $column = null)
3031
{
3132
}
3233

33-
public function apply(Builder $query, SearchToken $token): void
34+
public function apply(Builder|QueryBuilder $query, SearchToken $token): void
3435
{
3536
$escaped = $this->escapeLike($token->value);
3637
$pattern = $token->is_prefix ? $escaped . '%' : '%' . $escaped . '%';

app/DTO/BulkAlbumPatchData.php

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\DTO;
10+
11+
use App\Enum\AspectRatioType;
12+
use App\Enum\ColumnSortingAlbumType;
13+
use App\Enum\ColumnSortingPhotoType;
14+
use App\Enum\LicenseType;
15+
use App\Enum\OrderSortingType;
16+
use App\Enum\PhotoLayoutType;
17+
use App\Enum\TimelineAlbumGranularity;
18+
use App\Enum\TimelinePhotoGranularity;
19+
20+
/**
21+
* Typed payload for a bulk partial-update of album metadata and visibility.
22+
*
23+
* Only fields that were actually present in the incoming request are considered
24+
* "set". Use {@see has()} to distinguish "not sent" from "sent as null"
25+
* (which clears the field).
26+
*
27+
* Boolean fields (is_nsfw, is_public, …) are required to be non-null when
28+
* present, so their types are non-nullable; absent fields remain null here
29+
* and are guarded by {@see has()}.
30+
*/
31+
class BulkAlbumPatchData
32+
{
33+
/** @var string[] Names of fields that were present in the request. */
34+
private array $present_fields;
35+
36+
/** @var string[] */
37+
public array $album_ids;
38+
39+
public ?string $description;
40+
public ?string $copyright;
41+
public ?LicenseType $license;
42+
public ?PhotoLayoutType $photo_layout;
43+
public ?ColumnSortingPhotoType $photo_sorting_col;
44+
public ?OrderSortingType $photo_sorting_order;
45+
public ?ColumnSortingAlbumType $album_sorting_col;
46+
public ?OrderSortingType $album_sorting_order;
47+
public ?AspectRatioType $album_thumb_aspect_ratio;
48+
public ?TimelineAlbumGranularity $album_timeline;
49+
public ?TimelinePhotoGranularity $photo_timeline;
50+
// Boolean fields: null here means "not present in the request"
51+
public ?bool $is_nsfw;
52+
public ?bool $is_public;
53+
public ?bool $is_link_required;
54+
public ?bool $grants_full_photo_access;
55+
public ?bool $grants_download;
56+
public ?bool $grants_upload;
57+
58+
/**
59+
* @param string[] $album_ids
60+
* @param string[] $present_fields names of optional fields that were included in the request
61+
* @param ?string $description
62+
* @param ?string $copyright
63+
* @param ?LicenseType $license
64+
* @param ?PhotoLayoutType $photo_layout
65+
* @param ?ColumnSortingPhotoType $photo_sorting_col
66+
* @param ?OrderSortingType $photo_sorting_order
67+
* @param ?ColumnSortingAlbumType $album_sorting_col
68+
* @param ?OrderSortingType $album_sorting_order
69+
* @param ?AspectRatioType $album_thumb_aspect_ratio
70+
* @param ?TimelineAlbumGranularity $album_timeline
71+
* @param ?TimelinePhotoGranularity $photo_timeline
72+
* @param ?bool $is_nsfw
73+
* @param ?bool $is_public
74+
* @param ?bool $is_link_required
75+
* @param ?bool $grants_full_photo_access
76+
* @param ?bool $grants_download
77+
* @param ?bool $grants_upload
78+
*/
79+
public function __construct(
80+
array $album_ids,
81+
array $present_fields,
82+
?string $description,
83+
?string $copyright,
84+
?LicenseType $license,
85+
?PhotoLayoutType $photo_layout,
86+
?ColumnSortingPhotoType $photo_sorting_col,
87+
?OrderSortingType $photo_sorting_order,
88+
?ColumnSortingAlbumType $album_sorting_col,
89+
?OrderSortingType $album_sorting_order,
90+
?AspectRatioType $album_thumb_aspect_ratio,
91+
?TimelineAlbumGranularity $album_timeline,
92+
?TimelinePhotoGranularity $photo_timeline,
93+
?bool $is_nsfw,
94+
?bool $is_public,
95+
?bool $is_link_required,
96+
?bool $grants_full_photo_access,
97+
?bool $grants_download,
98+
?bool $grants_upload,
99+
) {
100+
$this->album_ids = $album_ids;
101+
$this->present_fields = $present_fields;
102+
$this->description = $description;
103+
$this->copyright = $copyright;
104+
$this->license = $license;
105+
$this->photo_layout = $photo_layout;
106+
$this->photo_sorting_col = $photo_sorting_col;
107+
$this->photo_sorting_order = $photo_sorting_order;
108+
$this->album_sorting_col = $album_sorting_col;
109+
$this->album_sorting_order = $album_sorting_order;
110+
$this->album_thumb_aspect_ratio = $album_thumb_aspect_ratio;
111+
$this->album_timeline = $album_timeline;
112+
$this->photo_timeline = $photo_timeline;
113+
$this->is_nsfw = $is_nsfw;
114+
$this->is_public = $is_public;
115+
$this->is_link_required = $is_link_required;
116+
$this->grants_full_photo_access = $grants_full_photo_access;
117+
$this->grants_download = $grants_download;
118+
$this->grants_upload = $grants_upload;
119+
}
120+
121+
/**
122+
* Build a DTO from a validated request values array.
123+
*
124+
* Handles all enum coercion and boolean normalization so that callers
125+
* (FormRequests, tests, …) never have to repeat that logic.
126+
*
127+
* @param array<string,mixed> $values the output of {@see \Illuminate\Foundation\Http\FormRequest::validated()}
128+
* @param string[] $present_fields names of optional fields that were present in the request
129+
*/
130+
public static function fromValidated(array $values, array $present_fields): self
131+
{
132+
return new self(
133+
album_ids: $values['album_ids'],
134+
present_fields: $present_fields,
135+
description: $values['description'] ?? null,
136+
copyright: $values['copyright'] ?? null,
137+
license: array_key_exists('license', $values) ? LicenseType::tryFrom($values['license']) : null,
138+
photo_layout: array_key_exists('photo_layout', $values) ? PhotoLayoutType::tryFrom($values['photo_layout']) : null,
139+
photo_sorting_col: array_key_exists('photo_sorting_col', $values) ? ColumnSortingPhotoType::tryFrom($values['photo_sorting_col']) : null,
140+
photo_sorting_order: array_key_exists('photo_sorting_order', $values) ? OrderSortingType::tryFrom($values['photo_sorting_order']) : null,
141+
album_sorting_col: array_key_exists('album_sorting_col', $values) ? ColumnSortingAlbumType::tryFrom($values['album_sorting_col']) : null,
142+
album_sorting_order: array_key_exists('album_sorting_order', $values) ? OrderSortingType::tryFrom($values['album_sorting_order']) : null,
143+
album_thumb_aspect_ratio: array_key_exists('album_thumb_aspect_ratio', $values) ? AspectRatioType::tryFrom($values['album_thumb_aspect_ratio']) : null,
144+
album_timeline: array_key_exists('album_timeline', $values) ? TimelineAlbumGranularity::tryFrom($values['album_timeline']) : null,
145+
photo_timeline: array_key_exists('photo_timeline', $values) ? TimelinePhotoGranularity::tryFrom($values['photo_timeline']) : null,
146+
is_nsfw: array_key_exists('is_nsfw', $values) ? filter_var($values['is_nsfw'], FILTER_VALIDATE_BOOLEAN) : null,
147+
is_public: array_key_exists('is_public', $values) ? filter_var($values['is_public'], FILTER_VALIDATE_BOOLEAN) : null,
148+
is_link_required: array_key_exists('is_link_required', $values) ? filter_var($values['is_link_required'], FILTER_VALIDATE_BOOLEAN) : null,
149+
grants_full_photo_access: array_key_exists('grants_full_photo_access', $values) ? filter_var($values['grants_full_photo_access'], FILTER_VALIDATE_BOOLEAN) : null,
150+
grants_download: array_key_exists('grants_download', $values) ? filter_var($values['grants_download'], FILTER_VALIDATE_BOOLEAN) : null,
151+
grants_upload: array_key_exists('grants_upload', $values) ? filter_var($values['grants_upload'], FILTER_VALIDATE_BOOLEAN) : null,
152+
);
153+
}
154+
155+
/**
156+
* Returns true if the named field was present in the original request.
157+
*
158+
* A field can be present-but-null (meaning "clear this value") or
159+
* present-with-a-value. A field that was absent should not be updated.
160+
*/
161+
public function has(string $field): bool
162+
{
163+
return in_array($field, $this->present_fields, true);
164+
}
165+
}

0 commit comments

Comments
 (0)