feat: module-owned i18n with merge-at-boot translation loading

Each module can now ship its own i18n/ directory with translation files
(default_de.json, default_en.json). At boot time, ModuleI18nLoader
preloads core translations then merges module translations on top via
Reflection into I18n::$strings — the t() helper stays unchanged.

- Add i18nPath property to ModuleManifest
- Add ModuleI18nLoader with Reflection-based preload
- Extract 59 module-exclusive keys from core i18n into module files
  (notifications: 8, bookmarks: 41, addressbook: 10)
- Expand TranslationKeysTest to scan modules/ and validate against
  merged core + module translations
- Add ModuleI18nContractTest for per-module de/en parity and completeness

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 22:08:34 +01:00
parent 3147009498
commit 1f0b1caf54
16 changed files with 601 additions and 133 deletions

View File

@@ -295,7 +295,6 @@
"Profile image": "Profilbild", "Profile image": "Profilbild",
"Profile": "Profil", "Profile": "Profil",
"Profile description": "Profilbeschreibung", "Profile description": "Profilbeschreibung",
"About": "Über",
"Info": "Info", "Info": "Info",
"Search results": "Suchergebnisse", "Search results": "Suchergebnisse",
"Result": "Ergebnis", "Result": "Ergebnis",
@@ -470,10 +469,6 @@
"Status & meta": "Status & Meta", "Status & meta": "Status & Meta",
"Audit": "Audit", "Audit": "Audit",
"Overview": "Übersicht", "Overview": "Übersicht",
"Quick actions": "Schnellaktionen",
"Send email": "E-Mail senden",
"Call phone": "Telefon anrufen",
"Call mobile": "Mobil anrufen",
"Choose color": "Farbe wählen", "Choose color": "Farbe wählen",
"Be careful - this resets the tenants color": "Achtung das setzt die Primärfarbe des Mandanten zurück", "Be careful - this resets the tenants color": "Achtung das setzt die Primärfarbe des Mandanten zurück",
"Code": "Code", "Code": "Code",
@@ -520,13 +515,11 @@
"Department can not be created": "Abteilung kann nicht erstellt werden", "Department can not be created": "Abteilung kann nicht erstellt werden",
"Department can not be updated": "Abteilung kann nicht aktualisiert werden", "Department can not be updated": "Abteilung kann nicht aktualisiert werden",
"Delete this department?": "Diese Abteilung wirklich löschen?", "Delete this department?": "Diese Abteilung wirklich löschen?",
"Delete this bookmark?": "Dieses Lesezeichen wirklich löschen?",
"Delete department": "Abteilung löschen", "Delete department": "Abteilung löschen",
"This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.", "This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.",
"Assigned departments": "Zugewiesene Abteilungen", "Assigned departments": "Zugewiesene Abteilungen",
"Select departments": "Abteilungen auswählen", "Select departments": "Abteilungen auswählen",
"Departments for tenant": "Abteilungen für Mandant", "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", "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", "Assign tenants first to select departments": "Bitte zuerst Mandanten zuweisen, um Abteilungen auszuwählen",
"No tenants available": "Keine Mandanten verfügbar", "No tenants available": "Keine Mandanten verfügbar",
@@ -549,7 +542,6 @@
"Delete permission": "Berechtigung löschen", "Delete permission": "Berechtigung löschen",
"This will permanently delete this role.": "Diese Rolle wird dauerhaft gelöscht.", "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 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": "Beschreibung",
"Description cannot be empty": "Beschreibung darf nicht leer sein", "Description cannot be empty": "Beschreibung darf nicht leer sein",
"Status is invalid": "Status ist ungültig", "Status is invalid": "Status ist ungültig",
@@ -647,7 +639,6 @@
"SMTP security": "SMTP Sicherheit", "SMTP security": "SMTP Sicherheit",
"SMTP from address": "Absender-Adresse", "SMTP from address": "Absender-Adresse",
"SMTP from name": "Absendername", "SMTP from name": "Absendername",
"Address book": "Adressbuch",
"Mobile": "Mobil", "Mobile": "Mobil",
"Short dial": "Kurzwahl", "Short dial": "Kurzwahl",
"setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)", "setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)",
@@ -659,7 +650,6 @@
"setting.smtp_from_name": "Absendername für ausgehende Nachrichten", "setting.smtp_from_name": "Absendername für ausgehende Nachrichten",
"High contrast": "Hoher Kontrast", "High contrast": "Hoher Kontrast",
"Toggle contrast": "Kontrast umschalten", "Toggle contrast": "Kontrast umschalten",
"View in address book": "Im Adressbuch ansehen",
"Organization & Quality": "Organisation & Qualität", "Organization & Quality": "Organisation & Qualität",
"Email security": "E-Mail Sicherheit", "Email security": "E-Mail Sicherheit",
"API": "API", "API": "API",
@@ -813,8 +803,6 @@
"Please fill required custom field: %s": "Bitte Pflicht-Zusatzfeld ausfüllen: %s", "Please fill required custom field: %s": "Bitte Pflicht-Zusatzfeld ausfüllen: %s",
"Invalid value for %s": "Ungültiger Wert für %s", "Invalid value for %s": "Ungültiger Wert für %s",
"Please select": "Bitte auswählen", "Please select": "Bitte auswählen",
"From": "Von",
"To": "Bis",
"perm.custom_fields.manage": "Mandanten-Zusatzfelder verwalten", "perm.custom_fields.manage": "Mandanten-Zusatzfelder verwalten",
"perm.custom_fields.edit_values": "Benutzer-Zusatzfelder bearbeiten", "perm.custom_fields.edit_values": "Benutzer-Zusatzfelder bearbeiten",
"Label": "Bezeichnung", "Label": "Bezeichnung",
@@ -999,7 +987,6 @@
"Import another file": "Weitere Datei importieren", "Import another file": "Weitere Datei importieren",
"No row errors found": "Keine Zeilenfehler gefunden", "No row errors found": "Keine Zeilenfehler gefunden",
"Row": "Zeile", "Row": "Zeile",
"Code": "Code",
"Message": "Nachricht", "Message": "Nachricht",
"No upload file provided": "Keine Upload-Datei übergeben", "No upload file provided": "Keine Upload-Datei übergeben",
"Upload exceeds the maximum size": "Upload überschreitet die maximale Dateigröße", "Upload exceeds the maximum size": "Upload überschreitet die maximale Dateigröße",
@@ -1212,7 +1199,6 @@
"Actor tenant": "Akteur-Mandant", "Actor tenant": "Akteur-Mandant",
"Event details": "Ereignisdetails", "Event details": "Ereignisdetails",
"Target type": "Zieltyp", "Target type": "Zieltyp",
"Request ID": "Request-ID",
"IP hash": "IP-Hash", "IP hash": "IP-Hash",
"User agent hash": "User-Agent-Hash", "User agent hash": "User-Agent-Hash",
"Channel": "Kanal", "Channel": "Kanal",
@@ -1299,45 +1285,5 @@
"Session expiring": "Sitzung läuft ab", "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?", "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", "Extend session": "Sitzung verlängern",
"Log out": "Abmelden", "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"
} }

