forked from fa/breadcrumb-the-shire
- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks - Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists - Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services - Add Microsoft OIDC SSO, API token management, and user lifecycle features - Add swagger-ui vendor integration and OpenAPI spec - Add production Docker setup and bin/ scripts - Update composer dependencies, config, templates, and frontend assets throughout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
643 lines
22 KiB
PHP
643 lines
22 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Import;
|
|
|
|
use MintyPHP\Service\Access\PermissionService;
|
|
use MintyPHP\Service\Audit\ImportAuditService;
|
|
use MintyPHP\Service\Import\Profile\DepartmentImportProfile;
|
|
use MintyPHP\Service\Import\Profile\ImportProfileInterface;
|
|
use MintyPHP\Service\Import\Profile\UserImportProfile;
|
|
use MintyPHP\Service\Settings\SettingService;
|
|
use MintyPHP\Service\Tenant\TenantScopeService;
|
|
|
|
class ImportService
|
|
{
|
|
private const SESSION_KEY = 'admin_imports_v1';
|
|
private const MAX_ROWS = 20000;
|
|
private const MAX_ERROR_ROWS = 500;
|
|
private const PROFILE_PERMISSION_MAP = [
|
|
'users' => PermissionService::USERS_IMPORT,
|
|
'departments' => PermissionService::DEPARTMENTS_IMPORT,
|
|
];
|
|
|
|
/**
|
|
* @return array<string, ImportProfileInterface>
|
|
*/
|
|
public static function profiles(): array
|
|
{
|
|
static $profiles = null;
|
|
if (is_array($profiles)) {
|
|
return $profiles;
|
|
}
|
|
|
|
$users = new UserImportProfile();
|
|
$departments = new DepartmentImportProfile();
|
|
$profiles = [
|
|
$users->key() => $users,
|
|
$departments->key() => $departments,
|
|
];
|
|
|
|
return $profiles;
|
|
}
|
|
|
|
public static function profile(string $type): ?ImportProfileInterface
|
|
{
|
|
$type = trim($type);
|
|
if ($type === '') {
|
|
return null;
|
|
}
|
|
$profiles = self::profiles();
|
|
return $profiles[$type] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{key:string,label:string}>
|
|
*/
|
|
public static function listProfileOptions(): array
|
|
{
|
|
$options = [];
|
|
foreach (self::profiles() as $profile) {
|
|
$options[] = [
|
|
'key' => $profile->key(),
|
|
'label' => $profile->label(),
|
|
];
|
|
}
|
|
return $options;
|
|
}
|
|
|
|
public static function requiredPermissionForType(string $type): string
|
|
{
|
|
$type = trim($type);
|
|
if ($type === '') {
|
|
return '';
|
|
}
|
|
return self::PROFILE_PERMISSION_MAP[$type] ?? '';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $upload
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function analyzeUpload(array $upload, string $type, int $currentUserId): array
|
|
{
|
|
$profile = self::profile($type);
|
|
if (!$profile) {
|
|
return ['ok' => false, 'code' => 'invalid_file_type'];
|
|
}
|
|
|
|
$stored = ImportTempFileService::storeUploadedFile($upload);
|
|
if (!$stored['ok']) {
|
|
$code = isset($stored['code']) ? (string) $stored['code'] : 'upload_missing';
|
|
return ['ok' => false, 'code' => $code];
|
|
}
|
|
|
|
$path = (string) ($stored['path'] ?? '');
|
|
$analysis = CsvReaderService::analyze($path, self::MAX_ROWS);
|
|
if (!$analysis['ok']) {
|
|
ImportTempFileService::delete($path);
|
|
$code = isset($analysis['code']) ? (string) $analysis['code'] : 'invalid_csv_header';
|
|
return ['ok' => false, 'code' => $code];
|
|
}
|
|
|
|
$headers = $analysis['headers'] ?? [];
|
|
$delimiter = (string) ($analysis['delimiter'] ?? ',');
|
|
$rowsTotal = (int) ($analysis['rows_total'] ?? 0);
|
|
|
|
$token = self::newToken();
|
|
self::setState($token, [
|
|
'token' => $token,
|
|
'type' => $profile->key(),
|
|
'path' => $path,
|
|
'source_filename' => trim((string) ($upload['name'] ?? '')),
|
|
'delimiter' => $delimiter,
|
|
'headers' => $headers,
|
|
'rows_total' => $rowsTotal,
|
|
'user_id' => $currentUserId,
|
|
'created_at' => time(),
|
|
]);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'token' => $token,
|
|
'type' => $profile->key(),
|
|
'headers' => $headers,
|
|
'rows_total' => $rowsTotal,
|
|
'delimiter' => $delimiter,
|
|
'allowed_targets' => $profile->allowedTargets(),
|
|
'required_targets' => $profile->requiredTargets(),
|
|
'auto_mapping' => self::buildAutoMapping($headers, $profile),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $mappingInput
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function preview(string $token, array $mappingInput, int $currentUserId): array
|
|
{
|
|
$state = self::state($token);
|
|
if (!$state) {
|
|
return ['ok' => false, 'code' => 'invalid_file_type'];
|
|
}
|
|
if ((int) ($state['user_id'] ?? 0) !== $currentUserId) {
|
|
return ['ok' => false, 'code' => 'invalid_file_type'];
|
|
}
|
|
|
|
$profile = self::profile((string) ($state['type'] ?? ''));
|
|
if (!$profile) {
|
|
return ['ok' => false, 'code' => 'invalid_file_type'];
|
|
}
|
|
|
|
$mappingResult = self::normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile);
|
|
if (!($mappingResult['ok'] ?? false)) {
|
|
return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')];
|
|
}
|
|
|
|
$mappedTargets = $mappingResult['mapped_targets'] ?? [];
|
|
if (
|
|
$profile->requiresAssignmentPermission()
|
|
&& self::usesAssignmentTargets($mappedTargets)
|
|
&& !self::canImportAssignments($currentUserId)
|
|
) {
|
|
return ['ok' => false, 'code' => 'assignment_permission_required'];
|
|
}
|
|
|
|
return self::process($state, $mappingResult['mapping'], $profile, $currentUserId, false);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $mappingInput
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function commit(string $token, array $mappingInput, int $currentUserId): array
|
|
{
|
|
$state = self::state($token);
|
|
if (!$state) {
|
|
return ['ok' => false, 'code' => 'invalid_file_type'];
|
|
}
|
|
if ((int) ($state['user_id'] ?? 0) !== $currentUserId) {
|
|
return ['ok' => false, 'code' => 'invalid_file_type'];
|
|
}
|
|
|
|
$profile = self::profile((string) ($state['type'] ?? ''));
|
|
if (!$profile) {
|
|
return ['ok' => false, 'code' => 'invalid_file_type'];
|
|
}
|
|
|
|
$mappingResult = self::normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile);
|
|
if (!($mappingResult['ok'] ?? false)) {
|
|
return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')];
|
|
}
|
|
|
|
$mappedTargets = $mappingResult['mapped_targets'] ?? [];
|
|
if (
|
|
$profile->requiresAssignmentPermission()
|
|
&& self::usesAssignmentTargets($mappedTargets)
|
|
&& !self::canImportAssignments($currentUserId)
|
|
) {
|
|
return ['ok' => false, 'code' => 'assignment_permission_required'];
|
|
}
|
|
|
|
$auditRunId = ImportAuditService::startRun(
|
|
(string) ($state['type'] ?? $profile->key()),
|
|
$mappingResult['mapped_targets'] ?? [],
|
|
isset($state['source_filename']) ? (string) $state['source_filename'] : null,
|
|
$currentUserId,
|
|
isset($_SESSION['current_tenant']['id']) ? (int) $_SESSION['current_tenant']['id'] : null
|
|
);
|
|
|
|
$result = null;
|
|
$forcedAuditStatus = null;
|
|
try {
|
|
$result = self::process($state, $mappingResult['mapping'], $profile, $currentUserId, true);
|
|
} catch (\Throwable $exception) {
|
|
$forcedAuditStatus = 'failed';
|
|
$result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1];
|
|
} finally {
|
|
ImportAuditService::finishRun($auditRunId, is_array($result) ? $result : [], $forcedAuditStatus);
|
|
}
|
|
|
|
ImportTempFileService::delete((string) ($state['path'] ?? ''));
|
|
self::clearState($token);
|
|
|
|
return is_array($result) ? $result : ['ok' => false, 'code' => 'unexpected_error'];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public static function state(string $token): ?array
|
|
{
|
|
$token = trim($token);
|
|
if ($token === '') {
|
|
return null;
|
|
}
|
|
|
|
$all = $_SESSION[self::SESSION_KEY] ?? [];
|
|
if (!is_array($all)) {
|
|
return null;
|
|
}
|
|
|
|
$state = $all[$token] ?? null;
|
|
if (!is_array($state)) {
|
|
return null;
|
|
}
|
|
|
|
$createdAt = (int) ($state['created_at'] ?? 0);
|
|
if ($createdAt > 0 && (time() - $createdAt) > 7200) {
|
|
ImportTempFileService::delete((string) ($state['path'] ?? ''));
|
|
self::clearState($token);
|
|
return null;
|
|
}
|
|
|
|
return $state;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $state
|
|
* @param array<string, string> $mapping
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function process(
|
|
array $state,
|
|
array $mapping,
|
|
ImportProfileInterface $profile,
|
|
int $currentUserId,
|
|
bool $commit
|
|
): array {
|
|
$path = (string) ($state['path'] ?? '');
|
|
$delimiter = (string) ($state['delimiter'] ?? ',');
|
|
$headers = $state['headers'] ?? [];
|
|
if (!is_array($headers) || !$headers) {
|
|
return ['ok' => false, 'code' => 'invalid_csv_header'];
|
|
}
|
|
|
|
$context = self::buildContext($currentUserId);
|
|
$seenRowKeys = [];
|
|
$processed = 0;
|
|
$created = 0;
|
|
$skipped = 0;
|
|
$failed = 0;
|
|
$wouldCreate = 0;
|
|
$errors = [];
|
|
$errorCounts = [];
|
|
|
|
CsvReaderService::streamRows(
|
|
$path,
|
|
$delimiter,
|
|
array_values($headers),
|
|
function (array $rowAssoc, int $lineNumber) use (
|
|
$profile,
|
|
$mapping,
|
|
$context,
|
|
$commit,
|
|
&$seenRowKeys,
|
|
&$processed,
|
|
&$created,
|
|
&$skipped,
|
|
&$failed,
|
|
&$wouldCreate,
|
|
&$errors,
|
|
&$errorCounts
|
|
): void {
|
|
$processed++;
|
|
$mapped = [];
|
|
$identifier = '';
|
|
try {
|
|
$mapped = self::mapRow($rowAssoc, $mapping);
|
|
$validation = $profile->validateMappedRow($mapped, $context);
|
|
if (!$validation['ok']) {
|
|
$failed++;
|
|
foreach ($validation['errors'] as $error) {
|
|
self::incrementErrorCount($errorCounts, (string) ($error['code'] ?? 'unexpected_error'));
|
|
self::pushError(
|
|
$errors,
|
|
$lineNumber,
|
|
$profile->rowIdentifier($mapped),
|
|
(string) $error['code'],
|
|
isset($error['message']) ? (string) $error['message'] : null
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
$normalized = $validation['normalized'];
|
|
$identifier = $profile->rowIdentifier($normalized);
|
|
$duplicateKey = $profile->inFileDuplicateKey($normalized);
|
|
if ($duplicateKey !== null && $duplicateKey !== '') {
|
|
if (isset($seenRowKeys[$duplicateKey])) {
|
|
$skipped++;
|
|
self::incrementErrorCount($errorCounts, 'duplicate_in_file');
|
|
self::pushError($errors, $lineNumber, $identifier, 'duplicate_in_file', null);
|
|
return;
|
|
}
|
|
$seenRowKeys[$duplicateKey] = true;
|
|
}
|
|
|
|
$result = $commit ? $profile->commitRow($normalized, $context) : $profile->dryRunRow($normalized, $context);
|
|
$status = (string) $result['status'];
|
|
$code = isset($result['code']) ? (string) $result['code'] : '';
|
|
$message = isset($result['message']) ? (string) $result['message'] : null;
|
|
|
|
if ($status === 'created') {
|
|
$created++;
|
|
return;
|
|
}
|
|
if ($status === 'would_create') {
|
|
$wouldCreate++;
|
|
return;
|
|
}
|
|
if ($status === 'skipped') {
|
|
$skipped++;
|
|
$errorCode = $code !== '' ? $code : 'email_exists';
|
|
self::incrementErrorCount($errorCounts, $errorCode);
|
|
self::pushError($errors, $lineNumber, $identifier, $errorCode, $message);
|
|
return;
|
|
}
|
|
|
|
$failed++;
|
|
$errorCode = $code !== '' ? $code : 'unexpected_error';
|
|
self::incrementErrorCount($errorCounts, $errorCode);
|
|
self::pushError($errors, $lineNumber, $identifier, $errorCode, $message);
|
|
} catch (\Throwable $exception) {
|
|
$failed++;
|
|
self::incrementErrorCount($errorCounts, 'unexpected_error');
|
|
if ($identifier === '' && $mapped) {
|
|
try {
|
|
$identifier = $profile->rowIdentifier($mapped);
|
|
} catch (\Throwable $innerException) {
|
|
$identifier = '';
|
|
}
|
|
}
|
|
self::pushError($errors, $lineNumber, $identifier, 'unexpected_error', null);
|
|
}
|
|
}
|
|
);
|
|
|
|
if ($commit) {
|
|
return [
|
|
'ok' => true,
|
|
'processed' => $processed,
|
|
'created' => $created,
|
|
'skipped' => $skipped,
|
|
'failed' => $failed,
|
|
'error_counts' => $errorCounts,
|
|
'errors' => $errors,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'rows_total' => $processed,
|
|
'valid_rows' => $wouldCreate + $skipped,
|
|
'would_create' => $wouldCreate,
|
|
'would_skip' => $skipped,
|
|
'would_fail' => $failed,
|
|
'errors' => $errors,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $headers
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function normalizeMapping(array $mappingInput, array $headers, ImportProfileInterface $profile): array
|
|
{
|
|
$mappingRaw = $mappingInput['mapping'] ?? $mappingInput;
|
|
if (!is_array($mappingRaw)) {
|
|
$mappingRaw = [];
|
|
}
|
|
|
|
$allowedTargets = array_keys($profile->allowedTargets());
|
|
$allowedMap = array_fill_keys($allowedTargets, true);
|
|
$requiredTargets = $profile->requiredTargets();
|
|
$requiredMap = array_fill_keys($requiredTargets, true);
|
|
$mapping = [];
|
|
$mappedTargets = [];
|
|
|
|
foreach ($headers as $header) {
|
|
$target = trim((string) ($mappingRaw[$header] ?? ''));
|
|
if ($target === '' || !isset($allowedMap[$target])) {
|
|
continue;
|
|
}
|
|
if (isset($mappedTargets[$target])) {
|
|
continue;
|
|
}
|
|
$mapping[$header] = $target;
|
|
$mappedTargets[$target] = true;
|
|
}
|
|
|
|
foreach ($requiredMap as $target => $_) {
|
|
if (!isset($mappedTargets[$target])) {
|
|
return ['ok' => false, 'code' => 'mapping_required_missing'];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'mapping' => $mapping,
|
|
'mapped_targets' => array_keys($mappedTargets),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $mapping
|
|
* @param array<string, string> $rowAssoc
|
|
* @return array<string, string>
|
|
*/
|
|
private static function mapRow(array $rowAssoc, array $mapping): array
|
|
{
|
|
$mapped = [];
|
|
foreach ($mapping as $header => $target) {
|
|
if (isset($mapped[$target])) {
|
|
continue;
|
|
}
|
|
$mapped[$target] = trim((string) ($rowAssoc[$header] ?? ''));
|
|
}
|
|
return $mapped;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $headers
|
|
* @return array<string, string>
|
|
*/
|
|
private static function buildAutoMapping(array $headers, ImportProfileInterface $profile): array
|
|
{
|
|
$mapping = [];
|
|
$aliases = $profile->autoMapAliases();
|
|
$normalizedAliasMap = [];
|
|
|
|
foreach ($aliases as $target => $targetAliases) {
|
|
$normalizedAliasMap[self::normalizeHeader($target)] = $target;
|
|
foreach ($targetAliases as $alias) {
|
|
$normalizedAliasMap[self::normalizeHeader($alias)] = $target;
|
|
}
|
|
}
|
|
|
|
$assignedTargets = [];
|
|
foreach ($headers as $header) {
|
|
$normalized = self::normalizeHeader($header);
|
|
if ($normalized === '') {
|
|
continue;
|
|
}
|
|
$target = $normalizedAliasMap[$normalized] ?? '';
|
|
if ($target === '' || isset($assignedTargets[$target])) {
|
|
continue;
|
|
}
|
|
$mapping[$header] = $target;
|
|
$assignedTargets[$target] = true;
|
|
}
|
|
|
|
return $mapping;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $mappedTargets
|
|
*/
|
|
private static function usesAssignmentTargets(array $mappedTargets): bool
|
|
{
|
|
foreach ($mappedTargets as $target) {
|
|
if (in_array($target, ['tenant', 'role', 'department'], true)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static function canImportAssignments(int $userId): bool
|
|
{
|
|
return PermissionService::userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function buildContext(int $currentUserId): array
|
|
{
|
|
$isGlobalAdmin = TenantScopeService::hasGlobalAccess($currentUserId);
|
|
return [
|
|
'current_user_id' => $currentUserId,
|
|
'is_global_admin' => $isGlobalAdmin,
|
|
'allowed_tenant_ids' => $isGlobalAdmin ? [] : TenantScopeService::getUserTenantIds($currentUserId),
|
|
'default_tenant_id' => SettingService::getDefaultTenantId(),
|
|
'default_role_id' => SettingService::getDefaultRoleId(),
|
|
'default_department_id' => SettingService::getDefaultDepartmentId(),
|
|
'can_import_assignments' => self::canImportAssignments($currentUserId),
|
|
];
|
|
}
|
|
|
|
private static function newToken(): string
|
|
{
|
|
try {
|
|
return bin2hex(random_bytes(24));
|
|
} catch (\Throwable $exception) {
|
|
return str_replace('.', '', uniqid('import_', true));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $state
|
|
*/
|
|
private static function setState(string $token, array $state): void
|
|
{
|
|
if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
|
|
$_SESSION[self::SESSION_KEY] = [];
|
|
}
|
|
$_SESSION[self::SESSION_KEY][$token] = $state;
|
|
}
|
|
|
|
private static function clearState(string $token): void
|
|
{
|
|
if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
|
|
return;
|
|
}
|
|
unset($_SESSION[self::SESSION_KEY][$token]);
|
|
}
|
|
|
|
private static function normalizeHeader(string $value): string
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$value = self::lower($value);
|
|
$value = str_replace(['-', '.', ' '], '_', $value);
|
|
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
|
|
$value = preg_replace('/_+/', '_', $value) ?? '';
|
|
return trim($value, '_');
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{row_number:int,identifier:string,code:string,message:string}> $errors
|
|
*/
|
|
private static function pushError(array &$errors, int $lineNumber, string $identifier, string $code, ?string $message): void
|
|
{
|
|
if (count($errors) >= self::MAX_ERROR_ROWS) {
|
|
return;
|
|
}
|
|
$errors[] = [
|
|
'row_number' => $lineNumber,
|
|
'identifier' => $identifier,
|
|
'code' => $code,
|
|
'message' => $message !== null && $message !== '' ? $message : self::defaultMessageForCode($code),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $errorCounts
|
|
*/
|
|
private static function incrementErrorCount(array &$errorCounts, string $code): void
|
|
{
|
|
$code = trim($code);
|
|
if ($code === '') {
|
|
return;
|
|
}
|
|
$errorCounts[$code] = (int) ($errorCounts[$code] ?? 0) + 1;
|
|
}
|
|
|
|
private static function defaultMessageForCode(string $code): string
|
|
{
|
|
$map = [
|
|
'upload_missing' => t('No upload file provided'),
|
|
'upload_too_large' => t('Upload exceeds the maximum size'),
|
|
'invalid_file_type' => t('Invalid file type'),
|
|
'invalid_csv_header' => t('CSV header is invalid'),
|
|
'empty_csv' => t('CSV file has no data rows'),
|
|
'max_rows_exceeded' => t('CSV row limit exceeded'),
|
|
'mapping_required_missing' => t('Required mapping is missing'),
|
|
'assignment_permission_required' => t('Assignment import permission is required'),
|
|
'invalid_email' => t('Email is invalid'),
|
|
'duplicate_in_file' => t('Duplicate entry in CSV file'),
|
|
'email_exists' => t('Email already exists'),
|
|
'invalid_locale' => t('Locale is invalid'),
|
|
'invalid_active' => t('Active value is invalid'),
|
|
'invalid_hire_date' => t('Hire date must be YYYY-MM-DD'),
|
|
'invalid_tenant_uuid' => t('Tenant must be a valid UUID'),
|
|
'tenant_not_found' => t('Tenant is missing, inactive or invalid'),
|
|
'role_not_found' => t('Role is missing, inactive or invalid'),
|
|
'department_not_found' => t('Department is missing, inactive or invalid'),
|
|
'department_tenant_mismatch' => t('Department does not belong to tenant'),
|
|
'assignment_out_of_scope' => t('Tenant assignment is out of your scope'),
|
|
'code_exists' => t('Code already exists'),
|
|
'department_exists' => t('Department already exists for this tenant'),
|
|
'create_failed' => t('Entry could not be created'),
|
|
'unexpected_error' => t('Unexpected error during import'),
|
|
];
|
|
return $map[$code] ?? t('Unexpected error during import');
|
|
}
|
|
|
|
public static function messageForCode(string $code): string
|
|
{
|
|
return self::defaultMessageForCode(trim($code));
|
|
}
|
|
|
|
private static function lower(string $value): string
|
|
{
|
|
if (function_exists('mb_strtolower')) {
|
|
return mb_strtolower($value, 'UTF-8');
|
|
}
|
|
return strtolower($value);
|
|
}
|
|
}
|