-
+
diff --git a/resources/js/v8/views/admin/Maintenance.vue b/resources/js/v8/views/admin/Maintenance.vue
index 098c38c2fee..5a96c52ed0c 100644
--- a/resources/js/v8/views/admin/Maintenance.vue
+++ b/resources/js/v8/views/admin/Maintenance.vue
@@ -23,6 +23,7 @@
+
@@ -55,6 +56,7 @@ import MaintenanceOldOrders from "@/v8/components/maintenance/MaintenanceOldOrde
import MaintenanceFulfillOrders from "@/v8/components/maintenance/MaintenanceFulfillOrders.vue";
import MaintenanceFulfillPrecompute from "@/v8/components/maintenance/MaintenanceFulfillPrecompute.vue";
import MaintenanceBackfillAlbumSizes from "@/v8/components/maintenance/MaintenanceBackfillAlbumSizes.vue";
+import MaintenanceRecomputeBuckets from "@/v8/components/maintenance/MaintenanceRecomputeBuckets.vue";
import MaintenanceFlushQueue from "@/v8/components/maintenance/MaintenanceFlushQueue.vue";
import MaintenanceBulkScanFaces from "@/v8/components/maintenance/MaintenanceBulkScanFaces.vue";
import MaintenanceBulkScanNsfw from "@/v8/components/maintenance/MaintenanceBulkScanNsfw.vue";
@@ -70,6 +72,6 @@ import { useLycheeStateStore } from "@/stores/LycheeState";
const lycheeStore = useLycheeStateStore();
const { rights } = useGlobalRights();
-const { is_face_recognition_enabled } = storeToRefs(lycheeStore);
+const { is_face_recognition_enabled, is_struct_of_array_enabled } = storeToRefs(lycheeStore);
const syncFaceEmbeddingsRef = useTemplateRef
>("syncFaceEmbeddingsRef");
diff --git a/routes/api_v2.php b/routes/api_v2.php
index 44d6f931563..76b51da644b 100644
--- a/routes/api_v2.php
+++ b/routes/api_v2.php
@@ -351,6 +351,7 @@
Route::post('/Maintenance::flushQueue', [Admin\Maintenance\FlushQueue::class, 'do']);
Route::get('/Maintenance::backfillAlbumSizes', [Admin\Maintenance\BackfillAlbumSizes::class, 'check']);
Route::post('/Maintenance::backfillAlbumSizes', [Admin\Maintenance\BackfillAlbumSizes::class, 'do']);
+Route::post('/Maintenance::recomputeBuckets', [Admin\Maintenance\RecomputeBuckets::class, 'do']);
/**
* STATISTICS.
diff --git a/tests/Feature_v3/Album/AlbumRootV3Test.php b/tests/Feature_v3/Album/AlbumRootV3Test.php
index 7a79ae75d5c..de87f9c3516 100644
--- a/tests/Feature_v3/Album/AlbumRootV3Test.php
+++ b/tests/Feature_v3/Album/AlbumRootV3Test.php
@@ -26,6 +26,7 @@
use App\Models\Configs;
use App\Models\Photo;
use App\Models\User;
+use App\Services\TitleSplitter;
use Illuminate\Support\Facades\DB;
use Tests\Feature_v3\Base\BaseApiWithDataTest;
@@ -408,6 +409,36 @@ public function testSortingColumnChangeRecomputesOwnScopeBucketsButNeverAffectsS
self::assertNotSame(['2024'], $after);
}
+ /**
+ * S-092-23: until the recompute job has run, the date scheme reads the
+ * alphabetical keys; an id that is not date-shaped is its own label.
+ */
+ public function testTitleModeChangeLabelsKeysOfThePreviousModeAsIsUntilTheRecompute(): void
+ {
+ Configs::set('sorting_albums_col', 'title');
+ Configs::set('timeline_albums_granularity', 'year');
+ Configs::set('title_bucket_mode', 'alphabetical');
+ $user = User::factory()->create();
+ foreach (['2019-05 Paris', 'Alpha'] as $title) {
+ $album = Album::factory()->as_root()->owned_by($user)->with_title($title)->create();
+ $album->title_base = TitleSplitter::split($album->title)->base;
+ $album->save();
+ }
+ $this->recomputeRootBuckets();
+
+ Configs::set('title_bucket_mode', 'date_prefix');
+
+ $pending = $this->actingAs($user)->getJsonV3('Albums/root/buckets?scope=own');
+ $this->assertOk($pending);
+ $pending->assertJson(['bucket_ids' => ['2019', 'alph'], 'labels' => ['2019', 'alph']]);
+
+ $this->recomputeRootBuckets();
+
+ $recomputed = $this->actingAs($user)->getJsonV3('Albums/root/buckets?scope=own');
+ $this->assertOk($recomputed);
+ $recomputed->assertJson(['bucket_ids' => ['2019', 'unknown'], 'labels' => ['2019', 'unknown']]);
+ }
+
// ── deduplicate_pinned_albums parity (S-062-20) ──────────────────
public function testDeduplicatePinnedAlbumsExcludesPinnedAlbumFromBothScopes(): void
diff --git a/tests/Feature_v3/Maintenance/RecomputeBucketsTest.php b/tests/Feature_v3/Maintenance/RecomputeBucketsTest.php
new file mode 100644
index 00000000000..1bd782f1460
--- /dev/null
+++ b/tests/Feature_v3/Maintenance/RecomputeBucketsTest.php
@@ -0,0 +1,69 @@
+ true]);
+ Queue::fake([RecomputeAllAlbumBucketsJob::class, RecomputeAllPhotoBucketsJob::class]);
+ }
+
+ public function testGuest(): void
+ {
+ $response = $this->postJson('Maintenance::recomputeBuckets');
+ $this->assertUnauthorized($response);
+ Queue::assertNothingPushed();
+ }
+
+ public function testUser(): void
+ {
+ $response = $this->actingAs($this->userLocked)->postJson('Maintenance::recomputeBuckets');
+ $this->assertForbidden($response);
+ Queue::assertNothingPushed();
+ }
+
+ public function testAdminQueuesAlbumAndPhotoRecompute(): void
+ {
+ $response = $this->actingAs($this->admin)->postJson('Maintenance::recomputeBuckets');
+ $this->assertNoContent($response);
+ Queue::assertPushed(RecomputeAllAlbumBucketsJob::class);
+ Queue::assertPushed(RecomputeAllPhotoBucketsJob::class);
+ }
+
+ public function testAdminForbiddenWithStructOfArraysDisabled(): void
+ {
+ config(['features.struct-of-array' => false]);
+
+ $response = $this->actingAs($this->admin)->postJson('Maintenance::recomputeBuckets');
+ $this->assertForbidden($response);
+ Queue::assertNothingPushed();
+ }
+}
diff --git a/tests/Precomputing/CoverSelection/RecomputeBucketsCommandTest.php b/tests/Precomputing/CoverSelection/RecomputeBucketsCommandTest.php
index 27c054716a8..1a6471bb73d 100644
--- a/tests/Precomputing/CoverSelection/RecomputeBucketsCommandTest.php
+++ b/tests/Precomputing/CoverSelection/RecomputeBucketsCommandTest.php
@@ -8,12 +8,15 @@
namespace Tests\Precomputing\CoverSelection;
+use App\Events\AlbumListingCacheFlushRequested;
+use App\Events\PhotoListingCacheFlushRequested;
use App\Models\Album;
use App\Models\Configs;
use App\Models\Photo;
use App\Models\User;
use App\Services\TitleSplitter;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Event;
use Tests\Precomputing\Base\BasePrecomputingTest;
/**
@@ -97,6 +100,19 @@ public function testCommandHandlesEmptyTables(): void
$this->assertTrue(true);
}
+ /**
+ * Cached listings hold ids read from the rewritten keys (FR-092-15).
+ */
+ public function testCommandFlushesAlbumAndPhotoListings(): void
+ {
+ Event::fake([AlbumListingCacheFlushRequested::class, PhotoListingCacheFlushRequested::class]);
+
+ $this->artisan('lychee:recompute-buckets')->assertExitCode(0);
+
+ Event::assertDispatched(AlbumListingCacheFlushRequested::class);
+ Event::assertDispatched(PhotoListingCacheFlushRequested::class);
+ }
+
public function testRerunAfterAlbumTitleBucketModeConfigChangeUpdatesExistingRows(): void
{
Configs::set('title_bucket_mode', 'date_prefix');
diff --git a/tests/Unit/Services/BucketSchemeTest.php b/tests/Unit/Services/BucketSchemeTest.php
index 8da50ddd7d2..dd310a6f810 100644
--- a/tests/Unit/Services/BucketSchemeTest.php
+++ b/tests/Unit/Services/BucketSchemeTest.php
@@ -228,6 +228,19 @@ public function testNonDateIdsAreTheirOwnLabels(): void
self::assertSame(['4'], $this->photos(ColumnSortingType::RATING_AVG)->labels(['4']));
}
+ /**
+ * S-092-23: keys written for another scheme, read before the recompute
+ * job has run.
+ */
+ public function testDateSchemeIdsThatAreNotDateShapedAreTheirOwnLabels(): void
+ {
+ $albums = $this->albums(ColumnSortingType::TITLE, TimelineAlbumGranularity::DAY);
+ self::assertSame(['2019', 'alph', '2020-03 tr', '2', '1 Jul'], $albums->labels(['2019', 'alph', '2020-03 tr', '2', '2026-07-01']));
+
+ $photos = $this->photos(ColumnSortingType::TAKEN_AT, TimelinePhotoGranularity::HOUR);
+ self::assertSame(['4:00', 'ab-cd-ef-gh'], $photos->labels(['2026-07-01-16', 'ab-cd-ef-gh']));
+ }
+
// ── Id normalisation ────────────────────────────────────────────
public function testNormalizeId(): void