View File

@@ -295,7 +295,6 @@
"Profile image": "Profile image", "Profile image": "Profile image",
"Profile": "Profile", "Profile": "Profile",
"Profile description": "Profile description", "Profile description": "Profile description",
"About": "About",
"Info": "Info", "Info": "Info",
"Search results": "Search results", "Search results": "Search results",
"Result": "Result", "Result": "Result",
@@ -470,10 +469,6 @@
"Status & meta": "Status & meta", "Status & meta": "Status & meta",
"Audit": "Audit", "Audit": "Audit",
"Overview": "Overview", "Overview": "Overview",
"Quick actions": "Quick actions",
"Send email": "Send email",
"Call phone": "Call phone",
"Call mobile": "Call mobile",
"Choose color": "Choose color", "Choose color": "Choose color",
"Be careful - this resets the tenants color": "Be careful - this resets the tenant color", "Be careful - this resets the tenants color": "Be careful - this resets the tenant color",
"Code": "Code", "Code": "Code",
@@ -520,13 +515,11 @@
"Department can not be created": "Department can not be created", "Department can not be created": "Department can not be created",
"Department can not be updated": "Department can not be updated", "Department can not be updated": "Department can not be updated",
"Delete this department?": "Delete this department?", "Delete this department?": "Delete this department?",
"Delete this bookmark?": "Delete this bookmark?",
"Delete department": "Delete department", "Delete department": "Delete department",
"This will permanently delete this department.": "This will permanently delete this department.", "This will permanently delete this department.": "This will permanently delete this department.",
"Assigned departments": "Assigned departments", "Assigned departments": "Assigned departments",
"Select departments": "Select departments", "Select departments": "Select departments",
"Departments for tenant": "Departments for tenant", "Departments for tenant": "Departments for tenant",
"No departments": "No departments",
"No departments available for this tenant": "No departments available for this tenant", "No departments available for this tenant": "No departments available for this tenant",
"Assign tenants first to select departments": "Assign tenants first to select departments", "Assign tenants first to select departments": "Assign tenants first to select departments",
"No tenants available": "No tenants available", "No tenants available": "No tenants available",
@@ -549,7 +542,6 @@
"Delete permission": "Delete permission", "Delete permission": "Delete permission",
"This will permanently delete this role.": "This will permanently delete this role.", "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 will permanently delete this permission.": "This will permanently delete this permission.",
"This action cannot be undone.": "This action cannot be undone.",
"Description": "Description", "Description": "Description",
"Description cannot be empty": "Description cannot be empty", "Description cannot be empty": "Description cannot be empty",
"Status is invalid": "Status is invalid", "Status is invalid": "Status is invalid",
@@ -647,7 +639,6 @@
"SMTP security": "SMTP security", "SMTP security": "SMTP security",
"SMTP from address": "SMTP from address", "SMTP from address": "SMTP from address",
"SMTP from name": "SMTP from name", "SMTP from name": "SMTP from name",
"Address book": "Address book",
"Mobile": "Mobile", "Mobile": "Mobile",
"Short dial": "Short dial", "Short dial": "Short dial",
"setting.smtp_host": "SMTP server host (e.g. smtp.example.com)", "setting.smtp_host": "SMTP server host (e.g. smtp.example.com)",
@@ -659,7 +650,6 @@
"setting.smtp_from_name": "From name for outgoing emails", "setting.smtp_from_name": "From name for outgoing emails",
"High contrast": "High contrast", "High contrast": "High contrast",
"Toggle contrast": "Toggle contrast", "Toggle contrast": "Toggle contrast",
"View in address book": "View in address book",
"Organization & Quality": "Organization & Quality", "Organization & Quality": "Organization & Quality",
"Email security": "Email security", "Email security": "Email security",
"API": "API", "API": "API",
@@ -813,8 +803,6 @@
"Please fill required custom field: %s": "Please fill required custom field: %s", "Please fill required custom field: %s": "Please fill required custom field: %s",
"Invalid value for %s": "Invalid value for %s", "Invalid value for %s": "Invalid value for %s",
"Please select": "Please select", "Please select": "Please select",
"From": "From",
"To": "To",
"perm.custom_fields.manage": "Manage tenant custom fields", "perm.custom_fields.manage": "Manage tenant custom fields",
"perm.custom_fields.edit_values": "Edit user custom field values", "perm.custom_fields.edit_values": "Edit user custom field values",
"Label": "Label", "Label": "Label",
@@ -999,7 +987,6 @@
"Import another file": "Import another file", "Import another file": "Import another file",
"No row errors found": "No row errors found", "No row errors found": "No row errors found",
"Row": "Row", "Row": "Row",
"Code": "Code",
"Message": "Message", "Message": "Message",
"No upload file provided": "No upload file provided", "No upload file provided": "No upload file provided",
"Upload exceeds the maximum size": "Upload exceeds the maximum size", "Upload exceeds the maximum size": "Upload exceeds the maximum size",
@@ -1212,7 +1199,6 @@
"Actor tenant": "Actor tenant", "Actor tenant": "Actor tenant",
"Event details": "Event details", "Event details": "Event details",
"Target type": "Target type", "Target type": "Target type",
"Request ID": "Request ID",
"IP hash": "IP hash", "IP hash": "IP hash",
"User agent hash": "User agent hash", "User agent hash": "User agent hash",
"Channel": "Channel", "Channel": "Channel",
@@ -1299,45 +1285,5 @@
"Session expiring": "Session expiring", "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?", "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", "Extend session": "Extend session",
"Log out": "Log out", "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"
} }

