diff --git a/i18n/default_de.json b/i18n/default_de.json index 14cf1eb..86e5499 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -295,7 +295,6 @@ "Profile image": "Profilbild", "Profile": "Profil", "Profile description": "Profilbeschreibung", - "About": "Über", "Info": "Info", "Search results": "Suchergebnisse", "Result": "Ergebnis", @@ -470,10 +469,6 @@ "Status & meta": "Status & Meta", "Audit": "Audit", "Overview": "Übersicht", - "Quick actions": "Schnellaktionen", - "Send email": "E-Mail senden", - "Call phone": "Telefon anrufen", - "Call mobile": "Mobil anrufen", "Choose color": "Farbe wählen", "Be careful - this resets the tenants color": "Achtung – das setzt die Primärfarbe des Mandanten zurück", "Code": "Code", @@ -520,13 +515,11 @@ "Department can not be created": "Abteilung kann nicht erstellt werden", "Department can not be updated": "Abteilung kann nicht aktualisiert werden", "Delete this department?": "Diese Abteilung wirklich löschen?", - "Delete this bookmark?": "Dieses Lesezeichen wirklich löschen?", "Delete department": "Abteilung löschen", "This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.", "Assigned departments": "Zugewiesene Abteilungen", "Select departments": "Abteilungen auswählen", "Departments for tenant": "Abteilungen für Mandant", - "No departments": "Keine Abteilungen", "No departments available for this tenant": "Für diesen Mandanten sind keine Abteilungen verfügbar", "Assign tenants first to select departments": "Bitte zuerst Mandanten zuweisen, um Abteilungen auszuwählen", "No tenants available": "Keine Mandanten verfügbar", @@ -549,7 +542,6 @@ "Delete permission": "Berechtigung löschen", "This will permanently delete this role.": "Diese Rolle wird dauerhaft gelöscht.", "This will permanently delete this permission.": "Diese Berechtigung wird dauerhaft gelöscht.", - "This action cannot be undone.": "Diese Aktion kann nicht rückgängig gemacht werden.", "Description": "Beschreibung", "Description cannot be empty": "Beschreibung darf nicht leer sein", "Status is invalid": "Status ist ungültig", @@ -647,7 +639,6 @@ "SMTP security": "SMTP Sicherheit", "SMTP from address": "Absender-Adresse", "SMTP from name": "Absendername", - "Address book": "Adressbuch", "Mobile": "Mobil", "Short dial": "Kurzwahl", "setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)", @@ -659,7 +650,6 @@ "setting.smtp_from_name": "Absendername für ausgehende Nachrichten", "High contrast": "Hoher Kontrast", "Toggle contrast": "Kontrast umschalten", - "View in address book": "Im Adressbuch ansehen", "Organization & Quality": "Organisation & Qualität", "Email security": "E-Mail Sicherheit", "API": "API", @@ -813,8 +803,6 @@ "Please fill required custom field: %s": "Bitte Pflicht-Zusatzfeld ausfüllen: %s", "Invalid value for %s": "Ungültiger Wert für %s", "Please select": "Bitte auswählen", - "From": "Von", - "To": "Bis", "perm.custom_fields.manage": "Mandanten-Zusatzfelder verwalten", "perm.custom_fields.edit_values": "Benutzer-Zusatzfelder bearbeiten", "Label": "Bezeichnung", @@ -999,7 +987,6 @@ "Import another file": "Weitere Datei importieren", "No row errors found": "Keine Zeilenfehler gefunden", "Row": "Zeile", - "Code": "Code", "Message": "Nachricht", "No upload file provided": "Keine Upload-Datei übergeben", "Upload exceeds the maximum size": "Upload überschreitet die maximale Dateigröße", @@ -1212,7 +1199,6 @@ "Actor tenant": "Akteur-Mandant", "Event details": "Ereignisdetails", "Target type": "Zieltyp", - "Request ID": "Request-ID", "IP hash": "IP-Hash", "User agent hash": "User-Agent-Hash", "Channel": "Kanal", @@ -1299,45 +1285,5 @@ "Session expiring": "Sitzung läuft ab", "Your session will expire in {countdown}. Would you like to continue?": "Ihre Sitzung läuft in {countdown} ab. Möchten Sie fortfahren?", "Extend session": "Sitzung verlängern", - "Log out": "Abmelden", - "Bookmarks": "Lesezeichen", - "Bookmark this page": "Seite als Lesezeichen speichern", - "Save bookmark": "Lesezeichen speichern", - "Bookmark name": "Lesezeichen-Name", - "Delete bookmark": "Lesezeichen löschen", - "No bookmarks yet": "Noch keine Lesezeichen", - "New group": "Neue Gruppe", - "No group": "Keine Gruppe", - "Assign to group": "Gruppe zuweisen", - "Group name": "Gruppenname", - "Delete group": "Gruppe löschen", - "Group": "Gruppe", - "Icon": "Symbol", - "Maximum bookmarks reached": "Maximale Anzahl an Lesezeichen erreicht", - "Edit bookmark": "Lesezeichen bearbeiten", - "Save changes": "Änderungen speichern", - "Bookmark saved": "Lesezeichen gespeichert", - "Bookmark updated": "Lesezeichen aktualisiert", - "Bookmark deleted": "Lesezeichen gelöscht", - "Group created": "Gruppe erstellt", - "Group deleted": "Gruppe gelöscht", - "Group delete failed": "Gruppe konnte nicht gelöscht werden", - "Delete group and keep bookmarks?": "Gruppe löschen und enthaltene Lesezeichen behalten?", - "Bookmark action failed": "Lesezeichen-Aktion fehlgeschlagen", - "Group action failed": "Gruppen-Aktion fehlgeschlagen", - "Reorder saved": "Sortierung gespeichert", - "Reorder failed": "Sortierung fehlgeschlagen", - "Move group up": "Gruppe nach oben", - "Move group down": "Gruppe nach unten", - "Move bookmark up": "Lesezeichen nach oben", - "Move bookmark down": "Lesezeichen nach unten", - "Group actions": "Gruppenaktionen", - "Bookmark actions": "Lesezeichenaktionen", - "Bookmark name is required": "Lesezeichen-Name ist erforderlich", - "Bookmark URL is invalid": "Lesezeichen-URL ist ungültig", - "Group not found": "Gruppe nicht gefunden", - "Edit group": "Gruppe bearbeiten", - "Group updated": "Gruppe aktualisiert", - "Group name is required": "Gruppenname ist erforderlich", - "Maximum groups reached": "Maximale Anzahl an Gruppen erreicht" + "Log out": "Abmelden" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 54de160..ff41ad2 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -295,7 +295,6 @@ "Profile image": "Profile image", "Profile": "Profile", "Profile description": "Profile description", - "About": "About", "Info": "Info", "Search results": "Search results", "Result": "Result", @@ -470,10 +469,6 @@ "Status & meta": "Status & meta", "Audit": "Audit", "Overview": "Overview", - "Quick actions": "Quick actions", - "Send email": "Send email", - "Call phone": "Call phone", - "Call mobile": "Call mobile", "Choose color": "Choose color", "Be careful - this resets the tenants color": "Be careful - this resets the tenant color", "Code": "Code", @@ -520,13 +515,11 @@ "Department can not be created": "Department can not be created", "Department can not be updated": "Department can not be updated", "Delete this department?": "Delete this department?", - "Delete this bookmark?": "Delete this bookmark?", "Delete department": "Delete department", "This will permanently delete this department.": "This will permanently delete this department.", "Assigned departments": "Assigned departments", "Select departments": "Select departments", "Departments for tenant": "Departments for tenant", - "No departments": "No departments", "No departments available for this tenant": "No departments available for this tenant", "Assign tenants first to select departments": "Assign tenants first to select departments", "No tenants available": "No tenants available", @@ -549,7 +542,6 @@ "Delete permission": "Delete permission", "This will permanently delete this role.": "This will permanently delete this role.", "This will permanently delete this permission.": "This will permanently delete this permission.", - "This action cannot be undone.": "This action cannot be undone.", "Description": "Description", "Description cannot be empty": "Description cannot be empty", "Status is invalid": "Status is invalid", @@ -647,7 +639,6 @@ "SMTP security": "SMTP security", "SMTP from address": "SMTP from address", "SMTP from name": "SMTP from name", - "Address book": "Address book", "Mobile": "Mobile", "Short dial": "Short dial", "setting.smtp_host": "SMTP server host (e.g. smtp.example.com)", @@ -659,7 +650,6 @@ "setting.smtp_from_name": "From name for outgoing emails", "High contrast": "High contrast", "Toggle contrast": "Toggle contrast", - "View in address book": "View in address book", "Organization & Quality": "Organization & Quality", "Email security": "Email security", "API": "API", @@ -813,8 +803,6 @@ "Please fill required custom field: %s": "Please fill required custom field: %s", "Invalid value for %s": "Invalid value for %s", "Please select": "Please select", - "From": "From", - "To": "To", "perm.custom_fields.manage": "Manage tenant custom fields", "perm.custom_fields.edit_values": "Edit user custom field values", "Label": "Label", @@ -999,7 +987,6 @@ "Import another file": "Import another file", "No row errors found": "No row errors found", "Row": "Row", - "Code": "Code", "Message": "Message", "No upload file provided": "No upload file provided", "Upload exceeds the maximum size": "Upload exceeds the maximum size", @@ -1212,7 +1199,6 @@ "Actor tenant": "Actor tenant", "Event details": "Event details", "Target type": "Target type", - "Request ID": "Request ID", "IP hash": "IP hash", "User agent hash": "User agent hash", "Channel": "Channel", @@ -1299,45 +1285,5 @@ "Session expiring": "Session expiring", "Your session will expire in {countdown}. Would you like to continue?": "Your session will expire in {countdown}. Would you like to continue?", "Extend session": "Extend session", - "Log out": "Log out", - "Bookmarks": "Bookmarks", - "Bookmark this page": "Bookmark this page", - "Save bookmark": "Save bookmark", - "Bookmark name": "Bookmark name", - "Delete bookmark": "Delete bookmark", - "No bookmarks yet": "No bookmarks yet", - "New group": "New group", - "No group": "No group", - "Assign to group": "Assign to group", - "Group name": "Group name", - "Delete group": "Delete group", - "Group": "Group", - "Icon": "Icon", - "Maximum bookmarks reached": "Maximum bookmarks reached", - "Edit bookmark": "Edit bookmark", - "Save changes": "Save changes", - "Bookmark saved": "Bookmark saved", - "Bookmark updated": "Bookmark updated", - "Bookmark deleted": "Bookmark deleted", - "Group created": "Group created", - "Group deleted": "Group deleted", - "Group delete failed": "Group delete failed", - "Delete group and keep bookmarks?": "Delete group and keep bookmarks?", - "Bookmark action failed": "Bookmark action failed", - "Group action failed": "Group action failed", - "Reorder saved": "Reorder saved", - "Reorder failed": "Reorder failed", - "Move group up": "Move group up", - "Move group down": "Move group down", - "Move bookmark up": "Move bookmark up", - "Move bookmark down": "Move bookmark down", - "Group actions": "Group actions", - "Bookmark actions": "Bookmark actions", - "Bookmark name is required": "Bookmark name is required", - "Bookmark URL is invalid": "Bookmark URL is invalid", - "Group not found": "Group not found", - "Edit group": "Edit group", - "Group updated": "Group updated", - "Group name is required": "Group name is required", - "Maximum groups reached": "Maximum groups reached" + "Log out": "Log out" } diff --git a/lib/App/Module/ModuleI18nLoader.php b/lib/App/Module/ModuleI18nLoader.php new file mode 100644 index 0000000..91e72cf --- /dev/null +++ b/lib/App/Module/ModuleI18nLoader.php @@ -0,0 +1,55 @@ + $manifests Active module manifests (already sorted) + * @param string $coreI18nDir Absolute path to the core i18n/ directory + */ + public static function preload(array $manifests, string $coreI18nDir): void + { + $locales = ['de', 'en']; + $domain = I18n::$domain ?: 'default'; + + $prop = new ReflectionProperty(I18n::class, 'strings'); + + foreach ($locales as $locale) { + // Load core file + $coreFile = $coreI18nDir . '/' . $domain . '_' . $locale . '.json'; + $merged = is_file($coreFile) + ? (json_decode(file_get_contents($coreFile), true) ?: []) + : []; + + // Merge each module's translations on top + foreach ($manifests as $manifest) { + if ($manifest->i18nPath === null) { + continue; + } + $moduleFile = $manifest->i18nPath . '/' . $domain . '_' . $locale . '.json'; + if (!is_file($moduleFile)) { + continue; + } + $moduleStrings = json_decode(file_get_contents($moduleFile), true); + if (is_array($moduleStrings)) { + $merged = array_merge($merged, $moduleStrings); + } + } + + // Inject via Reflection into I18n::$strings + $strings = $prop->getValue(); + $strings[$domain][$locale] = $merged; + $prop->setValue(null, $strings); + } + } +} diff --git a/lib/App/Module/ModuleManifest.php b/lib/App/Module/ModuleManifest.php index f4552aa..e07db7d 100644 --- a/lib/App/Module/ModuleManifest.php +++ b/lib/App/Module/ModuleManifest.php @@ -83,6 +83,8 @@ final class ModuleManifest public readonly ?string $migrationsPath; + public readonly ?string $i18nPath; + public readonly string $basePath; /** @@ -128,6 +130,11 @@ final class ModuleManifest $this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== '' ? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/') : null; + + $i18nPath = $raw['i18n_path'] ?? null; + $this->i18nPath = is_string($i18nPath) && trim($i18nPath) !== '' + ? rtrim($basePath, '/') . '/' . ltrim(trim($i18nPath), '/') + : null; } /** diff --git a/lib/App/Module/ModuleRegistry.php b/lib/App/Module/ModuleRegistry.php index 6759925..cb12a7a 100644 --- a/lib/App/Module/ModuleRegistry.php +++ b/lib/App/Module/ModuleRegistry.php @@ -148,6 +148,12 @@ final class ModuleRegistry // module classes (MintyPHP\Module\\*) resolve without a custom loader. self::registerModuleAutoloading($manifests); + // Preload core + module translations into I18n before any t() call. + $coreI18nDir = rtrim($modulesDir, '/') . '/../i18n'; + if (is_dir($coreI18nDir)) { + ModuleI18nLoader::preload($manifests, $coreI18nDir); + } + // Register and merge foreach ($manifests as $manifest) { $registry->registerModule($manifest); diff --git a/modules/addressbook/i18n/default_de.json b/modules/addressbook/i18n/default_de.json new file mode 100644 index 0000000..38279d5 --- /dev/null +++ b/modules/addressbook/i18n/default_de.json @@ -0,0 +1,12 @@ +{ + "About": "Über", + "Address book": "Adressbuch", + "Call mobile": "Mobil anrufen", + "Call phone": "Telefon anrufen", + "From": "Von", + "No departments": "Keine Abteilungen", + "Quick actions": "Schnellaktionen", + "Send email": "E-Mail senden", + "To": "Bis", + "View in address book": "Im Adressbuch ansehen" +} diff --git a/modules/addressbook/i18n/default_en.json b/modules/addressbook/i18n/default_en.json new file mode 100644 index 0000000..10dc3a5 --- /dev/null +++ b/modules/addressbook/i18n/default_en.json @@ -0,0 +1,12 @@ +{ + "About": "About", + "Address book": "Address book", + "Call mobile": "Call mobile", + "Call phone": "Call phone", + "From": "From", + "No departments": "No departments", + "Quick actions": "Quick actions", + "Send email": "Send email", + "To": "To", + "View in address book": "View in address book" +} diff --git a/modules/addressbook/module.php b/modules/addressbook/module.php index 46fc9a7..5a2ba16 100644 --- a/modules/addressbook/module.php +++ b/modules/addressbook/module.php @@ -94,4 +94,5 @@ return [ ], 'migrations_path' => 'db/updates', + 'i18n_path' => 'i18n', ]; diff --git a/modules/bookmarks/i18n/default_de.json b/modules/bookmarks/i18n/default_de.json new file mode 100644 index 0000000..5f97e3a --- /dev/null +++ b/modules/bookmarks/i18n/default_de.json @@ -0,0 +1,43 @@ +{ + "Bookmarks": "Lesezeichen", + "Bookmark this page": "Seite als Lesezeichen speichern", + "Save bookmark": "Lesezeichen speichern", + "Bookmark name": "Lesezeichen-Name", + "Delete bookmark": "Lesezeichen löschen", + "Delete this bookmark?": "Dieses Lesezeichen wirklich löschen?", + "No bookmarks yet": "Noch keine Lesezeichen", + "New group": "Neue Gruppe", + "No group": "Keine Gruppe", + "Assign to group": "Gruppe zuweisen", + "Group name": "Gruppenname", + "Delete group": "Gruppe löschen", + "Group": "Gruppe", + "Icon": "Symbol", + "Maximum bookmarks reached": "Maximale Anzahl an Lesezeichen erreicht", + "Edit bookmark": "Lesezeichen bearbeiten", + "Save changes": "Änderungen speichern", + "Bookmark saved": "Lesezeichen gespeichert", + "Bookmark updated": "Lesezeichen aktualisiert", + "Bookmark deleted": "Lesezeichen gelöscht", + "Group created": "Gruppe erstellt", + "Group deleted": "Gruppe gelöscht", + "Group delete failed": "Gruppe konnte nicht gelöscht werden", + "Delete group and keep bookmarks?": "Gruppe löschen und enthaltene Lesezeichen behalten?", + "Bookmark action failed": "Lesezeichen-Aktion fehlgeschlagen", + "Group action failed": "Gruppen-Aktion fehlgeschlagen", + "Reorder saved": "Sortierung gespeichert", + "Reorder failed": "Sortierung fehlgeschlagen", + "Move group up": "Gruppe nach oben", + "Move group down": "Gruppe nach unten", + "Move bookmark up": "Lesezeichen nach oben", + "Move bookmark down": "Lesezeichen nach unten", + "Group actions": "Gruppenaktionen", + "Bookmark actions": "Lesezeichenaktionen", + "Bookmark name is required": "Lesezeichen-Name ist erforderlich", + "Bookmark URL is invalid": "Lesezeichen-URL ist ungültig", + "Group not found": "Gruppe nicht gefunden", + "Edit group": "Gruppe bearbeiten", + "Group updated": "Gruppe aktualisiert", + "Group name is required": "Gruppenname ist erforderlich", + "Maximum groups reached": "Maximale Anzahl an Gruppen erreicht" +} diff --git a/modules/bookmarks/i18n/default_en.json b/modules/bookmarks/i18n/default_en.json new file mode 100644 index 0000000..5928564 --- /dev/null +++ b/modules/bookmarks/i18n/default_en.json @@ -0,0 +1,43 @@ +{ + "Bookmarks": "Bookmarks", + "Bookmark this page": "Bookmark this page", + "Save bookmark": "Save bookmark", + "Bookmark name": "Bookmark name", + "Delete bookmark": "Delete bookmark", + "Delete this bookmark?": "Delete this bookmark?", + "No bookmarks yet": "No bookmarks yet", + "New group": "New group", + "No group": "No group", + "Assign to group": "Assign to group", + "Group name": "Group name", + "Delete group": "Delete group", + "Group": "Group", + "Icon": "Icon", + "Maximum bookmarks reached": "Maximum bookmarks reached", + "Edit bookmark": "Edit bookmark", + "Save changes": "Save changes", + "Bookmark saved": "Bookmark saved", + "Bookmark updated": "Bookmark updated", + "Bookmark deleted": "Bookmark deleted", + "Group created": "Group created", + "Group deleted": "Group deleted", + "Group delete failed": "Group delete failed", + "Delete group and keep bookmarks?": "Delete group and keep bookmarks?", + "Bookmark action failed": "Bookmark action failed", + "Group action failed": "Group action failed", + "Reorder saved": "Reorder saved", + "Reorder failed": "Reorder failed", + "Move group up": "Move group up", + "Move group down": "Move group down", + "Move bookmark up": "Move bookmark up", + "Move bookmark down": "Move bookmark down", + "Group actions": "Group actions", + "Bookmark actions": "Bookmark actions", + "Bookmark name is required": "Bookmark name is required", + "Bookmark URL is invalid": "Bookmark URL is invalid", + "Group not found": "Group not found", + "Edit group": "Edit group", + "Group updated": "Group updated", + "Group name is required": "Group name is required", + "Maximum groups reached": "Maximum groups reached" +} diff --git a/modules/bookmarks/module.php b/modules/bookmarks/module.php index dda6bcc..5b605a7 100644 --- a/modules/bookmarks/module.php +++ b/modules/bookmarks/module.php @@ -130,4 +130,5 @@ return [ 'permissions' => [], 'migrations_path' => null, + 'i18n_path' => 'i18n', ]; diff --git a/modules/notifications/i18n/default_de.json b/modules/notifications/i18n/default_de.json new file mode 100644 index 0000000..a31062c --- /dev/null +++ b/modules/notifications/i18n/default_de.json @@ -0,0 +1,10 @@ +{ + "Notifications": "Benachrichtigungen", + "No notifications": "Keine Benachrichtigungen", + "Mark all as read": "Alle als gelesen markieren", + "Mark as read": "Als gelesen markieren", + "Dismiss": "Verwerfen", + "Action failed": "Aktion fehlgeschlagen", + "New user: %s": "Neuer Benutzer: %s", + "User deleted: %s": "Benutzer gelöscht: %s" +} diff --git a/modules/notifications/i18n/default_en.json b/modules/notifications/i18n/default_en.json new file mode 100644 index 0000000..a0dba59 --- /dev/null +++ b/modules/notifications/i18n/default_en.json @@ -0,0 +1,10 @@ +{ + "Notifications": "Notifications", + "No notifications": "No notifications", + "Mark all as read": "Mark all as read", + "Mark as read": "Mark as read", + "Dismiss": "Dismiss", + "Action failed": "Action failed", + "New user: %s": "New user: %s", + "User deleted: %s": "User deleted: %s" +} diff --git a/modules/notifications/module.php b/modules/notifications/module.php new file mode 100644 index 0000000..7e11510 --- /dev/null +++ b/modules/notifications/module.php @@ -0,0 +1,115 @@ + 'notifications', + 'version' => '1.0.0', + 'enabled_by_default' => true, + 'load_order' => 15, + 'requires' => [], + + 'routes' => [ + ['path' => 'notifications/list-data', 'target' => 'notifications/list-data'], + ['path' => 'notifications/mark-read-data', 'target' => 'notifications/mark-read-data'], + ['path' => 'notifications/delete-data', 'target' => 'notifications/delete-data'], + ['path' => 'notifications/unread-count-data', 'target' => 'notifications/unread-count-data'], + ], + 'public_paths' => [], + + 'container_registrars' => [ + \MintyPHP\Module\Notifications\NotificationsContainerRegistrar::class, + ], + + 'ui_slots' => [ + 'topbar.right_item' => [ + [ + 'key' => 'notifications-bell', + 'template' => 'templates/topbar-notification-bell.phtml', + 'permission' => 'notifications.view', + 'order' => 100, + ], + ], + 'layout.head_style' => [ + [ + 'key' => 'notifications-topbar-style', + 'path' => 'modules/notifications/css/layout/app-topbar-notifications.css', + 'permission' => 'notifications.view', + 'order' => 100, + ], + [ + 'key' => 'notifications-dropdown-style', + 'path' => 'modules/notifications/css/components/app-notification-dropdown.css', + 'permission' => 'notifications.view', + 'order' => 101, + ], + ], + 'runtime.component' => [ + [ + 'key' => 'notification-bell', + 'script' => 'modules/notifications/js/components/app-notification-bell.js', + 'export' => 'initNotificationBell', + 'scope' => 'global', + 'config_path' => 'components.notificationBell', + 'default_config' => [], + 'phase' => 'late', + 'permission' => 'notifications.view', + 'order' => 100, + ], + ], + ], + + 'authorization_policies' => [ + \MintyPHP\Module\Notifications\NotificationsAuthorizationPolicy::class, + ], + + 'permissions' => [ + [ + 'key' => 'notifications.view', + 'label' => 'Notifications: View', + 'description' => 'View and manage own notifications', + ], + ], + + 'event_listeners' => [ + 'user.created' => [ + ['class' => \MintyPHP\Module\Notifications\Listeners\UserCreatedNotificationListener::class, 'method' => 'handle'], + ], + 'user.deleted' => [ + ['class' => \MintyPHP\Module\Notifications\Listeners\UserDeletedNotificationListener::class, 'method' => 'handle'], + ], + ], + + 'scheduler_jobs' => [ + [ + 'job_key' => 'notifications_cleanup', + 'handler' => \MintyPHP\Module\Notifications\Handler\NotificationCleanupJobHandler::class, + 'label' => 'Notification cleanup', + 'description' => 'Purges read notifications older than 90 days', + 'default_enabled' => 1, + 'default_timezone' => 'UTC', + 'default_schedule_type' => 'daily', + 'default_schedule_interval' => 1, + 'default_schedule_time' => '04:00', + 'default_schedule_weekdays_csv' => null, + 'default_catchup_once' => 1, + 'allowed_schedule_types' => ['daily', 'weekly'], + ], + ], + + 'layout_context_providers' => [ + \MintyPHP\Module\Notifications\Providers\NotificationsLayoutProvider::class, + ], + 'session_providers' => [ + \MintyPHP\Module\Notifications\Providers\NotificationsSessionProvider::class, + ], + + 'search_resources' => [], + 'asset_groups' => [], + 'migrations_path' => 'db/updates', + 'i18n_path' => 'i18n', +]; diff --git a/tests/Architecture/ModuleI18nContractTest.php b/tests/Architecture/ModuleI18nContractTest.php new file mode 100644 index 0000000..374dfe9 --- /dev/null +++ b/tests/Architecture/ModuleI18nContractTest.php @@ -0,0 +1,127 @@ + + */ + public static function moduleProvider(): array + { + $root = realpath(__DIR__ . '/../..'); + if ($root === false) { + return []; + } + + $modules = []; + foreach (glob($root . '/modules/*/module.php') as $manifestFile) { + $raw = (static function (string $path): mixed { + return require $path; + })($manifestFile); + if (!is_array($raw)) { + continue; + } + $i18nPath = $raw['i18n_path'] ?? null; + if (!is_string($i18nPath) || trim($i18nPath) === '') { + continue; + } + $moduleDir = dirname($manifestFile); + $moduleId = basename($moduleDir); + $resolvedPath = rtrim($moduleDir, '/') . '/' . ltrim(trim($i18nPath), '/'); + $modules[$moduleId] = [$moduleId, $resolvedPath]; + } + + return $modules; + } + + #[DataProvider('moduleProvider')] + public function testModuleI18nFilesExistAndAreValidJson(string $moduleId, string $i18nPath): void + { + $this->assertDirectoryExists($i18nPath, "Module '{$moduleId}' declares i18n_path but directory does not exist."); + + $deFile = $i18nPath . '/default_de.json'; + $enFile = $i18nPath . '/default_en.json'; + + $this->assertFileExists($deFile, "Module '{$moduleId}' is missing default_de.json"); + $this->assertFileExists($enFile, "Module '{$moduleId}' is missing default_en.json"); + + $deRaw = file_get_contents($deFile); + $enRaw = file_get_contents($enFile); + $this->assertNotFalse($deRaw); + $this->assertNotFalse($enRaw); + + $this->assertIsArray(json_decode($deRaw, true), "Module '{$moduleId}' default_de.json is not valid JSON"); + $this->assertIsArray(json_decode($enRaw, true), "Module '{$moduleId}' default_en.json is not valid JSON"); + } + + #[DataProvider('moduleProvider')] + public function testModuleI18nKeyParity(string $moduleId, string $i18nPath): void + { + $de = $this->loadModuleKeys($i18nPath . '/default_de.json'); + $en = $this->loadModuleKeys($i18nPath . '/default_en.json'); + + $missingInEn = array_values(array_diff($de, $en)); + $this->assertSame( + [], + $missingInEn, + "Module '{$moduleId}' default_de.json keys missing from default_en.json:\n" . implode("\n", $missingInEn) + ); + + $missingInDe = array_values(array_diff($en, $de)); + $this->assertSame( + [], + $missingInDe, + "Module '{$moduleId}' default_en.json keys missing from default_de.json:\n" . implode("\n", $missingInDe) + ); + } + + #[DataProvider('moduleProvider')] + public function testModuleI18nValuesAreNonEmpty(string $moduleId, string $i18nPath): void + { + foreach (['default_de.json', 'default_en.json'] as $file) { + $path = $i18nPath . '/' . $file; + if (!is_file($path)) { + continue; + } + $data = json_decode(file_get_contents($path), true); + $this->assertIsArray($data); + + $empty = array_keys(array_filter($data, static fn ($v): bool => trim((string) $v) === '')); + $this->assertSame( + [], + $empty, + "Module '{$moduleId}' {$file} has empty values:\n" . implode("\n", $empty) + ); + } + } + + /** + * @return list + */ + private function loadModuleKeys(string $path): array + { + if (!is_file($path)) { + return []; + } + $data = json_decode(file_get_contents($path), true); + if (!is_array($data)) { + return []; + } + + $keys = array_keys($data); + sort($keys, SORT_STRING); + + return $keys; + } +} diff --git a/tests/I18n/TranslationKeysTest.php b/tests/I18n/TranslationKeysTest.php index 20742b8..bc90805 100644 --- a/tests/I18n/TranslationKeysTest.php +++ b/tests/I18n/TranslationKeysTest.php @@ -13,7 +13,8 @@ class TranslationKeysTest extends TestCase $root = realpath(__DIR__ . '/../..'); $this->assertNotFalse($root, 'Project root not found.'); - $paths = [ + // Core scan paths + $corePaths = [ $root . '/pages', $root . '/templates', $root . '/lib', @@ -21,6 +22,108 @@ class TranslationKeysTest extends TestCase ]; $extensions = ['php', 'phtml', 'js']; + // Collect core keys + $coreKeys = $this->collectKeysFromPaths($corePaths, $extensions); + + // Load core translations + $coreTranslations = $this->loadTranslationMap($root . '/i18n', ['default_de.json', 'default_en.json']); + + // Also load all module translations so core keys provided by modules are available + $moduleTranslations = $this->loadAllModuleTranslations($root . '/modules'); + $mergedTranslations = []; + foreach ($coreTranslations as $name => $map) { + $mergedTranslations[$name] = $map; + } + foreach ($moduleTranslations as $name => $maps) { + foreach ($maps as $locale => $map) { + $mergedTranslations[$locale] = array_merge($mergedTranslations[$locale] ?? [], $map); + } + } + + // Validate core keys exist in merged (core + module) translations + $missing = []; + foreach ($coreKeys as $key) { + foreach ($mergedTranslations as $name => $map) { + if (!array_key_exists($key, $map)) { + $missing[$name][] = $key; + } + } + } + + $messages = []; + foreach ($missing as $name => $keys) { + $messages[] = $name . ' missing: ' . implode(', ', $keys); + } + $this->assertSame([], $missing, implode(' | ', $messages)); + } + + public function testAllModuleTranslationKeysExist(): void + { + $root = realpath(__DIR__ . '/../..'); + $this->assertNotFalse($root, 'Project root not found.'); + + $modulesDir = $root . '/modules'; + if (!is_dir($modulesDir)) { + $this->markTestSkipped('No modules directory found.'); + } + + $extensions = ['php', 'phtml', 'js']; + + // Load core translations as fallback + $coreTranslations = $this->loadTranslationMap($root . '/i18n', ['default_de.json', 'default_en.json']); + + $allMissing = []; + + foreach (glob($modulesDir . '/*/module.php') as $manifestFile) { + $moduleDir = dirname($manifestFile); + $moduleId = basename($moduleDir); + + // Scan module directories for keys + $modulePaths = [ + $moduleDir . '/pages', + $moduleDir . '/templates', + $moduleDir . '/lib', + $moduleDir . '/web', + ]; + $moduleKeys = $this->collectKeysFromPaths($modulePaths, $extensions); + if ($moduleKeys === []) { + continue; + } + + // Load module translations and merge with core + $moduleI18nDir = $moduleDir . '/i18n'; + $moduleTranslations = is_dir($moduleI18nDir) + ? $this->loadTranslationMap($moduleI18nDir, ['default_de.json', 'default_en.json']) + : []; + + $merged = []; + foreach ($coreTranslations as $locale => $map) { + $merged[$locale] = array_merge($map, $moduleTranslations[$locale] ?? []); + } + + foreach ($moduleKeys as $key) { + foreach ($merged as $locale => $map) { + if (!array_key_exists($key, $map)) { + $allMissing["modules/{$moduleId} → {$locale}"][] = $key; + } + } + } + } + + $messages = []; + foreach ($allMissing as $context => $keys) { + $messages[] = $context . ' missing: ' . implode(', ', $keys); + } + $this->assertSame([], $allMissing, implode(' | ', $messages)); + } + + /** + * @param list $paths + * @param list $extensions + * @return list + */ + private function collectKeysFromPaths(array $paths, array $extensions): array + { $usedKeys = []; foreach ($paths as $path) { if (!is_dir($path)) { @@ -45,40 +148,71 @@ class TranslationKeysTest extends TestCase } } - $usedKeys = array_keys($usedKeys); - sort($usedKeys); + $keys = array_keys($usedKeys); + sort($keys); - $locales = [ - 'default_de.json' => $root . '/i18n/default_de.json', - 'default_en.json' => $root . '/i18n/default_en.json', - ]; + return $keys; + } + + /** + * @param list $files + * @return array> + */ + private function loadTranslationMap(string $dir, array $files): array + { $translations = []; - foreach ($locales as $name => $path) { - $this->assertFileExists($path, 'Missing locale file: ' . $name); + foreach ($files as $name) { + $path = $dir . '/' . $name; + if (!is_file($path)) { + continue; + } $raw = file_get_contents($path); - $this->assertNotFalse($raw, 'Failed to read locale file: ' . $name); + if ($raw === false) { + continue; + } $json = json_decode($raw, true); - $this->assertIsArray($json, 'Invalid JSON in locale file: ' . $name); - $translations[$name] = $json; + if (is_array($json)) { + $translations[$name] = $json; + } } - $missing = []; - foreach ($usedKeys as $key) { - foreach ($translations as $name => $map) { - if (!array_key_exists($key, $map)) { - $missing[$name][] = $key; + return $translations; + } + + /** + * @return array>> + */ + private function loadAllModuleTranslations(string $modulesDir): array + { + $result = []; + if (!is_dir($modulesDir)) { + return $result; + } + + foreach (glob($modulesDir . '/*/i18n') as $i18nDir) { + $moduleId = basename(dirname($i18nDir)); + foreach (['default_de.json', 'default_en.json'] as $file) { + $path = $i18nDir . '/' . $file; + if (!is_file($path)) { + continue; + } + $raw = file_get_contents($path); + if ($raw === false) { + continue; + } + $json = json_decode($raw, true); + if (is_array($json)) { + $result[$moduleId][$file] = $json; } } } - $messages = []; - foreach ($missing as $name => $keys) { - $messages[] = $name . ' missing: ' . implode(', ', $keys); - } - - $this->assertSame([], $missing, implode(' | ', $messages)); + return $result; } + /** + * @return list + */ private function extractTranslationKeys(string $content): array { $keys = [];