diff --git a/src/Command/MigrateImportCommand.php b/src/Command/MigrateImportCommand.php index 1d2634a..b4df9fd 100644 --- a/src/Command/MigrateImportCommand.php +++ b/src/Command/MigrateImportCommand.php @@ -3,6 +3,7 @@ namespace Modules\Core\Command; use Illuminate\Console\Command; +use Lunar\Models\Language; use Modules\Core\MigrateImport\ImportSpec; use Modules\Core\MigrateImport\RunMigrateImportJob; @@ -62,11 +63,29 @@ class MigrateImportCommand extends Command $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( source: $source, type: $type, filePath: $filePath, credentials: $credentials, + locale: $locale, ); RunMigrateImportJob::dispatch($spec); @@ -74,6 +93,26 @@ class MigrateImportCommand extends Command $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 // "shopify/products_export.csv"); absolute paths are used as-is. A // directory answer picks the first CSV file found inside it. diff --git a/src/MigrateImport/DefaultLocale.php b/src/MigrateImport/DefaultLocale.php deleted file mode 100644 index f85bd17..0000000 --- a/src/MigrateImport/DefaultLocale.php +++ /dev/null @@ -1,13 +0,0 @@ -code; - } -} diff --git a/src/MigrateImport/ImportLocale.php b/src/MigrateImport/ImportLocale.php new file mode 100644 index 0000000..396d273 --- /dev/null +++ b/src/MigrateImport/ImportLocale.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/src/MigrateImport/ImportSpec.php b/src/MigrateImport/ImportSpec.php index d5a8016..07bf86a 100644 --- a/src/MigrateImport/ImportSpec.php +++ b/src/MigrateImport/ImportSpec.php @@ -4,11 +4,22 @@ namespace Modules\Core\MigrateImport; 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 readonly string $source, public readonly string $type, public readonly ?string $filePath = null, public readonly ?array $credentials = null, + public readonly ?string $locale = null, ) { } } diff --git a/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php b/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php index c95e127..883ecbe 100644 --- a/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php +++ b/src/MigrateImport/Shopify/Resolvers/CollectionResolver.php @@ -7,7 +7,7 @@ use Lunar\FieldTypes\TranslatedText; use Lunar\Models\Collection; use Lunar\Models\CollectionGroup; use Lunar\Models\Product; -use Modules\Core\MigrateImport\DefaultLocale; +use Modules\Core\MigrateImport\ImportLocale; class CollectionResolver { @@ -45,7 +45,7 @@ class CollectionResolver 'collection_group_id' => $group->id, 'attribute_data' => [ 'name' => new TranslatedText(collect([ - DefaultLocale::code() => new Text($name), + ImportLocale::code() => new Text($name), ])), ], ]); diff --git a/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php b/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php index eaa892c..cfebec3 100644 --- a/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php +++ b/src/MigrateImport/Shopify/Resolvers/ImportAttributeResolver.php @@ -8,7 +8,7 @@ use Lunar\Models\Attribute; use Lunar\Models\AttributeGroup; use Lunar\Models\Product; use Lunar\Models\ProductType; -use Modules\Core\MigrateImport\DefaultLocale; +use Modules\Core\MigrateImport\ImportLocale; class ImportAttributeResolver { @@ -40,7 +40,7 @@ class ImportAttributeResolver [ 'attribute_group_id' => $group->id, 'position' => $nextPosition++, - 'name' => [DefaultLocale::code() => $definition['label']], + 'name' => [ImportLocale::code() => $definition['label']], 'section' => 'main', 'type' => $definition['type'], 'required' => false, @@ -49,7 +49,7 @@ class ImportAttributeResolver ? ['richtext' => false] : [], 'system' => false, - 'description' => [DefaultLocale::code() => ''], + 'description' => [ImportLocale::code() => ''], ], ); @@ -62,7 +62,7 @@ class ImportAttributeResolver return AttributeGroup::firstOrCreate( ['attributable_type' => Product::morphName(), 'handle' => 'import'], [ - 'name' => [DefaultLocale::code() => 'Additional Details'], + 'name' => [ImportLocale::code() => 'Additional Details'], 'position' => 100, ], ); diff --git a/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php index 99ceb20..47c3dcb 100644 --- a/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php +++ b/src/MigrateImport/Shopify/Resolvers/ProductAttributeResolver.php @@ -6,7 +6,7 @@ use Lunar\FieldTypes\Number; use Lunar\FieldTypes\Text; use Lunar\FieldTypes\TranslatedText; use Lunar\Models\ProductType; -use Modules\Core\MigrateImport\DefaultLocale; +use Modules\Core\MigrateImport\ImportLocale; class ProductAttributeResolver { @@ -43,7 +43,7 @@ class ProductAttributeResolver } return new TranslatedText(collect([ - DefaultLocale::code() => new Text($value), + ImportLocale::code() => new Text($value), ])); } } diff --git a/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php b/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php index 3bb9b0e..1c24e50 100644 --- a/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php +++ b/src/MigrateImport/Shopify/Resolvers/ProductOptionResolver.php @@ -5,7 +5,7 @@ namespace Modules\Core\MigrateImport\Shopify\Resolvers; use Illuminate\Support\Str; use Lunar\Models\ProductOption; use Lunar\Models\ProductOptionValue; -use Modules\Core\MigrateImport\DefaultLocale; +use Modules\Core\MigrateImport\ImportLocale; class ProductOptionResolver { @@ -24,8 +24,8 @@ class ProductOptionResolver return ProductOption::query()->firstOrCreate( ['handle' => $handle], [ - 'name' => [DefaultLocale::code() => $name], - 'label' => [DefaultLocale::code() => $name], + 'name' => [ImportLocale::code() => $name], + 'label' => [ImportLocale::code() => $name], 'shared' => true, ], ); @@ -46,7 +46,7 @@ class ProductOptionResolver return $existing ?? ProductOptionValue::create([ 'product_option_id' => $option->id, - 'name' => [DefaultLocale::code() => $value], + 'name' => [ImportLocale::code() => $value], ]); } } diff --git a/src/MigrateImport/Shopify/ShopifyExportImporter.php b/src/MigrateImport/Shopify/ShopifyExportImporter.php index ea5001f..bf16ef4 100644 --- a/src/MigrateImport/Shopify/ShopifyExportImporter.php +++ b/src/MigrateImport/Shopify/ShopifyExportImporter.php @@ -12,6 +12,7 @@ use Lunar\Models\Language; use Lunar\Models\Product; use Lunar\Models\ProductVariant; use Lunar\Models\Url; +use Modules\Core\MigrateImport\ImportLocale; use Modules\Core\MigrateImport\ImportSpec; use Modules\Core\MigrateImport\Importer; use Modules\Core\MigrateImport\Models\ImportMapping; @@ -48,16 +49,26 @@ class ShopifyExportImporter implements Importer public function import(ImportSpec $spec): void { - $groups = $this->csvReader->read($spec->filePath); - $imagesPath = dirname($spec->filePath).'/files'; - $collectionGroup = CollectionGroup::firstOrCreate( - ['handle' => 'shopify'], - ['name' => 'Shopify'], - ); - $currency = Currency::getDefault(); + if ($spec->locale !== null) { + ImportLocale::set($spec->locale); + } - foreach ($groups as $group) { - $this->importProduct($group, $imagesPath, $collectionGroup, $currency); + try { + $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_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,