View File

@@ -0,0 +1,55 @@
<?php
namespace MintyPHP\App\Module;
use MintyPHP\I18n;
use ReflectionProperty;
/**
* Preloads core + module translations into I18n::$strings at boot time.
*
* Since I18n::$strings is private static and lives in a vendor class,
* Reflection is used once per locale to inject the merged translation map.
*/
final class ModuleI18nLoader
{
/**
* @param list<ModuleManifest> $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);
}
}
}

View File

@@ -83,6 +83,8 @@ final class ModuleManifest
public readonly ?string $migrationsPath; public readonly ?string $migrationsPath;
public readonly ?string $i18nPath;
public readonly string $basePath; public readonly string $basePath;
/** /**
@@ -128,6 +130,11 @@ final class ModuleManifest
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== '' $this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/') ? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
: null; : null;
$i18nPath = $raw['i18n_path'] ?? null;
$this->i18nPath = is_string($i18nPath) && trim($i18nPath) !== ''
? rtrim($basePath, '/') . '/' . ltrim(trim($i18nPath), '/')
: null;
} }
/** /**

View File

@@ -148,6 +148,12 @@ final class ModuleRegistry
// module classes (MintyPHP\Module\<Name>\*) resolve without a custom loader. // module classes (MintyPHP\Module\<Name>\*) resolve without a custom loader.
self::registerModuleAutoloading($manifests); 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 // Register and merge
foreach ($manifests as $manifest) { foreach ($manifests as $manifest) {
$registry->registerModule($manifest); $registry->registerModule($manifest);

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -94,4 +94,5 @@ return [
], ],
'migrations_path' => 'db/updates', 'migrations_path' => 'db/updates',
'i18n_path' => 'i18n',
]; ];

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -130,4 +130,5 @@ return [
'permissions' => [], 'permissions' => [],
'migrations_path' => null, 'migrations_path' => null,
'i18n_path' => 'i18n',
]; ];

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,115 @@
<?php
/**
* Notifications module manifest.
*
* In-app notifications with bell icon, dropdown panel,
* event-driven creation, and scheduled cleanup.
*/
return [
'id' => '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',
];

View File

@@ -0,0 +1,127 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* GR-UI-015 (module extension): Every module with an i18n_path must ship
* matching default_de.json and default_en.json with identical keys and
* non-empty values.
*/
class ModuleI18nContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<array{string, string}>
*/
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<string>
*/
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;
}
}

