Controller -> Router, Display -> Response
Just gutting the brains of this thing.
This commit is contained in:
parent
b67269a202
commit
94f46fc583
5 changed files with 246 additions and 458 deletions
|
@ -1,206 +0,0 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Single Entry Controller
|
||||
*
|
||||
* PHP version 5
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* Redistribution of these files must retain the above copyright notice.
|
||||
*
|
||||
* @author Josh Sherman <josh@gravityblvd.com>
|
||||
* @copyright Copyright 2007-2014, Josh Sherman
|
||||
* @license http://www.opensource.org/licenses/mit-license.html
|
||||
* @package PICKLES
|
||||
* @link https://github.com/joshtronic/pickles
|
||||
*/
|
||||
|
||||
/**
|
||||
* Controller Class
|
||||
*
|
||||
* The heavy lifter of PICKLES, makes the calls to get the session and
|
||||
* configuration loaded. Loads modules, serves up user authentication when the
|
||||
* module asks for it, and loads the viewer that the module requested. Default
|
||||
* values are present to make things easier on the user.
|
||||
*
|
||||
* @usage <code>new Controller();</code>
|
||||
*/
|
||||
class Controller extends Object
|
||||
{
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* To save a few keystrokes, the Controller is executed as part of the
|
||||
* constructor instead of via a method. You either want the Controller or
|
||||
* you don't.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
try
|
||||
{
|
||||
// Catches requests that aren't lowercase
|
||||
$lowercase_request = strtolower($_REQUEST['request']);
|
||||
|
||||
if ($_REQUEST['request'] != $lowercase_request)
|
||||
{
|
||||
// @todo Rework the Browser class to handle the 301 (perhaps redirect301()) to not break other code
|
||||
header('Location: ' . substr_replace($_SERVER['REQUEST_URI'], $lowercase_request, 1, strlen($lowercase_request)), true, 301);
|
||||
throw new Exception();
|
||||
}
|
||||
|
||||
// Grabs the requested page
|
||||
$request = $_REQUEST['request'];
|
||||
|
||||
// Loads the module's information
|
||||
$module_class = strtr($request, '/', '_');
|
||||
$module_filename = SITE_MODULE_PATH . $request . '.php';
|
||||
$module_exists = file_exists($module_filename);
|
||||
|
||||
// Attempts to instantiate the requested module
|
||||
if ($module_exists)
|
||||
{
|
||||
if (class_exists($module_class))
|
||||
{
|
||||
$module = new $module_class;
|
||||
}
|
||||
}
|
||||
|
||||
// No module instantiated, load up a generic Module
|
||||
if (!isset($module))
|
||||
{
|
||||
$module = new Module();
|
||||
}
|
||||
|
||||
// Determines if we need to serve over HTTP or HTTPS
|
||||
if ($module->secure == false && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'])
|
||||
{
|
||||
header('Location: http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], true, 301);
|
||||
throw new Exception();
|
||||
}
|
||||
elseif ($module->secure == true && (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] == false))
|
||||
{
|
||||
header('Location: https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], true, 301);
|
||||
throw new Exception();
|
||||
}
|
||||
|
||||
// Gets the profiler status
|
||||
$profiler = $this->config->pickles['profiler'];
|
||||
$profiler = $profiler === true || stripos($profiler, 'timers') !== false;
|
||||
|
||||
$default_method = '__default';
|
||||
$role_method = null;
|
||||
|
||||
// Attempts to execute the default method
|
||||
// @todo Seems a bit redundant, refactor
|
||||
if ($default_method == $role_method || method_exists($module, $default_method))
|
||||
{
|
||||
// Starts a timer before the module is executed
|
||||
if ($profiler)
|
||||
{
|
||||
Profiler::timer('module ' . $default_method);
|
||||
}
|
||||
|
||||
$valid_request = false;
|
||||
$error_message = 'An unexpected error has occurred.';
|
||||
|
||||
// Determines if the request method is valid for this request
|
||||
if ($module->method)
|
||||
{
|
||||
if (!is_array($module->method))
|
||||
{
|
||||
$module->method = [$module->method];
|
||||
}
|
||||
|
||||
foreach ($module->method as $method)
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] == $method)
|
||||
{
|
||||
$valid_request = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$valid_request)
|
||||
{
|
||||
// @todo Should probably utilize that AJAX flag to determine the type of return
|
||||
$error_message = 'There was a problem with your request method.';
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$valid_request = true;
|
||||
}
|
||||
|
||||
$valid_form_input = true;
|
||||
|
||||
if ($valid_request && $module->validate)
|
||||
{
|
||||
$validation_errors = $module->__validate();
|
||||
|
||||
if ($validation_errors)
|
||||
{
|
||||
$error_message = implode(' ', $validation_errors);
|
||||
$valid_form_input = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note to Self: When building in caching will need to let the
|
||||
* module know to use the cache, either passing in a variable
|
||||
* or setting it on the object
|
||||
*/
|
||||
if ($valid_request && $valid_form_input)
|
||||
{
|
||||
$module_return = $module->$default_method();
|
||||
|
||||
if (!is_array($module_return))
|
||||
{
|
||||
$module_return = $module->response;
|
||||
}
|
||||
else
|
||||
{
|
||||
$module_return = array_merge($module_return, $module->response);
|
||||
}
|
||||
}
|
||||
|
||||
// Stops the module timer
|
||||
if ($profiler)
|
||||
{
|
||||
Profiler::timer('module ' . $default_method);
|
||||
}
|
||||
|
||||
$display = new Display($module);
|
||||
}
|
||||
|
||||
// Starts a timer for the display rendering
|
||||
if ($profiler)
|
||||
{
|
||||
Profiler::timer('display render');
|
||||
}
|
||||
|
||||
// Renders the content
|
||||
$output = $display->render();
|
||||
|
||||
// Stops the display timer
|
||||
if ($profiler)
|
||||
{
|
||||
Profiler::timer('display render');
|
||||
}
|
||||
}
|
||||
catch (Exception $e)
|
||||
{
|
||||
$output = $e->getMessage();
|
||||
}
|
||||
|
||||
echo $output;
|
||||
|
||||
// Display the Profiler's report if the stars are aligned
|
||||
if ($this->config->pickles['profiler'])
|
||||
{
|
||||
Profiler::report();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
*Display Class File for PICKLES
|
||||
*
|
||||
* PHP version 5
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* Redistribution of these files must retain the above copyright notice.
|
||||
*
|
||||
* @author Josh Sherman <josh@gravityblvd.com>
|
||||
* @copyright Copyright 2007-2014, Josh Sherman
|
||||
* @license http://www.opensource.org/licenses/mit-license.html
|
||||
* @package PICKLES
|
||||
* @link https://github.com/joshtronic/pickles
|
||||
*/
|
||||
|
||||
/**
|
||||
* Display Class
|
||||
*
|
||||
* If you can see it then it probably happened in here.
|
||||
*/
|
||||
class Display extends Object
|
||||
{
|
||||
/**
|
||||
* Module
|
||||
*
|
||||
* This is the module we are attempting to display output for.
|
||||
*/
|
||||
public $module = null;
|
||||
|
||||
public function __construct($module = null)
|
||||
{
|
||||
if ($module && $module instanceof Module)
|
||||
{
|
||||
$this->module = $module;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Starts up the buffer so we can capture it
|
||||
ob_start();
|
||||
|
||||
if (!is_array($this->module->response))
|
||||
{
|
||||
$this->module->response = [$this->module->response];
|
||||
}
|
||||
|
||||
// Checks for the PHPSESSID in the query string
|
||||
if (stripos($_SERVER['REQUEST_URI'], '?PHPSESSID=') === false)
|
||||
{
|
||||
// XHTML compliancy stuff
|
||||
// @todo Wonder if this could be yanked now that we're in HTML5 land
|
||||
ini_set('arg_separator.output', '&');
|
||||
ini_set('url_rewriter.tags', 'a=href,area=href,frame=src,input=src,fieldset=');
|
||||
|
||||
header('Content-type: text/html; charset=UTF-8');
|
||||
}
|
||||
else
|
||||
{
|
||||
// Redirect so Google knows to index the page without the session ID
|
||||
list($request_uri, $phpsessid) = explode('?PHPSESSID=', $_SERVER['REQUEST_URI'], 2);
|
||||
header('HTTP/1.1 301 Moved Permanently');
|
||||
header('Location: ' . $request_uri);
|
||||
|
||||
throw new Exception('Requested URI contains PHPSESSID, redirecting.');
|
||||
}
|
||||
|
||||
$response = [
|
||||
'meta' => [
|
||||
'status' => $this->module->status,
|
||||
'message' => $this->module->message,
|
||||
],
|
||||
];
|
||||
|
||||
if ($this->module->response)
|
||||
{
|
||||
$response['response'] = $this->module->response;
|
||||
}
|
||||
|
||||
header('Content-type: application/json');
|
||||
$pretty = isset($_REQUEST['pretty']) ? JSON_PRETTY_PRINT : false;
|
||||
echo json_encode($response, $pretty);
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
catch (Exception $e)
|
||||
{
|
||||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,41 +27,6 @@
|
|||
*/
|
||||
abstract class Module extends Object
|
||||
{
|
||||
/**
|
||||
* Page Title
|
||||
*
|
||||
* @var string, null by default
|
||||
* @todo Abandon for $this->meta
|
||||
*/
|
||||
public $title = null;
|
||||
|
||||
/**
|
||||
* Meta Description
|
||||
*
|
||||
* @var string, null by default
|
||||
* @todo Abandon for $this->meta
|
||||
*/
|
||||
public $description = null;
|
||||
|
||||
/**
|
||||
* Meta Keywords (comma separated)
|
||||
*
|
||||
* @var string, null by default
|
||||
* @todo Abandon for $this->meta
|
||||
*/
|
||||
public $keywords = null;
|
||||
|
||||
/**
|
||||
* Meta Data
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $meta = [
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'keywords' => '',
|
||||
];
|
||||
|
||||
/**
|
||||
* Secure
|
||||
*
|
||||
|
@ -72,20 +37,13 @@ abstract class Module extends Object
|
|||
public $secure = false;
|
||||
|
||||
/**
|
||||
* Security Settings
|
||||
* Filter
|
||||
*
|
||||
* @var boolean, null by default
|
||||
* Variables to filter.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $security = null;
|
||||
|
||||
/**
|
||||
* Method
|
||||
*
|
||||
* Request methods that are allowed to access the module.
|
||||
*
|
||||
* @var string or array, null by default
|
||||
*/
|
||||
public $method = null;
|
||||
public $filter = [];
|
||||
|
||||
/**
|
||||
* Validate
|
||||
|
@ -96,46 +54,13 @@ abstract class Module extends Object
|
|||
*/
|
||||
public $validate = [];
|
||||
|
||||
/**
|
||||
* Template
|
||||
*
|
||||
* This is the parent template that will be loaded if you are using the
|
||||
* 'template' return type in the Display class. Parent templates are found
|
||||
* in ./templates/__shared and use the phtml extension.
|
||||
*
|
||||
* @var string, 'index' by default
|
||||
*/
|
||||
public $template = 'index';
|
||||
|
||||
/**
|
||||
* Response
|
||||
*
|
||||
* Array of data that will be rendered as part of the display. This is
|
||||
* somewhat of a one way trip as you cannot get the variable unless you
|
||||
* reference the response array explicitly, $this->response['variable']
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $response = [];
|
||||
|
||||
/**
|
||||
* Output
|
||||
*
|
||||
* What should the class render as output? This can be a string or an array
|
||||
* containing either 'json', 'rss', 'template' or 'xml'. Default is to use
|
||||
* templates and if the template is not present, fall back to JSON.
|
||||
*
|
||||
* @var mixed string or array
|
||||
*/
|
||||
public $output = ['template', 'json'];
|
||||
|
||||
// @todo
|
||||
public $status = 200;
|
||||
public $message = 'OK';
|
||||
public $echo = false;
|
||||
public $limit = false;
|
||||
public $offset = false;
|
||||
public $errors = [];
|
||||
public $echo = false;
|
||||
public $limit = false;
|
||||
public $offset = false;
|
||||
public $errors = [];
|
||||
|
||||
// @todo if $status != 200 && $message == 'OK' ...
|
||||
|
||||
|
@ -146,81 +71,10 @@ abstract class Module extends Object
|
|||
* variable to tell it to automatically run the __default() method. This is
|
||||
* typically used when a module is called outside of the scope of the
|
||||
* controller (the registration page calls the login page in this manner.
|
||||
*
|
||||
* @param boolean $autorun optional flag to autorun __default()
|
||||
@ @param boolean $filter optional flag to disable autorun filtering
|
||||
* @param boolean $valiate optional flag to disable autorun validation
|
||||
*/
|
||||
public function __construct($autorun = false, $filter = true, $validate = true)
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(['cache', 'db']);
|
||||
|
||||
if ($autorun)
|
||||
{
|
||||
if ($filter)
|
||||
{
|
||||
// @todo
|
||||
//$this->__filter();
|
||||
}
|
||||
|
||||
if ($validate)
|
||||
{
|
||||
$errors = $this->__validate();
|
||||
|
||||
if (!$errors)
|
||||
{
|
||||
// @todo Fatal error perhaps?
|
||||
exit('Errors encountered, this is a @todo for form validation when calling modules from inside of modules');
|
||||
}
|
||||
}
|
||||
|
||||
$this->__default();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default "Magic" Method
|
||||
*
|
||||
* The __default() method is where you want to place any code that needs to
|
||||
* be executed at runtime.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
abstract public function __default();
|
||||
|
||||
/**
|
||||
* Magic Setter Method
|
||||
*
|
||||
* Places undefined properties into the response array as part of the
|
||||
* module's payload.
|
||||
*
|
||||
* @param string $variable name of the variable to be set
|
||||
* @param mixed $value value of the variable to be set
|
||||
*/
|
||||
public function __set($variable, $value)
|
||||
{
|
||||
$this->response[$variable] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic Getter Method
|
||||
*
|
||||
* Any variables not defined in this class are set in the response array
|
||||
* and default to false if not defined there.
|
||||
*
|
||||
* @param string $name name of the variable requested
|
||||
* @return mixed value of the variable or boolean false
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if (!isset($this->response[$name]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
return $this->response[$name];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
46
src/classes/Response.php
Normal file
46
src/classes/Response.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
class Response extends Object
|
||||
{
|
||||
public $status = 200;
|
||||
public $message = 'OK';
|
||||
public $echo = false;
|
||||
public $limit = false;
|
||||
public $offset = false;
|
||||
public $errors = false;
|
||||
public $response = false;
|
||||
public $profiler = false;
|
||||
|
||||
public function respond()
|
||||
{
|
||||
header('Content-type: application/json');
|
||||
|
||||
$meta = [
|
||||
'status' => $this->status,
|
||||
'message' => $this->message,
|
||||
];
|
||||
|
||||
foreach (['echo', 'limit', 'offset', 'errors'] as $variable)
|
||||
{
|
||||
if ($this->$variable)
|
||||
{
|
||||
$meta[$variable] = $this->$variable;
|
||||
}
|
||||
}
|
||||
|
||||
$response = ['meta' => $meta];
|
||||
|
||||
foreach (['response', 'profiler'] as $variable)
|
||||
{
|
||||
if ($this->$variable)
|
||||
{
|
||||
$response[$variable] = $this->$variable;
|
||||
}
|
||||
}
|
||||
|
||||
$pretty = isset($_REQUEST['pretty']) ? JSON_PRETTY_PRINT : false;
|
||||
|
||||
exit(json_encode($response, $pretty));
|
||||
}
|
||||
}
|
||||
|
190
src/classes/Router.php
Normal file
190
src/classes/Router.php
Normal file
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Single Entry Router
|
||||
*
|
||||
* PHP version 5
|
||||
*
|
||||
* Licensed under The MIT License
|
||||
* Redistribution of these files must retain the above copyright notice.
|
||||
*
|
||||
* @author Josh Sherman <josh@gravityblvd.com>
|
||||
* @copyright Copyright 2007-2014, Josh Sherman
|
||||
* @license http://www.opensource.org/licenses/mit-license.html
|
||||
* @package PICKLES
|
||||
* @link https://github.com/joshtronic/pickles
|
||||
*/
|
||||
|
||||
/**
|
||||
* Router Class
|
||||
*
|
||||
* The heavy lifter of PICKLES, makes the calls to get the session and
|
||||
* configuration loaded. Loads modules, serves up user authentication when the
|
||||
* module asks for it, and loads the viewer that the module requested. Default
|
||||
* values are present to make things easier on the user.
|
||||
*
|
||||
* @usage <code>new Router();</code>
|
||||
*/
|
||||
class Router extends Object
|
||||
{
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* To save a few keystrokes, the Controller is executed as part of the
|
||||
* constructor instead of via a method. You either want the Controller or
|
||||
* you don't.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
try
|
||||
{
|
||||
// Catches requests that aren't lowercase
|
||||
$lowercase_request = strtolower($_REQUEST['request']);
|
||||
|
||||
if ($_REQUEST['request'] != $lowercase_request)
|
||||
{
|
||||
// @todo Rework the Browser class to handle the 301 (perhaps redirect301()) to not break other code
|
||||
header('Location: ' . substr_replace($_SERVER['REQUEST_URI'], $lowercase_request, 1, strlen($lowercase_request)), true, 301);
|
||||
throw new Exception();
|
||||
}
|
||||
|
||||
// Grabs the requested page
|
||||
$request = $_REQUEST['request'];
|
||||
$components = explode('/', $request);
|
||||
$version = array_shift($components);
|
||||
$nouns = [];
|
||||
$uids = [];
|
||||
|
||||
// Loops through the components to determine nouns and IDs
|
||||
foreach ($components as $index => $component)
|
||||
{
|
||||
if ($index % 2)
|
||||
{
|
||||
$uids[end($nouns)] = $component;
|
||||
}
|
||||
else
|
||||
{
|
||||
$nouns[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
array_unshift($nouns, $version);
|
||||
|
||||
$class = implode('_', $nouns);
|
||||
|
||||
array_unshift($nouns, SITE_MODULE_PATH);
|
||||
|
||||
$filename = implode('/', $nouns) . '.php';
|
||||
|
||||
if (file_exists($filename))
|
||||
{
|
||||
if (class_exists($class))
|
||||
{
|
||||
$resource = new $class($uids);
|
||||
|
||||
// Determines if we need to serve over HTTP or HTTPS
|
||||
if ($resource->secure == false && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'])
|
||||
{
|
||||
header('Location: http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], true, 301);
|
||||
throw new Exception();
|
||||
}
|
||||
elseif ($resource->secure == true && (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] == false))
|
||||
{
|
||||
header('Location: https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], true, 301);
|
||||
throw new Exception();
|
||||
}
|
||||
|
||||
// Checks for the PHPSESSID in the query string
|
||||
if (stripos($_SERVER['REQUEST_URI'], '?PHPSESSID=') === false)
|
||||
{
|
||||
// XHTML compliancy stuff
|
||||
// @todo Wonder if this could be yanked now that we're in HTML5 land
|
||||
ini_set('arg_separator.output', '&');
|
||||
ini_set('url_rewriter.tags', 'a=href,area=href,frame=src,input=src,fieldset=');
|
||||
|
||||
// @todo Will want to generate the header based on if we're pushing documentation or API
|
||||
header('Content-type: text/html; charset=UTF-8');
|
||||
// header('Content-type: application/json');
|
||||
//header('Content-type: application/json; charset=UTF-8');
|
||||
}
|
||||
else
|
||||
{
|
||||
// Redirect so Google knows to index the page without the session ID
|
||||
list($request_uri, $phpsessid) = explode('?PHPSESSID=', $_SERVER['REQUEST_URI'], 2);
|
||||
header('HTTP/1.1 301 Moved Permanently');
|
||||
header('Location: ' . $request_uri);
|
||||
|
||||
throw new Exception('Requested URI contains PHPSESSID, redirecting.');
|
||||
}
|
||||
|
||||
// Gets the profiler status
|
||||
$profiler = $this->config->pickles['profiler'];
|
||||
$profiler = $profiler === true || stripos($profiler, 'timers') !== false;
|
||||
|
||||
$method = strtolower($_SERVER['REQUEST_METHOD']);
|
||||
|
||||
if (method_exists($resource, $method))
|
||||
{
|
||||
// Starts a timer before the resource is executed
|
||||
if ($profiler)
|
||||
{
|
||||
Profiler::timer('resource ' . $method);
|
||||
}
|
||||
|
||||
$response = new Response();
|
||||
|
||||
if ($resource->validate)
|
||||
{
|
||||
$validation_errors = $resource->__validate();
|
||||
|
||||
if ($validation_errors)
|
||||
{
|
||||
$response->status = 400;
|
||||
$response->message = implode(' ', $validation_errors);
|
||||
}
|
||||
}
|
||||
|
||||
if ($response->status == 200)
|
||||
{
|
||||
$resource_return = $resource->$method();
|
||||
|
||||
if ($resource_return)
|
||||
{
|
||||
$response->response = $resource_return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stops the resource timer
|
||||
if ($profiler)
|
||||
{
|
||||
Profiler::timer('resource ' . $method);
|
||||
}
|
||||
|
||||
$response->respond();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception('Missing method');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception('Missing class');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception('Missing file');
|
||||
}
|
||||
}
|
||||
catch (Exception $e)
|
||||
{
|
||||
// @todo
|
||||
exit('fuuuu');
|
||||
$output = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue