diff --git a/commands/MigrateController.php b/commands/MigrateController.php index 8f29e90..9e5af18 100644 --- a/commands/MigrateController.php +++ b/commands/MigrateController.php @@ -2,7 +2,6 @@ namespace uhi67\umvc\commands; -use Codeception\Util\Debug; use Exception; use Throwable; use uhi67\umvc\AppHelper; @@ -103,7 +102,9 @@ public function actionUp() { // Execute new migrations $n = 0; foreach($new as $file) { - $this->connection->pdo->beginTransaction(); + if(!$this->connection->pdo->beginTransaction()) { + echo "Error starting transaction\n"; + } $name = pathinfo($file, PATHINFO_FILENAME); $ext = pathinfo($file, PATHINFO_EXTENSION); $filename = $this->migrationPath.'/'.$file; @@ -167,15 +168,23 @@ public function actionUp() { break; } } - // Note: MySQL auto-commits transactions on DDL statements. Therefore we may find our transaction already gone if($this->connection->pdo->inTransaction()) { - $this->connection->pdo->commit(); + $this->connection->pdo->commit(); } + else { + if($this->verbose > 1) echo "Transaction lost...\n"; + } } catch(Throwable $e) { - if($this->connection->pdo->inTransaction()) $this->connection->pdo->rollBack(); - if(ENV_DEV) Debug::debug(sprintf("Exception in migration: '%s' in file '%s' at line '%d'", $e->getMessage(), $e->getFile(), $e->getLine())); - printf("Exception: %s in file %s at line %d\n", $e->getMessage(), $e->getFile(), $e->getLine()); + echo "Applying migration '". $name."' caused an exception\n"; + printf("'%s' in file %s at line %d\n", $e->getMessage(), $e->getFile(), $e->getLine()); + + if($this->connection->pdo->inTransaction()) { + $this->connection->pdo->rollBack(); + } + else { + if($this->verbose > 1) echo "Transaction lost...\n"; + } throw new Exception("Applying migration '". $name."' caused an exception", 500, $e); } } diff --git a/lib/Ansi.php b/lib/Ansi.php index 1faafdf..4cccd8c 100644 --- a/lib/Ansi.php +++ b/lib/Ansi.php @@ -89,7 +89,7 @@ class Ansi { * @param bool $close -- restore color after the message * @return string -- string with pre- and appended ansi color commands */ - public static function color($string, $fg, $bg='null', $close=true) { + public static function color($string, $fg, $bg='', $close=true) { if(!$fg) $fg = $bg=='white' ? 'black' : 'white'; if(!is_string($fg)) return $string.'*'; //throw new InternalException('fg must be string'); $color = self::$colors[trim(strtolower($fg))] ?? '0'; diff --git a/lib/App.php b/lib/App.php index 76e18b4..9a56006 100644 --- a/lib/App.php +++ b/lib/App.php @@ -5,6 +5,7 @@ use Codeception\Util\Debug; use ErrorException; use Exception; +use Symfony\Component\HttpFoundation\Response; use Throwable; /** @@ -54,6 +55,10 @@ * @package UMVC Simple Application Framework */ class App extends Component { + const + EXIT_STATUS_OK = 0, + EXIT_STATUS_ERROR = 1; // General error + /** @var array $config -- configuration settings */ public $config; /** @var string $title of the application (used in CLI echo) */ @@ -61,12 +66,14 @@ class App extends Component { /** @var string|Controller|null -- the default controller of the application */ public $mainControllerClass = null; + /** @var string -- base URL of the application's landing page */ + public $baseUrl; /** @var string -- base path of the application */ public $basePath; - /** @var string -- path of the runtime directory, default is $basePath.'/runtime' */ - public $runtimePath; + /** @var string -- path of the runtime directory, default is $basePath.'/runtime' */ + public $runtimePath; /** @var string -- URL of the current page without query parameters */ - public $baseUrl; + public $currentUrl; /** @var UserInterface|Model|null $user -- The logged-in user or null */ public $user; @@ -100,9 +107,10 @@ class App extends Component { * locale can be an ISO 639-1 language code ('en') optionally extended with a ISO 3166-1-a2 region ('en-GB') */ public $source_locale = 'en-GB'; - /** @var string $locale -- the current locale for localization, e.g. "hu-HU".*/ + /** @var string $locale -- the current locale for localization, e.g. "hu-HU". */ public $locale = 'en-GB'; - + /** @var string[] $classPath -- The path of the actually executed Controller including controller name, see also {@see Controller::$classPath} */ + public $classPath; /** @var Component[] $_components -- the configured components */ private $_components; @@ -110,18 +118,18 @@ class App extends Component { private $_assets; /** @var Connection $_connection -- the default database connection */ private $_connection; - /** @var bool|string -- Actually requested locale of current render for partial views */ - private $userLocale = true; - - /** - * We are being executed from the CLI - * @return bool - */ - public static function isCLI() { - return php_sapi_name() == "cli"; - } - - /** + /** @var bool|string -- Actually requested locale of current render for partial views */ + private $userLocale = true; + + /** + * We are being executed from the CLI + * @return bool + */ + public static function isCLI() { + return php_sapi_name() == "cli"; + } + + /** * Initializes the components defined in the config. * * 'components' as name=>config pairs define the common components for web API. @@ -138,22 +146,22 @@ public function init() { error_reporting(E_ALL); // Other configurable settings - $conf = ['title', 'mainControllerClass', 'layout', 'basePath', 'runtimePath']; + $conf = ['title', 'mainControllerClass', 'layout', 'basePath', 'runtimePath', 'baseUrl']; foreach($conf as $key) { if(array_key_exists($key, $this->config)) $this->$key = $this->config[$key]; } - if(!$this->basePath) $this->basePath = dirname(__DIR__, 4); - if(!$this->runtimePath) $this->runtimePath = $this->basePath.'/runtime'; - - if($this->sapi != 'cli') { - $this->baseUrl = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : null; - if(!is_dir($logDir = $this->runtimePath.'/logs')) { - if(!@mkdir($logDir, 0774, true)) { - throw new Exception("Failed to create dir `$logDir`"); - } - } - if(!$this->request) $this->request = new Request(); - if(!$this->session) $this->session = new Session(); + if(!$this->basePath) $this->basePath = dirname(__DIR__, 4); + if(!$this->runtimePath) $this->runtimePath = $this->basePath.'/runtime'; + + if($this->sapi != 'cli') { + $this->currentUrl = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : null; + if(!is_dir($logDir = $this->runtimePath.'/logs')) { + if(!@mkdir($logDir, 0774, true)) { + throw new Exception("Failed to create dir `$logDir`"); + } + } + if(!$this->request) $this->request = new Request(['parent'=>$this]); + if(!$this->session) $this->session = new Session(['parent'=>$this]); } $components = $this->config['components'] ?? []; @@ -163,12 +171,12 @@ public function init() { $referredComponents = $this->config['components'] ?? []; } - // Default components - if(!isset($components['l10n'])) $components['l10n'] = [ - 'class' => L10n::class, - ]; + // Default components + if(!isset($components['l10n'])) $components['l10n'] = [ + 'class' => L10n::class, + ]; - $this->_components = []; + $this->_components = []; if($components) { foreach ($components as $name => $config) { if(is_integer($name)) { @@ -225,10 +233,12 @@ public function error(int $status, string $message) { /** * Create App from given config file and run. + * If an integer is returned from the controller, it used as exit status. + * If a string or stringable returned, outputs to the standard output, and returns with OK. * Called from index.php * * @param $configFile - * @return int + * @return int -- exit status */ public static function createRun($configFile) { try { @@ -246,7 +256,7 @@ public static function createRun($configFile) { set_error_handler(function($severity, $errstr, $errfile, $errline) { $err = new ErrorException($errstr, 0, $severity, $errfile, $errline); AppHelper::showException($err); - exit(500); + exit(self::EXIT_STATUS_ERROR); }); register_shutdown_function(function() { $error = error_get_last(); @@ -259,11 +269,15 @@ public static function createRun($configFile) { // Default application class (uhi67\umvc\App) may be overriden in config $class = $config['class'] ?? ($config[0] ?? App::class); $app = App::create(['class'=>$class, 'config'=>$config]); - return $app->run(); + $response = $app->run(); + if(is_int($response)) return $response; + if(!is_string($response)) echo json_encode($response); + else echo $response; + return self::EXIT_STATUS_OK; } catch(Throwable $e) { AppHelper::showException($e); - return -1; + return self::EXIT_STATUS_ERROR; } } @@ -274,28 +288,35 @@ public static function createRun($configFile) { * Path elements are mapped to FQ class-name + optional action-name. * Called from createRun and codeception connector * - * @return int -- HTTP status code + * @return string|int -- output or exit status code */ public function run() { try { if(!$this->url) $this->url = ArrayHelper::getValue($_SERVER, 'REQUEST_URI'); - if(!$this->baseUrl) $this->baseUrl = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : null; + if(!$this->currentUrl) $this->currentUrl = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : null; if(!$this->query) $this->query = $_GET; - if(!$this->path) $this->path = parse_url($this->url, PHP_URL_PATH); + if(!$this->path) { + $this->path = parse_url($this->url, PHP_URL_PATH); + } + $basePath = $this->baseUrl ? explode('/', trim(parse_url($this->baseUrl, PHP_URL_PATH), '/')) : []; $this->path = $this->path ? explode('/', trim($this->path, '/')) : []; + while($basePath && $basePath[0]==$this->path[0]) { + array_shift($basePath); array_shift($this->path); + } if(ENV_DEV) Debug::debug('[url] '.$this->url); if($this->path==[''] && $this->mainControllerClass) { // The default action of main page can be called in the short way + $this->classPath = [$this->mainControllerClass::getClassPath()]; return $this->runController($this->mainControllerClass, [], $this->query); } else { // Find the actual controller class for this path, and let it go for ($i = 1; $i <= count($this->path); $i++) { - $classPath = array_slice($this->path, 0, $i); - $classPath[$i - 1] = AppHelper::camelize($classPath[$i - 1]); - $controllerClass = 'app\controllers\\' . implode('\\', $classPath) . 'Controller'; + $this->classPath = array_slice($this->path, 0, $i); + $camelizedClassPath = array_map(function($p) { return AppHelper::camelize($p); }, $this->classPath); + $controllerClass = 'app\controllers\\' . implode('\\', $camelizedClassPath) . 'Controller'; if (class_exists($controllerClass)) { return $this->runController($controllerClass, array_slice($this->path, $i), $this->query); } @@ -305,6 +326,7 @@ public function run() { // Last resort: main controller action, if exists $action = $this->path[0]??'default'; if($this->mainControllerClass && is_callable([$this->mainControllerClass, 'action'.$action])) { + $this->classPath = [$this->mainControllerClass::getClassPath()]; return $this->runController($this->mainControllerClass, $this->path, $this->query); } @@ -322,11 +344,18 @@ public function run() { * @param string $controllerClass -- ClassName ot the controller to be called * @param string[] $path -- the remainder of the request path after the controller name * @param array $query -- the actual GET query - * @return int -- HTTP response status + * @return string|int -- output or exit status * @throws Exception -- if invalid action was requested */ public function runController($controllerClass, $path, $query) { - $this->controller = new $controllerClass(['app' => $this, 'path' => $path, 'query' => $query]); + if(App::isCLI()) $this->classPath = [$controllerClass::shortName()]; + if(!is_array($this->classPath)) throw new Exception('Invalid classPath: '.print_r($this->classPath, true)); + $this->controller = new $controllerClass([ + 'app' => $this, + 'path' => $path, + 'classPath' => implode('/', $this->classPath), + 'query' => $query + ]); return $this->controller->go(); } @@ -354,98 +383,98 @@ public function getConnection($required=false) { return $this->_connection; } - /** - * ## Returns rendered contents of the view - * - * ### Definitions of localized views:** - * - * - source locale: the locale used in the source code and the base language of the translations. - * - default view: the original view path without localization, e.g 'main/index' written in the language and rules of the source locale - * - localized view: the view path with locale code, e.g. 'main/en/index' or 'main/en-GB/index' whichever fits better. - * - source-locale view: the default view or the localized view of the source-locale - * - locale can be an ISO 639-1 language code ('en') optionally extended with a ISO 3166-1-a2 region ('en-GB') - * - * ### Rules for locale and language codes** - * - * - If current locale is 'en-GB', the path with 'en-GB' is preferred, otherwise 'en' is used. No other 'en-*' is used - * - If current locale is 'en', the path with 'en' is used, no 'en-*' is recognized. - * - * ### Locale selection - * - * - true: use current locale if the localized view exists, otherwise use the default view or source-locale view. - * - false: do not use localized view, even if exists. If the unlocalized (default) view does not exist, an exception occurs. - * - explicit locale: use the specified locale, as defined at 'true' case. - * - * Note: returns an error message rendered as a string on internal rendering errors or Exception - * - * @param string $viewName -- basename of a php view-file in the `views` directory, without extension and without localization code - * @param array $params -- parameters to assign to variables used in the view - * @param string $layout -- the layout applied to the result after the view rendered. If false, no layout will be applied. - * @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 null|string -- null if view file (or layout file if applied) does not exist - * @throws Exception -- if view path does not exist - */ - public function render($viewName, $params=[], $layout=null, $layoutParams=[], $locale=true) { - if($locale === null || $locale===true) $locale = $this->locale; - if($locale) { - $this->userLocale = $locale; - // Priority order: 1. Localized view (with long or short locale) / 2. untranslated / 3. default-locale view (long/short) - $viewFile = $this->localizedViewFile($viewName, $locale); - if(!$viewFile) { - $viewFile = $this->viewFile($viewName); - } - if(!$viewFile) { - $viewFile = $this->localizedViewFile($viewName, $this->source_locale); - } - } else { - $viewFile = $this->viewFile($viewName); - } - if(!$viewFile) return null; - return $this->renderFile($viewFile, $params, $layout, $layoutParams); - } - - /** - * Returns best localized view filename using long or short locale. Checks if the view file exists. - * Returns null if none of them exists. - * - * @param string $viewName - * @param string|null $locale -- optional - * @return string|null - * @throws Exception -- if view path does not exist - */ - public function localizedViewFile($viewName, $locale) { - // 1. Look up view file using full locale - $lv = $locale ? $this->localizedViewName($viewName, $locale) : $viewName; - $viewFile = $this->viewFile($lv); - if(!$viewFile && $locale) { - // 2. Look up view file using short language code - $lv = $this->localizedViewName($viewName, substr($locale, 0, 2)); - $viewFile = $this->viewFile($lv); - } - return $viewFile; - } - - /** - * Returns view name completed with location path. - * - * Examples: - * - * - 'view1', 'la' => 'la/view1' - * - 'controller/action', 'la' => 'controller/la/action' - * - * The result of invalid $viewName or $locale is undefined! - * - * @param string $viewName - * @param string $locale - * @return string - */ - private function localizedViewName($viewName, $locale) { - $p = strrpos($viewName, '/'); - if($p===false) $p = -1; - return substr($viewName,0, $p+1) . $locale . '/'.substr($viewName, $p+1); - } + /** + * ## Returns rendered contents of the view + * + * ### Definitions of localized views:** + * + * - source locale: the locale used in the source code and the base language of the translations. + * - default view: the original view path without localization, e.g 'main/index' written in the language and rules of the source locale + * - localized view: the view path with locale code, e.g. 'main/en/index' or 'main/en-GB/index' whichever fits better. + * - source-locale view: the default view or the localized view of the source-locale + * - locale can be an ISO 639-1 language code ('en') optionally extended with a ISO 3166-1-a2 region ('en-GB') + * + * ### Rules for locale and language codes** + * + * - If current locale is 'en-GB', the path with 'en-GB' is preferred, otherwise 'en' is used. No other 'en-*' is used + * - If current locale is 'en', the path with 'en' is used, no 'en-*' is recognized. + * + * ### Locale selection + * + * - true: use current locale if the localized view exists, otherwise use the default view or source-locale view. + * - false: do not use localized view, even if exists. If the unlocalized (default) view does not exist, an exception occurs. + * - explicit locale: use the specified locale, as defined at 'true' case. + * + * Note: returns an error message rendered as a string on internal rendering errors or Exception + * + * @param string $viewName -- basename of a php view-file in the `views` directory, without extension and without localization code + * @param array $params -- parameters to assign to variables used in the view + * @param string $layout -- the layout applied to the result after the view rendered. If false, no layout will be applied. + * @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 string -- output + * @throws Exception -- if view path does not exist + */ + public function render($viewName, $params=[], $layout=null, $layoutParams=[], $locale=true) { + if($locale === null || $locale===true) $locale = $this->locale; + if($locale) { + $this->userLocale = $locale; + // Priority order: 1. Localized view (with long or short locale) / 2. untranslated / 3. default-locale view (long/short) + $viewFile = $this->localizedViewFile($viewName, $locale); + if(!$viewFile) { + $viewFile = $this->viewFile($viewName); + } + if(!$viewFile) { + $viewFile = $this->localizedViewFile($viewName, $this->source_locale); + } + } else { + $viewFile = $this->viewFile($viewName); + } + if(!$viewFile) throw new Exception("View file is not found for '$viewName", HTTP::HTTP_NOT_FOUND); + return $this->renderFile($viewFile, $params, $layout, $layoutParams); + } + + /** + * Returns best localized view filename using long or short locale. Checks if the view file exists. + * Returns null if none of them exists. + * + * @param string $viewName + * @param string|null $locale -- optional + * @return string|null + * @throws Exception -- if view path does not exist + */ + public function localizedViewFile($viewName, $locale) { + // 1. Look up view file using full locale + $lv = $locale ? $this->localizedViewName($viewName, $locale) : $viewName; + $viewFile = $this->viewFile($lv); + if(!$viewFile && $locale) { + // 2. Look up view file using short language code + $lv = $this->localizedViewName($viewName, substr($locale, 0, 2)); + $viewFile = $this->viewFile($lv); + } + return $viewFile; + } + + /** + * Returns view name completed with location path. + * + * Examples: + * + * - 'view1', 'la' => 'la/view1' + * - 'controller/action', 'la' => 'controller/la/action' + * + * The result of invalid $viewName or $locale is undefined! + * + * @param string $viewName + * @param string $locale + * @return string + */ + private function localizedViewName($viewName, $locale) { + $p = strrpos($viewName, '/'); + if($p===false) $p = -1; + return substr($viewName,0, $p+1) . $locale . '/'.substr($viewName, $p+1); + } /** * Returns rendered contents of the view using a $viewFile * @@ -456,46 +485,46 @@ private function localizedViewName($viewName, $locale) { * @param string|bool $layout -- the layout applied to this render after the view rendered. If false, no layout will be applied. * @param array $layoutParams -- optional parameters for the layout view * - * @return null|string + * @return string * @throws Exception -- if file does not exist */ public function renderFile($viewFile, $params=[], $layout=null, $layoutParams=[]) { - try { - if($layout === null) $layout = $this->layout; - if($viewFile && !AppHelper::pathIsAbsolute($viewFile)) $viewFile = $this->basePath.'/views/' . $viewFile.'.php'; - if(!file_exists($viewFile)) throw new Exception("View file '$viewFile' does not exist", HTTP::HTTP_NOT_FOUND); - $content = $this->renderPhpFile($viewFile, $params??[]); - if($layout) { + try { + if($layout === null) $layout = $this->layout; + if($viewFile && !AppHelper::pathIsAbsolute($viewFile)) $viewFile = $this->basePath.'/views/' . $viewFile.'.php'; + if(!file_exists($viewFile)) throw new Exception("View file '$viewFile' does not exist", HTTP::HTTP_NOT_FOUND); + $content = $this->renderPhpFile($viewFile, $params??[]); + if($layout) { $content = $this->render($layout, array_merge(['content'=>$content], $layoutParams??[]), false); - } - } - catch(Throwable $e) { - $content = "
Render error in view '$viewFile': ".$e->getMessage().'
'; - } + } + } + catch(Throwable $e) { + $content = "
Render error in view '$viewFile': ".$e->getMessage().'
'; + } return $content; } - /** - * Returns the file name of the view file. - * Returns null if view file does not exist. - * View can be in the application or in the framework. - * - * @param string $viewName - * @return string|null - * @throws Exception -- if view path does not exist - */ - public function viewFile($viewName) { - $viewPath = $this->basePath.'/views'; - if(!is_dir($viewPath)) throw new Exception("View path '$viewPath' does not exist"); - $viewFile = $viewPath . '/' . $viewName.'.php'; - // If view not found in the app, look up in the framework - if(!file_exists($viewFile)) { - $viewPath = dirname(__DIR__) . '/views'; - $viewFile = $viewPath . '/' . $viewName . '.php'; - } - if(!file_exists($viewFile)) return null; - return $viewFile; - } + /** + * Returns the file name of the view file. + * Returns null if view file does not exist. + * View can be in the application or in the framework. + * + * @param string $viewName + * @return string|null + * @throws Exception -- if view path does not exist + */ + public function viewFile($viewName) { + $viewPath = $this->basePath.'/views'; + if(!is_dir($viewPath)) throw new Exception("View path '$viewPath' does not exist"); + $viewFile = $viewPath . '/' . $viewName.'.php'; + // If view not found in the app, look up in the framework + if(!file_exists($viewFile)) { + $viewPath = dirname(__DIR__) . '/views'; + $viewFile = $viewPath . '/' . $viewName . '.php'; + } + if(!file_exists($viewFile)) return null; + return $viewFile; + } /** * Renders a partial view without layout @@ -504,8 +533,8 @@ public function viewFile($viewName) { */ public function renderPartial($viewName, $params=[]) { $result = $this->render($viewName, $params, false, null, $this->userLocale); - if(ENV_DEV && $result===null) return "[ **Render error: view '$viewName' not found** ]"; - return $result; + if(ENV_DEV && $result===null) return "[ **Render error: view '$viewName' not found** ]"; + return $result; } @@ -526,12 +555,12 @@ private function renderPhpFile($_file_, $_params_ = []) { require $_file_; return ob_get_clean(); } - catch(Throwable $e) { - echo "

Server error

"; - echo "
Error rendering file '$_file_'
\n"; - if(ENV_DEV) AppHelper::showException($e); - return ob_get_clean(); - } + catch(Throwable $e) { + echo "

Server error

"; + echo "
Error rendering file '$_file_'
\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() ["

Szia, haver!

\r\n

Welcome.

", 'main/rendertest3', ['name'=>'haver'], false, null, 'hu'], ]; } -} \ No newline at end of file + + /** + * @dataProvider provCreateUrl + * @return void + */ + public function testCreateUrl($expected, $def, $abs) { + $this->assertEquals($expected, $this->app->createUrl($def, $abs)); + } + function provCreateUrl() { + return [ + ['http://umvc.test/', null, true], + ]; + } +} diff --git a/views/_form/_field.php b/views/_form/_field.php index a5b2bf2..f7649ef 100644 --- a/views/_form/_field.php +++ b/views/_form/_field.php @@ -12,7 +12,7 @@ $hasErrors = $field->error ? 'has-error' : ''; ?>
renderOptions($field->divOptions) ?>> - type != 'submit'): ?> + type != 'submit' && $field->label!==false): ?> renderInput(); ?> diff --git a/www/index.php b/www/index.php index fd231ea..dff4531 100644 --- a/www/index.php +++ b/www/index.php @@ -5,4 +5,4 @@ */ require_once dirname(__DIR__) . '/vendor/autoload.php'; $configFile = dirname(__DIR__) . '/config/config.php'; -\uhi67\umvc\App::createRun($configFile); +return \uhi67\umvc\App::createRun($configFile);