View File

@@ -13,7 +13,8 @@ class TranslationKeysTest extends TestCase
$root = realpath(__DIR__ . '/../..'); $root = realpath(__DIR__ . '/../..');
$this->assertNotFalse($root, 'Project root not found.'); $this->assertNotFalse($root, 'Project root not found.');
$paths = [ // Core scan paths
$corePaths = [
$root . '/pages', $root . '/pages',
$root . '/templates', $root . '/templates',
$root . '/lib', $root . '/lib',
@@ -21,6 +22,108 @@ class TranslationKeysTest extends TestCase
]; ];
$extensions = ['php', 'phtml', 'js']; $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<string> $paths
* @param list<string> $extensions
* @return list<string>
*/
private function collectKeysFromPaths(array $paths, array $extensions): array
{
$usedKeys = []; $usedKeys = [];
foreach ($paths as $path) { foreach ($paths as $path) {
if (!is_dir($path)) { if (!is_dir($path)) {
@@ -45,40 +148,71 @@ class TranslationKeysTest extends TestCase
} }
} }
$usedKeys = array_keys($usedKeys); $keys = array_keys($usedKeys);
sort($usedKeys); sort($keys);
$locales = [ return $keys;
'default_de.json' => $root . '/i18n/default_de.json', }
'default_en.json' => $root . '/i18n/default_en.json',
]; /**
* @param list<string> $files
* @return array<string, array<string, string>>
*/
private function loadTranslationMap(string $dir, array $files): array
{
$translations = []; $translations = [];
foreach ($locales as $name => $path) { foreach ($files as $name) {
$this->assertFileExists($path, 'Missing locale file: ' . $name); $path = $dir . '/' . $name;
if (!is_file($path)) {
continue;
}
$raw = file_get_contents($path); $raw = file_get_contents($path);
$this->assertNotFalse($raw, 'Failed to read locale file: ' . $name); if ($raw === false) {
continue;
}
$json = json_decode($raw, true); $json = json_decode($raw, true);
$this->assertIsArray($json, 'Invalid JSON in locale file: ' . $name); if (is_array($json)) {
$translations[$name] = $json; $translations[$name] = $json;
}
} }
$missing = []; return $translations;
foreach ($usedKeys as $key) { }
foreach ($translations as $name => $map) {
if (!array_key_exists($key, $map)) { /**
$missing[$name][] = $key; * @return array<string, array<string, array<string, string>>>
*/
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 = []; return $result;
foreach ($missing as $name => $keys) {
$messages[] = $name . ' missing: ' . implode(', ', $keys);
}
$this->assertSame([], $missing, implode(' | ', $messages));
} }
/**
* @return list<string>
*/
private function extractTranslationKeys(string $content): array private function extractTranslationKeys(string $content): array
{ {
$keys = []; $keys = [];