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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
55
lib/App/Module/ModuleI18nLoader.php
Normal file
55
lib/App/Module/ModuleI18nLoader.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -148,6 +148,12 @@ final class ModuleRegistry
|
||||
// module classes (MintyPHP\Module\<Name>\*) 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);
|
||||
|
||||
12
modules/addressbook/i18n/default_de.json
Normal file
12
modules/addressbook/i18n/default_de.json
Normal 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"
|
||||
}
|
||||
12
modules/addressbook/i18n/default_en.json
Normal file
12
modules/addressbook/i18n/default_en.json
Normal 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"
|
||||
}
|
||||
@@ -94,4 +94,5 @@ return [
|
||||
],
|
||||
|
||||
'migrations_path' => 'db/updates',
|
||||
'i18n_path' => 'i18n',
|
||||
];
|
||||
|
||||
43
modules/bookmarks/i18n/default_de.json
Normal file
43
modules/bookmarks/i18n/default_de.json
Normal 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"
|
||||
}
|
||||
43
modules/bookmarks/i18n/default_en.json
Normal file
43
modules/bookmarks/i18n/default_en.json
Normal 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"
|
||||
}
|
||||
@@ -130,4 +130,5 @@ return [
|
||||
|
||||
'permissions' => [],
|
||||
'migrations_path' => null,
|
||||
'i18n_path' => 'i18n',
|
||||
];
|
||||
|
||||
10
modules/notifications/i18n/default_de.json
Normal file
10
modules/notifications/i18n/default_de.json
Normal 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"
|
||||
}
|
||||
10
modules/notifications/i18n/default_en.json
Normal file
10
modules/notifications/i18n/default_en.json
Normal 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"
|
||||
}
|
||||
115
modules/notifications/module.php
Normal file
115
modules/notifications/module.php
Normal 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',
|
||||
];
|
||||
127
tests/Architecture/ModuleI18nContractTest.php
Normal file
127
tests/Architecture/ModuleI18nContractTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> $paths
|
||||
* @param list<string> $extensions
|
||||
* @return list<string>
|
||||
*/
|
||||
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<string> $files
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
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<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 = [];
|
||||
foreach ($missing as $name => $keys) {
|
||||
$messages[] = $name . ' missing: ' . implode(', ', $keys);
|
||||
}
|
||||
|
||||
$this->assertSame([], $missing, implode(' | ', $messages));
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractTranslationKeys(string $content): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
Reference in New Issue
Block a user