From 9d9fdefa9258c27d81d251936f5b1f8335b949e0 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 30 Aug 2026 12:33:32 +0600 Subject: [PATCH 1/3] feat(gallery): natural-sort positions after batch upload After Uppy upload-complete, re-rank product gallery files by strnatcasecmp on name so numbered batches match ms2-style order without manual drag. --- .../src/Processors/Gallery/SortByName.php | 56 +++++++++++++ .../Services/Product/ProductImageService.php | 78 ++++++++++++++++++ .../ProductImageServiceSortByNameTest.php | 81 +++++++++++++++++++ .../src/components/gallery/ProductGallery.vue | 37 ++++++++- vueManager/src/composables/useGalleryApi.js | 13 +++ 5 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 core/components/minishop3/src/Processors/Gallery/SortByName.php create mode 100644 core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php diff --git a/core/components/minishop3/src/Processors/Gallery/SortByName.php b/core/components/minishop3/src/Processors/Gallery/SortByName.php new file mode 100644 index 000000000..aeaef46bc --- /dev/null +++ b/core/components/minishop3/src/Processors/Gallery/SortByName.php @@ -0,0 +1,56 @@ +getProperty('product_id'); + + if ($productId <= 0) { + return $this->failure($this->modx->lexicon('ms3_gallery_err_ns')); + } + + /** @var msProductData|null $productData */ + $productData = $this->modx->getObject(msProductData::class, ['id' => $productId]); + if (!$productData) { + return $this->failure($this->modx->lexicon('ms3_gallery_err_no_product')); + } + + /** @var ProductImageService|null $imageService */ + $imageService = $this->modx->services->get('ms3_product_image'); + if (!$imageService instanceof ProductImageService) { + return $this->failure($this->modx->lexicon('ms3_err_unknown')); + } + + $saved = $imageService->sortProductImagesByName($productData); + if ($saved === false) { + return $this->failure($this->modx->lexicon('ms3_err_unknown')); + } + + $thumb = (string) $productData->get('thumb'); + if ($thumb === '') { + /** @var MiniShop3 $ms3 */ + $ms3 = $this->modx->services->get('ms3'); + $thumb = (string) ($ms3->config['defaultThumb'] ?? ''); + } + + return $this->success('', [ + 'thumb' => $thumb, + ]); + } +} diff --git a/core/components/minishop3/src/Services/Product/ProductImageService.php b/core/components/minishop3/src/Services/Product/ProductImageService.php index ee89b52a1..be0edb6a5 100644 --- a/core/components/minishop3/src/Services/Product/ProductImageService.php +++ b/core/components/minishop3/src/Services/Product/ProductImageService.php @@ -119,6 +119,74 @@ public function rankProductImages(msProductData $productData, array $ranks): boo return true; } + /** + * Build natural-sort positions for gallery rows by name (fallback: file), then id. + * + * @param list $rows + * @return array file_id => position (0..n-1) + */ + public static function buildNaturalSortRanks(array $rows): array + { + if ($rows === []) { + return []; + } + + usort($rows, static function (array $a, array $b): int { + $cmp = strnatcasecmp(self::naturalSortKey($a), self::naturalSortKey($b)); + if ($cmp !== 0) { + return $cmp; + } + + $cmp = strnatcasecmp((string) ($a['file'] ?? ''), (string) ($b['file'] ?? '')); + if ($cmp !== 0) { + return $cmp; + } + + return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0); + }); + + $ranks = []; + foreach (array_values($rows) as $position => $row) { + $ranks[(int) $row['id']] = $position; + } + + return $ranks; + } + + /** + * Re-rank top-level gallery files by natural sort on name/file (#616). + * + * Includes all parent_id=0 rows (not only type=image), matching Upload position counting. + * + * @return bool|mixed save result from updateProductImage(), or true when gallery is empty + */ + public function sortProductImagesByName(msProductData $productData): mixed + { + $productId = (int) $productData->get('id'); + + $rows = []; + + /** @var msProductFile $file */ + foreach ($this->modx->getIterator(msProductFile::class, [ + 'product_id' => $productId, + 'parent_id' => 0, + ]) as $file) { + $rows[] = [ + 'id' => (int) $file->get('id'), + 'name' => (string) $file->get('name'), + 'file' => (string) $file->get('file'), + ]; + } + + if ($rows === []) { + return true; + } + + $this->rankProductImages($productData, self::buildNaturalSortRanks($rows)); + + return $this->updateProductImage($productData); + } + /** * Set which gallery file is the product preview without changing sort order (#130). * @@ -233,6 +301,16 @@ private function getMainGalleryFile(int $productId, ?int $fileId = null): ?msPro return $file ?: null; } + /** + * @param array{name?: string, file?: string} $row + */ + private static function naturalSortKey(array $row): string + { + $name = trim((string) ($row['name'] ?? '')); + + return $name !== '' ? $name : (string) ($row['file'] ?? ''); + } + /** * Remove empty product catalog * diff --git a/core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php b/core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php new file mode 100644 index 000000000..e100982a0 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php @@ -0,0 +1,81 @@ + 3, 'name' => '10.jpg', 'file' => '10.jpg'], + ['id' => 1, 'name' => '2.jpg', 'file' => '2.jpg'], + ['id' => 2, 'name' => '01.jpg', 'file' => '01.jpg'], + ]; + + $ranks = ProductImageService::buildNaturalSortRanks($rows); + + self::assertSame([2 => 0, 1 => 1, 3 => 2], $ranks); + } + + /** + * Upload processor stores name without extension (01.jpg → "01"). + */ + public function testBuildNaturalSortRanksOrdersExtensionlessUploadNames(): void + { + $rows = [ + ['id' => 3, 'name' => '10', 'file' => 'hash10.jpg'], + ['id' => 1, 'name' => '2', 'file' => 'hash2.jpg'], + ['id' => 2, 'name' => '01', 'file' => 'hash01.jpg'], + ]; + + $ranks = ProductImageService::buildNaturalSortRanks($rows); + + self::assertSame([2 => 0, 1 => 1, 3 => 2], $ranks); + } + + public function testBuildNaturalSortRanksIsCaseInsensitive(): void + { + $rows = [ + ['id' => 1, 'name' => 'B.jpg', 'file' => 'b.jpg'], + ['id' => 2, 'name' => 'a.jpg', 'file' => 'a.jpg'], + ]; + + $ranks = ProductImageService::buildNaturalSortRanks($rows); + + self::assertSame([2 => 0, 1 => 1], $ranks); + } + + public function testBuildNaturalSortRanksFallsBackToFileWhenNameEmpty(): void + { + $rows = [ + ['id' => 1, 'name' => '', 'file' => 'z.jpg'], + ['id' => 2, 'name' => '', 'file' => 'a.jpg'], + ]; + + $ranks = ProductImageService::buildNaturalSortRanks($rows); + + self::assertSame([2 => 0, 1 => 1], $ranks); + } + + public function testBuildNaturalSortRanksTieBreaksById(): void + { + $rows = [ + ['id' => 5, 'name' => 'same.jpg', 'file' => 'same.jpg'], + ['id' => 2, 'name' => 'same.jpg', 'file' => 'same.jpg'], + ]; + + $ranks = ProductImageService::buildNaturalSortRanks($rows); + + self::assertSame([2 => 0, 5 => 1], $ranks); + } + + public function testBuildNaturalSortRanksReturnsEmptyForEmptyInput(): void + { + self::assertSame([], ProductImageService::buildNaturalSortRanks([])); + } +} diff --git a/vueManager/src/components/gallery/ProductGallery.vue b/vueManager/src/components/gallery/ProductGallery.vue index 3ea3e67f3..d09baeaec 100644 --- a/vueManager/src/components/gallery/ProductGallery.vue +++ b/vueManager/src/components/gallery/ProductGallery.vue @@ -37,6 +37,7 @@ const { isLoading, fetchGalleryList, sortFiles, + sortFilesByName, deleteFiles, deleteAll, regenerateThumbs, @@ -346,10 +347,40 @@ function onChangeSource(sourceId) { } /** - * Handle upload events + * Serialize overlapping Uppy `complete` handlers (allowMultipleUploadBatches) + * so SortByName does not race on the same product in one tab (#616). */ -function onUploadComplete() { - loadImages() +let uploadCompleteQueue = Promise.resolve() + +/** + * After batch upload: natural-sort positions by filename (#616), then reload grid. + */ +function onUploadComplete(result) { + uploadCompleteQueue = uploadCompleteQueue + .then(() => runUploadComplete(result)) + .catch(() => {}) +} + +async function runUploadComplete(result) { + const hasUploads = (result?.successful?.length ?? 0) > 0 + + try { + if (hasUploads) { + const { thumb } = await sortFilesByName(props.productId) + if (thumb) { + updateProductThumb(thumb) + } + } + } catch (error) { + toast.add({ + severity: 'error', + summary: _('ms3_gallery_errors'), + detail: error.message, + life: 5000, + }) + } finally { + await loadImages() + } } onMounted(() => { diff --git a/vueManager/src/composables/useGalleryApi.js b/vueManager/src/composables/useGalleryApi.js index ba4805967..cbf0fcc93 100644 --- a/vueManager/src/composables/useGalleryApi.js +++ b/vueManager/src/composables/useGalleryApi.js @@ -131,6 +131,18 @@ export function useGalleryApi() { return { thumb: data.object?.thumb || '' } } + /** + * Sort gallery files by natural filename order (#616). + * @param {number} productId + * @returns {Promise<{thumb: string}>} + */ + async function sortFilesByName(productId) { + const data = await connectorRequest('MiniShop3\\Processors\\Gallery\\SortByName', { + product_id: productId, + }) + return { thumb: data.object?.thumb || '' } + } + /** * Delete files by IDs * @param {number[]} ids @@ -226,6 +238,7 @@ export function useGalleryApi() { isLoading, fetchGalleryList, sortFiles, + sortFilesByName, deleteFiles, deleteAll, regenerateThumbs, From f4cf6b63b8a002ac36c899cac5b742755a6ed868 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Sun, 30 Aug 2026 12:36:47 +0600 Subject: [PATCH 2/3] fix(gallery): drop redundant array_values for PHPStan usort already reindexes the list; array_values was a no-op warning. --- .../minishop3/src/Services/Product/ProductImageService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/components/minishop3/src/Services/Product/ProductImageService.php b/core/components/minishop3/src/Services/Product/ProductImageService.php index be0edb6a5..ce48347a0 100644 --- a/core/components/minishop3/src/Services/Product/ProductImageService.php +++ b/core/components/minishop3/src/Services/Product/ProductImageService.php @@ -146,7 +146,7 @@ public static function buildNaturalSortRanks(array $rows): array }); $ranks = []; - foreach (array_values($rows) as $position => $row) { + foreach ($rows as $position => $row) { $ranks[(int) $row['id']] = $position; } From ad3e9932e624263f0ec73d189dfe7fd5178ebf25 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Tue, 8 Sep 2026 06:49:11 +0600 Subject: [PATCH 3/3] fix(gallery): case-fold UTF-8 before natural sort by name mb_strtolower so Cyrillic mixed-case batches sort like ASCII; add a regression test that strnatcasecmp alone would fail. --- .../Services/Product/ProductImageService.php | 19 ++++++++++++++++--- .../ProductImageServiceSortByNameTest.php | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/core/components/minishop3/src/Services/Product/ProductImageService.php b/core/components/minishop3/src/Services/Product/ProductImageService.php index ce48347a0..7db56657e 100644 --- a/core/components/minishop3/src/Services/Product/ProductImageService.php +++ b/core/components/minishop3/src/Services/Product/ProductImageService.php @@ -132,12 +132,15 @@ public static function buildNaturalSortRanks(array $rows): array } usort($rows, static function (array $a, array $b): int { - $cmp = strnatcasecmp(self::naturalSortKey($a), self::naturalSortKey($b)); + $cmp = strnatcmp(self::naturalSortKey($a), self::naturalSortKey($b)); if ($cmp !== 0) { return $cmp; } - $cmp = strnatcasecmp((string) ($a['file'] ?? ''), (string) ($b['file'] ?? '')); + $cmp = strnatcmp( + self::foldNaturalSortString((string) ($a['file'] ?? '')), + self::foldNaturalSortString((string) ($b['file'] ?? '')), + ); if ($cmp !== 0) { return $cmp; } @@ -307,8 +310,18 @@ private function getMainGalleryFile(int $productId, ?int $fileId = null): ?msPro private static function naturalSortKey(array $row): string { $name = trim((string) ($row['name'] ?? '')); + $key = $name !== '' ? $name : (string) ($row['file'] ?? ''); + + return self::foldNaturalSortString($key); + } - return $name !== '' ? $name : (string) ($row['file'] ?? ''); + /** + * Case-fold for natural sort. strnatcasecmp is C-locale and skips multibyte + * letters (Cyrillic), so UTF-8 names need mb_strtolower first (#616 review). + */ + private static function foldNaturalSortString(string $value): string + { + return mb_strtolower($value, 'UTF-8'); } /** diff --git a/core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php b/core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php index e100982a0..b41aa3e25 100644 --- a/core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php +++ b/core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php @@ -50,6 +50,23 @@ public function testBuildNaturalSortRanksIsCaseInsensitive(): void self::assertSame([2 => 0, 1 => 1], $ranks); } + /** + * strnatcasecmp alone does not case-fold Cyrillic under locale C (#616 review). + */ + public function testBuildNaturalSortRanksIsCaseInsensitiveForCyrillic(): void + { + $rows = [ + ['id' => 1, 'name' => 'Фото 1.jpg', 'file' => 'f1.jpg'], + ['id' => 4, 'name' => 'Фото 10.jpg', 'file' => 'f10.jpg'], + ['id' => 2, 'name' => 'фото 2.jpg', 'file' => 'f2.jpg'], + ['id' => 3, 'name' => 'фото 3.jpg', 'file' => 'f3.jpg'], + ]; + + $ranks = ProductImageService::buildNaturalSortRanks($rows); + + self::assertSame([1 => 0, 2 => 1, 3 => 2, 4 => 3], $ranks); + } + public function testBuildNaturalSortRanksFallsBackToFileWhenNameEmpty(): void { $rows = [