Skip to content
Open
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
56 changes: 56 additions & 0 deletions core/components/minishop3/src/Processors/Gallery/SortByName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace MiniShop3\Processors\Gallery;

use MiniShop3\MiniShop3;
use MiniShop3\Model\msProductData;
use MiniShop3\Model\msProductFile;
use MiniShop3\Services\Product\ProductImageService;
use MODX\Revolution\Processors\ModelProcessor;

/**
* Re-rank gallery images by natural sort on filename after batch upload (#616).
*/
class SortByName extends ModelProcessor
{
public $classKey = msProductFile::class;
public $languageTopics = ['minishop3:default', 'minishop3:product'];
public $permission = 'msproductfile_save';

public function process()
{
$productId = (int) $this->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,
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<array{id: int, name: string, file: string}> $rows
* @return array<int, int> 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).
*
Expand Down Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Tests\Unit\Services\Product;

use MiniShop3\Services\Product\ProductImageService;
use PHPUnit\Framework\TestCase;

final class ProductImageServiceSortByNameTest extends TestCase
{
public function testBuildNaturalSortRanksOrdersNumericFilenamesNaturally(): void
{
$rows = [
['id' => 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([]));
}
}
37 changes: 34 additions & 3 deletions vueManager/src/components/gallery/ProductGallery.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const {
isLoading,
fetchGalleryList,
sortFiles,
sortFilesByName,
deleteFiles,
deleteAll,
regenerateThumbs,
Expand Down Expand Up @@ -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(() => {
Expand Down
13 changes: 13 additions & 0 deletions vueManager/src/composables/useGalleryApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -226,6 +238,7 @@ export function useGalleryApi() {
isLoading,
fetchGalleryList,
sortFiles,
sortFilesByName,
deleteFiles,
deleteAll,
regenerateThumbs,
Expand Down