\n";
+ if(ENV_DEV) AppHelper::showException($e);
+ return ob_get_clean();
+ }
finally {
while(ob_get_level() > $_level_) ob_end_clean();
}
@@ -593,11 +622,11 @@ public static function addFlash($message, $severity='info') {
static::setFlashMessages($flash_messages);
}
- public static function getFlashMessages($clear=false) {
- $flashMessages = $_SESSION['flash_messages'] ?? [];
- if($clear) static::clearFlashMessages();
- return $flashMessages;
- }
+ public static function getFlashMessages($clear=false) {
+ $flashMessages = $_SESSION['flash_messages'] ?? [];
+ if($clear) static::clearFlashMessages();
+ return $flashMessages;
+ }
private static function setFlashMessages(array $messages) { $_SESSION['flash_messages'] = $messages; }
public static function clearFlashMessages() { static::setFlashMessages([]); }
@@ -635,7 +664,7 @@ public function redirect($url): bool {
if(is_array($url)) $url = $this->createUrl($url);
$this->sendHeader('Location: '.$url);
$this->responseStatus = 302;
- return true;
+ return App::EXIT_STATUS_ERROR;
}
/**
@@ -652,7 +681,8 @@ public function redirect($url): bool {
* @throws Exception
*/
public function createUrl(array $url, $absolute=false): string {
- $baseUrl = $url[0] ?? $this->url;
+ if(isset($url[0]) && $url[0]=='') $baseUrl = $this->baseUrl;
+ else $baseUrl = $url[0] ?? $this->url;
unset($url[0]);
parse_str(parse_url($baseUrl, PHP_URL_QUERY), $query);
$baseUrl = parse_url($baseUrl, PHP_URL_PATH);
@@ -699,15 +729,15 @@ public function cached($key, $compute, $refresh=false) {
* @param string $message -- string only
*/
public static function log($level, $message, $params=[]) {
- $logfile = self::$app->runtimePath . '/logs/app.log';
- $sid = session_id();
- $uid = App::$app->getUserId();
- if(!is_string($message)) $message = json_encode($message);
- if($params) {
- foreach($params as $k=>$v) $message = str_replace("\{$k\}", $v, $message);
- }
- $data_to_log = date(DATE_ATOM) . ' '. $level . ' ('.$uid.') ['.$sid.'] ' . $message . PHP_EOL;
- file_put_contents($logfile, $data_to_log, FILE_APPEND + LOCK_EX);
+ $logfile = self::$app->runtimePath . '/logs/app.log';
+ $sid = session_id();
+ $uid = App::$app->getUserId();
+ if(!is_string($message)) $message = json_encode($message);
+ if($params) {
+ foreach($params as $k=>$v) $message = str_replace("\{$k\}", $v, $message);
+ }
+ $data_to_log = date(DATE_ATOM) . ' '. $level . ' ('.$uid.') ['.$sid.'] ' . $message . PHP_EOL;
+ file_put_contents($logfile, $data_to_log, FILE_APPEND + LOCK_EX);
}
/**
@@ -739,14 +769,22 @@ public function runCli() {
for ($i = 1; $i <= count($this->path); $i++) {
$classPath = array_slice($this->path, 0, $i);
$classPath[$i - 1] = AppHelper::camelize($classPath[$i - 1]);
+ // Case 1: Command class is in the current application
$controllerClass = 'app\commands\\' . ($pathinfo = implode('\\', $classPath)) . 'Controller';
if (class_exists($controllerClass)) {
return $this->runController($controllerClass, array_slice($this->path, $i), $this->query);
}
+ // Case 2: Command class is in the framework
$controllerClass = 'uhi67\umvc\commands\\' . implode('\\', $classPath) . 'Controller';
if (class_exists($controllerClass)) {
return $this->runController($controllerClass, array_slice($this->path, $i), $this->query);
}
+ // Case 3: Command class is in another component
+ $controllerClass = implode('\\', $classPath) . 'Controller';
+ if (class_exists($controllerClass)) {
+ $this->classPath = $classPath; $this->classPath[count($this->classPath)-1] .= 'Controller';
+ return $this->runController($controllerClass, array_slice($this->path, $i), $this->query);
+ }
}
throw new Exception("Command not found ($pathinfo, $controllerClass)", HTTP::HTTP_NOT_FOUND);
}
@@ -776,23 +814,23 @@ public function runCli() {
*/
public function linkAssetFile(string $package, string $resource, ?array $patterns=null) {
if(!$this->controller) throw new Exception('No controller is executed');
- try {
- if(!isset($this->controller->assets[$package])) {
- // Create a new asset package (copies files on init)
- $asset = new Asset([
- 'path' => $package,
- 'patterns' => $patterns,
- ]);
- // Register the asset package
- $this->controller->registerAsset($asset);
- }
- else $asset = $this->controller->assets[$package];
- return $asset->url($resource);
- }
- catch(Throwable $e) {
- App::log('error', "Error in asset '$package' at resource '$resource': {msg}", ['msg'=>$e->getMessage()]);
- return '/js/error.js?res='.urlencode("$package::$resource");
- }
+ try {
+ if(!isset($this->controller->assets[$package])) {
+ // Create a new asset package (copies files on init)
+ $asset = new Asset([
+ 'path' => $package,
+ 'patterns' => $patterns,
+ ]);
+ // Register the asset package
+ $this->controller->registerAsset($asset);
+ }
+ else $asset = $this->controller->assets[$package];
+ return $asset->url($resource);
+ }
+ catch(Throwable $e) {
+ App::log('error', "Error in asset '$package' at resource '$resource': {msg}", ['msg'=>$e->getMessage()]);
+ return '/js/error.js?res='.urlencode("$package::$resource");
+ }
}
/**
@@ -809,6 +847,7 @@ public function __get($name) {
}
public function hasComponent($name, $type=Component::class) {
+ if($this->_components===null) throw new Exception('Configuration error: components definition is missing');
if(array_key_exists($name, $this->_components) && $this->_components[$name] instanceof $type) return $this->_components[$name];
return null;
}
@@ -819,7 +858,7 @@ public function getComponents() {
public static function cli($configFile) {
try {
- if(!file_exists($configFile)) throw new Exception("Config file at '$configFile' is missing.");
+ if(!file_exists($configFile)) throw new Exception("Config file at '$configFile' is missing.");
$config = include $configFile;
defined('ENV') || define('ENV', $config['application_env'] ?? 'production');
defined('ENV_DEV') || define('ENV_DEV', ENV != 'production');
@@ -855,52 +894,52 @@ public static function getUserId() {
return App::$app->user ? App::$app->user->getUserId() : '';
}
- /**
- * This method is only introduced to avoid duplicating explanations in the source code.
- * When used as shown below, a PHP runtime error will be raised for variables expected by a view that are not passed.
- * Please note that such an error is automatically raised when the variable is explicitly referenced in the PHP view.
- * However, in some cases, like when the variable is referenced in the PHP view but only in a JS
- * Therefore, this method helps prevent unnecessary headaches for the developer.
- *
- * Usage in a template/view file:
- *
+ /**
+ * This method is only introduced to avoid duplicating explanations in the source code.
+ * When used as shown below, a PHP runtime error will be raised for variables expected by a view that are not passed.
+ * Please note that such an error is automatically raised when the variable is explicitly referenced in the PHP view.
+ * However, in some cases, like when the variable is referenced in the PHP view but only in a JS
+ * Therefore, this method helps prevent unnecessary headaches for the developer.
+ *
+ * Usage in a template/view file:
+ *
* assert($this->requireVars($var1, ..., $varN), ''); // $var are variables expected by the view
* // the use of assert() is to not impact production environment
- *
- * @param array $variables -- Variable list of PHP variables
- * @return bool
- * @noinspection PhpUnusedParameterInspection
- */
- public function requireVars(...$variables) {
- return true; // for assert() to succeed: see usage in documentation above
- }
-
- /**
- * Localizes a messaget text using configured localization (l10n) class.
- *
- * Category syntax
- * - umvc -- framework messages, located in the /vendor/uhi67/umvc/messages dir
- * - avendor/alib -- library texts, located in the /vendor/avendor/alib/messages dir
- * - avendor/alib/acat -- library category, located in the /vendor/avendor/alib/messages/acat dir
- * - any/other -- application categories, depending on current l10n class (e.g. located in the /messages/any/other dir of the application)
- * - app -- the default if none specified; depending on current l10n class (e.g. located in the /messages/app dir of the application)
- *
- * @param string $category
- * @param string $message
- * @param array $params
- * @param string|null $locale
- * @return string
- * @throws Exception
- */
- public static function l($category, $message, $params=[], $locale=null) {
- if(!static::$app) throw new Exception('Application is not initialized');
- if(!static::$app->hasComponent('l10n')) {
- if($params) $message = Apphelper::substitute($message, $params);
- return $message;
- }
- if(!$locale) $locale = static::$app->locale;
- return static::$app->l10n->getText($category, $message, $params, $locale);
- }
+ *
+ * @param array $variables -- Variable list of PHP variables
+ * @return bool
+ * @noinspection PhpUnusedParameterInspection
+ */
+ public function requireVars(...$variables) {
+ return true; // for assert() to succeed: see usage in documentation above
+ }
+
+ /**
+ * Localizes a messaget text using configured localization (l10n) class.
+ *
+ * Category syntax
+ * - umvc -- framework messages, located in the /vendor/uhi67/umvc/messages dir
+ * - avendor/alib -- library texts, located in the /vendor/avendor/alib/messages dir
+ * - avendor/alib/acat -- library category, located in the /vendor/avendor/alib/messages/acat dir
+ * - any/other -- application categories, depending on current l10n class (e.g. located in the /messages/any/other dir of the application)
+ * - app -- the default if none specified; depending on current l10n class (e.g. located in the /messages/app dir of the application)
+ *
+ * @param string $category
+ * @param string $message
+ * @param array $params
+ * @param string|null $locale
+ * @return string
+ * @throws Exception
+ */
+ public static function l($category, $message, $params=[], $locale=null) {
+ if(!static::$app) throw new Exception('Application is not initialized');
+ if(!static::$app->hasComponent('l10n')) {
+ if($params) $message = Apphelper::substitute($message, $params);
+ return $message;
+ }
+ if(!$locale) $locale = static::$app->locale;
+ return static::$app->l10n->getText($category, $message, $params, $locale);
+ }
}
diff --git a/lib/AppHelper.php b/lib/AppHelper.php
index d4635b3..293e7cc 100644
--- a/lib/AppHelper.php
+++ b/lib/AppHelper.php
@@ -110,7 +110,7 @@ public static function format_date($str, $fmt) {
* @param int|null $responseStatus -- HTTP response status, default is 500=HTTP_INTERNAL_SERVER_ERROR
*/
static function showException($e, $responseStatus=null) {
- defined('ENV_DEV') || define('ENV_DEV', 'production');
+ defined('ENV_DEV') || define('ENV_DEV', 'production');
$responseStatus = $responseStatus ?: HTTP::HTTP_INTERNAL_SERVER_ERROR;
$title = HTTP::$statusTexts[$responseStatus] ?? 'Internal application error';
@@ -129,7 +129,7 @@ static function showException($e, $responseStatus=null) {
}
while($e = $e->getPrevious()) {
- $message = sprintf("%s in file '%s' at line '%d'", html_entity_decode($e->getMessage()), $e->getFile(), $e->getLine());
+ $message = sprintf("%s in file '%s' at line '%d'", html_entity_decode($e->getMessage()), $e->getFile(), $e->getLine());
echo Ansi::color(PHP_EOL.PHP_EOL.$message, 'light purple').PHP_EOL;
$trace = explode(PHP_EOL, $e->getTraceAsString());
foreach($trace as $line) {
@@ -176,7 +176,7 @@ static function showException($e, $responseStatus=null) {
htmlspecialchars($e->getTraceAsString())
);
while ($e = $e->getPrevious()) {
- $message = sprintf("%s in file '%s' at line '%d'", htmlspecialchars($e->getMessage()), $e->getFile(), $e->getLine());
+ $message = sprintf("%s in file '%s' at line '%d'", htmlspecialchars($e->getMessage()), $e->getFile(), $e->getLine());
echo PHP_EOL, PHP_EOL, $message, PHP_EOL;
echo htmlspecialchars($e->getTraceAsString());
}
@@ -224,42 +224,42 @@ public static function camelize($id): ?string {
return strtr(ucwords(strtr($id, ['_' => ' ', '.' => '_ ', '\\' => '_ ', '-' => ' '])), [' ' => '']);
}
- /**
- * Converts a string to human-readable form, e.g. for an auto-generated field label
- *
- * Redundant '_id' or 'Id' postfix will be eliminated.
- *
- * @return string|null The camelized string
- */
- public static function humanize($id): ?string {
- if(is_null($id)) return null;
- return static::mb_ucwords(preg_replace('~[_.-]~', ' ', preg_replace('/_id$/', '', static::underscore(static::camelize($id)))));
- }
-
- /**
- * Converts a (camelized) string to underscore format.
- * Existing underscore ($separator) will be converted to '.'.
- * Replaces all non-name character to _.
- *
- * The result string should be appropriate for a filename or a Model attribute name (using _)
- *
- * Example: 'MyClass' --> 'my_class'
- * But: 'MyClass_id' --> 'my_class.id'
- *
- * If you want to keep existing separators, call camelize first.
- *
- * @param string|null $id -- an identifier in CamelCase
- * @param string $separator -- the separator character to be used between words, default is '_'
- * @return string|null The underscored string, e.g. camel_case
- */
- public static function underscore(?string $id, string $separator='_'): ?string {
- if(is_null($id)) return null;
- $id = preg_replace('/[^A-Za-z\d.'.$separator.']+/', $separator, $id);
- $id = preg_replace(['/([A-Z]+)([A-Z][a-z\d])/', '/([a-z\d])([A-Z])/'], ['\\1'.$separator.'\\2', '\\1'.$separator.'\\2'], $id);
- return strtolower($id);
- }
-
- /**
+ /**
+ * Converts a string to human-readable form, e.g. for an auto-generated field label
+ *
+ * Redundant '_id' or 'Id' postfix will be eliminated.
+ *
+ * @return string|null The camelized string
+ */
+ public static function humanize($id): ?string {
+ if(is_null($id)) return null;
+ return static::mb_ucwords(preg_replace('~[_.-]~', ' ', preg_replace('/_id$/', '', static::underscore(static::camelize($id)))));
+ }
+
+ /**
+ * Converts a (camelized) string to underscore format.
+ * Existing underscore ($separator) will be converted to '.'.
+ * Replaces all non-name character to _.
+ *
+ * The result string should be appropriate for a filename or a Model attribute name (using _)
+ *
+ * Example: 'MyClass' --> 'my_class'
+ * But: 'MyClass_id' --> 'my_class.id'
+ *
+ * If you want to keep existing separators, call camelize first.
+ *
+ * @param string|null $id -- an identifier in CamelCase
+ * @param string $separator -- the separator character to be used between words, default is '_'
+ * @return string|null The underscored string, e.g. camel_case
+ */
+ public static function underscore(?string $id, string $separator='_'): ?string {
+ if(is_null($id)) return null;
+ $id = preg_replace('/[^A-Za-z\d.'.$separator.']+/', $separator, $id);
+ $id = preg_replace(['/([A-Z]+)([A-Z][a-z\d])/', '/([a-z\d])([A-Z])/'], ['\\1'.$separator.'\\2', '\\1'.$separator.'\\2'], $id);
+ return strtolower($id);
+ }
+
+ /**
* unicode-safe capitalize first letter of all words
*
* @param string $string
@@ -385,108 +385,108 @@ public static function jsonStringFrom($data) {
return is_string($data) ? $data : '';
}
- /**
- * Substitutes {$key} patterns of the text to values of associative data
- * Used primarily for native language texts, but used for SQL generation where substitution is not based on SQL data syntax.
- * If no substitution possible, the pattern remains unchanged without error
- * Special cases:
- * - {DMY$var} - convert hungarian date to english (deprecated)
- * - {$var/subvar} - array resolution within array values (using multiple levels possible)
- * - Using special characters if necessary: `{{}` -> `{`, `}` -> `}`
- * - values of DateTime will be substituted as SHORT date of the application's language.
- *
- * @param string $text
- * @param array $data
- *
- * @return string
- */
- public static function substitute($text, $data) {
- return preg_replace_callback(/* @lang */'#{(DMY|MDY)?(\\$[a-zA-Z_]+[\\\\/a-zA-Z0-9_-]*)}#', function($mm) use($data) {
- if($mm[2]=='{') return '{';
- if(substr($mm[2],0,1)=='$') {
- // a keyname
- $subvars = explode('/', substr($mm[2],1));
- $d = $data;
- foreach($subvars as $subvar) {
- if(is_array($d) && array_key_exists($subvar, $d)) $d = $d[$subvar]===null ? '#null#' : $d[$subvar];
- else return $mm[0];
- }
- }
- else {
- // Other expression (not implemented)
- return $mm[0];
- }
- if($d instanceof DateTime) $d = static::formatDateTime($d, IntlDateFormatter::SHORT, IntlDateFormatter::NONE);
- if($mm[1]=='MDY') {
- $d = static::formatDateTime($d, IntlDateFormatter::SHORT, IntlDateFormatter::NONE, 'en');
- }
- if($mm[1]=='DMY') {
- $d = static::formatDateTime($d, IntlDateFormatter::SHORT, IntlDateFormatter::NONE, 'en-GB');
- }
- return $d;
- }, $text);
- }
-
- /**
- * formats a DateTime value using given locale
- *
- * @param DateTime $datetime
- * @param int $datetype -- date format as IntlDateFormatter::NONE, type values are 'NONE', 'SHORT', 'MEDIUM', 'LONG', 'FULL'
- * @param int $timetype -- time format as IntlDateFormatter::NONE, type values are 'NONE', 'SHORT', 'MEDIUM', 'LONG', 'FULL'
- * @param string $locale -- locale in ll-cc format (ISO 639-1 && ISO 3166-1), null to use default
- * @return string
- */
- public static function formatDateTime($datetime, $datetype, $timetype, $locale=null) {
- if(!$locale) $locale = App::$app->locale;
- if(!$locale) $locale="en-GB";
- $pattern = null;
- if(substr($locale, 0,2)=='hu') {
- if($datetype == IntlDateFormatter::SHORT && $timetype == IntlDateFormatter::SHORT)
- $pattern = 'yyyy.MM.dd. H:mm';
- if($datetype == IntlDateFormatter::SHORT && $timetype == IntlDateFormatter::NONE)
- $pattern = 'yyyy.MM.dd.';
- }
- $dateFormatter = new IntlDateFormatter($locale, $datetype, $timetype, null, null, $pattern);
- return $dateFormatter->format($datetime);
- }
-
- /**
- * Waits for a test to satisfy (i.e. to return a truthy value)
- *
- * See usage example in {@see MigrateController::actionWait()}
- *
- * @param Closure $test -- test to run. Must return truthy value on success
- * @param int $timeout -- seconds to giving up waiting, the minimum allowed value is 1
- * @param int $interval -- seconds between retry attempts, the minimum allowed value is 1
- * @return bool -- true if test succeeded within timeout, false otherwise
- */
- public static function waitFor($test, $timeout=60, $interval=1) {
- $startTime = time();
- $interval = max(1, $interval);
- $timeout = max(1, $timeout);
- $timeoutPassed = $startTime+$timeout;
- do {
- $lastTry = time();
- if($test()) return true;
- sleep(max(0, min($timeoutPassed - time(), $lastTry+$interval - time())));
- }
- while(time() < $timeoutPassed);
- return false;
- }
-
- /**
- * Returns true if path is absolute, false if not (relative).
- * Empty string considered as relative.
- * Can be used for file system and URL paths as well.
- * Both Windows and Linux file system paths are detected.
- * The path itself is not validated, malformed paths can be either absolute or relative.
- * Note: Paths beginning with drive letter on Windows but not \\ still considered as absolute.
- *
- * @param string $path
- * @return bool
- */
- public static function pathIsAbsolute(string $path): bool {
- return preg_match('~^(/|\\\\|[\w]+:)~', $path);
- }
+ /**
+ * Substitutes {$key} patterns of the text to values of associative data
+ * Used primarily for native language texts, but used for SQL generation where substitution is not based on SQL data syntax.
+ * If no substitution possible, the pattern remains unchanged without error
+ * Special cases:
+ * - {DMY$var} - convert hungarian date to english (deprecated)
+ * - {$var/subvar} - array resolution within array values (using multiple levels possible)
+ * - Using special characters if necessary: `{{}` -> `{`, `}` -> `}`
+ * - values of DateTime will be substituted as SHORT date of the application's language.
+ *
+ * @param string $text
+ * @param array $data
+ *
+ * @return string
+ */
+ public static function substitute($text, $data) {
+ return preg_replace_callback(/* @lang */'#{(DMY|MDY)?(\\$[a-zA-Z_]+[\\\\/a-zA-Z0-9_-]*)}#', function($mm) use($data) {
+ if($mm[2]=='{') return '{';
+ if(substr($mm[2],0,1)=='$') {
+ // a keyname
+ $subvars = explode('/', substr($mm[2],1));
+ $d = $data;
+ foreach($subvars as $subvar) {
+ if(is_array($d) && array_key_exists($subvar, $d)) $d = $d[$subvar]===null ? '#null#' : $d[$subvar];
+ else return $mm[0];
+ }
+ }
+ else {
+ // Other expression (not implemented)
+ return $mm[0];
+ }
+ if($d instanceof DateTime) $d = static::formatDateTime($d, IntlDateFormatter::SHORT, IntlDateFormatter::NONE);
+ if($mm[1]=='MDY') {
+ $d = static::formatDateTime($d, IntlDateFormatter::SHORT, IntlDateFormatter::NONE, 'en');
+ }
+ if($mm[1]=='DMY') {
+ $d = static::formatDateTime($d, IntlDateFormatter::SHORT, IntlDateFormatter::NONE, 'en-GB');
+ }
+ return $d;
+ }, $text);
+ }
+
+ /**
+ * formats a DateTime value using given locale
+ *
+ * @param DateTime $datetime
+ * @param int $datetype -- date format as IntlDateFormatter::NONE, type values are 'NONE', 'SHORT', 'MEDIUM', 'LONG', 'FULL'
+ * @param int $timetype -- time format as IntlDateFormatter::NONE, type values are 'NONE', 'SHORT', 'MEDIUM', 'LONG', 'FULL'
+ * @param string $locale -- locale in ll-cc format (ISO 639-1 && ISO 3166-1), null to use default
+ * @return string
+ */
+ public static function formatDateTime($datetime, $datetype, $timetype, $locale=null) {
+ if(!$locale) $locale = App::$app->locale;
+ if(!$locale) $locale="en-GB";
+ $pattern = null;
+ if(substr($locale, 0,2)=='hu') {
+ if($datetype == IntlDateFormatter::SHORT && $timetype == IntlDateFormatter::SHORT)
+ $pattern = 'yyyy.MM.dd. H:mm';
+ if($datetype == IntlDateFormatter::SHORT && $timetype == IntlDateFormatter::NONE)
+ $pattern = 'yyyy.MM.dd.';
+ }
+ $dateFormatter = new IntlDateFormatter($locale, $datetype, $timetype, null, null, $pattern);
+ return $dateFormatter->format($datetime);
+ }
+
+ /**
+ * Waits for a test to satisfy (i.e. to return a truthy value)
+ *
+ * See usage example in {@see MigrateController::actionWait()}
+ *
+ * @param Closure $test -- test to run. Must return truthy value on success
+ * @param int $timeout -- seconds to giving up waiting, the minimum allowed value is 1
+ * @param int $interval -- seconds between retry attempts, the minimum allowed value is 1
+ * @return bool -- true if test succeeded within timeout, false otherwise
+ */
+ public static function waitFor($test, $timeout=60, $interval=1) {
+ $startTime = time();
+ $interval = max(1, $interval);
+ $timeout = max(1, $timeout);
+ $timeoutPassed = $startTime+$timeout;
+ do {
+ $lastTry = time();
+ if($test()) return true;
+ sleep(max(0, min($timeoutPassed - time(), $lastTry+$interval - time())));
+ }
+ while(time() < $timeoutPassed);
+ return false;
+ }
+
+ /**
+ * Returns true if path is absolute, false if not (relative).
+ * Empty string considered as relative.
+ * Can be used for file system and URL paths as well.
+ * Both Windows and Linux file system paths are detected.
+ * The path itself is not validated, malformed paths can be either absolute or relative.
+ * Note: Paths beginning with drive letter on Windows but not \\ still considered as absolute.
+ *
+ * @param string $path
+ * @return bool
+ */
+ public static function pathIsAbsolute(string $path): bool {
+ return preg_match('~^(/|\\\\|[\w]+:)~', $path);
+ }
}
diff --git a/lib/Asset.php b/lib/Asset.php
index 858183d..42395e2 100644
--- a/lib/Asset.php
+++ b/lib/Asset.php
@@ -51,7 +51,7 @@ public function init() {
if(!$this->id) $this->id = substr(md5($this->dir),0,16);
if(!$this->cacheDir) $this->cacheDir = App::$app->basePath.'/www/assets/cache/'.$this->id;
- if(!$this->cacheUrl) $this->cacheUrl = '/assets/cache/'.$this->id;
+ if(!$this->cacheUrl) $this->cacheUrl = App::$app->baseUrl.'/assets/cache/'.$this->id;
if(!is_dir($this->cacheDir)) mkdir($this->cacheDir, 0774, true);
if(!$this->patterns) $this->patterns = ['*'];
diff --git a/lib/BaseModel.php b/lib/BaseModel.php
index d1ccde1..cb1f157 100644
--- a/lib/BaseModel.php
+++ b/lib/BaseModel.php
@@ -244,17 +244,21 @@ public function loadFrom(array $source, $instanceName=null) {
/**
* Inserts field-name and its error message into $errors array.
+ * If multiple fieldnames are specified, all fields get the same error.
*
- * @param string $fieldName -- the name of field
- * @param string $message ($1 is a placeholder for field name)
+ * @param string|array $fieldNames -- the name of field or array of multiple file names
+ * @param string $message ($1 is a placeholder for the field name)
*
* @return false -- always
* @throws Exception
*/
- public function addError($fieldName, $message) {
- $message = str_replace('$1', $fieldName, $message);
- if(!isset($this->_errors[$fieldName])) $this->_errors[$fieldName] = [];
- $this->_errors[$fieldName][] = $message;
+ public function addError($fieldNames, $message) {
+ if(!is_array($fieldNames)) $fieldNames = [$fieldNames];
+ foreach($fieldNames as $fieldName) {
+ $message = str_replace('$1', $this->attributeLabel($fieldName), $message);
+ if(!isset($this->_errors[$fieldName])) $this->_errors[$fieldName] = [];
+ $this->_errors[$fieldName][] = $message;
+ }
return false;
}
@@ -299,7 +303,7 @@ public function validate($attributeNames=null) {
$rules = static::rules();
foreach($rules as $field=>$def) {
if($def===null) continue; // Overridden rule may be null
- // Global rules (skips if individual fields are specified)
+ // Global rules (skipped if individual fields are specified in $attributeNames)
if(is_numeric($field)) {
if($attributeNames) continue;
// Global rule is 'ruleName' or ['ruleName', arg1, arg2, ...]
@@ -724,6 +728,7 @@ public function toArray($fields = null, $recursive = false) {
return $recursive ? ArrayHelper::toArray($data) : $data;
}
+ #[\ReturnTypeWillChange]
/**
* Serializes model data for json_encode
*
diff --git a/lib/Column.php b/lib/Column.php
index 6125789..b32f350 100644
--- a/lib/Column.php
+++ b/lib/Column.php
@@ -26,6 +26,7 @@
* - string **$class** -- custom class for value cell
* - string **$headerClass** -- custom header class
* - string|Model **$model** the model name used in the table
+ * - string **$format** -- set to 'raw' to display HTML content, otherwise htmlspecialchars filter is applied
*
* @package UMVC Simple Application Framework
*/
@@ -60,6 +61,8 @@ class Column extends Component
public $model;
/** @var string|null|bool -- display null value as. default is Grid's. Set false to disable (=empty string) */
public $emptyValue;
+ /** @var string $format -- currently only 'raw' is supported to supress htmlspecialchar filtering */
+ public $format;
/**
* @param Grid|null $grid
@@ -110,18 +113,18 @@ public function init() {
if($this->emptyValue===false) $this->emptyValue = '';
}
- /**
- * Renders the search cell
- *
- * - boolean: display default filter input or none
- * - string: display in filter cell as it is
- * - array: display a selection
- *
- * @param array|BaseModel $search -- the search Model or an array with actual search values indexed by field names
- *
- * @return string
- * @throws Exception
- */
+ /**
+ * Renders the search cell
+ *
+ * - boolean: display default filter input or none
+ * - string: display in filter cell as it is
+ * - array: display a selection
+ *
+ * @param array|BaseModel $search -- the search Model or an array with actual search values indexed by field names
+ *
+ * @return string
+ * @throws Exception
+ */
public function renderSearch($search) {
$searchModel = 'search';
$fieldName = $searchModel.'['.$this->searchField.']';
@@ -158,7 +161,7 @@ public function renderValue(Model $model) {
else {
if(is_string($this->value)) $content = $model->{$this->value};
else $content = $model->{$this->field};
- if($content!==null) $content = htmlspecialchars($content);
+ if($content!==null) $content = $this->format=='raw' ? $content : htmlspecialchars($content);
}
// Distinguish null value from empty string
if($content===null) $content = $this->emptyValue;
@@ -166,16 +169,16 @@ public function renderValue(Model $model) {
return Html::tag('td', $content, $options);
}
- /**
- * Renders the table header cell. Using priority multiple orders can be applied.
- *
- * $orders is the $actualColumnOrder values by field names.
- * $actualColumnOrder is null, or actual order direction, with an optional priority postfix separated by ;
- * Example: ['id'=>null, 'name'=>'ASC;1', ...]
- *
- * @param array|null $orders
- * @throws Exception
- */
+ /**
+ * Renders the table header cell. Using priority multiple orders can be applied.
+ *
+ * $orders is the $actualColumnOrder values by field names.
+ * $actualColumnOrder is null, or actual order direction, with an optional priority postfix separated by ;
+ * Example: ['id'=>null, 'name'=>'ASC;1', ...]
+ *
+ * @param array|null $orders
+ * @throws Exception
+ */
public function renderHeader($orders) {
$class = $this->order ? 'header-ordered' : '';
if($this->headerClass) $class .= ($class ? ' ': '') . $this->headerClass;
diff --git a/lib/Command.php b/lib/Command.php
index 89036b8..4be87b8 100644
--- a/lib/Command.php
+++ b/lib/Command.php
@@ -24,12 +24,14 @@ class Command extends Component
public $query;
/** @var string|null -- name of the currently executed action (without 'action' prefix) */
public $action;
+ /** @var string $classPath -- the controller id path for controller Id property (Compatibility with Controller)*/
+ public $classPath = null;
/**
* Execute the request by this controller
*
* @throws Exception if no matching action
- * @return int -- HTTP response status
+ * @return string|int -- output or exit status
*/
public function go() {
// Search for action method to call
@@ -47,7 +49,7 @@ public function go() {
// Call the action method with the required parameters from the request
if($methodName) {
- if(!$this->beforeAction()) return 0;
+ if(!$this->beforeAction()) return self::EXIT_STATUS_OK;
$args = [];
$ref = new ReflectionMethod($this, $methodName);
foreach($ref->getParameters() as $param) {
diff --git a/lib/Component.php b/lib/Component.php
index 2a20354..6340b3f 100644
--- a/lib/Component.php
+++ b/lib/Component.php
@@ -295,12 +295,11 @@ public function getNode() {
/**
* Returns class name without namespace of the caller class.
- * Callable dynamically and statically as well.
+ * The proper static call is static::shortName()
*
* @return string
*/
public function getShortName() {
- if(!isset($this)) return static::shortName(get_class());
$reflect = new ReflectionClass($this);
return $reflect->getShortName();
}
diff --git a/lib/Controller.php b/lib/Controller.php
index feb32be..1595355 100644
--- a/lib/Controller.php
+++ b/lib/Controller.php
@@ -28,6 +28,7 @@
* - jsonErrorResponse(): generates a JSON-formatted error response (HTTP status is still 200 in this case)
* - render(): same as ->app->render();
*
+ * @property-read string $actionPath -- controller/action, e.g. 'course/update'
* @package UMVC Simple Application Framework
*/
class Controller extends Component
@@ -43,11 +44,28 @@ class Controller extends Component
/** @var Asset[] $assets -- registered assets indexed by name */
public $assets = [];
+ /** @var string $classPath -- the controller id path for controller Id property */
+ public $classPath = null;
public function init() {
+ if(!$this->classPath) $this->classPath = static::getClasspath();
$this->registerAssets();
}
+ public static function getClassPath() {
+ return AppHelper::underscore(preg_replace('/Controller$/', '', static::shortName()), '-');
+ }
+
+ /**
+ * The full qualified identifier of the action (`"path"/"controller"/"action"`, eg. 'admin/teacher/create')
+ * to use as a permission name in access control
+ *
+ * @return string
+ */
+ public function getActionPath(): string {
+ return $this->classPath.'/'.$this->action;
+ }
+
/**
* Descendant classes must override and register asset packages here.
* The default implementation is empty.
@@ -61,7 +79,7 @@ public function registerAssets() {
* Determines and performs the requested action using $this controller
*
* @throws Exception if no matching action
- * @return int -- HTTP response status
+ * @return string|int -- output string or exit status
*/
public function go() {
// Search for action method to call
@@ -80,7 +98,7 @@ public function go() {
// Call the action method with the required parameters from the request
if($methodName) {
- if(!$this->beforeAction()) return 0;
+ if(!$this->beforeAction()) return HTTP::HTTP_FORBIDDEN; // Silently failed
$args = [];
$ref = new ReflectionMethod($this, $methodName);
foreach($ref->getParameters() as $param) {
@@ -94,7 +112,7 @@ public function go() {
}
}
$status = call_user_func_array([$this, $methodName], $args);
- return $status ?: ($this->app->responseStatus ?: 200);
+ return $status ?: $this->app->responseStatus;
}
// We are here if no action method was found for the request
@@ -126,7 +144,7 @@ public function actionDefault() {
*
* @param array|object $response -- array or object containing the output data. Null is not permitted, use empty array for empty data
* @param array $headers -- more custom headers to send
- * @return int
+ * @return string
* @throws Exception -- if the response is not a valid data to convert to JSON.
*/
public function jsonResponse($response, $headers=[]) {
@@ -134,9 +152,7 @@ public function jsonResponse($response, $headers=[]) {
$this->app->sendHeader('Content-Type: application/json');
$result = json_encode($response);
if(!$result) throw new Exception('Invalid data');
- echo $result;
- //Debug::debug('[JSON result] '.$result);
- return 0;
+ return $result;
}
/**
@@ -146,7 +162,7 @@ public function jsonResponse($response, $headers=[]) {
*
* @param array[] $models -- array containing the output data. Null is not permitted, use empty array for empty data
* @param array $headers -- more custom headers to send
- * @return int
+ * @return int -- exit status
* @throws Exception -- if the response is not a valid data to convert to JSON.
*/
public function csvResponse($models, $headers=[]) {
@@ -165,7 +181,7 @@ public function csvResponse($models, $headers=[]) {
}
fclose($s);
}
- return 0;
+ return App::EXIT_STATUS_OK;
}
/**
@@ -219,46 +235,46 @@ public function errorResponse($error, $format='HTML') {
* @param array $layoutParams -- optional parameters for the layout view
* @param string|bool|null $locale -- use localized layout selection (ISO 639-1 language / ISO 3166-1-a2 locale), see above
*
- * @return false|string
- * @throws Exception
+ * @return string -- output
+ * @throws Exception -- if view does not exist or other error occurs
*/
public function render($viewName, $params=[], $layout=null, $layoutParams=[], $locale=null) {
- if($locale === null || $locale===true) $locale = $this->app->locale;
- if($locale) {
- // Priority order: 1. Localized view (with long or short locale) / 2. untranslated / 3. default-locale view (long/short)
- $lv = $this->localizedView($viewName, $locale);
- if(!$lv && !($this->app->viewFile($viewName) && file_exists($this->app->viewFile($viewName)))) {
- $lv = $this->localizedView($viewName, App::$app->source_locale);
- }
- if($lv) $viewName = $lv;
- }
+ if($locale === null || $locale===true) $locale = $this->app->locale;
+ if($locale) {
+ // Priority order: 1. Localized view (with long or short locale) / 2. untranslated / 3. default-locale view (long/short)
+ $lv = $this->localizedView($viewName, $locale);
+ if(!$lv && !($this->app->viewFile($viewName) && file_exists($this->app->viewFile($viewName)))) {
+ $lv = $this->localizedView($viewName, App::$app->source_locale);
+ }
+ if($lv) $viewName = $lv;
+ }
return $this->app->render($viewName, $params, $layout, $layoutParams);
}
- /**
- * Returns localized view name using long or short locale. Checks if the view file exists.
- * Returns null if none of them exists.
- *
- * @param string $viewName
- * @param string $locale
- * @return string|null
- * @throws Exception
- */
- private function localizedView($viewName, $locale) {
- // Look up view file using full locale
- $lv = $this->localizedViewName($viewName, $locale);
- if ($this->app->viewFile($lv) && file_exists($this->app->viewFile($lv))) return $lv;
- // Look up view file using short language code
- $lv = $this->localizedViewName($viewName, substr($locale,0,2));
- if($this->app->viewFile($lv) && file_exists($this->app->viewFile($lv))) return $lv;
- return null;
- }
+ /**
+ * Returns localized view name using long or short locale. Checks if the view file exists.
+ * Returns null if none of them exists.
+ *
+ * @param string $viewName
+ * @param string $locale
+ * @return string|null
+ * @throws Exception
+ */
+ private function localizedView($viewName, $locale) {
+ // Look up view file using full locale
+ $lv = $this->localizedViewName($viewName, $locale);
+ if ($this->app->viewFile($lv) && file_exists($this->app->viewFile($lv))) return $lv;
+ // Look up view file using short language code
+ $lv = $this->localizedViewName($viewName, substr($locale,0,2));
+ if($this->app->viewFile($lv) && file_exists($this->app->viewFile($lv))) return $lv;
+ return null;
+ }
- private function localizedViewName($viewName, $locale) {
- $p = strrpos($viewName, '/');
- if($p===false) $p = -1;
- return substr($viewName,0, $p+1) . $locale . '/'.substr($viewName, $p+1);
- }
+ private function localizedViewName($viewName, $locale) {
+ $p = strrpos($viewName, '/');
+ if($p===false) $p = -1;
+ return substr($viewName,0, $p+1) . $locale . '/'.substr($viewName, $p+1);
+ }
/**
* @param Asset $asset
@@ -308,5 +324,4 @@ public function linkAssets($extensions=null) {
return $html;
}
-
}
diff --git a/lib/Field.php b/lib/Field.php
index 78c9b15..10c4f8e 100644
--- a/lib/Field.php
+++ b/lib/Field.php
@@ -23,8 +23,8 @@ class Field extends Component
public $modelName;
/** @var string $divClass -- the additional classnames for the enclosing div */
public $divClass = '';
- /** @var string $label -- the label text, default is the attribute label defined in the Model class */
- public $label;
+ /** @var string $label -- the label text, default is the attribute label defined in the Model class. Null: use default label from model. False: No label displayed at all. */
+ public $label = null;
/** @var string|array $labelClass -- the additional classnames for the field label */
public $labelClass = '';
/** @var string $notice -- a notice text between the label and the input */
@@ -68,7 +68,7 @@ class Field extends Component
public function init() {
if($this->model) {
if(!$this->modelName) $this->modelName = $this->model->tableName();
- if(!$this->label) $this->label = $this->model->attributeLabel($this->fieldName);
+ if($this->label===null) $this->label = $this->model->attributeLabel($this->fieldName);
if($this->fieldName) {
if(!$this->id) $this->id = 'field-' . $this->modelName . '-' . $this->fieldName;
if(!$this->value) $this->value = $this->model->{$this->fieldName};
diff --git a/lib/Form.php b/lib/Form.php
index d6127d7..ca27835 100644
--- a/lib/Form.php
+++ b/lib/Form.php
@@ -6,6 +6,8 @@
/**
* Form class is a simple widget, currently a simple wrapper for the field method.
*
+ * Form visuel elements are compatible with Bootstrap 3
+ *
* **Example**
*
* The following example renders some fields of different types in a form:
diff --git a/lib/HTTP.php b/lib/HTTP.php
index d74d632..f69186a 100644
--- a/lib/HTTP.php
+++ b/lib/HTTP.php
@@ -321,7 +321,7 @@ public static function isHTTPS() {
* @return string
*/
public static function getSelfURL() {
- $baseurl = App::$app->baseUrl;
+ $baseurl = App::$app->currentUrl;
if (!empty($baseurl)) {
$protocol = parse_url($baseurl, PHP_URL_SCHEME);
$hostname = parse_url($baseurl, PHP_URL_HOST);
diff --git a/lib/Model.php b/lib/Model.php
index e557e17..878351f 100644
--- a/lib/Model.php
+++ b/lib/Model.php
@@ -382,7 +382,7 @@ public function delete() {
* - other expression formats
* @param array $params the parameter values for $1 parameters or name=>value pairs for $name parameters
* @param Connection|null $connection -- database connection. Default is from App.
- * @param null $query -- (output only) return the query created. Affected rows are accessible as $query->affected
+ * @param Query|null $query -- (output only) return the query created. Affected rows are accessible as $query->affected
* @return bool success
* @throws Exception
*/
@@ -599,14 +599,36 @@ public function __set($name, $value) {
/**
* ## Validates model to uniqueness of the given field
*
- * Returns true if the given fields has a value of null.
+ * Returns true if any of the the given fields has a value of null.
*
* @param string $fieldName -- for single field (null if called as multiple)
+ * @param array $fieldNames -- field names for multiple-field validation
*
* @return bool -- model is valid
* @throws Exception -- when a DB request failed
*/
- public function validateUnique($fieldName) {
+ public function validateUnique($fieldName, $fieldNames=[]) {
+ if($fieldName===null) {
+ // Multiple-field uniqueness
+ $fieldNames = $fieldNames ?: [$fieldName];
+ $values = $this->getAttributes($fieldNames);
+ foreach($values as $value) if(is_null($value)) return true;
+ if($this->isNew) {
+ $existingInstance = static::getOne($values);
+ } else {
+ // If already saved, itself must be ignored
+ $existingInstance = static::first(array_merge(
+ ['and'],
+ $this->db->asExpression($this->getAttributes($fieldNames)),
+ [['not', [$this->db->asExpression($this->getOldPrimaryKey(true))]]]
+ ), null, null, $this->db);
+ }
+ if($existingInstance) {
+ $this->addError($fieldNames, App::l('umvc', '$1 must be unique'));
+ return false;
+ }
+ return true;
+ }
$value = $this->$fieldName;
if(is_null($value)) return true;
$e = static::getOne([$fieldName=>$value]);
@@ -617,7 +639,7 @@ public function validateUnique($fieldName) {
foreach ($pk as $k => $v) if ($e->$k == $v) return true;
}
if($e) {
- $this->addError($fieldName, App::l('umvc','must be unique'));
+ $this->addError($fieldName, App::l('umvc','$1 must be unique'));
return false;
}
return true;
diff --git a/lib/Query.php b/lib/Query.php
index 1653866..0faf462 100644
--- a/lib/Query.php
+++ b/lib/Query.php
@@ -739,7 +739,6 @@ public function getColumn($column=0) {
$index = $this->_indexField ?: false;
while ($row = $this->stmt->fetch()) {
if($index && !isset($row[$index])) throw new Exception("Invalid index column $index");
- if(!$row[$index]) { var_dump("Empty index: ", $row); exit; }
if($index) $result[$row[$index]] = $row[$column];
else $result[] = $row[$column];
}
@@ -1146,7 +1145,7 @@ public function normalizeAlias(&$aliases=null) {
// Generate aliases for FROM tables
if(!empty($this->joins) || is_array($this->_from) && count($this->_from)>1) {
if(is_string($this->_from)) {
- $aliases[] = $alias = lcfirst(($this->_from)::shortName());
+ $aliases[] = $alias = ($this->_from)::tableName();
$this->_from = array($alias => $this->_from);
}
else if(is_array($this->_from)) {
@@ -1156,7 +1155,7 @@ public function normalizeAlias(&$aliases=null) {
*/
foreach($this->_from as $i=>$model) {
if(is_int($i)) {
- $aliases[] = $alias = $this->findUniqueAlias(lcfirst($model::shortName()), $aliases);
+ $aliases[] = $alias = $this->findUniqueAlias($model::tableName(), $aliases);
$this->_from[$alias] = $model;
unset($this->_from[$i]);
}
@@ -1168,7 +1167,7 @@ public function normalizeAlias(&$aliases=null) {
if(is_array($this->_joins)) {
foreach ($this->_joins as $i => $joinDef) {
if(is_int($i)) {
- $aliases[] = $alias = $this->findUniqueAlias(lcfirst(Model::shortName($joinDef[0])), $aliases);
+ $aliases[] = $alias = $this->findUniqueAlias($joinDef[0]::tableName(), $aliases);
$this->_joins[$alias] = $joinDef;
unset($this->_joins[$i]);
}
@@ -1337,7 +1336,7 @@ public function preprocessAssociative($list) {
* - string beginning with '(' will be returned literally. No identifier quoting or model check will be applied.
* - string beginning with single quote or E' will be treated as string literal. Closing quote will be omitted and internal double single quotes are allowed
* - integer or float: a numeric literal
- * - '?' is a numeric-indexed parameters. Don't use, always use ':' named parameters.
+ * - '?' is for numeric-indexed parameters. Don't use, always use ':' named parameters.
* - string beginning with a ':' is string-indexed parameter
* - any other string: a field name. Unqualified field name will be qualified with alias if alias is given.
* - array(OP, ...): operator with operands (expressions), only numeric indices {@see $_operators}
diff --git a/lib/Request.php b/lib/Request.php
index 4f548d6..f725f15 100644
--- a/lib/Request.php
+++ b/lib/Request.php
@@ -13,6 +13,7 @@
class Request extends Component {
/** @var string $request -- original full request uri */
public $url;
+ /** @var string -- base URL of the application's landing page (Default is auto-detected) */
public $baseUrl;
/** @var array $query -- get query variables */
public $query;
@@ -24,8 +25,9 @@ public function init() {
if(!$this->url) $this->url = ArrayHelper::getValue($_SERVER, 'REQUEST_URI');
// Determines original baseurl (canonic)
+ if(!$this->baseUrl && $this->parent->baseUrl) $this->baseUrl = $this->parent->baseUrl;
if(!$this->baseUrl && isset($_SERVER['HTTP_HOST'])) {
- $prot = isset($_SERVER['REQUEST_SCHEME']) ? $_SERVER['REQUEST_SCHEME'] : 'http';
+ $prot = $_SERVER['REQUEST_SCHEME'] ?? 'http';
$prot = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] ? "https" : $prot;
$this->baseUrl = $prot . "://" . $_SERVER['HTTP_HOST'] . ArrayHelper::getValue($_SERVER, 'SCRIPT_NAME', '');
if(substr($this->baseUrl,-10)=='/index.php') $this->baseUrl = substr($this->baseUrl, 0, -10);
diff --git a/lib/Session.php b/lib/Session.php
index f04d8c9..44a0f5f 100644
--- a/lib/Session.php
+++ b/lib/Session.php
@@ -41,7 +41,7 @@ class Session extends Component {
* @throws Exception
*/
public function prepare() {
- if($this->cookie_domain === true && $this->parent instanceof App) $this->cookie_domain = parse_url($this->parent->baseUrl, PHP_URL_HOST);
+ if($this->cookie_domain === true && $this->parent instanceof App) $this->cookie_domain = parse_url($this->parent->currentUrl, PHP_URL_HOST);
if(App::isCLI()) return;
ini_set("session.gc_maxlifetime", $this->lifetime + 900);
ini_set("session.lifetime", $this->lifetime);
diff --git a/lib/UserInterface.php b/lib/UserInterface.php
index da47bcb..f973397 100644
--- a/lib/UserInterface.php
+++ b/lib/UserInterface.php
@@ -12,7 +12,7 @@
* ...
* }
* ```
- *
+ * @property-read string $userId
*/
interface UserInterface {
/**
diff --git a/lib/testhelper/AppConnector.php b/lib/testhelper/AppConnector.php
index 36c92d9..34dfaaf 100644
--- a/lib/testhelper/AppConnector.php
+++ b/lib/testhelper/AppConnector.php
@@ -66,7 +66,7 @@ public function resetApplication() {
$this->app->path = null;
$this->app->url = null;
$this->app->query = null;
- $this->app->baseUrl = null;
+ $this->app->currentUrl = null;
}
/**
diff --git a/tests/unit/AppTest.php b/tests/unit/AppTest.php
index 21eb37e..e12b32a 100644
--- a/tests/unit/AppTest.php
+++ b/tests/unit/AppTest.php
@@ -85,4 +85,17 @@ public function provRender()
["