File: /home/xedaptot/ai.naniguide.com/app/Model/Language.php
<?php
/**
* Language class.
*
* Model class for languages
*
* LICENSE: This product includes software developed at
* the Acelle Co., Ltd. (http://acellemail.com/).
*
* @category MVC Model
*
* @author N. Pham <[email protected]>
* @author L. Pham <[email protected]>
* @copyright Acelle Co., Ltd
* @license Acelle Co., Ltd
*
* @version 1.0
*
* @link http://acellemail.com
*/
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
use App\Library\Facades\Hook;
use App\Library\Traits\HasUid;
use Exception;
use DB;
class Language extends Model
{
use HasUid;
public const STATUS_ACTIVE = 'active';
public const STATUS_INACTIVE = 'inactive';
/**
* Per-file report produced by the last upload() call.
* Declared as a public property so it does not hit Eloquent's attribute magic.
*
* Shape:
* [
* 'files' => [
* [
* 'id' => string,
* 'title' => string,
* 'status' => 'ok' | 'locked' | 'restored_missing' | 'skipped' | 'error',
* 'details' => array,
* ],
* ...
* ],
* 'summary' => [
* 'total' => int,
* 'ok' => int,
* 'locked' => int,
* 'restored_missing' => int,
* 'skipped' => int,
* 'errors' => int,
* ],
* ]
*/
public $uploadReport = null;
/**
* Get users.
*
* @return mixed
*/
public function users()
{
return $this->hasMany('App\Model\User');
}
/**
* Customer association.
*
* @return mixed
*/
public function customers()
{
return $this->hasMany('App\Model\Customer');
}
/**
* Admin association.
*
* @return mixed
*/
public function admins()
{
return $this->hasMany('App\Model\Admin');
}
/**
* Language folder path.
*
* @return string
*/
public function languageDir()
{
return resource_path(join_paths('lang', $this->code));
}
public static function getDirWhichNewLanguageCopyFrom()
{
return base_path('resources/lang/default');
}
public function scopeActive($query)
{
$query->where('status', '=', self::STATUS_ACTIVE);
}
/**
* Get select options.
*
* @return array
*/
public static function getSelectOptions()
{
$options = self::active()->get()->map(function ($item) {
return ['value' => $item->id, 'text' => $item->name];
});
// japan only en and ja
if (config('custom.japan')) {
$options = self::active()->get()->filter(function ($item) {
return in_array($item->code, ['en','ja']);
})->map(function ($item) {
return ['value' => $item->id, 'text' => $item->name];
});
}
return $options->values()->all();
}
/**
* Search items.
*
* @return collect
*/
public function scopeSearch($query, $keyword)
{
// Keyword
if (!empty(trim($keyword))) {
$keyword = trim($keyword);
foreach (explode(' ', $keyword) as $keyword) {
$query = $query->where(function ($q) use ($keyword) {
$q->orwhere('languages.name', 'like', '%'.$keyword.'%')
->orwhere('languages.code', 'like', '%'.$keyword.'%')
->orwhere('languages.region_code', 'like', '%'.$keyword.'%');
});
}
}
}
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name', 'code', 'region_code', 'status'
];
/**
* Get validation rules.
*
* @return object
*/
public function rules()
{
return [
'name' => 'required',
'code' => 'required|unique:languages,code,'.$this->id,
];
}
public function scopeDefault($query)
{
return $query->where('is_default', '=', true);
}
/**
* Get is default language.
*
* @var object
*/
public static function getFirstDefaultLanguage()
{
return self::default()->first();
}
/**
* Get locale array from file.
*
* @var array
*/
public function getLocaleArrayFromFile($filename)
{
clearstatcache();
if (function_exists('opcache_invalidate')) {
opcache_invalidate(join_paths($this->languageDir(), $filename.'.php'));
}
$arr = self::fileToArray(join_paths($this->languageDir(), $filename.'.php'));
return $arr;
}
/**
* Read locale file.
*
* @var text
*/
public function readLocaleFile($filename)
{
$text = \Illuminate\Support\Facades\File::get(join_paths($this->languageDir(), $filename.'.php'));
return $text;
}
/**
* Read locale file.
*
* @var text
*/
public function localeToYaml($filename)
{
$text = $this->readLocaleFile($filename);
return yaml_parse($text);
}
/**
* Update language file from yaml.
*
* @var text
*/
public function updateFromYaml($filename, $yaml)
{
self::yamlToFile(join_paths($this->languageDir(), $filename.'.php'), $yaml);
}
/**
* Update language file from yaml.
*
* @var text
*/
public function getBuilderLang()
{
return include join_paths($this->languageDir(), 'builder.php');
}
/**
* all language code.
*
* @return array
*/
public static function languageCodes()
{
$arr = config('languages');
if (config('custom.japan')) {
$arr = [
'en' => 'English',
'ja' => 'Japanese (ja)',
];
}
$result = [];
foreach ($arr as $key => $name) {
$result[] = [
'text' => strtoupper($key).' / '.$name,
'value' => $key,
];
}
return $result;
}
/**
* Disable language.
*
* @return array
*/
public function disable()
{
$this->status = self::STATUS_INACTIVE;
$this->save();
}
/**
* Enable language.
*
* @return array
*/
public function enable()
{
$this->status = self::STATUS_ACTIVE;
$this->save();
}
public static function fileToArray($pathToFile)
{
return \Illuminate\Support\Facades\File::getRequire($pathToFile);
}
public static function arrayToYaml($array)
{
return \Yaml::dump($array);
}
public static function fileToYaml($path)
{
return self::arrayToYaml(self::fileToArray($path));
}
public static function yamlToFile($pathToFile, $yaml)
{
$content = '<?php return '.var_export(\Yaml::parse($yaml), true).' ?>';
$bytes_written = \Illuminate\Support\Facades\File::put($pathToFile, $content);
}
// Actually it list translation files registered via the Hook `add_translation_file`, but we call it "language files" for simplicity
// one language may have multiple translation files, for example: messages.php, validation.php, warmup.php, refactor/account.php, etc
// This is a shortcut to get all translation files for a language, with additional information like file type (default vs plugin), file title, etc
// The most important attribute is $path, which is the real file path of this translation file, so it can be read/written when translating or updating translation files
public function getAllLanguageFiles()
{
// Returns all translation files registered for the current language,
// keyed by the hook `id`. Example:
//
// [
// 'messages' => [
// 'id' => 'messages',
// 'type' => 'default', // default | plugin
// 'path' => '/acellemail/resources/lang/en/messages.php',
// 'file_title' => 'messages.php',
// 'master_translation_file' => '/acellemail/resources/lang/default/messages.php',
// ],
// 'validation' => [ ... ],
// ...
// ]
$paths = [];
$files = Hook::collect('add_translation_file');
foreach ($files as $file) {
if (isset($paths[$file['id']])) {
// Some PHP-version / boot-order combinations cause the same
// ServiceProvider::boot() to run twice (observed on PHP 8.2-FPM
// for App\Cashier\CashierServiceProvider), producing duplicate
// Hook::add('add_translation_file', …) entries with identical
// payload. Treat exact-duplicate id as benign: log + skip. If
// the duplicate carries DIFFERENT paths, that's a real plugin
// conflict → still throw.
if ($paths[$file['id']]['file_title'] === $file['file_title']) {
\Log::warning('Duplicate translation file registration skipped: '.$file['id']);
continue;
}
throw new \Exception('Translation file id collision (different payload): ' . $file['id']);
}
list($masterFile, $langFile) = $this->resolveTranslationFilePaths($file);
$paths[$file['id']] = [
'id' => $file['id'],
'type' => isset($file['type']) ? $file['type'] : 'plugin',
'path' => $langFile,
'file_title' => $file['file_title'],
'master_translation_file' => $masterFile,
];
}
return $paths;
}
/**
* Lock keys: returns a new array shaped like $master but with scalar values
* replaced by the corresponding value from $user (when present).
*
* - Keys that exist only in $user are dropped (can't add new keys).
* - Keys that exist only in $master are kept (can't delete keys).
* - Nested arrays recurse the same rule.
*/
protected static function lockKeysToMaster(array $master, array $user): array
{
$result = [];
foreach ($master as $key => $masterValue) {
if (is_array($masterValue)) {
$userValue = (isset($user[$key]) && is_array($user[$key])) ? $user[$key] : [];
$result[$key] = self::lockKeysToMaster($masterValue, $userValue);
} else {
$result[$key] = (array_key_exists($key, $user) && !is_array($user[$key]))
? $user[$key]
: $masterValue;
}
}
return $result;
}
public function getLanguageFilesByType($type)
{
$langFiles = $this->getAllLanguageFiles();
foreach ($langFiles as $key => $langFile) {
if ($langFile['type'] != $type) {
unset($langFiles[$key]);
}
}
return $langFiles;
}
public function getLanguageFileOptions()
{
$arr = [];
foreach ($this->getAllLanguageFiles() as $key => $langFile) {
$arr[] = ['value' => $langFile['id'], 'text' => $langFile['file_title']];
;
}
return $arr;
}
public static function newDefaultLanguage()
{
$language = new self();
$language->status = self::STATUS_ACTIVE;
return $language;
}
// @todo 'dump' is just an alias for many tasks that may be involved
// + create missing translation files
// + update existing translation files from its original source
// + more
public static function dump()
{
// Update or create translation files
foreach (self::get() as $language) {
$language->createOrUpdateTranslationFiles();
}
}
public function detectNewPhrases()
{
foreach ($this->getAllLanguageFiles() as $fileId => $file) {
$masterArray = include $file['master_translation_file'];
$langArray = include $file['path'];
$diff = array_diff_key($masterArray, $langArray);
if (!empty($diff)) {
foreach ($diff as $key => $value) {
TranslationPhrase::firstOrCreate([
'file' => $fileId,
'key' => $key,
]);
}
}
}
}
/**
* Resolve the master (origin) and target (language) file paths for
* a given Hook source. Pure path resolution — no side effects.
*
* @return array{string, string} [$masterFile, $langFile]
*/
public function resolveTranslationFilePaths($source)
{
if (!array_key_exists('master_translation_file', $source)) {
throw new Exception('[master_translation_file] is not available for '.$source['file_name']);
}
if (array_key_exists('master_translation_file_by_language', $source) && array_key_exists($this->code, $source['master_translation_file_by_language'])) {
$masterFile = $source['master_translation_file_by_language'][$this->code];
} else {
$masterFile = $source['master_translation_file'];
}
if ($masterFile == false) {
throw new Exception(sprintf('%s: master translation file does not exist: "%s". File path returns false! Make sure realpath() returns a valid file path.', $this->name, $source["file_title"]));
}
$langFile = join_paths($source['translation_folder'], $this->code, $source['file_name']);
if (!file_exists($masterFile)) {
throw new Exception($this->name.': Master translation file does not exist: "'.$masterFile.'". Make sure it is registered correctly');
}
return [$masterFile, $langFile];
}
/**
* Create (if missing) and sync a translation file from its master.
*/
public function createTranslationFile($source)
{
list($masterFile, $langFile) = $this->resolveTranslationFilePaths($source);
if (!file_exists($langFile)) {
\App\Helpers\pcopy($masterFile, $langFile);
}
// Sync target file with master: merge new keys, drop removed keys.
// Skip when origin and target resolve to the same physical file.
// A plugin can register master_translation_file pointing to an
// existing language file (reusing it as its own master). Without
// this guard, updateTranslationFile would overwrite the file with
// a var_export'd copy of itself, destroying formatting and content.
if (realpath($masterFile) != realpath($langFile)) {
\App\Helpers\updateTranslationFile($langFile, $masterFile, $overwrite = false, $deleteTargetKeys = true, $sort = true);
}
return [$masterFile, $langFile];
}
/*
* Hook::collect("add_translation_file") returns an array of translation files registered by plugins and the system.
* For example:
* [
"id" => "acelle_warmup",
"plugin_name" => "Application/Core",
"file_title" => "Warmup",
"translation_folder" => "/home/nghi/mailixa/resources/lang",
"file_name" => "warmup.php",
"master_translation_file" => "/home/nghi/mailixa/resources/lang/default/warmup.php",
], [
"id" => "acelle_refactor_account",
"plugin_name" => "Application/Core",
"file_title" => "Refactor — Account",
"translation_folder" => "/home/nghi/mailixa/resources/lang",
"file_name" => "refactor/account.php",
"master_translation_file" => "/home/nghi/mailixa/resources/lang/default/refactor/account.php",
], [
...
*/
public function createOrUpdateTranslationFiles()
{
foreach (Hook::collect('add_translation_file') as $source) {
$this->createTranslationFile($source);
}
}
public static function createFromArray($attributes)
{
$language = self::newDefaultLanguage();
$language->fill($attributes);
$language->status = self::STATUS_INACTIVE;
// make validator
$validator = \Validator::make($attributes, $language->rules());
// redirect if fails
if ($validator->fails()) {
return [$language, $validator];
}
DB::transaction(function () use (&$language) {
// save
$language->save();
// Create translation files for this newly created language
$language->createOrUpdateTranslationFiles();
});
return [$language, true];
}
public function updateFromRequest($request)
{
// make validator
$validator = \Validator::make($request->all(), $this->rules());
// redirect if fails
if ($validator->fails()) {
return $validator;
}
// rename locale folder
if ($this->code != $request->code) {
rename(base_path("resources/lang/") . $this->code, base_path("resources/lang/") . $request->code);
}
$this->fill($request->all());
// save
$this->save();
return true;
}
public function deleteAndCleanup()
{
// Change deleting language's users to the default langauge
$default_language = self::getFirstDefaultLanguage();
if (!$default_language) {
throw new \Exception('Something went wrong! Can not find the default language.');
}
$this->customers()->update(['language_id' => $default_language->id]);
$this->admins()->update(['language_id' => $default_language->id]);
// delete language folder
$des = $this->languageDir();
if (file_exists($des)) {
\App\Library\Tool::xdelete($des);
}
$this->delete();
}
public function translateFile($fileId, $content)
{
$file = $this->findFileById($fileId);
$validator = \Validator::make(['content' => $content], [
'content' => 'required',
]);
$validator->after(function ($validator) use ($content) {
try {
\Yaml::parse($content);
} catch (\Exception $e) {
$validator->errors()->add('content', $e->getMessage());
}
});
if ($validator->fails()) {
return [$file, $validator];
}
// Enforce key integrity against the master translation file
// (usually lang/default/*.php). Keys the admin added are stripped;
// keys the admin deleted are restored from the master. Only the
// VALUES of existing keys can be edited.
$masterFile = $file['master_translation_file'] ?? null;
if ($masterFile && file_exists($masterFile)) {
$masterArray = include $masterFile;
$submitted = \Yaml::parse($content);
if (!is_array($submitted)) {
$submitted = [];
}
$locked = self::lockKeysToMaster(
is_array($masterArray) ? $masterArray : [],
$submitted
);
$content = self::arrayToYaml($locked);
}
self::yamlToFile($file['path'], $content);
return [$file, $validator];
}
// Throw exception
public static function getFirstLanguageByCode($code)
{
$lang = self::where('code', $code)->first();
return $lang;
}
public function findFileById($id)
{
if (!isset($this->getAllLanguageFiles()[$id])) {
throw new \Exception('Can not find translation file with id: ' . $id);
}
return $this->getAllLanguageFiles()[$id];
}
public function getDefaultFile()
{
$files = $this->getAllLanguageFiles();
return array_shift($files);
}
/**
* Upload a language package (ZIP).
*
* Strategy (safe, per-file):
* 1. Validate + extract ZIP to an isolated temp dir (NEVER touch languageDir directly).
* 2. For each translation file registered via the Hook `add_translation_file`:
* - If file missing from ZIP → restore from master (lang/default/...).
* - If file has PHP syntax error → skip, report error (keep existing file).
* - Otherwise → lock keys to master array shape
* (extras dropped, missing restored).
* 3. Clean up temp dir.
* 4. Report per-file status via `$this->uploadReport`.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Contracts\Validation\Validator
*/
public function upload($request)
{
$this->uploadReport = [
'files' => [],
'summary' => [
'total' => 0,
'ok' => 0,
'locked' => 0,
'restored_missing' => 0,
'skipped' => 0,
'errors' => 0,
],
];
$validator = \Validator::make($request->all(), [
'file' => 'required',
]);
$validator->after(function ($validator) use ($request) {
$file_ext = $request->file('file')->guessExtension();
if ($file_ext !== 'zip') {
$validator->errors()->add('content', 'Upload file is not zip file');
return;
}
$tmp_path = storage_path('tmp');
if (!is_dir($tmp_path)) {
mkdir($tmp_path, 0755, true);
}
$file_name = 'language-package-' . uniqid();
$request->file('file')->move($tmp_path, $file_name);
$tmp_zip = storage_path("tmp/{$file_name}");
$zip = new \ZipArchive();
if ($zip->open($tmp_zip) !== true) {
@unlink($tmp_zip);
$validator->errors()->add('content', 'Upload file is not a valid archive file');
return;
}
$tmpExtract = storage_path('tmp/lang-upload-' . uniqid());
$zip->extractTo($tmpExtract);
$zip->close();
@unlink($tmp_zip);
try {
$this->processExtractedLanguagePackage($tmpExtract);
} catch (\Throwable $e) {
$validator->errors()->add('content', 'Failed to process uploaded package: ' . $e->getMessage());
} finally {
\Illuminate\Support\Facades\File::deleteDirectory($tmpExtract);
}
});
return $validator;
}
/**
* Walk through the extracted temp dir and apply each registered translation file
* to the real language dir, using master as the source of truth for keys + files.
*/
protected function processExtractedLanguagePackage(string $tmpExtract): void
{
$langDir = $this->languageDir();
$langDirPrefix = $langDir . '/';
$langDirPrefixLen = strlen($langDirPrefix);
foreach ($this->getAllLanguageFiles() as $fileId => $file) {
$targetPath = $file['path'];
// Only process files that live inside this language's dir.
// External plugin translation files (absolute paths outside resource_path/lang/{code})
// are skipped silently — they aren't shipped in the ZIP.
if (strncmp($targetPath, $langDirPrefix, $langDirPrefixLen) !== 0) {
continue;
}
$relativePath = substr($targetPath, $langDirPrefixLen);
$masterFile = $file['master_translation_file'] ?? null;
$tempFile = $tmpExtract . '/' . $relativePath;
// --- Missing from ZIP → restore from master ----------------------------
if (!file_exists($tempFile)) {
if ($masterFile && file_exists($masterFile)) {
$this->ensureDir(dirname($targetPath));
copy($masterFile, $targetPath);
$this->reportLangFile($fileId, $file, 'restored_missing', [
'relative_path' => $relativePath,
'reason' => 'File missing from ZIP — restored from master default.',
]);
} else {
$this->reportLangFile($fileId, $file, 'error', [
'relative_path' => $relativePath,
'reason' => 'File missing from ZIP and no master file available.',
]);
}
continue;
}
// --- Syntax check ------------------------------------------------------
$parsed = self::safeIncludePhpFile($tempFile);
if ($parsed === false) {
$this->reportLangFile($fileId, $file, 'error', [
'relative_path' => $relativePath,
'reason' => 'PHP syntax error or file does not return an array. Kept existing file untouched.',
]);
continue;
}
// --- Lock keys to master array shape -----------------------------------
if ($masterFile && file_exists($masterFile)) {
$masterArray = include $masterFile;
if (!is_array($masterArray)) {
$masterArray = [];
}
$extra = count(array_diff_key($parsed, $masterArray));
$missing = count(array_diff_key($masterArray, $parsed));
$locked = self::lockKeysToMaster($masterArray, $parsed);
$this->ensureDir(dirname($targetPath));
self::writePhpArrayFile($targetPath, $locked);
$status = ($extra > 0 || $missing > 0) ? 'locked' : 'ok';
$this->reportLangFile($fileId, $file, $status, [
'relative_path' => $relativePath,
'extra_keys_dropped' => $extra,
'missing_keys_restored' => $missing,
]);
} else {
// No master → accept as-is after syntax check.
$this->ensureDir(dirname($targetPath));
copy($tempFile, $targetPath);
$this->reportLangFile($fileId, $file, 'ok', [
'relative_path' => $relativePath,
'reason' => 'No master file; copied as-is after syntax check.',
]);
}
}
}
protected function reportLangFile(string $fileId, array $file, string $status, array $details = []): void
{
$this->uploadReport['files'][] = [
'id' => $fileId,
'title' => $file['file_title'] ?? $fileId,
'status' => $status,
'details' => $details,
];
$this->uploadReport['summary']['total']++;
$summaryKey = ($status === 'error') ? 'errors' : $status;
if (isset($this->uploadReport['summary'][$summaryKey])) {
$this->uploadReport['summary'][$summaryKey]++;
}
}
protected function ensureDir(string $dir): void
{
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
}
/**
* Safely include a PHP file that is expected to `return <array>`.
* Catches parse errors and any other throwables.
*
* @return array|false Parsed array on success, false on syntax/parse error or non-array return.
*/
protected static function safeIncludePhpFile(string $path)
{
try {
$result = (static function ($__p) {
return include $__p;
})($path);
} catch (\Throwable $e) {
return false;
}
return is_array($result) ? $result : false;
}
protected static function writePhpArrayFile(string $path, array $data): void
{
$content = '<?php return ' . var_export($data, true) . ' ?>';
\Illuminate\Support\Facades\File::put($path, $content);
}
public static function getJapan()
{
return self::where('code', '=', 'ja')->first();
}
public static function getByCode($code)
{
return self::where('code', '=', $code)->first();
}
public static function getDefaultLanguage()
{
return self::getByCode(config('app.locale'));
}
}