Fix: Updating MIgrateImportCommand to accept language for import

This commit is contained in:
2026-09-24 21:41:11 +03:00
parent c7035d6782
commit b4e9b8a4a9
9 changed files with 140 additions and 35 deletions
+39
View File
@@ -3,6 +3,7 @@
namespace Modules\Core\Command; namespace Modules\Core\Command;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Lunar\Models\Language;
use Modules\Core\MigrateImport\ImportSpec; use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\RunMigrateImportJob; use Modules\Core\MigrateImport\RunMigrateImportJob;
@@ -62,11 +63,29 @@ class MigrateImportCommand extends Command
$credentials = null; $credentials = null;
} }
// Shopify's own product export is a flat CSV — one Title/Body
// (HTML)/etc. column per row, no per-locale columns at all — so
// its text is necessarily written in exactly one language, and
// there is no reliable way to detect which one from the file
// itself. Modules\Core\MigrateImport\ImportLocale::code() used to
// (as its former name, DefaultLocale, admits) assume it always
// matched this store's own Lunar\Models\
// Language::getDefault(), which is often wrong (a store's default
// admin/storefront language and the language a given export
// happens to be written in are two independent facts) — every
// imported product's name/description then saved silently under
// the wrong language, invisible unless that language happened to
// also be selected when viewing/editing the product afterward.
$locale = $source === 'shopify' && $type === 'export'
? $this->askImportLocale()
: null;
$spec = new ImportSpec( $spec = new ImportSpec(
source: $source, source: $source,
type: $type, type: $type,
filePath: $filePath, filePath: $filePath,
credentials: $credentials, credentials: $credentials,
locale: $locale,
); );
RunMigrateImportJob::dispatch($spec); RunMigrateImportJob::dispatch($spec);
@@ -74,6 +93,26 @@ class MigrateImportCommand extends Command
$this->info('Import queued.'); $this->info('Import queued.');
} }
/**
* Choices come from Language::all() — the same list an admin manages
* from the Filament panel (Settings > Languages) — not a hardcoded
* set, so a language this store doesn't have yet simply isn't
* offered here; the hint below says where to add it instead of this
* command silently accepting an arbitrary code Lunar has no row for.
*/
private function askImportLocale(): string
{
$languages = Language::orderBy('default', 'desc')->get(['code', 'name']);
return $this->choice(
"Which language is the export file's own text (product titles, descriptions, etc.) written in?\n".
' (Not necessarily this store\'s default language — the two are independent. '.
"If the language you need isn't listed, add it first from the admin panel under Languages.)",
$languages->mapWithKeys(fn (Language $language) => [$language->code => "{$language->name} ({$language->code})"])->all(),
$languages->first()?->code,
);
}
// Answers are relative to storage/app/private/imports (e.g. "shopify" or // Answers are relative to storage/app/private/imports (e.g. "shopify" or
// "shopify/products_export.csv"); absolute paths are used as-is. A // "shopify/products_export.csv"); absolute paths are used as-is. A
// directory answer picks the first CSV file found inside it. // directory answer picks the first CSV file found inside it.
-13
View File
@@ -1,13 +0,0 @@
<?php
namespace Modules\Core\MigrateImport;
use Lunar\Models\Language;
class DefaultLocale
{
public static function code(): string
{
return Language::getDefault()->code;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\MigrateImport;
use Lunar\Models\Language;
/**
* The language every resolver in an import run writes product-facing text
* under (name, description, option names, ...) — NOT necessarily this
* store's own Lunar\Models\Language::getDefault(). An export file has no
* reliable way to declare its own language, and a store's default admin/
* storefront language is an independent fact from whatever language a
* given export happens to be written in — conflating the two (this
* class's own former name, DefaultLocale, said as much) used to silently
* save every imported product's text under the wrong language, invisible
* unless that language was also the one selected while viewing/editing
* the product afterward.
*
* Set once per import run from the operator's own answer (see
* Command\MigrateImportCommand::askImportLocale(), threaded through
* ImportSpec::$locale) at the top of Importer::import() — every resolver
* downstream (ProductAttributeResolver, ProductOptionResolver,
* CollectionResolver, ImportAttributeResolver) calls code() exactly as
* before, unaware anything changed. Falls back to Language::getDefault()
* only when nothing was ever set (e.g. an import path with no locale
* concept of its own — JudgeMe's reviews-only export never calls set()).
*/
class ImportLocale
{
private static ?string $code = null;
public static function set(string $code): void
{
self::$code = $code;
}
public static function code(): string
{
return self::$code ?? Language::getDefault()->code;
}
/**
* A static property outlives a single request only inside a
* long-running worker process, where a queued job for one import
* must not leak its locale into the next — called at the end of
* every Importer::import() run, success or failure.
*/
public static function reset(): void
{
self::$code = null;
}
}
+11
View File
@@ -4,11 +4,22 @@ namespace Modules\Core\MigrateImport;
class ImportSpec class ImportSpec
{ {
/**
* @param ?string $locale The language the export file's own text
* (product titles, descriptions, option names, ...) is actually
* written in — asked of the operator at import time (see
* Command\MigrateImportCommand), since an export has no reliable
* way to declare its own language and it does not necessarily match
* this store's Lunar\Models\Language::getDefault(). Null for a
* source/type this doesn't apply to (e.g. an API-based import with
* no free-text file to attribute a single language to).
*/
public function __construct( public function __construct(
public readonly string $source, public readonly string $source,
public readonly string $type, public readonly string $type,
public readonly ?string $filePath = null, public readonly ?string $filePath = null,
public readonly ?array $credentials = null, public readonly ?array $credentials = null,
public readonly ?string $locale = null,
) { ) {
} }
} }
@@ -7,7 +7,7 @@ use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Collection; use Lunar\Models\Collection;
use Lunar\Models\CollectionGroup; use Lunar\Models\CollectionGroup;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\MigrateImport\DefaultLocale; use Modules\Core\MigrateImport\ImportLocale;
class CollectionResolver class CollectionResolver
{ {
@@ -45,7 +45,7 @@ class CollectionResolver
'collection_group_id' => $group->id, 'collection_group_id' => $group->id,
'attribute_data' => [ 'attribute_data' => [
'name' => new TranslatedText(collect([ 'name' => new TranslatedText(collect([
DefaultLocale::code() => new Text($name), ImportLocale::code() => new Text($name),
])), ])),
], ],
]); ]);
@@ -8,7 +8,7 @@ use Lunar\Models\Attribute;
use Lunar\Models\AttributeGroup; use Lunar\Models\AttributeGroup;
use Lunar\Models\Product; use Lunar\Models\Product;
use Lunar\Models\ProductType; use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale; use Modules\Core\MigrateImport\ImportLocale;
class ImportAttributeResolver class ImportAttributeResolver
{ {
@@ -40,7 +40,7 @@ class ImportAttributeResolver
[ [
'attribute_group_id' => $group->id, 'attribute_group_id' => $group->id,
'position' => $nextPosition++, 'position' => $nextPosition++,
'name' => [DefaultLocale::code() => $definition['label']], 'name' => [ImportLocale::code() => $definition['label']],
'section' => 'main', 'section' => 'main',
'type' => $definition['type'], 'type' => $definition['type'],
'required' => false, 'required' => false,
@@ -49,7 +49,7 @@ class ImportAttributeResolver
? ['richtext' => false] ? ['richtext' => false]
: [], : [],
'system' => false, 'system' => false,
'description' => [DefaultLocale::code() => ''], 'description' => [ImportLocale::code() => ''],
], ],
); );
@@ -62,7 +62,7 @@ class ImportAttributeResolver
return AttributeGroup::firstOrCreate( return AttributeGroup::firstOrCreate(
['attributable_type' => Product::morphName(), 'handle' => 'import'], ['attributable_type' => Product::morphName(), 'handle' => 'import'],
[ [
'name' => [DefaultLocale::code() => 'Additional Details'], 'name' => [ImportLocale::code() => 'Additional Details'],
'position' => 100, 'position' => 100,
], ],
); );
@@ -6,7 +6,7 @@ use Lunar\FieldTypes\Number;
use Lunar\FieldTypes\Text; use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText; use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\ProductType; use Lunar\Models\ProductType;
use Modules\Core\MigrateImport\DefaultLocale; use Modules\Core\MigrateImport\ImportLocale;
class ProductAttributeResolver class ProductAttributeResolver
{ {
@@ -43,7 +43,7 @@ class ProductAttributeResolver
} }
return new TranslatedText(collect([ return new TranslatedText(collect([
DefaultLocale::code() => new Text($value), ImportLocale::code() => new Text($value),
])); ]));
} }
} }
@@ -5,7 +5,7 @@ namespace Modules\Core\MigrateImport\Shopify\Resolvers;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lunar\Models\ProductOption; use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue; use Lunar\Models\ProductOptionValue;
use Modules\Core\MigrateImport\DefaultLocale; use Modules\Core\MigrateImport\ImportLocale;
class ProductOptionResolver class ProductOptionResolver
{ {
@@ -24,8 +24,8 @@ class ProductOptionResolver
return ProductOption::query()->firstOrCreate( return ProductOption::query()->firstOrCreate(
['handle' => $handle], ['handle' => $handle],
[ [
'name' => [DefaultLocale::code() => $name], 'name' => [ImportLocale::code() => $name],
'label' => [DefaultLocale::code() => $name], 'label' => [ImportLocale::code() => $name],
'shared' => true, 'shared' => true,
], ],
); );
@@ -46,7 +46,7 @@ class ProductOptionResolver
return $existing ?? ProductOptionValue::create([ return $existing ?? ProductOptionValue::create([
'product_option_id' => $option->id, 'product_option_id' => $option->id,
'name' => [DefaultLocale::code() => $value], 'name' => [ImportLocale::code() => $value],
]); ]);
} }
} }
@@ -12,6 +12,7 @@ use Lunar\Models\Language;
use Lunar\Models\Product; use Lunar\Models\Product;
use Lunar\Models\ProductVariant; use Lunar\Models\ProductVariant;
use Lunar\Models\Url; use Lunar\Models\Url;
use Modules\Core\MigrateImport\ImportLocale;
use Modules\Core\MigrateImport\ImportSpec; use Modules\Core\MigrateImport\ImportSpec;
use Modules\Core\MigrateImport\Importer; use Modules\Core\MigrateImport\Importer;
use Modules\Core\MigrateImport\Models\ImportMapping; use Modules\Core\MigrateImport\Models\ImportMapping;
@@ -48,16 +49,26 @@ class ShopifyExportImporter implements Importer
public function import(ImportSpec $spec): void public function import(ImportSpec $spec): void
{ {
$groups = $this->csvReader->read($spec->filePath); if ($spec->locale !== null) {
$imagesPath = dirname($spec->filePath).'/files'; ImportLocale::set($spec->locale);
$collectionGroup = CollectionGroup::firstOrCreate( }
['handle' => 'shopify'],
['name' => 'Shopify'],
);
$currency = Currency::getDefault();
foreach ($groups as $group) { try {
$this->importProduct($group, $imagesPath, $collectionGroup, $currency); $groups = $this->csvReader->read($spec->filePath);
$imagesPath = dirname($spec->filePath).'/files';
$collectionGroup = CollectionGroup::firstOrCreate(
['handle' => 'shopify'],
['name' => 'Shopify'],
);
$currency = Currency::getDefault();
foreach ($groups as $group) {
$this->importProduct($group, $imagesPath, $collectionGroup, $currency);
}
} finally {
// A queue worker process outlives a single import run — this
// must not leak into whatever's imported next.
ImportLocale::reset();
} }
} }
@@ -100,7 +111,12 @@ class ShopifyExportImporter implements Importer
[ [
'element_type' => $product->getMorphClass(), 'element_type' => $product->getMorphClass(),
'element_id' => $product->id, 'element_id' => $product->id,
'language_id' => Language::getDefault()->id, // ImportLocale, not Language::getDefault() — same
// reasoning as ProductAttributeResolver et al.: this
// product's handle/slug came from the export, written in
// whatever language the operator said the file is in,
// not necessarily this store's own default language.
'language_id' => Language::where('code', ImportLocale::code())->value('id'),
], ],
[ [
'slug' => $group->handle, 'slug' => $group->handle,