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..7db56657e 100644 --- a/core/components/minishop3/src/Services/Product/ProductImageService.php +++ b/core/components/minishop3/src/Services/Product/ProductImageService.php @@ -119,6 +119,77 @@ 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 = strnatcmp(self::naturalSortKey($a), self::naturalSortKey($b)); + if ($cmp !== 0) { + return $cmp; + } + + $cmp = strnatcmp( + self::foldNaturalSortString((string) ($a['file'] ?? '')), + self::foldNaturalSortString((string) ($b['file'] ?? '')), + ); + if ($cmp !== 0) { + return $cmp; + } + + return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0); + }); + + $ranks = []; + foreach ($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 +304,26 @@ 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'] ?? '')); + $key = $name !== '' ? $name : (string) ($row['file'] ?? ''); + + return self::foldNaturalSortString($key); + } + + /** + * 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'); + } + /** * 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..b41aa3e25 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Product/ProductImageServiceSortByNameTest.php @@ -0,0 +1,98 @@ + 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); + } + + /** + * 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 = [ + ['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,