Some checks failed
Main Confidence / confidence (push) Failing after 56s
## Summary - add the localization v1 foundation with request-time locale resolution and workspace or user preference handling - localize the first-wave platform surfaces for auth, shell, dashboards, findings, baseline compare, and review workspace chrome - add Pest coverage for locale resolution, preference flows, fallback behavior, notifications, and governance surface localization ## Scope - active spec: specs/252-platform-localization-v1 - target branch: dev ## Notes - machine-readable artifacts remain invariant and are not localized in this slice - the branch includes the related spec kit artifacts for the feature Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #293
216 lines
6.4 KiB
PHP
216 lines
6.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Localization;
|
|
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Settings\SettingsResolver;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Illuminate\Http\Request;
|
|
|
|
class LocaleResolver
|
|
{
|
|
public const SESSION_OVERRIDE_KEY = 'tenantpilot.locale_override';
|
|
|
|
public const REQUEST_ATTRIBUTE = 'tenantpilot.resolved_locale';
|
|
|
|
public const SETTING_DOMAIN = 'localization';
|
|
|
|
public const SETTING_DEFAULT_LOCALE = 'default_locale';
|
|
|
|
public const SOURCE_EXPLICIT_OVERRIDE = 'explicit_override';
|
|
|
|
public const SOURCE_USER_PREFERENCE = 'user_preference';
|
|
|
|
public const SOURCE_WORKSPACE_DEFAULT = 'workspace_default';
|
|
|
|
public const SOURCE_SYSTEM_DEFAULT = 'system_default';
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const SUPPORTED_LOCALES = ['en', 'de'];
|
|
|
|
public function __construct(
|
|
private SettingsResolver $settingsResolver,
|
|
private WorkspaceContext $workspaceContext,
|
|
) {}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public static function supportedLocales(): array
|
|
{
|
|
return self::SUPPORTED_LOCALES;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function localeOptions(): array
|
|
{
|
|
return [
|
|
'en' => __('localization.locales.en'),
|
|
'de' => __('localization.locales.de'),
|
|
];
|
|
}
|
|
|
|
public static function isSupported(mixed $locale): bool
|
|
{
|
|
return self::normalize($locale) !== null;
|
|
}
|
|
|
|
public static function normalize(mixed $locale): ?string
|
|
{
|
|
if (! is_string($locale)) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = strtolower(trim($locale));
|
|
|
|
return in_array($normalized, self::SUPPORTED_LOCALES, true) ? $normalized : null;
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* locale: string,
|
|
* source: string,
|
|
* fallback_locale: string,
|
|
* user_preference_locale: ?string,
|
|
* workspace_default_locale: ?string,
|
|
* machine_artifacts_invariant: true
|
|
* }
|
|
*/
|
|
public function resolve(Request $request, ?string $plane = null): array
|
|
{
|
|
$plane = $this->normalizePlane($plane, $request);
|
|
|
|
$explicitOverride = $this->explicitOverride($request);
|
|
$systemDefault = (string) config('app.fallback_locale', 'en');
|
|
|
|
if ($plane === 'system') {
|
|
return $this->resolveFromSources(
|
|
explicitOverride: $explicitOverride,
|
|
userPreference: null,
|
|
workspaceDefault: null,
|
|
systemDefault: $systemDefault,
|
|
includeUserPreference: false,
|
|
includeWorkspaceDefault: false,
|
|
);
|
|
}
|
|
|
|
$user = $request->user();
|
|
$userPreference = $user instanceof User ? $user->preferred_locale : null;
|
|
$workspaceDefault = $this->workspaceDefault($request);
|
|
|
|
return $this->resolveFromSources(
|
|
explicitOverride: $explicitOverride,
|
|
userPreference: $userPreference,
|
|
workspaceDefault: $workspaceDefault,
|
|
systemDefault: $systemDefault,
|
|
includeUserPreference: true,
|
|
includeWorkspaceDefault: true,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* locale: string,
|
|
* source: string,
|
|
* fallback_locale: string,
|
|
* user_preference_locale: ?string,
|
|
* workspace_default_locale: ?string,
|
|
* machine_artifacts_invariant: true
|
|
* }
|
|
*/
|
|
public function resolveFromSources(
|
|
mixed $explicitOverride,
|
|
mixed $userPreference,
|
|
mixed $workspaceDefault,
|
|
mixed $systemDefault,
|
|
bool $includeUserPreference = true,
|
|
bool $includeWorkspaceDefault = true,
|
|
): array {
|
|
$fallbackLocale = self::normalize(config('app.fallback_locale', 'en')) ?? 'en';
|
|
|
|
$candidates = [
|
|
self::SOURCE_EXPLICIT_OVERRIDE => self::normalize($explicitOverride),
|
|
];
|
|
|
|
if ($includeUserPreference) {
|
|
$candidates[self::SOURCE_USER_PREFERENCE] = self::normalize($userPreference);
|
|
}
|
|
|
|
if ($includeWorkspaceDefault) {
|
|
$candidates[self::SOURCE_WORKSPACE_DEFAULT] = self::normalize($workspaceDefault);
|
|
}
|
|
|
|
$candidates[self::SOURCE_SYSTEM_DEFAULT] = self::normalize($systemDefault) ?? $fallbackLocale;
|
|
|
|
foreach ($candidates as $source => $locale) {
|
|
if ($locale !== null) {
|
|
return [
|
|
'locale' => $locale,
|
|
'source' => $source,
|
|
'fallback_locale' => $fallbackLocale,
|
|
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
|
|
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
|
|
'machine_artifacts_invariant' => true,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'locale' => $fallbackLocale,
|
|
'source' => self::SOURCE_SYSTEM_DEFAULT,
|
|
'fallback_locale' => $fallbackLocale,
|
|
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
|
|
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
|
|
'machine_artifacts_invariant' => true,
|
|
];
|
|
}
|
|
|
|
private function explicitOverride(Request $request): ?string
|
|
{
|
|
$queryLocale = self::normalize($request->query('locale'));
|
|
|
|
if ($queryLocale !== null) {
|
|
return $queryLocale;
|
|
}
|
|
|
|
if (! $request->hasSession()) {
|
|
return null;
|
|
}
|
|
|
|
return self::normalize($request->session()->get(self::SESSION_OVERRIDE_KEY));
|
|
}
|
|
|
|
private function workspaceDefault(Request $request): ?string
|
|
{
|
|
$workspace = $this->workspaceContext->currentWorkspace($request);
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
return null;
|
|
}
|
|
|
|
return self::normalize($this->settingsResolver->resolveValue(
|
|
workspace: $workspace,
|
|
domain: self::SETTING_DOMAIN,
|
|
key: self::SETTING_DEFAULT_LOCALE,
|
|
));
|
|
}
|
|
|
|
private function normalizePlane(?string $plane, Request $request): string
|
|
{
|
|
$plane = strtolower(trim((string) $plane));
|
|
|
|
if (in_array($plane, ['admin', 'tenant', 'system'], true)) {
|
|
return $plane;
|
|
}
|
|
|
|
return $request->is('system', 'system/*') ? 'system' : 'admin';
|
|
}
|
|
}
|