commit a75696e4e70cfdee461eb0ced2060372828dd516 Author: coderkun Date: Sat May 3 22:37:04 2014 +0200 use client?s mimetype as fallback for uploads diff --git a/.hgignore b/.hgignore new file mode 100644 index 00000000..63a582df --- /dev/null +++ b/.hgignore @@ -0,0 +1,8 @@ +syntax: regexp +^media/* +^tmp/* +^uploads/* +^seminarymedia/* +^seminaryuploads/* +^www/analytics/config/config.ini.php* +^www/analytics/temp/* diff --git a/.htaccess b/.htaccess new file mode 100644 index 00000000..cb3fb9ef --- /dev/null +++ b/.htaccess @@ -0,0 +1,50 @@ +Options -Indexes -MultiViews + +ErrorDocument 403 /www/error403.html +ErrorDocument 404 /www/error404.html +ErrorDocument 500 /www/error500.html + + + + + Require all granted + + + Require all denied + + + + Require all denied + + + + Require all denied + + + + + Allow From All + + + Order Deny,Allow + Deny From All + + + + Order Deny,Allow + Deny From All + + + + Order Deny,Allow + Deny From All + + + + + + RewriteEngine On + + RewriteBase / + RewriteRule ^(.*)$ www/$1 [L] + diff --git a/agents/BottomlevelAgent.inc b/agents/BottomlevelAgent.inc new file mode 100644 index 00000000..37816614 --- /dev/null +++ b/agents/BottomlevelAgent.inc @@ -0,0 +1,25 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\agents; + + + /** + * The BottomlevelAgent is the standard Agent and can have indefinite + * SubAgents. + * + * @author coderkun + */ + abstract class BottomlevelAgent extends \nre\core\Agent + { + } + +?> diff --git a/agents/IntermediateAgent.inc b/agents/IntermediateAgent.inc new file mode 100644 index 00000000..7139653d --- /dev/null +++ b/agents/IntermediateAgent.inc @@ -0,0 +1,48 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\agents; + + + /** + * The IntermediateAgent assumes the task of a module. There is only one + * IntermediateAgent per request. + * + * @author coderkun + */ + abstract class IntermediateAgent extends \nre\core\Agent + { + + + + + /** + * Get the layout if it was explicitly defined. + * + * @return string Layout of the IntermediateAgent + */ + public static function getLayout($agentName) + { + // Determine classname + $className = Autoloader::concatClassNames($agentName, 'Agent'); + + // Check property + if(isset($className::$layout)) { + return $className::$layout; + } + + + return null; + } + + } + +?> diff --git a/agents/ToplevelAgent.inc b/agents/ToplevelAgent.inc new file mode 100644 index 00000000..9d6d6f8a --- /dev/null +++ b/agents/ToplevelAgent.inc @@ -0,0 +1,395 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\agents; + + + /** + * The ToplevelAgent assumes the task of a FrontController. There is + * only one per request. + * + * @author coderkun + */ + class ToplevelAgent extends \nre\core\Agent + { + /** + * Stage: Load + * + * @var string + */ + const STAGE_LOAD = 'load'; + /** + * Stage: Run + * + * @var string + */ + const STAGE_RUN = 'run'; + + /** + * Current request + * + * @var Request + */ + private $request; + /** + * Current response + * + * @var Response + */ + private $response; + /** + * Layout instace + * + * @var Layout + */ + private $layout = null; + /** + * IntermediateAgent instance + * + * @var IntermediateAgent + */ + private $intermediateAgent = null; + + + + + /** + * Construct a ToplevelAgent. + * + * @throws ServiceUnavailableException + * @throws DatamodelException + * @throws DriverNotValidException + * @throws DriverNotFoundException + * @throws ViewNotFoundException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ControllerNotValidException + * @throws ControllerNotFoundException + * @param Request $request Current request + * @param Response $respone Current response + * @param Logger $log Log-system + */ + protected function __construct(\nre\core\Request $request, \nre\core\Response $response, \nre\core\Logger $log=null) + { + // Store values + $this->request = $request; + $this->response = $response; + + + // Create response + $response = clone $response; + $response->clearParams(1); + $response->addParams( + null, + \nre\configs\CoreConfig::$defaults['action'] + ); + + // Call parent constructor + parent::__construct($request, $response, $log, true); + + + // Load IntermediateAgent + $this->loadIntermediateAgent(); + } + + + + + /** + * Run the Controller of this Agent and its SubAgents. + * + * @throws ServiceUnavailableException + * @param Request $request Current request + * @param Response $response Current response + * @return Exception Last occurred exception of SubAgents + */ + public function run(\nre\core\Request $request, \nre\core\Response $response) + { + try { + return $this->_run($request, $response); + } + catch(\nre\exceptions\AccessDeniedException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_FORBIDDEN, self::STAGE_RUN); + } + catch(\nre\exceptions\ParamsNotValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND, self::STAGE_RUN); + } + catch(\nre\exceptions\IdNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND, self::STAGE_RUN); + } + catch(\nre\exceptions\DatamodelException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE, self::STAGE_RUN); + } + catch(\nre\exceptions\ActionNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND, self::STAGE_RUN); + } + } + + + /** + * Generate output of the Controller of this Agent and its + * SubAgents. + * + * @param array $data View data + * @return string Generated output + */ + public function render($data=array()) + { + // Render IntermediateAgent + $data = array(); + $data['intermediate'] = $this->intermediateAgent->render(); + + + // Render ToplevelAgent + return parent::render($data); + } + + + /** + * Return the IntermediateAgent. + * + * @return IntermediateAgent IntermediateAgent + */ + public function getIntermediateAgent() + { + return $this->intermediateAgent; + } + + + + + /** + * Load a SubAgent and add it. + * + * @throws ServiceUnavailableException + * @throws FatalDatamodelException + * @throws AgentNotFoundException + * @throws AgentNotValidException + * @param string $agentName Name of the Agent to load + * @param mixed … Additional parameters for the agent + */ + protected function addSubAgent($agentName) + { + try { + call_user_func_array( + array( + $this, + '_addSubAgent' + ), + func_get_args() + ); + } + catch(\nre\exceptions\DatamodelException $e) { + throw new \nre\exceptions\FatalDatamodelException($e->getDatamodelMessage(), $e->getDatamodelErrorNumber()); + } + } + + + + + /** + * Load IntermediateAgent defined by the current request. + * + * @throws ServiceUnavailableException + */ + private function loadIntermediateAgent() + { + try { + $this->_loadIntermediateAgent(); + } + catch(\nre\exceptions\ViewNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND); + } + catch(\nre\exceptions\DatamodelException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\DriverNotValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\DriverNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\ModelNotValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\ModelNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\ControllerNotValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\ControllerNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND); + } + catch(\nre\exceptions\AgentNotValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\AgentNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND); + } + } + + + /** + * Load IntermediateAgent defined by the current request. + * + * @throws ServiceUnavailableException + */ + private function _loadIntermediateAgent() + { + // Determine IntermediateAgent + $agentName = $this->response->getParam(1); + if(is_null($agentName)) { + $agentName = $this->request->getParam(1, 'intermediate'); + $this->response->addParam($agentName); + } + + // Load IntermediateAgent + IntermediateAgent::load($agentName); + + + // Determine Action + $action = $this->response->getParam(2); + if(is_null($action)) { + $action = $this->request->getParam(2, 'action'); + $this->response->addParam($action); + } + + // Construct IntermediateAgent + $this->intermediateAgent = \nre\agents\IntermediateAgent::factory( + $agentName, + $this->request, + $this->response, + $this->log + ); + } + + + /** + * Run the Controller of this Agent and its SubAgents. + * + * @throws AccessDeniedException + * @throws IdNotFoundException + * @throws ServiceUnavailableException + * @throws DatamodelException + * @param Request $request Current request + * @param Response $response Current response + * @return Exception Last occurred exception of SubAgents + */ + private function _run(\nre\core\Request $request, \nre\core\Response $response) + { + // Run IntermediateAgent + $this->runIntermediateAgent(); + + + // TODO Request instead of response? + $response = clone $response; + $response->clearParams(2); + $response->addParam(\nre\configs\CoreConfig::$defaults['action']); + + + // Run ToplevelAgent + return parent::run($request, $response); + } + + + /** + * Run IntermediateAgent. + * + * @throws AccessDeniedException + * @throws ParamsNotValidException + * @throws IdNotFoundException + * @throws ServiceUnavailableException + * @throws DatamodelException + */ + private function runIntermediateAgent() + { + $this->intermediateAgent->run( + $this->request, + $this->response + ); + } + + + /** + * Handle an error that occurred during + * loading/cnostructing/running of the IntermediateAgent. + * + * @throws ServiceUnavailableException + * @param Exception $exception Occurred exception + * @param int $httpStatusCode HTTP-statuscode + * @param string $stage Stage of execution + */ + private function error($exception, $httpStatusCode, $stage=self::STAGE_LOAD) + { + // Log error + $this->log($exception, \nre\core\Logger::LOGMODE_AUTO); + + + try { + // Define ErrorAgent + $this->response->clearParams(1); + $this->response->addParams( + \nre\configs\AppConfig::$defaults['intermediate-error'], + \nre\configs\CoreConfig::$defaults['action'], + $httpStatusCode + ); + + // Load ErrorAgent + $this->_loadIntermediateAgent(); + + // Run ErrorAgent + if($stage == self::STAGE_RUN) { + $this->_run($this->request, $this->response); + } + } + catch(\nre\exceptions\ActionNotFoundException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\DatamodelException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\DriverNotValidException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\DriverNotFoundException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\ModelNotValidException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\ModelNotFoundException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\ViewNotFoundException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\ControllerNotValidException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\ControllerNotFoundException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\AgentNotValidException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(\nre\exceptions\AgentNotFoundException $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + catch(Exception $e) { + throw new \nre\exceptions\ServiceUnavailableException($e); + } + } + + + } + +?> diff --git a/agents/bottomlevel/MenuAgent.inc b/agents/bottomlevel/MenuAgent.inc new file mode 100644 index 00000000..49512791 --- /dev/null +++ b/agents/bottomlevel/MenuAgent.inc @@ -0,0 +1,37 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\bottomlevel; + + + /** + * Agent to display a menu. + * + * @author Oliver Hanraths + */ + class MenuAgent extends \nre\agents\BottomlevelAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + // Add Seminary menu + $this->addSubAgent('Seminarymenu'); + } + + } + +?> diff --git a/agents/bottomlevel/QuestgroupshierarchypathAgent.inc b/agents/bottomlevel/QuestgroupshierarchypathAgent.inc new file mode 100644 index 00000000..a2cb8086 --- /dev/null +++ b/agents/bottomlevel/QuestgroupshierarchypathAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\bottomlevel; + + + /** + * Agent to display the Questgroups hierarchy path. + * + * @author Oliver Hanraths + */ + class QuestgroupshierarchypathAgent extends \nre\agents\BottomlevelAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/bottomlevel/SeminarybarAgent.inc b/agents/bottomlevel/SeminarybarAgent.inc new file mode 100644 index 00000000..10315ab5 --- /dev/null +++ b/agents/bottomlevel/SeminarybarAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\bottomlevel; + + + /** + * Agent to display a sidebar with Seminary related information. + * + * @author Oliver Hanraths + */ + class SeminarybarAgent extends \nre\agents\BottomlevelAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/bottomlevel/SeminarymenuAgent.inc b/agents/bottomlevel/SeminarymenuAgent.inc new file mode 100644 index 00000000..375eab1e --- /dev/null +++ b/agents/bottomlevel/SeminarymenuAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\bottomlevel; + + + /** + * Agent to display a menu with Seminary related links. + * + * @author Oliver Hanraths + */ + class SeminarymenuAgent extends \nre\agents\BottomlevelAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/bottomlevel/UserrolesAgent.inc b/agents/bottomlevel/UserrolesAgent.inc new file mode 100644 index 00000000..b6d6e65b --- /dev/null +++ b/agents/bottomlevel/UserrolesAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\bottomlevel; + + + /** + * Agent to display and manage userroles. + * + * @author Oliver Hanraths + */ + class UserrolesAgent extends \nre\agents\BottomlevelAgent + { + + + + + /** + * Action: user. + */ + public function user(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/AchievementsAgent.inc b/agents/intermediate/AchievementsAgent.inc new file mode 100644 index 00000000..e6b965f9 --- /dev/null +++ b/agents/intermediate/AchievementsAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to list Achievements. + * + * @author Oliver Hanraths + */ + class AchievementsAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/CharactergroupsAgent.inc b/agents/intermediate/CharactergroupsAgent.inc new file mode 100644 index 00000000..77ac5f5a --- /dev/null +++ b/agents/intermediate/CharactergroupsAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to display Character groups. + * + * @author Oliver Hanraths + */ + class CharactergroupsAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/CharactergroupsquestsAgent.inc b/agents/intermediate/CharactergroupsquestsAgent.inc new file mode 100644 index 00000000..bfc87de1 --- /dev/null +++ b/agents/intermediate/CharactergroupsquestsAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to display Character groups Quests. + * + * @author Oliver Hanraths + */ + class CharactergroupsquestsAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/CharactersAgent.inc b/agents/intermediate/CharactersAgent.inc new file mode 100644 index 00000000..25102198 --- /dev/null +++ b/agents/intermediate/CharactersAgent.inc @@ -0,0 +1,43 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to list registered Characters and their data. + * + * @author Oliver Hanraths + */ + class CharactersAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + + /** + * Action: character. + */ + public function character(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/ErrorAgent.inc b/agents/intermediate/ErrorAgent.inc new file mode 100644 index 00000000..85bb6f95 --- /dev/null +++ b/agents/intermediate/ErrorAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to show an error page. + * + * @author Oliver Hanraths + */ + class ErrorAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/IntroductionAgent.inc b/agents/intermediate/IntroductionAgent.inc new file mode 100644 index 00000000..3a06fdff --- /dev/null +++ b/agents/intermediate/IntroductionAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to show an introduction page. + * + * @author Oliver Hanraths + */ + class IntroductionAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/LibraryAgent.inc b/agents/intermediate/LibraryAgent.inc new file mode 100644 index 00000000..aedc890a --- /dev/null +++ b/agents/intermediate/LibraryAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to list Quest topics. + * + * @author Oliver Hanraths + */ + class LibraryAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/MediaAgent.inc b/agents/intermediate/MediaAgent.inc new file mode 100644 index 00000000..c3fd35ae --- /dev/null +++ b/agents/intermediate/MediaAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to process and show media. + * + * @author Oliver Hanraths + */ + class MediaAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/QuestgroupsAgent.inc b/agents/intermediate/QuestgroupsAgent.inc new file mode 100644 index 00000000..52ecd4e1 --- /dev/null +++ b/agents/intermediate/QuestgroupsAgent.inc @@ -0,0 +1,36 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to display Questgroups. + * + * @author Oliver Hanraths + */ + class QuestgroupsAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: questgroup. + */ + public function questgroup(\nre\core\Request $request, \nre\core\Response $response) + { + $this->addSubAgent('Questgroupshierarchypath', 'index', $request->getParam(3), $request->getParam(4)); + } + + } + +?> diff --git a/agents/intermediate/QuestsAgent.inc b/agents/intermediate/QuestsAgent.inc new file mode 100644 index 00000000..273a47ab --- /dev/null +++ b/agents/intermediate/QuestsAgent.inc @@ -0,0 +1,54 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to display Quests. + * + * @author Oliver Hanraths + */ + class QuestsAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: quest. + */ + public function quest(\nre\core\Request $request, \nre\core\Response $response) + { + $this->addSubAgent('Questgroupshierarchypath', 'index', $request->getParam(3), $request->getParam(4), true); + } + + + /** + * Action: submissions. + */ + public function submissions(\nre\core\Request $request, \nre\core\Response $response) + { + $this->addSubAgent('Questgroupshierarchypath', 'index', $request->getParam(3), $request->getParam(4), true); + } + + + /** + * Action: submission. + */ + public function submission(\nre\core\Request $request, \nre\core\Response $response) + { + $this->addSubAgent('Questgroupshierarchypath', 'index', $request->getParam(3), $request->getParam(4), true); + } + + } + +?> diff --git a/agents/intermediate/SeminariesAgent.inc b/agents/intermediate/SeminariesAgent.inc new file mode 100644 index 00000000..53f08124 --- /dev/null +++ b/agents/intermediate/SeminariesAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to list registered seminaries. + * + * @author Oliver Hanraths + */ + class SeminariesAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/UploadsAgent.inc b/agents/intermediate/UploadsAgent.inc new file mode 100644 index 00000000..457b6a49 --- /dev/null +++ b/agents/intermediate/UploadsAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to process and show user uploads. + * + * @author Oliver Hanraths + */ + class UploadsAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/intermediate/UsersAgent.inc b/agents/intermediate/UsersAgent.inc new file mode 100644 index 00000000..a9094490 --- /dev/null +++ b/agents/intermediate/UsersAgent.inc @@ -0,0 +1,44 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\intermediate; + + + /** + * Agent to list registered users and their data. + * + * @author Oliver Hanraths + */ + class UsersAgent extends \nre\agents\IntermediateAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + + /** + * Action: user. + */ + public function user(\nre\core\Request $request, \nre\core\Response $response) + { + $this->addSubAgent('Userroles', 'user'); + } + + } + +?> diff --git a/agents/toplevel/BinaryAgent.inc b/agents/toplevel/BinaryAgent.inc new file mode 100644 index 00000000..f2d6d33e --- /dev/null +++ b/agents/toplevel/BinaryAgent.inc @@ -0,0 +1,41 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\toplevel; + + + /** + * Agent to display binary data (e. g. images). + * + * @author Oliver Hanraths + */ + class BinaryAgent extends \hhu\z\ToplevelAgent + { + + + + + protected function __construct(\nre\core\Request $request, \nre\core\Response $response, \nre\core\Logger $log=null) + { + parent::__construct($request, $response, $log); + } + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/toplevel/FaultAgent.inc b/agents/toplevel/FaultAgent.inc new file mode 100644 index 00000000..1ceb682e --- /dev/null +++ b/agents/toplevel/FaultAgent.inc @@ -0,0 +1,35 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\toplevel; + + + /** + * Agent to display a toplevel error page. + * + * @author Oliver Hanraths + */ + class FaultAgent extends \nre\agents\ToplevelAgent + { + + + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + } + + } + +?> diff --git a/agents/toplevel/HtmlAgent.inc b/agents/toplevel/HtmlAgent.inc new file mode 100644 index 00000000..bedcf848 --- /dev/null +++ b/agents/toplevel/HtmlAgent.inc @@ -0,0 +1,70 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\agents\toplevel; + + + /** + * Agent to display a HTML-page. + * + * @author Oliver Hanraths + */ + class HtmlAgent extends \hhu\z\ToplevelAgent + { + + + + + protected function __construct(\nre\core\Request $request, \nre\core\Response $response, \nre\core\Logger $log=null) + { + parent::__construct($request, $response, $log); + + + $this->setLanguage($request); + } + + + /** + * Action: index. + */ + public function index(\nre\core\Request $request, \nre\core\Response $response) + { + // Add menu + $this->addSubAgent('Menu'); + + // Add Seminary sidebar + $this->addSubAgent('Seminarybar'); + } + + + + + private function setLanguage(\nre\core\Request $request) + { + // Set domain + $domain = \nre\configs\AppConfig::$app['genericname']; + + // Get language + $locale = $request->getGetParam('lang', 'language'); + if(is_null($locale)) { + return; + } + + // Load translation + putenv("LC_ALL=$locale"); + setlocale(LC_ALL, $locale); + bindtextdomain($domain, ROOT.DS.\nre\configs\AppConfig::$dirs['locale']); + textdomain($domain); + } + + } + +?> diff --git a/apis/WebApi.inc b/apis/WebApi.inc new file mode 100644 index 00000000..6851ee2d --- /dev/null +++ b/apis/WebApi.inc @@ -0,0 +1,250 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\apis; + + + /** + * WebApi-implementation. + * + * This class runs and renders an web-applictaion. + * + * @author coderkun + */ + class WebApi extends \nre\core\Api + { + + + + + /** + * Construct a new WebApi. + */ + public function __construct() + { + parent::__construct( + new \nre\requests\WebRequest(), + new \nre\responses\WebResponse() + ); + + // Add routes + $this->addRoutes(); + + // Disable screen logging for AJAX requests + if($this->request->getParam(0, 'toplevel') == 'ajax') { + $this->log->disableAutoLogToScreen(); + } + } + + + + + /** + * Run application. + * + * This method runs the application and handles all errors. + */ + public function run() + { + try { + $exception = parent::run(); + + if(!is_null($exception)) { + $this->errorService($exception); + } + } + catch(\nre\exceptions\ServiceUnavailableException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\ActionNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND); + } + catch(\nre\exceptions\FatalDatamodelException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\DatamodelException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\DriverNotValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\DriverNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\ModelNotValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\ModelNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\ViewNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND); + } + catch(\nre\exceptions\ControllerNotValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\ControllerNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND); + } + catch(\nre\exceptions\AgentNoaatValidException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_SERVICE_UNAVAILABLE); + } + catch(\nre\exceptions\AgentNotFoundException $e) { + $this->error($e, \nre\core\WebUtils::HTTP_NOT_FOUND); + } + catch(\nre\exceptions\ClassNotValidException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + $this->errorService($e); + } + } + + + /** + * Render output. + */ + public function render() + { + // Generate output + parent::render(); + + + // Set HTTP-header + $this->response->header(); + + // Show output + echo $this->response->getOutput(); + } + + + + + /** + * Add routes (normal and reverse) defined in the AppConfig. + */ + private function addRoutes() + { + // Normal routes + if(property_exists('\nre\configs\AppConfig', 'routes')) { + foreach(\nre\configs\AppConfig::$routes as &$route) { + $this->request->addRoute($route[0], $route[1], $route[2]); + } + } + + // Reverse routes + if(property_exists('\nre\configs\AppConfig', 'reverseRoutes')) { + foreach(\nre\configs\AppConfig::$reverseRoutes as &$route) { + $this->request->addReverseRoute($route[0], $route[1], $route[2]); + } + } + + // Revalidate request + $this->request->revalidate(); + } + + + /** + * Handle an error that orrcurred during the + * loading/constructing/running of the ToplevelAgent. + * + * @param Exception $exception Occurred exception + * @param int $httpStatusCode HTTP-statuscode + */ + private function error(\nre\core\Exception $exception, $httpStatusCode) + { + // Log error message + $this->log($exception, \nre\core\Logger::LOGMODE_AUTO); + + try { + // Set agent for handling errors + $this->response->clearParams(); + $this->response->addParams( + \nre\configs\AppConfig::$defaults['toplevel-error'], + \nre\configs\AppConfig::$defaults['intermediate-error'], + \nre\configs\CoreConfig::$defaults['action'], + $httpStatusCode + ); + + // Run this agent + parent::run(); + } + catch(\nre\exceptions\ServiceUnavailableException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\ActionNotFoundException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\DatamodelException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\DriverNotValidException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\DriverNotFoundException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\ModelNotValidException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\ModelNotFoundException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\ViewNotFoundException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\ControllerNotValidException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\ControllerNotFoundException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\AgentNotValidException $e) { + $this->errorService($e); + } + catch(\nre\exceptions\AgentNotFoundException $e) { + $this->errorService($e); + } + catch(Exception $e) { + $this->errorService($e); + } + } + + + /** + * Handle a error which cannot be handles by the system (and + * HTTP 503). + * + * $param Exception $exception Occurred exception + */ + private function errorService($exception) + { + // Log error message + $this->log($exception, \nre\core\Logger::LOGMODE_AUTO); + + // Set HTTP-rtatuscode + $this->response->addHeader(\nre\core\WebUtils::getHttpHeader(503)); + + + // Read and print static error file + $fileName = ROOT.DS.\nre\configs\CoreConfig::getClassDir('views').DS.\nre\configs\CoreConfig::$defaults['errorFile'].\nre\configs\CoreConfig::getFileExt('views'); + ob_start(); + include($fileName); + $this->response->setOutput(ob_get_clean()); + + + // Prevent further execution + $this->response->setExit(); + } + + } + +?> diff --git a/app/Controller.inc b/app/Controller.inc new file mode 100644 index 00000000..461c9fd1 --- /dev/null +++ b/app/Controller.inc @@ -0,0 +1,106 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Abstract class for implementing an application Controller. + * + * @author Oliver Hanraths + */ + abstract class Controller extends \nre\core\Controller + { + /** + * Required components + * + * @var array + */ + public $components = array('auth'); + /** + * Linker instance + * + * @var Linker + */ + protected $linker = null; + + + + + /** + * Construct a new application Controller. + * + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ViewNotFoundException + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + * @param Agent $agent Corresponding Agent + */ + public function __construct($layoutName, $action, $agent) + { + parent::__construct($layoutName, $action, $agent); + } + + + + /** + * Prefilter that is executed before running the Controller. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::preFilter($request, $response); + + // Create linker + $this->linker = new \nre\core\Linker($this->request); + + // Create text formatter + $this->set('t', new \hhu\z\TextFormatter($this->linker)); + + // Create date and time and number formatter + $this->set('dateFormatter', new \IntlDateFormatter( + \nre\core\Config::getDefault('locale'), + \IntlDateFormatter::MEDIUM, + \IntlDateFormatter::NONE, + NULL + )); + $this->set('timeFormatter', new \IntlDateFormatter( + \nre\core\Config::getDefault('locale'), + \IntlDateFormatter::NONE, + \IntlDateFormatter::SHORT, + NULL + )); + $this->set('numberFormatter', new \NumberFormatter( + \nre\core\Config::getDefault('locale'), + \NumberFormatter::DEFAULT_STYLE + )); + } + + + /** + * Postfilter that is executed after running the Controller. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function postFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::postFilter($request, $response); + } + + } + +?> diff --git a/app/Model.inc b/app/Model.inc new file mode 100644 index 00000000..32835d04 --- /dev/null +++ b/app/Model.inc @@ -0,0 +1,42 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Abstract class for implementing an application Model. + * + * @author Oliver Hanraths + */ + class Model extends \nre\models\DatabaseModel + { + + + + + /** + * Construct a new application Model. + * + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + */ + public function __construct() + { + parent::__construct('mysqli', \nre\configs\AppConfig::$database); + } + + } + +?> diff --git a/app/QuesttypeAgent.inc b/app/QuesttypeAgent.inc new file mode 100644 index 00000000..23c4d1b2 --- /dev/null +++ b/app/QuesttypeAgent.inc @@ -0,0 +1,267 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Abstract class for implementing a QuesttypeAgent. + * + * @author Oliver Hanraths + */ + abstract class QuesttypeAgent extends \nre\agents\BottomlevelAgent + { + /** + * Current request + * + * @var Request + */ + private $request; + /** + * Current response + * + * @var Response + */ + private $response; + + + + + /** + * Load a QuesttypeAgent. + * + * @static + * @throws QuesttypeAgentNotFoundException + * @throws QuesttypeAgentNotValidException + * @param string $questtypeName Name of the QuesttypeAgent to load + */ + public static function load($questtypeName) + { + // Determine full classname + $className = self::getClassName($questtypeName); + + try { + // Load class + static::loadClass($questtypeName, $className); + + // Validate class + static::checkClass($className, get_class()); + } + catch(\nre\exceptions\ClassNotValidException $e) { + throw new \hhu\z\exceptions\QuesttypeAgentNotValidException($e->getClassName()); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + throw new \hhu\z\exceptions\QuesttypeAgentNotFoundException($e->getClassName()); + } + } + + + /** + * Instantiate a QuesttypeAgent (Factory Pattern). + * + * @static + * @throws DatamodelException + * @throws DriverNotValidException + * @throws DriverNotFoundException + * @throws ViewNotFoundException + * @throws QuesttypeModelNotValidException + * @throws QuesttypeModelNotFoundException + * @throws QuesttypeControllerNotValidException + * @throws QuesttypeControllerNotFoundException + * @param string $questtypeName Name of the QuesttypeAgent to instantiate + * @param Request $request Current request + * @param Response $respone Current respone + * @param Logger $log Log-system + */ + public static function factory($questtypeName, \nre\core\Request $request, \nre\core\Response $response, \nre\core\Logger $log=null) + { + // Determine full classname + $className = self::getClassName($questtypeName); + + // Construct and return Questmodule + return new $className($request, $response, $log); + } + + + /** + * Determine the Agent-classname for the given Questtype-name. + * + * @static + * @param string $questtypeName Questtype-name to get Agent-classname of + * @return string Classname for the Questtype-name + */ + private static function getClassName($questtypeName) + { + $className = \nre\core\ClassLoader::concatClassNames($questtypeName, \nre\core\ClassLoader::stripClassType(\nre\core\ClassLoader::stripNamespace(get_class())), 'agent'); + + + return \nre\configs\AppConfig::$app['namespace']."questtypes\\$className"; + } + + + /** + * Load the class of a QuesttypeAgent. + * + * @static + * @throws ClassNotFoundException + * @param string $questtypeName Name of the QuesttypeAgent to load + * @param string $fullClassName Name of the class to load + */ + private static function loadClass($questtypeName, $fullClassName) + { + // Determine folder to look in + $className = explode('\\', $fullClassName); + $className = array_pop($className); + + // Determine filename + $fileName = ROOT.DS.\nre\configs\AppConfig::$dirs['questtypes'].DS.strtolower($questtypeName).DS.$className.\nre\configs\CoreConfig::getFileExt('includes'); + + // Check file + if(!file_exists($fileName)) + { + throw new \nre\exceptions\ClassNotFoundException( + $fullClassName + ); + } + + // Include file + include_once($fileName); + } + + + /** + * Check inheritance of the QuesttypeAgent-class. + * + * @static + * @throws ClassNotValidException + * @param string $className Name of the class to check + * @param string $parentClassName Name of the parent class + */ + public static function checkClass($className, $parentClassName) + { + // Check if class is subclass of parent class + if(!is_subclass_of($className, $parentClassName)) { + throw new \nre\exceptions\ClassNotValidException( + $className + ); + } + } + + + + + /** + * Construct a new QuesttypeAgent. + * + * @throws DatamodelException + * @throws DriverNotValidException + * @throws DriverNotFoundException + * @throws ViewNotFoundException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws QuesttypeModelNotValidException + * @throws QuesttypeModelNotFoundException + * @throws QuesttypeControllerNotValidException + * @throws QuesttypeControllerNotFoundException + * @param Request $request Current request + * @param Response $respone Current response + * @param Logger $log Log-system + */ + protected function __construct(\nre\core\Request $request, \nre\core\Response $response, \nre\core\Logger $log=null) + { + // Store values + $this->request = $request; + $this->response = $response; + + + // Call parent constructor + parent::__construct($request, $response, $log); + } + + + + + /** + * Save the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + $this->controller->saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers); + } + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + return $this->controller->matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers); + } + + + + + /** + * Load the Controller of this Agent. + * + * @throws DatamodelException + * @throws DriverNotValidException + * @throws DriverNotFoundException + * @throws ViewNotFoundException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws QuesttypeModelNotValidException + * @throws QuesttypeModelNotFoundException + * @throws QuesttypeControllerNotValidException + * @throws QuesttypeControllerNotFoundException + */ + protected function loadController() + { + // Determine Controller name + $controllerName = \nre\core\ClassLoader::stripClassType(\nre\core\ClassLoader::getClassName(get_class($this))); + + // Determine ToplevelAgent + $toplevelAgentName = $this->response->getParam(0); + if(is_null($toplevelAgentName)) { + $toplevelAgentName = $this->request->getParam(0, 'toplevel'); + $this->response->addParam($toplevelAgentName); + } + + // Determine Action + $action = $this->response->getParam(2); + if(is_null($action)) { + $action = $this->request->getParam(2, 'action'); + $this->response->addParam($action); + } + + + // Load Controller + \hhu\z\QuesttypeController::load($controllerName); + + // Construct Controller + $this->controller = QuesttypeController::factory($controllerName, $toplevelAgentName, $action, $this); + } + + } + +?> diff --git a/app/QuesttypeController.inc b/app/QuesttypeController.inc new file mode 100644 index 00000000..fadfded0 --- /dev/null +++ b/app/QuesttypeController.inc @@ -0,0 +1,308 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Abstract class for implementing a QuesttypeController. + * + * @author Oliver Hanraths + */ + abstract class QuesttypeController extends \hhu\z\Controller + { + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'questgroups', 'quests', 'characters'); + + + + + /** + * Save the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public abstract function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers); + + + /** + * Save additional data for the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $data Additional (POST-) data + */ + public abstract function saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $data); + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + * @return boolean True/false for a right/wrong answer or null for moderator evaluation + */ + public abstract function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers); + + + /** + * Action: quest. + * + * Show the task of a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param Exception $exception Character submission exception + */ + public abstract function quest($seminary, $questgroup, $quest, $character, $exception); + + + + /** + * Action: submission. + * + * Show the submission of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + */ + public abstract function submission($seminary, $questgroup, $quest, $character); + + + + + /** + * Load a QuesttypeController. + * + * @static + * @throws QuesttypeControllerNotFoundException + * @throws QuesttypeControllerNotValidException + * @param string $controllerName Name of the QuesttypeController to load + */ + public static function load($controllerName) + { + // Determine full classname + $className = self::getClassName($controllerName); + + try { + // Load class + static::loadClass($controllerName, $className); + + // Validate class + static::checkClass($className, get_class()); + } + catch(\nre\exceptions\ClassNotValidException $e) { + throw new \hhu\z\exceptions\QuesttypeControllerNotValidException($e->getClassName()); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + throw new \hhu\z\exceptions\QuesttypeControllerNotFoundException($e->getClassName()); + } + } + + + /** + * Instantiate a QuesttypeController (Factory Pattern). + * + * @static + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws QuesttypeModelNotValidException + * @throws QuesttypeModelNotFoundException + * @throws ViewNotFoundException + * @param string $controllerName Name of the QuesttypeController to instantiate + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + */ + public static function factory($controllerName, $layoutName, $action, $agent) + { + // Determine full classname + $className = self::getClassName($controllerName); + + // Construct and return Controller + return new $className($layoutName, $action, $agent); + } + + + /** + * Determine the Controller-classname for the given Questtype-name. + * + * @static + * @param string $questtypeName Questtype-name to get Controller-classname of + * @return string Classname for the Questtype-name + */ + private static function getClassName($questtypeName) + { + $className = \nre\core\ClassLoader::concatClassNames($questtypeName, \nre\core\ClassLoader::stripClassType(\nre\core\ClassLoader::stripNamespace(get_class())), 'controller'); + + + return \nre\configs\AppConfig::$app['namespace']."questtypes\\$className"; + } + + + /** + * Load the class of a QuesttypeController + * + * @static + * @throws ClassNotFoundException + * @param string $questtypeName Name of the QuesttypeController to load + * @param string $fullClassName Name of the class to load + */ + private static function loadClass($questtypeName, $fullClassName) + { + // Determine folder to look in + $className = explode('\\', $fullClassName); + $className = array_pop($className); + + // Determine filename + $fileName = ROOT.DS.\nre\configs\AppConfig::$dirs['questtypes'].DS.strtolower($questtypeName).DS.$className.\nre\configs\CoreConfig::getFileExt('includes'); + + // Check file + if(!file_exists($fileName)) + { + throw new \nre\exceptions\ClassNotFoundException( + $fullClassName + ); + } + + // Include file + include_once($fileName); + } + + + /** + * Check inheritance of the QuesttypeController-class. + * + * @static + * @throws ClassNotValidException + * @param string $className Name of the class to check + * @param string $parentClassName Name of the parent class + */ + public static function checkClass($className, $parentClassName) + { + // Check if class is subclass of parent class + if(!is_subclass_of($className, $parentClassName)) { + throw new \nre\exceptions\ClassNotValidException( + $className + ); + } + } + + + + + /** + * Construct a new application Controller. + * + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws QuesttypeModelNotValidException + * @throws QuesttypeModelNotFoundException + * @throws ViewNotFoundException + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + * @param Agent $agent Corresponding Agent + */ + public function __construct($layoutName, $action, $agent) + { + parent::__construct($layoutName, $action, $agent); + } + + + + + /** + * Load the Models of this Controller. + * + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws QuesttypeModelNotValidException + * @throws QuesttypeModelNotFoundException + */ + protected function loadModels() + { + // Load default models + parent::loadModels(); + + // Load QuesttypeModel + $this->loadModel(); + } + + + /** + * Load the Model of the Questtype. + * + * @throws QuesttypeModelNotValidException + * @throws QuesttypeModelNotFoundException + */ + private function loadModel() + { + // Determine Model + $model = \nre\core\ClassLoader::stripClassType(\nre\core\ClassLoader::stripClassType(\nre\core\ClassLoader::stripNamespace(get_class($this)))); + + // Load class + QuesttypeModel::load($model); + + // Construct Model + $modelName = ucfirst(strtolower($model)); + $this->$modelName = QuesttypeModel::factory($model); + } + + + /** + * Load the View of this QuesttypeController. + * + * @throws ViewNotFoundException + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + */ + protected function loadView($layoutName, $action) + { + // Check Layout name + if(is_null($layoutName)) { + return; + } + + // Determine controller name + $controllerName = \nre\core\ClassLoader::stripClassType(\nre\core\ClassLoader::getClassName(get_class($this))); + + + // Load view + $this->view = QuesttypeView::loadAndFactory($layoutName, $controllerName, $action); + } + + } + +?> diff --git a/app/QuesttypeModel.inc b/app/QuesttypeModel.inc new file mode 100644 index 00000000..d30699fb --- /dev/null +++ b/app/QuesttypeModel.inc @@ -0,0 +1,154 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Abstract class for implementing a QuesttypeModel. + * + * @author Oliver Hanraths + */ + abstract class QuesttypeModel extends \hhu\z\Model + { + + + + + /** + * Load a Model. + * + * @static + * @throws QuesttypeModelNotFoundException + * @throws QuesttypeModelNotValidException + * @param string $modelName Name of the QuesttypeModel to load + */ + public static function load($modelName) + { + // Determine full classname + $className = self::getClassName($modelName); + + try { + // Load class + static::loadClass($modelName, $className); + + // Validate class + static::checkClass($className, get_class()); + } + catch(\nre\exceptions\ClassNotValidException $e) { + throw new \hhu\z\exceptions\QuesttypeModelNotValidException($e->getClassName()); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + throw new \hhu\z\exceptions\QuesttypeModelNotFoundException($e->getClassName()); + } + } + + + /** + * Instantiate a QuesttypeModel (Factory Pattern). + * + * @static + * @param string $questtypeName Name of the QuesttypeModel to instantiate + */ + public static function factory($questtypeName) + { + // Determine full classname + $className = self::getClassName($questtypeName); + + // Construct and return Model + return new $className(); + } + + + /** + * Determine the Model-classname for the given Questtype-name. + * + * @static + * @param string $questtypeName Questtype-name to get Model-classname of + * @return string Classname for the Questtype-name + */ + private static function getClassName($questtypeName) + { + $className = \nre\core\ClassLoader::concatClassNames($questtypeName, \nre\core\ClassLoader::stripClassType(\nre\core\ClassLoader::stripNamespace(get_class())), 'model'); + + + return \nre\configs\AppConfig::$app['namespace']."questtypes\\$className"; + } + + + /** + * Load the class of a QuesttypeModel. + * + * @static + * @throws ClassNotFoundException + * @param string $questtypeName Name of the QuesttypeModel to load + * @param string $fullClassName Name of the class to load + */ + private static function loadClass($questtypeName, $fullClassName) + { + // Determine folder to look in + $className = explode('\\', $fullClassName); + $className = array_pop($className); + + // Determine filename + $fileName = ROOT.DS.\nre\configs\AppConfig::$dirs['questtypes'].DS.strtolower($questtypeName).DS.$className.\nre\configs\CoreConfig::getFileExt('includes'); + + // Check file + if(!file_exists($fileName)) + { + throw new \nre\exceptions\ClassNotFoundException( + $fullClassName + ); + } + + // Include file + include_once($fileName); + } + + + /** + * Check inheritance of the QuesttypeModel-class. + * + * @static + * @throws ClassNotValidException + * @param string $className Name of the class to check + * @param string $parentClassName Name of the parent class + */ + public static function checkClass($className, $parentClassName) + { + // Check if class is subclass of parent class + if(!is_subclass_of($className, $parentClassName)) { + throw new \nre\exceptions\ClassNotValidException( + $className + ); + } + } + + + + + /** + * Construct a new QuesttypeModel. + * + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws QuesttypeModelNotValidException + * @throws QuesttypeModelNotFoundException + */ + public function __construct() + { + parent::__construct(); + } + + } + +?> diff --git a/app/QuesttypeView.inc b/app/QuesttypeView.inc new file mode 100644 index 00000000..8e77ee00 --- /dev/null +++ b/app/QuesttypeView.inc @@ -0,0 +1,76 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Abstract class for implementing a QuesttypeView. + * + * @author Oliver Hanraths + */ + class QuesttypeView extends \nre\core\View + { + + + + + /** + * Load and instantiate the QuesttypeView of a QuesttypeAgent. + * + * @throws ViewNotFoundException + * @param string $layoutName Name of Layout in use + * @param string $agentName Name of the Agent + * @param string $action Current Action + * @param bool $isToplevel Agent is a ToplevelAgent + */ + public static function loadAndFactory($layoutName, $agentName=null, $action=null, $isToplevel=false) + { + return new QuesttypeView($layoutName, $agentName, $action, $isToplevel); + } + + + + + /** + * Construct a new QuesttypeView. + * + * @throws ViewNotFoundException + * @param string $layoutName Name of Layout in use + * @param string $agentName Name of the Agent + * @param string $action Current Action + * @param bool $isToplevel Agent is a ToplevelAgent + */ + protected function __construct($layoutName, $agentName=null, $action=null, $isToplevel=false) + { + // Create template filename + // LayoutName + $fileName = ROOT.DS.\nre\configs\AppConfig::$dirs['questtypes'].DS.strtolower($agentName).DS.strtolower($layoutName).DS; + + // Action + $fileName .= strtolower($action); + + // File extension + $fileName .= \nre\configs\CoreConfig::getFileExt('views'); + + + // Check template file + if(!file_exists($fileName)) { + throw new \nre\exceptions\ViewNotFoundException($fileName); + } + + // Save filename + $this->templateFilename = $fileName; + } + + } + +?> diff --git a/app/TextFormatter.inc b/app/TextFormatter.inc new file mode 100644 index 00000000..e11a42ed --- /dev/null +++ b/app/TextFormatter.inc @@ -0,0 +1,141 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Class to format text with different syntax tags. + * + * @author Oliver Hanraths + */ + class TextFormatter + { + /** + * Linker to create links. + * + * @var Linker + */ + private $linker; + /** + * Media-Model to retrieve media data + * + * @static + * @var model + */ + private static $Media = null; + + + + + /** + * Create a new text formatter. + * + * @param Linker $linker Linker to create links with + */ + public function __construct(\nre\core\Linker $linker) + { + $this->linker = $linker; + } + + + + + /** + * Format a string. + * + * @param string $string String to format + * @return string Formatted string + */ + public function t($string) + { + // Remove chars + $string = htmlspecialchars($string, ENT_NOQUOTES); + + // Create tables + $string = str_replace('[table]', '

', $string); + $string = str_replace('[/table]', '

', $string); + $string = str_replace('[tr]', '', $string); + $string = str_replace('[/tr]', '', $string); + $string = str_replace('[th]', '', $string); + $string = str_replace('[/th]', '', $string); + $string = str_replace('[td]', '', $string); + $string = str_replace('[/td]', '', $string); + + // Create links + $string = preg_replace('!(^|\s)"([^"]+)":(https?://[^\s]+)(\s|$)!i', '$1$2$4', $string); + $string = preg_replace('!(^|\s)(https?://[^\s]+)(\s|$)!i', '$1$2$4', $string); + + // Handle Seminarymedia + $seminarymedia = array(); + preg_match_all('/\[seminarymedia:(\d+)\]/iu', $string, $matches); //, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + $seminarymediaIds = array_unique($matches[1]); + foreach($seminarymediaIds as &$seminarymediaId) + { + $replacement = null; + if(!is_null(\hhu\z\controllers\SeminaryController::$seminary) && $this->loadMediaModel()) + { + try { + $medium = self::$Media->getSeminaryMediaById($seminarymediaId); + $replacement = sprintf( + '%s', + $this->linker->link(array('media','seminary', \hhu\z\controllers\SeminaryController::$seminary['url'],$medium['url'])), + $medium['description'] + ); + } + catch(\nre\exceptions\IdNotFoundException $e) { + } + } + + $seminarymedia[$seminarymediaId] = $replacement; + } + foreach($seminarymedia as $seminarymediaId => $replacement) { + $string = str_replace("[seminarymedia:$seminarymediaId]", $replacement, $string); + } + + + // Return processed string + return nl2br($string); + } + + + + + /** + * Load the Media-Model if it is not loaded + * + * @return boolean Whether the Media-Model has been loaded or not + */ + private function loadMediaModel() + { + // Do not load Model if it has already been loaded + if(!is_null(self::$Media)) { + return true; + } + + try { + // Load class + Model::load('media'); + + // Construct Model + self::$Media = Model::factory('media'); + } + catch(\Exception $e) { + } + + + // Return whether Media-Model has been loaded or not + return !is_null(self::$Media); + } + + } + +?> diff --git a/app/ToplevelAgent.inc b/app/ToplevelAgent.inc new file mode 100644 index 00000000..97aea57b --- /dev/null +++ b/app/ToplevelAgent.inc @@ -0,0 +1,36 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Abstract class for implementing an application Controller. + * + * @author Oliver Hanraths + */ + abstract class ToplevelAgent extends \nre\agents\ToplevelAgent + { + + + + + protected function __construct(\nre\core\Request $request, \nre\core\Response $response, \nre\core\Logger $log=null) + { + parent::__construct($request, $response, $log); + + + // Set timezone + date_default_timezone_set(\nre\configs\AppConfig::$app['timeZone']); + } + } + +?> diff --git a/app/Utils.inc b/app/Utils.inc new file mode 100644 index 00000000..b1a97b2d --- /dev/null +++ b/app/Utils.inc @@ -0,0 +1,140 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z; + + + /** + * Class for implementing utility methods. + * + * @author Oliver Hanraths + */ + class Utils + { + + + /** + * Mask HTML-chars for save output. + * + * @static + * @param string $string String to be masked + * @return string Masked string + */ + public static function t($string) + { + return nl2br(htmlspecialchars($string)); + } + + + /** + * ‚htmlspecialchars‘ with support for UTF-8. + * + * @static + * @param string $string String to be masked + * @return string Masked string + */ + public static function htmlspecialchars_utf8($string) + { + return htmlspecialchars($string, ENT_COMPAT, 'UTF-8'); + } + + + /** + * Cut a string to the given length but only word boundaries. + * + * @static + * @param string $string String to cut + * @param int $length Length to cut string + * @param int $scope Maximum length to cut string regardless word boundaries + * @return string Cutted string + */ + public static function shortenString($string, $length, $scope) + { + // Determine length + $length = min($length, strlen($string)); + + // Look for word boundary + if(($pos = strpos($string, ' ', $length)) !== false) + { + // Check if boundary is outside of scope + if($pos > $length + $scope) { + $pos = strrpos(substr($string, 0, $pos), ' '); + } + } + else { + $pos = strlen($string); + } + + + // Cut string and return it + return substr($string, 0, $pos); + } + + + /** + * Send an e‑mail. + * + * @param string $from Sender of mail + * @param mixed $to One (string) or many (array) receivers + * @param string $subject Subject of mail + * @param string $message Message of mail + * @param boolean $html Whether mail should be formatted as HTML or not + * @return Whether mail has been send or not + */ + public static function sendMail($from, $to, $subject, $message, $html=false) + { + // Set receivers + $to = is_array($to) ? implode(',', $to) : $to; + + // Set header + $headers = array(); + $headers[] = 'Content-type: text/'.($html ? 'html' : 'plain').'; charset=UTF-8'; + if(!is_null($from)) { + $headers[] = "From: $from"; + } + $header = implode("\r\n", $headers)."\r\n"; + + + // Send mail + return mail($to, $subject, $message, $header); + } + + + /** + * Detect Mimetype of a file. + * + * @param string $filename Name of file to detect Mimetype of + * @param string $defaultMimetype Default Mimetype to use + * @return string Detected Mimetype of file + */ + public static function getMimetype($filename, $defaultMimetype=null) + { + $mimetype = (!is_null($defaultMimetype)) ? $defaultMimetype : 'application/octet-stream'; + // Use Fileinfo + if(class_exists('\finfo')) + { + $finfo = new \finfo(FILEINFO_MIME_TYPE); + if(!is_null($finfo)) { + $mimetype = $finfo->file($filename); + } + } + // Use deprecated mime_content_type() + elseif(function_exists('mime_content_type')) { + $mimetype = mime_content_type($filename); + } + + + return $mimetype; + } + + } + +?> diff --git a/app/controllers/IntermediateController.inc b/app/controllers/IntermediateController.inc new file mode 100644 index 00000000..19647d7e --- /dev/null +++ b/app/controllers/IntermediateController.inc @@ -0,0 +1,139 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Abstract class for implementing a Controller of an IntermediateAgent. + * + * @author Oliver Hanraths + */ + abstract class IntermediateController extends \hhu\z\Controller + { + /** + * Required models + * + * @var array + */ + public $models = array('users', 'userroles', 'seminaries', 'characters'); + /** + * Current user + * + * @var array + */ + public static $user = null; + + + + + /** + * Construct a new IntermediateController. + * + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ViewNotFoundException + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + * @param Agent $agent Corresponding Agent + */ + public function __construct($layoutName, $action, $agent) + { + parent::__construct($layoutName, $action, $agent); + } + + + + /** + * Prefilter that is executed before running the Controller. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::preFilter($request, $response); + + // Get userdata + try { + self::$user = $this->Users->getUserById($this->Auth->getUserId()); + self::$user['roles'] = array_map(function($r) { return $r['name']; }, $this->Userroles->getUserrolesForUserById(self::$user['id'])); + } + catch(\nre\exceptions\IdNotFoundException $e) { + } + + // Check permissions + $this->checkPermission($request, $response); + + // Set userdata + $this->set('loggedUser', self::$user); + } + + + /** + * Postfilter that is executed after running the Controller. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function postFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::postFilter($request, $response); + } + + + + + /** + * Check user permissions. + * + * @throws AccessDeniedException + */ + private function checkPermission(\nre\core\Request $request, \nre\core\Response $response) + { + // Determine user + $userRoles = array('guest'); + if(!is_null(self::$user)) { + $userRoles = self::$user['roles']; + } + + + // Do not check error pages + if($response->getParam(0, 'toplevel') == \nre\core\Config::getDefault('toplevel-error')) { + return; + } + if($response->getParam(1, 'intermediate') == \nre\core\Config::getDefault('intermediate-error')) { + return; + } + + // Determine permissions of Intermediate Controller for current action + $controller = $this->agent->controller; + $action = $this->request->getParam(2, 'action'); + if(!property_exists($controller, 'permissions')) { + return; // Allow if nothing is specified + } + if(!array_key_exists($action, $controller->permissions)) { + return; // Allow if Action is not specified + } + $permissions = $controller->permissions[$action]; + + + // Check permissions + if(count(array_intersect($userRoles, $permissions)) == 0) { + throw new \nre\exceptions\AccessDeniedException(); + } + } + + } + +?> diff --git a/app/controllers/SeminaryController.inc b/app/controllers/SeminaryController.inc new file mode 100644 index 00000000..abd18350 --- /dev/null +++ b/app/controllers/SeminaryController.inc @@ -0,0 +1,300 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Abstract class for implementing a Controller for a Seminary and its + * concepts. + * + * @author Oliver Hanraths + */ + abstract class SeminaryController extends \hhu\z\controllers\IntermediateController + { + /** + * Required components + * + * @var array + */ + public $components = array('achievement', 'auth', 'notification'); + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'characters', 'characterroles', 'achievements'); + /** + * Current Seminary + * + * var array + */ + public static $seminary = null; + /** + * Character of current user and Seminary + * + * @var array + */ + public static $character = null; + + + + + /** + * Construct a new Seminary Controller. + * + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ViewNotFoundException + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + * @param Agent $agent Corresponding Agent + */ + public function __construct($layoutName, $action, $agent) + { + parent::__construct($layoutName, $action, $agent); + } + + + + /** + * Prefilter that is executed before running the Controller. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::preFilter($request, $response); + + // Get Seminary and Character data + try { + self::$seminary = $this->Seminaries->getSeminaryByUrl($this->request->getParam(3)); + if(!is_null(self::$user)) + { + self::$character = $this->Characters->getCharacterForUserAndSeminary(self::$user['id'], self::$seminary['id']); + self::$character['characterroles'] = array_map(function($r) { return $r['name']; }, $this->Characterroles->getCharacterrolesForCharacterById(self::$character['id'])); + } + } + catch(\nre\exceptions\IdNotFoundException $e) { + } + + // Check permissions + $this->checkPermission($request, $response); + + // Check achievements + $this->checkAchievements($request, $response); + + // Set Seminary and Character data + $this->set('loggedSeminary', self::$seminary); + $this->set('loggedCharacter', self::$character); + } + + + /** + * Postfilter that is executed after running the Controller. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function postFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::postFilter($request, $response); + } + + + + + /** + * Check user permissions. + * + * @throws AccessDeniedException + */ + private function checkPermission(\nre\core\Request $request, \nre\core\Response $response) + { + // Do not check index page + if(is_null($request->getParam(3))) { + return; + } + + + // Determine permissions for current action + $action = $this->request->getParam(2, 'action'); + if(!property_exists($this, 'seminaryPermissions')) { + return; // Allow if nothing is specified + } + if(!array_key_exists($action, $this->seminaryPermissions)) { + return; // Allow if Action is not specified + } + $permissions = $this->seminaryPermissions[$action]; + + + // Check permissions + if(is_null(self::$character) || !array_key_exists('characterroles', self::$character) || count(array_intersect(self::$character['characterroles'], $permissions)) == 0) { + throw new \nre\exceptions\AccessDeniedException(); + } + } + + + /** + * Check for newly achieved Achievements. + */ + private function checkAchievements(\nre\core\Request $request, \nre\core\Response $response) + { + // Check if Character is present + if(is_null(self::$character)) { + return; + } + + // Get unachieved Achievments + $achievements = $this->Achievements->getUnachhievedAchievementsForCharacter(self::$seminary['id'], self::$character['id']); + if(in_array('user', self::$character['characterroles'])) { + $achievements = array_merge($achievements, $this->Achievements->getUnachievedOnlyOnceAchievementsForSeminary(self::$seminary['id'])); + } + + // Check conditions + foreach($achievements as &$achievement) + { + // Check deadline + if(!is_null($achievement['deadline']) && $achievement['deadline'] < date('Y-m-d H:i:s')) { + continue; + } + + // Get conditions + $conditions = array(); + $progress = 0; + switch($achievement['condition']) + { + // Date conditions + case 'date': + $conditionsDate = $this->Achievements->getAchievementConditionsDate($achievement['id']); + foreach($conditionsDate as &$condition) + { + $conditions[] = array( + 'func' => 'checkAchievementConditionDate', + 'params' => array( + $condition['select'] + ) + ); + } + break; + // Character conditions + case 'character': + $conditionsCharacter = $this->Achievements->getAchievementConditionsCharacter($achievement['id']); + foreach($conditionsCharacter as &$condition) + { + $conditions[] = array( + 'func' => 'checkAchievementConditionCharacter', + 'params' => array( + $condition['field'], + $condition['value'], + self::$character['id'] + ) + ); + } + break; + // Quest conditions + case 'quest': + $conditionsQuest = $this->Achievements->getAchievementConditionsQuest($achievement['id']); + foreach($conditionsQuest as &$condition) + { + $conditions[] = array( + 'func' => 'checkAchievementConditionQuest', + 'params' => array( + $condition['field'], + $condition['count'], + $condition['value'], + $condition['status'], + $condition['groupby'], + $condition['quest_id'], + self::$character['id'] + ) + ); + } + break; + // Achievement conditions + case 'achievement': + $conditionsAchievement = $this->Achievements->getAchievementConditionsAchievement($achievement['id']); + foreach($conditionsAchievement as &$condition) + { + $conditions[] = array( + 'func' => 'checkAchievementConditionAchievement', + 'params' => array( + $condition['field'], + $condition['count'], + $condition['value'], + $condition['groupby'], + $condition['meta_achievement_id'], + self::$character['id'] + ) + ); + } + break; + } + + // Do not achieve Achievements without conditions + if(empty($conditions)) { + continue; + } + + // Check conditions + $achieved = ($achievement['all_conditions'] == 1); + foreach($conditions as &$condition) + { + // Calculate result of condition + $result = call_user_func_array( + array( + $this->Achievements, + $condition['func'] + ), + $condition['params'] + ); + + // The overall result and abort if possible + if($achievement['all_conditions']) + { + if(!$result) { + $achieved = false; + break; + } + } + else + { + if($result) { + $achieved = true; + break; + } + } + + } + + // Achievement achieved + if($achieved) + { + // Set status + $this->Achievements->setAchievementAchieved($achievement['id'], self::$character['id']); + + // Add notification + $this->Notification->addNotification( + \hhu\z\controllers\components\NotificationComponent::TYPE_ACHIEVEMENT, + $achievement['title'], + $this->linker->link(array('achievements', 'index', self::$seminary['url']), 0, true, null, true, $achievement['url']), + (!is_null($achievement['achieved_achievementsmedia_id']) ? $this->linker->link(array('media','achievement',self::$seminary['url'],$achievement['url'])) : null) + ); + } + } + } + + } + +?> diff --git a/app/exceptions/FileUploadException.inc b/app/exceptions/FileUploadException.inc new file mode 100644 index 00000000..3fb62e6f --- /dev/null +++ b/app/exceptions/FileUploadException.inc @@ -0,0 +1,75 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: File upload went wrong + * + * @author Oliver Hanraths + */ + class FileUploadException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 203; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'File upload went wrong'; + + /** + * Nested message + * + * @var string + */ + private $nestedMessage; + + + + + /** + * Construct a new exception. + */ + function __construct($nestedMessage=null, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $nestedMessage + ); + + // Store values + $this->nestedMessage = $nestedMessage; + } + + + + + /** + * Get nested message. + * + * @return Nested message + */ + public function getNestedMessage() + { + return $this->nestedMessage; + } + + } + +?> diff --git a/app/exceptions/MaxFilesizeException.inc b/app/exceptions/MaxFilesizeException.inc new file mode 100644 index 00000000..f16f335e --- /dev/null +++ b/app/exceptions/MaxFilesizeException.inc @@ -0,0 +1,51 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: File exceeds size maximum. + * + * @author Oliver Hanraths + */ + class MaxFilesizeException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 202; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'File exceeds size maximum'; + + + + + /** + * Construct a new exception. + */ + function __construct($message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code + ); + } + + } + +?> diff --git a/app/exceptions/QuesttypeAgentNotFoundException.inc b/app/exceptions/QuesttypeAgentNotFoundException.inc new file mode 100644 index 00000000..5f6f4ea9 --- /dev/null +++ b/app/exceptions/QuesttypeAgentNotFoundException.inc @@ -0,0 +1,77 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: QuesttypeAgent not found. + * + * @author Oliver Hanraths + */ + class QuesttypeAgentNotFoundException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 101; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'QuesttypeAgent not found'; + + /** + * Name of the class that was not found + * + * @var string + */ + private $questtypeName; + + + + + /** + * Construct a new exception. + * + * @param string $questtypeName Name of the QuesttypeAgent that was not found + */ + function __construct($questtypeName, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $questtypeName + ); + + // Store values + $this->questtypeName = $questtypeName; + } + + + + + /** + * Get the name of the QuesttypeAgent that was not found. + * + * @return string Name of the QuesttypeAgent that was not found + */ + public function getClassName() + { + return $this->questtypeName; + } + + } + +?> diff --git a/app/exceptions/QuesttypeAgentNotValidException.inc b/app/exceptions/QuesttypeAgentNotValidException.inc new file mode 100644 index 00000000..8fa7237f --- /dev/null +++ b/app/exceptions/QuesttypeAgentNotValidException.inc @@ -0,0 +1,77 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: QuesttypeAgent not valid. + * + * @author Oliver Hanraths + */ + class QuesttypeAgentNotValidException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 102; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'QuesttypeAgent not valid'; + + /** + * Name of the invalid class + * + * @var string + */ + private $questtypeName; + + + + + /** + * Construct a new exception. + * + * @param string $questtypeName Name of the invalid QuesttypeAgent + */ + function __construct($questtypeName, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $questtypeName + ); + + // Store value + $this->questtypeName = $questtypeName; + } + + + + + /** + * Get the name of the invalid QuesttypeAgent. + * + * @return string Name of the invalid QuesttypeAgent + */ + public function getClassName() + { + return $this->questtypeName; + } + + } + +?> diff --git a/app/exceptions/QuesttypeControllerNotFoundException.inc b/app/exceptions/QuesttypeControllerNotFoundException.inc new file mode 100644 index 00000000..6d3a4a36 --- /dev/null +++ b/app/exceptions/QuesttypeControllerNotFoundException.inc @@ -0,0 +1,77 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: QuesttypeController not found. + * + * @author Oliver Hanraths + */ + class QuesttypeControllerNotFoundException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 103; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'QuesttypeController not found'; + + /** + * Name of the class that was not found + * + * @var string + */ + private $questtypeName; + + + + + /** + * Construct a new exception. + * + * @param string $questtypeName Name of the QuesttypeController that was not found + */ + function __construct($questtypeName, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $questtypeName + ); + + // Store values + $this->questtypeName = $questtypeName; + } + + + + + /** + * Get the name of the QuesttypeController that was not found. + * + * @return string Name of the QuesttypeController that was not found + */ + public function getClassName() + { + return $this->questtypeName; + } + + } + +?> diff --git a/app/exceptions/QuesttypeControllerNotValidException.inc b/app/exceptions/QuesttypeControllerNotValidException.inc new file mode 100644 index 00000000..8c6735b8 --- /dev/null +++ b/app/exceptions/QuesttypeControllerNotValidException.inc @@ -0,0 +1,77 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: QuesttypeController not valid. + * + * @author Oliver Hanraths + */ + class QuesttypeControllerNotValidException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 104; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'QuesttypeController not valid'; + + /** + * Name of the invalid class + * + * @var string + */ + private $questtypeName; + + + + + /** + * Construct a new exception. + * + * @param string $questtypeName Name of the invalid QuesttypeController + */ + function __construct($questtypeName, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $questtypeName + ); + + // Store value + $this->questtypeName = $questtypeName; + } + + + + + /** + * Get the name of the invalid QuesttypeController. + * + * @return string Name of the invalid QuesttypeController + */ + public function getClassName() + { + return $this->questtypeName; + } + + } + +?> diff --git a/app/exceptions/QuesttypeModelNotFoundException.inc b/app/exceptions/QuesttypeModelNotFoundException.inc new file mode 100644 index 00000000..a2aa01c8 --- /dev/null +++ b/app/exceptions/QuesttypeModelNotFoundException.inc @@ -0,0 +1,77 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: QuesttypeModel not found. + * + * @author Oliver Hanraths + */ + class QuesttypeModelNotFoundException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 105; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'QuesttypeModel not found'; + + /** + * Name of the class that was not found + * + * @var string + */ + private $questtypeName; + + + + + /** + * Construct a new exception. + * + * @param string $questtypeName Name of the QuesttypeModel that was not found + */ + function __construct($questtypeName, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $questtypeName + ); + + // Store values + $this->questtypeName = $questtypeName; + } + + + + + /** + * Get the name of the QuesttypeModel that was not found. + * + * @return string Name of the QuesttypeModel that was not found + */ + public function getClassName() + { + return $this->questtypeName; + } + + } + +?> diff --git a/app/exceptions/QuesttypeModelNotValidException.inc b/app/exceptions/QuesttypeModelNotValidException.inc new file mode 100644 index 00000000..4a78beb6 --- /dev/null +++ b/app/exceptions/QuesttypeModelNotValidException.inc @@ -0,0 +1,77 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: QuesttypeModel not valid. + * + * @author Oliver Hanraths + */ + class QuesttypeModelNotValidException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 106; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'QuesttypeModel not valid'; + + /** + * Name of the invalid class + * + * @var string + */ + private $questtypeName; + + + + + /** + * Construct a new exception. + * + * @param string $questtypeName Name of the invalid QuesttypeModel + */ + function __construct($questtypeName, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $questtypeName + ); + + // Store value + $this->questtypeName = $questtypeName; + } + + + + + /** + * Get the name of the invalid QuesttypeModel. + * + * @return string Name of the invalid QuesttypeModel + */ + public function getClassName() + { + return $this->questtypeName; + } + + } + +?> diff --git a/app/exceptions/SubmissionNotValidException.inc b/app/exceptions/SubmissionNotValidException.inc new file mode 100644 index 00000000..e2923bdf --- /dev/null +++ b/app/exceptions/SubmissionNotValidException.inc @@ -0,0 +1,77 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: Character submission not valid. + * + * @author Oliver Hanraths + */ + class SubmissionNotValidException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 200; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'Character submission not valid'; + + /** + * Nested exception + * + * @var Exception + */ + private $nestedException; + + + + + /** + * Construct a new exception. + * + * @param string $nestedException Nested exception + */ + function __construct($nestedException, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $nestedException + ); + + // Store value + $this->nestedException = $nestedException; + } + + + + + /** + * Get Nested exception. + * + * @return string Nested exception + */ + public function getNestedException() + { + return $this->nestedException; + } + + } + +?> diff --git a/app/exceptions/WrongFiletypeException.inc b/app/exceptions/WrongFiletypeException.inc new file mode 100644 index 00000000..131356b8 --- /dev/null +++ b/app/exceptions/WrongFiletypeException.inc @@ -0,0 +1,75 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\exceptions; + + + /** + * Exception: File has wrong filetype. + * + * @author Oliver Hanraths + */ + class WrongFiletypeException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 201; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'File has wrong type “%s”'; + + /** + * Type of file + * + * @var string + */ + private $type; + + + + + /** + * Construct a new exception. + */ + function __construct($type, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $type + ); + + // Store values + $this->type = $type; + } + + + + + /** + * Get type of file. + * + * @return Type of file + */ + public function getType() + { + return $this->type; + } + + } + +?> diff --git a/app/lib/Password.inc b/app/lib/Password.inc new file mode 100644 index 00000000..f9acf7d5 --- /dev/null +++ b/app/lib/Password.inc @@ -0,0 +1,316 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\lib + { + + + /** + * Class to ensure that Compatibility library below is loaded. + * + * @author Oliver Hanraths + */ + class Password + { + + /** + * Call this function to ensure this file is loaded. + */ + public static function load() + { + } + + } + + } + + + + +/** + * A Compatibility library with PHP 5.5's simplified password hashing API. + * + * @author Anthony Ferrara + * @license http://www.opensource.org/licenses/mit-license.html MIT License + * @copyright 2012 The Authors + */ + +namespace { + +if (!defined('PASSWORD_DEFAULT')) { + + define('PASSWORD_BCRYPT', 1); + define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); + + /** + * Hash the password using the specified algorithm + * + * @param string $password The password to hash + * @param int $algo The algorithm to use (Defined by PASSWORD_* constants) + * @param array $options The options for the algorithm to use + * + * @return string|false The hashed password, or false on error. + */ + function password_hash($password, $algo, array $options = array()) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING); + return null; + } + if (!is_string($password)) { + trigger_error("password_hash(): Password must be a string", E_USER_WARNING); + return null; + } + if (!is_int($algo)) { + trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING); + return null; + } + $resultLength = 0; + switch ($algo) { + case PASSWORD_BCRYPT: + // Note that this is a C constant, but not exposed to PHP, so we don't define it here. + $cost = 10; + if (isset($options['cost'])) { + $cost = $options['cost']; + if ($cost < 4 || $cost > 31) { + trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING); + return null; + } + } + // The length of salt to generate + $raw_salt_len = 16; + // The length required in the final serialization + $required_salt_len = 22; + $hash_format = sprintf("$2y$%02d$", $cost); + // The expected length of the final crypt() output + $resultLength = 60; + break; + default: + trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING); + return null; + } + $salt_requires_encoding = false; + if (isset($options['salt'])) { + switch (gettype($options['salt'])) { + case 'NULL': + case 'boolean': + case 'integer': + case 'double': + case 'string': + $salt = (string) $options['salt']; + break; + case 'object': + if (method_exists($options['salt'], '__tostring')) { + $salt = (string) $options['salt']; + break; + } + case 'array': + case 'resource': + default: + trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); + return null; + } + if (PasswordCompat\binary\_strlen($salt) < $required_salt_len) { + trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", PasswordCompat\binary\_strlen($salt), $required_salt_len), E_USER_WARNING); + return null; + } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { + $salt_requires_encoding = true; + } + } else { + $buffer = ''; + $buffer_valid = false; + if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { + $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM); + if ($buffer) { + $buffer_valid = true; + } + } + if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { + $buffer = openssl_random_pseudo_bytes($raw_salt_len); + if ($buffer) { + $buffer_valid = true; + } + } + if (!$buffer_valid && @is_readable('/dev/urandom')) { + $f = fopen('/dev/urandom', 'r'); + $read = PasswordCompat\binary\_strlen($buffer); + while ($read < $raw_salt_len) { + $buffer .= fread($f, $raw_salt_len - $read); + $read = PasswordCompat\binary\_strlen($buffer); + } + fclose($f); + if ($read >= $raw_salt_len) { + $buffer_valid = true; + } + } + if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) { + $bl = PasswordCompat\binary\_strlen($buffer); + for ($i = 0; $i < $raw_salt_len; $i++) { + if ($i < $bl) { + $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); + } else { + $buffer .= chr(mt_rand(0, 255)); + } + } + } + $salt = $buffer; + $salt_requires_encoding = true; + } + if ($salt_requires_encoding) { + // encode string with the Base64 variant used by crypt + $base64_digits = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + $bcrypt64_digits = + './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + $base64_string = base64_encode($salt); + $salt = strtr(rtrim($base64_string, '='), $base64_digits, $bcrypt64_digits); + } + $salt = PasswordCompat\binary\_substr($salt, 0, $required_salt_len); + + $hash = $hash_format . $salt; + + $ret = crypt($password, $hash); + + if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != $resultLength) { + return false; + } + + return $ret; + } + + /** + * Get information about the password hash. Returns an array of the information + * that was used to generate the password hash. + * + * array( + * 'algo' => 1, + * 'algoName' => 'bcrypt', + * 'options' => array( + * 'cost' => 10, + * ), + * ) + * + * @param string $hash The password hash to extract info from + * + * @return array The array of information about the hash. + */ + function password_get_info($hash) { + $return = array( + 'algo' => 0, + 'algoName' => 'unknown', + 'options' => array(), + ); + if (PasswordCompat\binary\_substr($hash, 0, 4) == '$2y$' && PasswordCompat\binary\_strlen($hash) == 60) { + $return['algo'] = PASSWORD_BCRYPT; + $return['algoName'] = 'bcrypt'; + list($cost) = sscanf($hash, "$2y$%d$"); + $return['options']['cost'] = $cost; + } + return $return; + } + + /** + * Determine if the password hash needs to be rehashed according to the options provided + * + * If the answer is true, after validating the password using password_verify, rehash it. + * + * @param string $hash The hash to test + * @param int $algo The algorithm used for new password hashes + * @param array $options The options array passed to password_hash + * + * @return boolean True if the password needs to be rehashed. + */ + function password_needs_rehash($hash, $algo, array $options = array()) { + $info = password_get_info($hash); + if ($info['algo'] != $algo) { + return true; + } + switch ($algo) { + case PASSWORD_BCRYPT: + $cost = isset($options['cost']) ? $options['cost'] : 10; + if ($cost != $info['options']['cost']) { + return true; + } + break; + } + return false; + } + + /** + * Verify a password against a hash using a timing attack resistant approach + * + * @param string $password The password to verify + * @param string $hash The hash to verify against + * + * @return boolean If the password matches the hash + */ + function password_verify($password, $hash) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); + return false; + } + $ret = crypt($password, $hash); + if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != PasswordCompat\binary\_strlen($hash) || PasswordCompat\binary\_strlen($ret) <= 13) { + return false; + } + + $status = 0; + for ($i = 0; $i < PasswordCompat\binary\_strlen($ret); $i++) { + $status |= (ord($ret[$i]) ^ ord($hash[$i])); + } + + return $status === 0; + } +} + +} + +namespace PasswordCompat\binary { + /** + * Count the number of bytes in a string + * + * We cannot simply use strlen() for this, because it might be overwritten by the mbstring extension. + * In this case, strlen() will count the number of *characters* based on the internal encoding. A + * sequence of bytes might be regarded as a single multibyte character. + * + * @param string $binary_string The input string + * + * @internal + * @return int The number of bytes + */ + function _strlen($binary_string) { + if (function_exists('mb_strlen')) { + return mb_strlen($binary_string, '8bit'); + } + return strlen($binary_string); + } + + /** + * Get a substring based on byte limits + * + * @see _strlen() + * + * @param string $binary_string The input string + * @param int $start + * @param int $length + * + * @internal + * @return string The substring + */ + function _substr($binary_string, $start, $length) { + if (function_exists('mb_substr')) { + return mb_substr($binary_string, $start, $length, '8bit'); + } + return substr($binary_string, $start, $length); + } + +} diff --git a/bootstrap.inc b/bootstrap.inc new file mode 100644 index 00000000..55647ac0 --- /dev/null +++ b/bootstrap.inc @@ -0,0 +1,33 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + // Include required classes + require_once(ROOT.DS.'configs'.DS.'CoreConfig.inc'); + require_once(ROOT.DS.\nre\configs\CoreConfig::getClassDir('core').DS.'Autoloader.inc'); + + + // Set PHP-logging + ini_set('error_log', ROOT.DS.\nre\configs\CoreConfig::getClassDir('logs').DS.'php'.\nre\configs\CoreConfig::getFileExt('logs')); + + // Register autoloader + \nre\core\Autoloader::register(); + + + // Initialize WebApi + $webApi = new \nre\apis\WebApi(); + + // Run WebApi + $webApi->run(); + + // Render output + $webApi->render(); + +?> diff --git a/configs/AppConfig.inc b/configs/AppConfig.inc new file mode 100644 index 00000000..150e69bc --- /dev/null +++ b/configs/AppConfig.inc @@ -0,0 +1,272 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace nre\configs; + + + /** + * Application configuration. + * + * This class contains static variables with configuration values for + * the specific application. + * + * @author Oliver Hanraths + */ + final class AppConfig + { + + /** + * Application values + * + * @static + * @var array + */ + public static $app = array( + 'name' => 'Questlab', + 'genericname' => 'The Legend of Z', + 'namespace' => 'hhu\\z\\', + 'timeZone' => 'Europe/Berlin', + 'mailsender' => 'noreply@zyren.inf-d.de' + ); + + + /** + * Default values + * + * @static + * @var array + */ + public static $defaults = array( + 'toplevel' => 'html', + 'toplevel-error' => 'fault', + 'intermediate' => 'introduction', + 'intermediate-error' => 'error', + 'language' => 'de_DE.utf8', + 'locale' => 'de-DE' + ); + + + /** + * Directories + * + * @static + * @var array + */ + public static $dirs = array( + 'locale' => 'locale', + 'media' => 'media', + 'seminarymedia' => 'seminarymedia', + 'questtypes' => 'questtypes', + 'temporary' => 'tmp', + 'uploads' => 'uploads', + 'seminaryuploads' => 'seminaryuploads' + ); + + + /** + * Media sizes + * + * @static + * @var array + */ + public static $media = array( + 'questgroup' => array( + 'width' => 480, + 'height' => 5000 + ), + 'avatar' => array( + 'width' => 500, + 'height' => 500 + ) + ); + + + /** + * Allowed mimetypes with sizes + * + * @static + * @var array + */ + public static $mimetypes = array( + 'charactergroupsquests' => array( + array( + 'mimetype' => 'image/jpeg', + 'size' => 1048576, + ) + ) + ); + + + /** + * Miscellaneous settings + * + * @static + * @var array + */ + public static $misc = array( + 'ranking_range' => 2, + 'achievements_range' => 3 + ); + + + /** + * Validation settings for user input + * + * @static + * @var array + */ + public static $validation = array( + 'username' => array( + 'minlength' => 5, + 'maxlength' => 12, + 'regex' => '/^\w*$/' + ), + 'email' => array( + 'regex' => '/^\S+@[\w\d.-]{2,}\.[\w]{2,6}$/iU' + ), + 'prename' => array( + 'minlength' => 2, + 'maxlength' => 32, + 'regex' => '/^(\S| )*$/' + ), + 'surname' => array( + 'minlength' => 2, + 'maxlength' => 32, + 'regex' => '/^\S*$/' + ), + 'password' => array( + 'minlength' => 5, + 'maxlength' => 64 + ), + 'charactername' => array( + 'minlength' => 5, + 'maxlength' => 12, + 'regex' => '/^\w*$/' + ), + 'charactergroupsgroupname' => array( + 'minlength' => 4, + 'maxlength' => 32, + 'regex' => '/^(\S| )*$/' + ), + 'preferred' => array( + 'regex' => '/^(0|1)$/' + ), + 'charactergroupname' => array( + 'minlength' => 4, + 'maxlength' => 32, + 'regex' => '/^(\S| )*$/' + ), + 'motto' => array( + 'maxlength' => 128 + ), + 'title' => array( + 'minlength' => 4, + 'maxlength' => 32, + 'regex' => '/^(\S| )*$/' + ), + 'xps' => array( + 'minlength' => 1, + 'regex' => '/^(\d*)$/' + ) + ); + + + /** + * Routes + * + * @static + * @var array + */ + public static $routes = array( + array('^users/([^/]+)/(edit|delete)/?$', 'users/$2/$1', true), + array('^users/(?!(index|login|register|logout|manage|create|edit|delete))/?', 'users/user/$1', true), + array('^seminaries/([^/]+)/(edit|delete)/?$', 'seminaries/$2/$1', true), + array('^seminaries/(?!(index|create|edit|delete))/?', 'seminaries/seminary/$1', true), + array('^questgroups/([^/]+)/(create)/?$', 'questgroups/$2/$1', true), + array('^questgroups/([^/]+)/([^/]+)/?$', 'questgroups/questgroup/$1/$2', true), + array('^quests/([^/]+)/?$', 'quests/index/$1', true), + array('^quests/([^/]+)/(create|createmedia)/?$', 'quests/$2/$1' , true), + array('^quests/([^/]+)/([^/]+)/([^/]+)/(submissions)/?$', 'quests/$4/$1/$2/$3', true), + array('^quests/([^/]+)/([^/]+)/([^/]+)/(submission)/([^/]+)/?$', 'quests/$4/$1/$2/$3/$5', true), + array('^quests/(?!(index|create|createmedia))/?', 'quests/quest/$1', true), + array('^characters/([^/]+)/(register|manage)/?$', 'characters/$2/$1', true), + array('^characters/([^/]+)/?$', 'characters/index/$1', true), + array('^characters/([^/]+)/([^/]+)/(edit|delete)/?$', 'characters/$3/$1/$2', true), + array('^characters/([^/]+)/(?!(index|register|manage))/?', 'characters/character/$1/$2', true), + array('^charactergroups/([^/]+)/?$', 'charactergroups/index/$1', true), + array('^charactergroups/([^/]+)/(create)/?$', 'charactergroups/creategroupsgroup/$1/$2', true), + array('^charactergroups/([^/]+)/([^/]+)/?$', 'charactergroups/groupsgroup/$1/$2', true), + array('^charactergroups/([^/]+)/([^/]+)/(edit|delete)/?$', 'charactergroups/$3groupsgroup/$1/$2', true), + array('^charactergroups/([^/]+)/([^/]+)/(create)/?$', 'charactergroups/creategroup/$1/$2/$3', true), + array('^charactergroups/([^/]+)/([^/]+)/([^/]+)/?$', 'charactergroups/group/$1/$2/$3', true), + array('^charactergroups/([^/]+)/([^/]+)/([^/]+)/(manage|edit|delete)/?$', 'charactergroups/$4group/$1/$2/$3', true), + array('^charactergroupsquests/([^/]+)/([^/]+)/create/?$', 'charactergroupsquests/create/$1/$2', true), + array('^charactergroupsquests/([^/]+)/([^/]+)/([^/]+)/?$', 'charactergroupsquests/quest/$1/$2/$3', true), + array('^charactergroupsquests/([^/]+)/([^/]+)/([^/]+)/(edit|delete|manage)/?$', 'charactergroupsquests/$4/$1/$2/$3', true), + array('^achievements/([^/]+)/?$', 'achievements/index/$1', true), + array('^library/([^/]+)/?$', 'library/index/$1', true), + array('^library/([^/]+)/([^/]+)/?$', 'library/topic/$1/$2', true), + array('^media/(.*)$', 'media/$1?layout=binary', false), + array('^uploads/(.*)$', 'uploads/$1?layout=binary', false) + ); + + + /** + * Reverse routes + * + * @static + * @var array + */ + public static $reverseRoutes = array( + array('^users/user/(.*)$', 'users/$1', true), + array('^users/([^/]+)/(.*)$', 'users/$2/$1', true), + array('^seminaries/seminary/(.*)$', 'seminaries/$1', false), + array('^questgroups/create/(.*)$', 'questgroups/$2/$1', true), + array('^questgroups/questgroup/(.*)$', 'questgroups/$1', true), + array('^quests/index/([^/]+)$', 'quests/$1', true), + array('^quests/quest/(.*)$', 'quests/$1', true), + array('^quests/(create|createmedia)/(.*)$', 'quests/$2/$1' , true), + array('^quests/(submissions)/(.*)$', 'quests/$2/$1', true), + array('^quests/(submission)/([^/]+)/([^/]+)/([^/]+)/([^/]+)$', 'quests/$2/$3/$4/$1/$5', true), + array('^characters/(index|character)/(.*)$', 'characters/$2', true), + array('^characters/(register|manage)/([^/]+)$', 'characters/$2/$1', true), + array('^characters/(edit|delete)/([^/]+)/([^/]+)$', 'characters/$2/$3/$1', true), + array('^charactergroups/index/([^/]+)$', 'charactergroups/$1', true), + array('^charactergroups/creategroupsgroup/([^/]+)$', 'charactergroups/$1/create', true), + array('^charactergroups/groupsgroup/([^/]+)/([^/]+)$', 'charactergroups/$1/$2', true), + array('^charactergroups/(edit|delete)groupsgroup/([^/]+)/([^/]+)$', 'charactergroups/$2/$3/$1', true), + array('^charactergroups/creategroup/([^/]+)/([^/]+)$', 'charactergroups/$1/$2/create', true), + array('^charactergroups/group/([^/]+)/([^/]+)/([^/]+)$', 'charactergroups/$1/$2/$3', true), + array('^charactergroups/(manage|edit|delete)group/([^/]+)/([^/]+)/([^/]+)$', 'charactergroups/$2/$3/$4/$1', true), + array('^charactergroupsquests/create/([^/]+)/([^/]+)/?$', 'charactergroupsquests/$1/$2/create', true), + array('^charactergroupsquests/quest/(.*)$', 'charactergroupsquests/$1', true), + array('^charactergroupsquests/(edit|delete|manage)/([^/]+)/([^/]+)/([^/]+)$', 'charactergroupsquests/$2/$3/$4/$1', true), + array('^achievements/index/(.*)$', 'achievements/$1', true), + array('^library/(index|topic)/(.*)$', 'library/$2', true) + ); + + + /** + * Database connection settings + * + * @static + * @var array + */ + public static $database = array( + 'user' => 'z', + 'host' => 'localhost', + 'password' => 'legendofZ', + 'db' => 'z' + ); + + } + +?> diff --git a/configs/CoreConfig.inc b/configs/CoreConfig.inc new file mode 100644 index 00000000..45bc7e4a --- /dev/null +++ b/configs/CoreConfig.inc @@ -0,0 +1,167 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\configs; + + + /** + * Core configuration. + * + * This class contains static variables with configuration values. + * + * @author coderkun + */ + final class CoreConfig + { + + /** + * Core values + * + * @static + * @var array + */ + public static $core = array( + 'namespace' => 'nre\\', + ); + + + /** + * Directories + * + * @static + * @var array + */ + public static $dirs = array( + 'core' => 'core', + 'publicDir' => 'www' + ); + + + /** + * File extensions + * + * @static + * @var array + */ + public static $fileExts = array( + 'default' => 'inc', + 'views' => 'tpl', + 'logs' => 'log', + ); + + + /** + * Default values + * + * @static + * @var array + */ + public static $defaults = array( + 'action' => 'index', + 'errorFile' => 'error', + 'inlineErrorFile' => 'inlineerror' + ); + + + /** + * Miscellaneous settings + * + * @static + * @var array + */ + public static $misc = array( + 'fileExtDot' => '.' + ); + + + /** + * Logging settings + * + * @static + * @var array + */ + public static $log = array( + 'filename' => 'errors', + 'format' => 'Fehler %d: %s in %s, Zeile %d' + ); + + + /** + * Class-specific settings + * + * @static + * @var array + */ + public static $classes = array( + 'linker' => array( + 'url' => array( + 'length' => 128, + 'delimiter' => '-' + ) + ) + ); + + + + + /** + * Determine the directory for a specific classtype. + * + * @param string $classType Classtype to get directory of + * @return string Directory of given classtype + */ + public static function getClassDir($classType) + { + // Default directory (for core classes) + $classDir = self::$dirs['core']; + + // Configurable directory + if(array_key_exists($classType, self::$dirs)) { + $classDir = self::$dirs[$classType]; + } + else + { + // Default directory for classtype + if(is_dir(ROOT.DS.$classType)) { + $classDir = $classType; + } + } + + + // Return directory + return $classDir; + } + + + /** + * Determine the file extension for a specific filetype. + * + * @param string $fileType Filetype to get file extension of + * @return string File extension of given filetype + */ + public static function getFileExt($fileType) + { + // Default file extension + $fileExt = self::$fileExts['default']; + + // Configurable file extension + if(array_key_exists($fileType, self::$fileExts)) { + $fileExt = self::$fileExts[$fileType]; + } + + + // Return file extension + return self::$misc['fileExtDot'].$fileExt; + } + + } + +?> diff --git a/controllers/AchievementsController.inc b/controllers/AchievementsController.inc new file mode 100644 index 00000000..89b3f703 --- /dev/null +++ b/controllers/AchievementsController.inc @@ -0,0 +1,172 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to list Achievements. + * + * @author Oliver Hanraths + */ + class AchievementsController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('achievements', 'seminaries', 'media', 'characters'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'index' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'index' => array('admin', 'moderator', 'user') + ); + + + + + /** + * Action: index. + * + * List Achievements of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of Seminary + */ + public function index($seminaryUrl) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character + $character = SeminaryController::$character; + + // Get seldom Achievements + $seldomAchievements = $this->Achievements->getSeldomAchievements($seminary['id'], \nre\configs\AppConfig::$misc['achievements_range'], false); + foreach($seldomAchievements as &$achievement) { + $achievement['achieved'] = $this->Achievements->hasCharacterAchievedAchievement($achievement['id'], $character['id']); + } + + // Get Characters with the most Achievements + $successfulCharacters = $this->Characters->getCharactersWithMostAchievements($seminary['id'], \nre\configs\AppConfig::$misc['achievements_range'], false); + + // Get achieved Achievements + $achievedAchievements = $this->Achievements->getAchievedAchievementsForCharacter($character['id'], false); + + // Get unachieved Achievements + $unachievedAchievements = $this->Achievements->getUnachhievedAchievementsForCharacter($seminary['id'], $character['id'], true, false); + foreach($unachievedAchievements as &$achievement) + { + // Get Character progress + if($achievement['progress']) + { + $conditions = array(); + switch($achievement['condition']) + { + // Character conditions + case 'character': + $conditionsCharacter = $this->Achievements->getAchievementConditionsCharacter($achievement['id']); + foreach($conditionsCharacter as &$condition) + { + $conditions[] = array( + 'func' => 'getAchievementConditionCharacterProgress', + 'params' => array( + $condition['field'], + $condition['value'], + $character['id'] + ) + ); + } + break; + // Quest conditions + case 'quest': + $conditionsQuest = $this->Achievements->getAchievementConditionsQuest($achievement['id']); + foreach($conditionsQuest as &$condition) + { + $conditions[] = array( + 'func' => 'getAchievementConditionQuestProgress', + 'params' => array( + $condition['field'], + $condition['count'], + $condition['value'], + $condition['status'], + $condition['groupby'], + $condition['quest_id'], + $character['id'] + ) + ); + } + break; + // Achievement conditions + case 'achievement': + $conditionsAchievement = $this->Achievements->getAchievementConditionsAchievement($achievement['id']); + foreach($conditionsAchievement as &$condition) + { + $conditions[] = array( + 'func' => 'getAchievementConditionAchievementProgress', + 'params' => array( + $condition['field'], + $condition['count'], + $condition['value'], + $condition['groupby'], + $condition['meta_achievement_id'], + $character['id'] + ) + ); + } + break; + } + + $characterProgresses = array(); + foreach($conditions as &$condition) + { + // Calculate progress of condition + $characterProgresses[] = call_user_func_array( + array( + $this->Achievements, + $condition['func'] + ), + $condition['params'] + ); + } + + $achievement['characterProgress'] = array_sum($characterProgresses) / count($characterProgresses); + } + } + + // Get ranking + $character['rank'] = $this->Achievements->getCountRank($seminary['id'], count($achievedAchievements)); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('character', $character); + $this->set('seldomAchievements', $seldomAchievements); + $this->set('successfulCharacters', $successfulCharacters); + $this->set('achievedAchievements', $achievedAchievements); + $this->set('unachievedAchievements', $unachievedAchievements); + } + + } + +?> diff --git a/controllers/BinaryController.inc b/controllers/BinaryController.inc new file mode 100644 index 00000000..0d4396ef --- /dev/null +++ b/controllers/BinaryController.inc @@ -0,0 +1,37 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the BinaryAgent to show binary data. + * + * @author Oliver Hanraths + */ + class BinaryController extends \hhu\z\controllers\IntermediateController + { + + + + + /** + * Action: index. + * + * Create binary data. + */ + public function index() + { + } + + } + +?> diff --git a/controllers/CharactergroupsController.inc b/controllers/CharactergroupsController.inc new file mode 100644 index 00000000..e82d1fae --- /dev/null +++ b/controllers/CharactergroupsController.inc @@ -0,0 +1,610 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the CharactergroupsAgent to display Character groups. + * + * @author Oliver Hanraths + */ + class CharactergroupsController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'charactergroups', 'charactergroupsquests', 'characters', 'avatars', 'media'); + /** + * Required components + * + * @var array + */ + public $components = array('validation'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'index' => array('admin', 'moderator', 'user'), + 'groupsgroup' => array('admin', 'moderator', 'user'), + 'creategroupsgroup' => array('admin', 'moderator', 'user'), + 'editgroupsgroup' => array('admin', 'moderator', 'user'), + 'deletegroupsgroup' => array('admin', 'moderator', 'user'), + 'group' => array('admin', 'moderator', 'user'), + 'managegroup' => array('admin', 'moderator', 'user'), + 'creategroup' => array('admin', 'moderator', 'user'), + 'editgroup' => array('admin', 'moderator', 'user'), + 'deletegroup' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'index' => array('admin', 'moderator', 'user'), + 'groupsgroup' => array('admin', 'moderator', 'user'), + 'creategroupsgroup' => array('admin', 'moderator'), + 'editgroupsgroup' => array('admin', 'moderator'), + 'deletegroupsgroup' => array('admin', 'moderator'), + 'group' => array('admin', 'moderator', 'user'), + 'managegroup' => array('admin', 'moderator'), + 'creategroup' => array('admin', 'moderator'), + 'editgroup' => array('admin', 'moderator'), + 'deletegroup' => array('admin', 'moderator') + ); + + + + + /** + * Action: index. + * + * Show Character groups-groups for a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + */ + public function index($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-groups + $groupsgroups = $this->Charactergroups->getGroupsroupsForSeminary($seminary['id']); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroups', $groupsgroups); + } + + + /** + * Action: groupsgroups. + * + * Show Character groups for a Character groups-group of a + * Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + */ + public function groupsgroup($seminaryUrl, $groupsgroupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character groups + $groups = $this->Charactergroups->getGroupsForGroupsgroup($groupsgroup['id']); + + // Get Character groups-group Quests + $quests = $this->Charactergroupsquests->getQuestsForCharactergroupsgroup($groupsgroup['id']); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('groups', $groups); + $this->set('quests', $quests); + } + + + /** + * Action: creategroupsgroups. + * + * Create a new Character groups-group for a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + */ + public function creategroupsgroup($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Values + $charactergroupsgroupname = ''; + $preferred = false; + $fields = array('charactergroupsgroupname'); + $validation = array(); + + // Create a new Character groups-group + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('create'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $charactergroupsgroupname = $this->request->getPostParam('charactergroupsgroupname'); + if($this->Charactergroups->characterGroupsgroupNameExists($charactergroupsgroupname)) { + $validation = $this->Validation->addValidationResult($validation, 'charactergroupsgroupname', 'exist', true); + } + $preferred = !is_null($this->request->getPostParam('preferred')); + + // Create groups-group + if($validation === true) + { + $groupsgroupId = $this->Charactergroups->createGroupsgroup( + $this->Auth->getUserId(), + $seminary['id'], + $charactergroupsgroupname, + $preferred + ); + + // Redirect to groups-group page + $groupsgroup = $this->Charactergroups->getGroupsgroupById($groupsgroupId); + $this->redirect($this->linker->link(array('groupsgroup', $seminary['url'], $groupsgroup['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('charactergroupsgroupname', $charactergroupsgroupname); + $this->set('preferred', $preferred); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: editgroupsgroups. + * + * Edit a Character groups-group of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + */ + public function editgroupsgroup($seminaryUrl, $groupsgroupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Values + $charactergroupsgroupname = $groupsgroup['name']; + $preferred = $groupsgroup['preferred']; + $fields = array('charactergroupsgroupname'); + $validation = array(); + + // Edit Character groups-group + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('edit'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $charactergroupsgroupname = $this->request->getPostParam('charactergroupsgroupname'); + if($this->Charactergroups->characterGroupsgroupNameExists($charactergroupsgroupname, $groupsgroup['id'])) { + $validation = $this->Validation->addValidationResult($validation, 'charactergroupsgroupname', 'exist', true); + } + $preferred = !is_null($this->request->getPostParam('preferred')); + + // Edit groups-group + if($validation === true) + { + $this->Charactergroups->editGroupsgroup( + $groupsgroup['id'], + $charactergroupsgroupname, + $preferred + ); + + // Redirect to user page + $groupsgroup = $this->Charactergroups->getGroupsgroupById($groupsgroup['id']); + $this->redirect($this->linker->link(array('groupsgroup', $seminary['url'], $groupsgroup['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('charactergroupsgroupname', $charactergroupsgroupname); + $this->set('preferred', $preferred); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: deletegroupsgroups. + * + * Delete a Character groups-group of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + */ + public function deletegroupsgroup($seminaryUrl, $groupsgroupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Check request method + if($this->request->getRequestMethod() == 'POST') + { + // Check confirmation + if(!is_null($this->request->getPostParam('delete'))) + { + // Delete seminary + $this->Charactergroups->deleteGroupsgroup($groupsgroup['id']); + + // Redirect to overview + $this->redirect($this->linker->link(array('index', $seminary['url']), 1)); + } + + // Redirect to entry + $this->redirect($this->linker->link(array('groupsgroup', $seminary['url'], $groupsgroup['url']), 1)); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + } + + + /** + * Action: group. + * + * Show a Character group for a Character groups-group of a + * Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $groupUrl URL-Title of a Character group + */ + public function group($seminaryUrl, $groupsgroupUrl, $groupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character group + $group = $this->Charactergroups->getGroupByUrl($groupsgroup['id'], $groupUrl); + $group['characters'] = $this->Characters->getCharactersForGroup($group['id']); + $group['rank'] = $this->Charactergroups->getXPRank($groupsgroup['id'], $group['xps']); + + // Get Character avatars + foreach($group['characters'] as &$character) + { + $avatar = $this->Avatars->getAvatarById($character['avatar_id']); + if(!is_null($avatar['small_avatarpicture_id'])) { + $character['small_avatar'] = $this->Media->getSeminaryMediaById($avatar['small_avatarpicture_id']); + } + } + + // Get Character groups Quests + $quests = $this->Charactergroupsquests->getQuestsForGroup($group['id']); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('group', $group); + $this->set('quests', $quests); + } + + + /** + * Action: managegroup. + * + * Manage a Character group for a Character groups-group of a + * Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $groupUrl URL-Title of a Character group + */ + public function managegroup($seminaryUrl, $groupsgroupUrl, $groupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character group + $group = $this->Charactergroups->getGroupByUrl($groupsgroup['id'], $groupUrl); + + // Manage + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('actions')) && count($this->request->getPostParam('actions')) > 0) // && !is_null($this->request->getPostParam('characters')) && count($this->request->getPostParam('characters')) > 0) + { + $actions = $this->request->getPostParam('actions'); + $action = array_keys($actions)[0]; + $selectedCharacters = $this->request->getPostParam('characters'); + + switch($action) + { + // Add Characters to group + case 'addcharacters': + foreach($selectedCharacters as &$characterId) { + $this->Charactergroups->addCharacterToCharactergroup($group['id'], $characterId); + } + break; + // Remove Characters from group + case 'removecharacters': + foreach($selectedCharacters as &$characterId) { + $this->Charactergroups->removeCharacterFromCharactergroup($group['id'], $characterId); + } + break; + } + } + + // Get additional data for group + $group['characters'] = $this->Characters->getCharactersForGroup($group['id']); + $group['rank'] = $this->Charactergroups->getXPRank($groupsgroup['id'], $group['xps']); + + // Get Character avatars + foreach($group['characters'] as &$character) + { + $avatar = $this->Avatars->getAvatarById($character['avatar_id']); + if(!is_null($avatar['small_avatarpicture_id'])) { + $character['small_avatar'] = $this->Media->getSeminaryMediaById($avatar['small_avatarpicture_id']); + } + } + + // Get Character groups Quests + $quests = $this->Charactergroupsquests->getQuestsForGroup($group['id']); + + // Get all Characters of Seminary + $groupCharacterIds = array_map(function($c) { return $c['id']; }, $group['characters']); + $seminaryCharacters = $this->Characters->getCharactersForSeminary($seminary['id'], true); + $characters = array(); + foreach($seminaryCharacters as &$character) { + if(!in_array($character['id'], $groupCharacterIds)) { + $characters[] = $character; + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('group', $group); + $this->set('quests', $quests); + $this->set('characters', $characters); + } + + + /** + * Action: creategroup. + * + * Create a Character group for a Character groups-group of a + * Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + */ + public function creategroup($seminaryUrl, $groupsgroupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Values + $charactergroupname = ''; + $motto = ''; + $fields = array('charactergroupname', 'motto'); + $validation = array(); + + // Create a new Character groups-group + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('create'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $charactergroupname = $this->request->getPostParam('charactergroupname'); + if($this->Charactergroups->characterGroupNameExists($charactergroupname)) { + $validation = $this->Validation->addValidationResult($validation, 'charactergroupname', 'exist', true); + } + $motto = $this->request->getPostParam('motto'); + + // Create group + if($validation === true) + { + $groupId = $this->Charactergroups->createGroup( + $this->Auth->getUserId(), + $groupsgroup['id'], + $charactergroupname, + $motto + ); + + // Redirect to group page + $group = $this->Charactergroups->getGroupById($groupId); + $this->redirect($this->linker->link(array('group', $seminary['url'], $groupsgroup['url'], $group['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('charactergroupname', $charactergroupname); + $this->set('motto', $motto); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: editgroup. + * + * Edit a Character group for a Character groups-group of a + * Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $groupUrl URL-Title of a Character group + */ + public function editgroup($seminaryUrl, $groupsgroupUrl, $groupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character group + $group = $this->Charactergroups->getGroupByUrl($groupsgroup['id'], $groupUrl); + + // Values + $charactergroupname = $group['name']; + $motto = $group['motto']; + $fields = array('charactergroupname', 'motto'); + $validation = array(); + + // Edit Character group + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('edit'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $charactergroupname = $this->request->getPostParam('charactergroupname'); + if($this->Charactergroups->characterGroupNameExists($charactergroupname, $group['id'])) { + $validation = $this->Validation->addValidationResult($validation, 'charactergroupname', 'exist', true); + } + $motto = $this->request->getPostParam('motto'); + + // Edit group + if($validation === true) + { + $this->Charactergroups->editGroup( + $group['id'], + $charactergroupname, + $motto + ); + + // Redirect to user page + $group = $this->Charactergroups->getGroupById($group['id']); + $this->redirect($this->linker->link(array('group', $seminary['url'], $groupsgroup['url'], $group['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('charactergroupname', $charactergroupname); + $this->set('motto', $motto); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: deletegroup. + * + * Delete a Character group for a Character groups-group of a + * Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $groupUrl URL-Title of a Character group + */ + public function deletegroup($seminaryUrl, $groupsgroupUrl, $groupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character group + $group = $this->Charactergroups->getGroupByUrl($groupsgroup['id'], $groupUrl); + + // Check request method + if($this->request->getRequestMethod() == 'POST') + { + // Check confirmation + if(!is_null($this->request->getPostParam('delete'))) + { + // Delete seminary + $this->Charactergroups->deleteGroup($group['id']); + + // Redirect to overview + $this->redirect($this->linker->link(array('groupsgroup', $seminary['url'], $groupsgroup['url']), 1)); + } + + // Redirect to entry + $this->redirect($this->linker->link(array('group', $seminary['url'], $groupsgroup['url'], $group['url']), 1)); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('group', $group); + } + + } + +?> diff --git a/controllers/CharactergroupsquestsController.inc b/controllers/CharactergroupsquestsController.inc new file mode 100644 index 00000000..09a0da0d --- /dev/null +++ b/controllers/CharactergroupsquestsController.inc @@ -0,0 +1,503 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the CharactergroupsquestsAgent to display Character + * groups Quests. + * + * @author Oliver Hanraths + */ + class CharactergroupsquestsController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'charactergroups', 'charactergroupsquests', 'media', 'questgroups', 'uploads'); + /** + * Required components + * + * @var array + */ + public $components = array('validation'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'quest' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'quest' => array('admin', 'moderator', 'user') + ); + + + + + /** + * Action: quest. + * + * Show a Character groups Quest for a Character groups-group + * of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $questUrl URL-Title of a Character groups Quest + */ + public function quest($seminaryUrl, $groupsgroupUrl, $questUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character groups-group Quests + $quest = $this->Charactergroupsquests->getQuestByUrl($groupsgroup['id'], $questUrl); + + // Get Questgroup + $questgroup = $this->Questgroups->getQuestgroupById($quest['questgroups_id']); + $questgroup['entered'] = $this->Questgroups->hasCharacterEnteredQuestgroup($questgroup['id'], self::$character['id']); + + // Get Character groups-groups + $groups = $this->Charactergroups->getGroupsForQuest($quest['id']); + + // Media + $questmedia = null; + if(!is_null($quest['questsmedia_id'])) { + $questmedia = $this->Media->getSeminaryMediaById($quest['questsmedia_id']); + } + + // Get uploads + $uploads = $this->Charactergroupsquests->getMediaForQuest($quest['id']); + foreach($uploads as &$upload) { + $upload['upload'] = $this->Uploads->getSeminaryuploadById($upload['seminaryupload_id']); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('quest', $quest); + $this->set('questgroup', $questgroup); + $this->set('groups', $groups); + $this->set('media', $questmedia); + $this->set('uploads', $uploads); + } + + + /** + * Action: manage. + * + * Manage a Character groups Quest for a Character groups-group + * of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $questUrl URL-Title of a Character groups Quest + */ + public function manage($seminaryUrl, $groupsgroupUrl, $questUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character groups-group Quests + $quest = $this->Charactergroupsquests->getQuestByUrl($groupsgroup['id'], $questUrl); + + // Get Questgroup + $questgroup = $this->Questgroups->getQuestgroupById($quest['questgroups_id']); + $questgroup['entered'] = $this->Questgroups->hasCharacterEnteredQuestgroup($questgroup['id'], self::$character['id']); + + // Get Character groups + $groups = $this->Charactergroups->getGroupsForGroupsgroup($groupsgroup['id']); + + // Get allowed mimetypes + $mimetypes = \nre\configs\AppConfig::$mimetypes['charactergroupsquests']; + + // Manage + $validation = array(); + if($this->request->getRequestMethod() == 'POST') + { + // Upload media + if(!is_null($this->request->getPostParam('setmedia'))) + { + $file = $_FILES['media']; + + // Check error + if($file['error'] !== 0 || empty($file['tmp_name'])) { + $validation = $this->Validation->addValidationResult($validation, 'media', 'error', $file['error']); + } + + // Check mimetype + $mediaMimetype = null; + $file['mimetype'] = \hhu\z\Utils::getMimetype($file['tmp_name'], $file['type']); + foreach($mimetypes as &$mimetype) { + if($mimetype['mimetype'] == $file['mimetype']) { + $mediaMimetype = $mimetype; + break; + } + } + if(is_null($mediaMimetype)) { + $validation = $this->Validation->addValidationResult($validation, 'media', 'mimetype', $file['mimetype']); + } + elseif($file['size'] > $mediaMimetype['size']) { + $validation = $this->Validation->addValidationResult($validation, 'media', 'size', $mediaMimetype['size']); + } + + // Upload media + if($validation === true || empty($valiadion)) + { + // Create filename + $filename = sprintf( + '%s-%d-%s.%s', + 'charactergroupsquest', + $quest['id'], + date('Ymd-His'), + mb_substr($file['name'], strrpos($file['name'], '.')+1) + ); + + // Upload file + $this->Charactergroupsquests->uploadMediaForQuest($this->Auth->getUserId(), $seminary['id'], $quest['id'], $file, $filename); + } + } + + // Set XPs of Character groups for this Character groups Quest + if(!is_null($this->request->getPostParam('setxps'))) + { + $xps = $this->request->getPostParam('xps'); + foreach($groups as &$group) + { + if(array_key_exists($group['url'], $xps) && $xps[$group['url']] != 'null') + { + $xpsFactor = intval($xps[$group['url']]) / $quest['xps']; + $this->Charactergroupsquests->setXPsOfGroupForQuest($quest['id'], $group['id'], $xpsFactor); + } + else { + $this->Charactergroupsquests->deleteGroupForQuest($quest['id'], $group['id']); + } + } + } + + // Redirect to Quest page + if($validation === true || empty($validation)) { + $this->redirect($this->linker->link(array('quest', $seminary['url'], $groupsgroup['url'], $quest['url']), 1)); + } + } + + // Get icon + $questmedia = null; + if(!is_null($quest['questsmedia_id'])) { + $questmedia = $this->Media->getSeminaryMediaById($quest['questsmedia_id']); + } + + // Get uploads + $uploads = $this->Charactergroupsquests->getMediaForQuest($quest['id']); + foreach($uploads as &$upload) { + $upload['upload'] = $this->Uploads->getSeminaryuploadById($upload['seminaryupload_id']); + } + + + // Set XPs for Groups + foreach($groups as &$group) { + $group['quest_group'] = $this->Charactergroupsquests->getXPsOfGroupForQuest($quest['id'], $group['id']); + } + + + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('quest', $quest); + $this->set('uploads', $uploads); + $this->set('mimetypes', $mimetypes); + $this->set('questgroup', $questgroup); + $this->set('groups', $groups); + $this->set('media', $questmedia); + $this->set('validation', $validation); + } + + + /** + * Action: create. + * + * Create a new Character groups Quest for a Character + * groups-group of a Seminary. + * + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + */ + public function create($seminaryUrl, $groupsgroupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Questgroups + $questgroups = $this->Questgroups->getQuestgroupsForSeminary($seminary['id']); + + // Values + $title = ''; + $xps = 0; + $description = ''; + $rules = ''; + $wonText = ''; + $lostText = ''; + $fields = array('title', 'xps'); + $validation = array(); + + // Create a new Character groups Quest + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('create'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $title = $this->request->getPostParam('title'); + if($this->Charactergroupsquests->characterGroupsQuestTitleExists($title)) { + $validation = $this->Validation->addValidationResult($validation, 'title', 'exist', true); + } + $xps = $this->request->getPostParam('xps'); + $description = $this->request->getPostParam('description'); + $rules = $this->request->getPostParam('rules'); + $wonText = $this->request->getPostParam('wonText'); + $lostText = $this->request->getPostParam('lostText'); + + // Validate Questgroup + $questgroupIndex = null; + foreach($questgroups as $index => &$questgroup) + { + $questgroup['selected'] = ($questgroup['url'] == $this->request->getPostParam('questgroup')); + if($questgroup['selected']) { + $questgroupIndex = $index; + } + } + if(is_null($questgroupIndex)) { + throw new \nre\exceptions\ParamsNotValidException($questgroup); + } + + // Create groups Quest + if($validation === true) + { + $questId = $this->Charactergroupsquests->createQuest( + $this->Auth->getUserId(), + $groupsgroup['id'], + $questgroups[$questgroupIndex]['id'], + $title, + $description, + $xps, + $rules, + $wonText, + $lostText + ); + + // Redirect to Quest page + $quest = $this->Charactergroupsquests->getQuestById($questId); + $this->redirect($this->linker->link(array('quest', $seminary['url'], $groupsgroup['url'], $quest['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('questgroups', $questgroups); + $this->set('title', $title); + $this->set('xps', $xps); + $this->set('description', $description); + $this->set('rules', $rules); + $this->set('wonText', $wonText); + $this->set('lostText', $lostText); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: edit. + * + * Edit a Character groups Quest of a Character groups-group + * of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $questUrl URL-Title of a Character groups Quest + */ + public function edit($seminaryUrl, $groupsgroupUrl, $questUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character groups-group Quests + $quest = $this->Charactergroupsquests->getQuestByUrl($groupsgroup['id'], $questUrl); + + // Get Questgroups + $questgroups = $this->Questgroups->getQuestgroupsForSeminary($seminary['id']); + foreach($questgroups as $index => &$questgroup) { + $questgroup['selected'] = ($questgroup['id'] == $quest['questgroups_id']); + } + + // Values + $title = $quest['title']; + $xps = $quest['xps']; + $description = $quest['description']; + $rules = $quest['rules']; + $wonText = $quest['won_text']; + $lostText = $quest['lost_text']; + $fields = array('title', 'xps'); + $validation = array(); + + // Edit Character group + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('edit'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $title = $this->request->getPostParam('title'); + if($this->Charactergroupsquests->characterGroupsQuestTitleExists($title, $quest['id'])) { + $validation = $this->Validation->addValidationResult($validation, 'title', 'exist', true); + } + $xps = $this->request->getPostParam('xps'); + $description = $this->request->getPostParam('description'); + $rules = $this->request->getPostParam('rules'); + $wonText = $this->request->getPostParam('wonText'); + $lostText = $this->request->getPostParam('lostText'); + + // Validate Questgroup + $questgroupIndex = null; + foreach($questgroups as $index => &$questgroup) + { + $questgroup['selected'] = ($questgroup['url'] == $this->request->getPostParam('questgroup')); + if($questgroup['selected']) { + $questgroupIndex = $index; + } + } + if(is_null($questgroupIndex)) { + throw new \nre\exceptions\ParamsNotValidException($questgroup); + } + + // Edit groups Quest + if($validation === true) + { + $this->Charactergroupsquests->editQuest( + $quest['id'], + $groupsgroup['id'], + $questgroups[$questgroupIndex]['id'], + $title, + $description, + $xps, + $rules, + $wonText, + $lostText + ); + + // Redirect to Quest page + $quest = $this->Charactergroupsquests->getQuestById($quest['id']); + $this->redirect($this->linker->link(array('quest', $seminary['url'], $groupsgroup['url'], $quest['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('quest', $quest); + $this->set('questgroups', $questgroups); + $this->set('title', $title); + $this->set('xps', $xps); + $this->set('description', $description); + $this->set('rules', $rules); + $this->set('wonText', $wonText); + $this->set('lostText', $lostText); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: delete. + * + * Delete a Character groups Quest of a Character groups-group + * of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $questUrl URL-Title of a Character groups Quest + */ + public function delete($seminaryUrl, $groupsgroupUrl, $questUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character groups-group Quests + $quest = $this->Charactergroupsquests->getQuestByUrl($groupsgroup['id'], $questUrl); + + // Check request method + if($this->request->getRequestMethod() == 'POST') + { + // Check confirmation + if(!is_null($this->request->getPostParam('delete'))) + { + // Delete seminary + $this->Charactergroupsquests->deleteQuest($quest['id']); + + // Redirect to overview + $this->redirect($this->linker->link(array('charactergroups', 'groupsgroup', $seminary['url'], $groupsgroup['url']))); + } + + // Redirect to entry + $this->redirect($this->linker->link(array('quest', $seminary['url'], $groupsgroup['url'], $quest['url']), 1)); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('groupsgroup', $groupsgroup); + $this->set('quest', $quest); + } + + } + +?> diff --git a/controllers/CharactersController.inc b/controllers/CharactersController.inc new file mode 100644 index 00000000..c0303da4 --- /dev/null +++ b/controllers/CharactersController.inc @@ -0,0 +1,757 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to list registered users and their data. + * + * @author Oliver Hanraths + */ + class CharactersController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'characters', 'users', 'charactergroups', 'charactertypes', 'seminarycharacterfields', 'avatars', 'media', 'quests', 'questgroups', 'questtopics'); + /** + * Required components + * + * @var array + */ + public $components = array('validation'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'index' => array('admin', 'moderator', 'user'), + 'character' => array('admin', 'moderator', 'user'), + 'register' => array('admin', 'moderator', 'user'), + 'manage' => array('admin', 'moderator', 'user'), + 'edit' => array('admin', 'moderator', 'user'), + 'delete' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'index' => array('admin', 'moderator'), + 'character' => array('admin', 'moderator', 'user'), + 'manage' => array('admin', 'moderator'), + 'edit' => array('admin', 'moderator', 'user'), + 'delete' => array('admin', 'moderator') + ); + + + + + /** + * Action: index. + * + * List registered Characters for a Seminary + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + */ + public function index($seminaryUrl) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Seminarycharacterfields + $characterfields = $this->Seminarycharacterfields->getFieldsForSeminary($seminary['id']); + + // Get registered Characters + $characters = $this->Characters->getCharactersForSeminary($seminary['id']); + foreach($characters as &$character) + { + $character['xplevel'] = $this->Characters->getXPLevelOfCharacters($character['id']); + $character['user'] = $this->Users->getUserById($character['user_id']); + $character['characterroles'] = array_map(function($r) { return $r['name']; }, $this->Characterroles->getCharacterrolesForCharacterById($character['id'])); + $character['characterfields'] = array(); + foreach($this->Seminarycharacterfields->getFieldsForCharacter($character['id']) as $value) { + $character['characterfields'][$value['url']] = $value; + } + } + + // Sort Characters + global $sortorder; + $sortorder = ($this->request->getRequestMethod() == 'GET') ? $this->request->getGetParam('sortorder') : null; + $sortorder = (!is_null($sortorder)) ? $sortorder : 'xps'; + $sortMethod = 'sortCharactersBy'.ucfirst(strtolower($sortorder)); + if(method_exists($this, $sortMethod)) { + usort($characters, array($this, $sortMethod)); + } + elseif(in_array($sortorder, array_map(function($f) { return $f['title']; }, $characterfields))) { + usort($characters, function($a, $b) { + global $sortorder; + return $this->sortCharactersByField($a, $b, $sortorder); + }); + } + else { + throw new \nre\exceptions\ParamsNotValidException($sortorder); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('characters', $characters); + $this->set('characterfields', $characterfields); + $this->set('sortorder', $sortorder); + } + + + /** + * Action: character. + * + * Show a Charater and its details. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $characterUrl URL-name of a Charater + */ + public function character($seminaryUrl, $characterUrl) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + $seminary['achievable_xps'] = $this->Seminaries->getTotalXPs($seminary['id']); + + // Get Character + $character = $this->Characters->getCharacterByUrl($seminary['id'], $characterUrl); + $character['quest_xps'] = $this->Characters->getQuestXPsOfCharacter($character['id']); + $character['xplevel'] = $this->Characters->getXPLevelOfCharacters($character['id']); + $character['rank'] = $this->Characters->getXPRank($seminary['id'], $character['xps']); + + // Get User + $user = $this->Users->getUserById($character['user_id']); + + // Get Character groups + $groups = $this->Charactergroups->getGroupsForCharacter($character['id']); + foreach($groups as &$group) { + $group['groupsgroup'] = $this->Charactergroups->getGroupsgroupById($group['charactergroupsgroup_id']); + } + + // Get Achievements + $achievements = $this->Achievements->getAchievedAchievementsForCharacter($character['id']); + + // Get Achievements with deadline (milestones) + $milestones = $this->Achievements->getDeadlineAchievements($seminary['id']); + foreach($milestones as &$milestone) { + $milestone['achieved'] = $this->Achievements->hasCharacterAchievedAchievement($milestone['id'], $character['id']); + } + + // Get ranking + $ranking = array( + 'superior' => $this->Characters->getSuperiorCharacters($seminary['id'], $character['xps'], \nre\configs\AppConfig::$misc['ranking_range']), + 'inferior' => $this->Characters->getInferiorCharacters($seminary['id'], $character['id'], $character['xps'], \nre\configs\AppConfig::$misc['ranking_range']) + ); + + // Get Quest topics + $questtopics = $this->Questtopics->getQuesttopicsForSeminary($seminary['id']); + foreach($questtopics as &$questtopic) + { + $questtopic['questcount'] = $this->Questtopics->getQuestCountForQuesttopic($questtopic['id']); + $questtopic['characterQuestcount'] = $this->Questtopics->getCharacterQuestCountForQuesttopic($questtopic['id'], $character['id']); + } + + // Get “last” Quest + $lastQuest = null; + if(count(array_intersect(array('admin', 'moderator'), \hhu\z\controllers\SeminaryController::$character['characterroles'])) > 0) + { + $lastQuest = $this->Quests->getLastQuestForCharacter($character['id']); + if(!is_null($lastQuest)) { + $lastQuest['questgroup'] = $this->Questgroups->getQuestgroupById($lastQuest['questgroup_id']); + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('character', $character); + $this->set('user', $user); + $this->set('groups', $groups); + $this->set('achievements', $achievements); + $this->set('milestones', $milestones); + $this->set('ranking', $ranking); + $this->set('questtopics', $questtopics); + $this->set('lastQuest', $lastQuest); + } + + + /** + * Acton: register. + * + * Register a new character for a Seminary. + * + * @throws IdNotFoundException + * @throws ParamsNotValidException + * @param string $seminaryUrl URL-Title of a Seminary + */ + public function register($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Check for already existing Character + try { + $this->Characters->getCharacterForUserAndSeminary($this->Auth->getUserId(), $seminary['id']); + throw new \nre\exceptions\AccessDeniedException(); + } + catch(\nre\exceptions\IdNotFoundException $e) { + // The should be the case + } + + + // Character types + $types = $this->Charactertypes->getCharacterTypesForSeminary($seminary['id']); + + // Character fields + $fields = $this->Seminarycharacterfields->getFieldsForSeminary($seminary['id']); + + // Register Character + $charactername = ''; + $validation = true; + $fieldsValidation = true; + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('create'))) + { + // Validate Character properties + $validation = $this->Validation->validateParams($this->request->getPostParams(), array('charactername')); + $charactername = $this->request->getPostParam('charactername'); + if($this->Characters->characterNameExists($charactername)) { + $validation = $this->Validation->addValidationResult($validation, 'charactername', 'exist', true); + } + + // Validate type + $typeIndex = null; + foreach($types as $index => &$type) + { + $type['selected'] = ($type['url'] == $this->request->getPostParam('type')); + if($type['selected']) { + $typeIndex = $index; + } + } + if(is_null($typeIndex)) { + $validation = $this->Validation->addValidationResult($validation, 'type', 'exist', false); + } + + // Validate fields + $fieldsValues = $this->request->getPostParam('fields'); + foreach($fields as &$field) + { + if(!array_key_exists($field['url'], $fieldsValues)) { + throw new \nre\exceptions\ParamsNotValidException($index); + } + $field['uservalue'] = $fieldsValues[$field['url']]; + if($field['required']) + { + $fieldValidation = $this->Validation->validate($fieldsValues[$field['url']], array('regex'=>$field['regex'])); + if($fieldValidation !== true) + { + if(!is_array($fieldsValidation)) { + $fieldsValidation = array(); + } + $fieldsValidation[$field['url']] = $fieldValidation; + } + } + } + + // Register + if($validation === true && $fieldsValidation === true) + { + $characterId = $this->Characters->createCharacter($this->Auth->getUserId(), $types[$typeIndex]['id'], $charactername); + + // Add Seminary fields + foreach($fields as &$field) { + if(!empty($fieldsValues[$field['url']])) { + $this->Seminarycharacterfield->setSeminaryFieldOfCharacter($field['id'], $characterId, $fieldsValues[$field['url']]); + } + } + + // Send mail + $this->sendRegistrationMail($charactername); + + // Redirect + $this->redirect($this->linker->link(array('seminaries'))); + } + } + + // Get XP-levels + $xplevels = $this->Characters->getXPLevelsForSeminary($seminary['id']); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('types', $types); + $this->set('fields', $fields); + $this->set('charactername', $charactername); + $this->set('validation', $validation); + $this->set('fieldsValidation', $fieldsValidation); + $this->set('xplevels', $xplevels); + } + + + /** + * Action: manage. + * + * Manage Characters. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + */ + public function manage($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Set default properties to show + $properties = array( + 'username', + 'xps', + 'roles' + ); + + $selectedCharacters = array(); + global $sortorder; + if($this->request->getRequestMethod() == 'POST') + { + // Set sortorder + $sortorder = $this->request->getPostParam('sortorder'); + + // Do action + $selectedCharacters = $this->request->getPostParam('characters'); + if(!is_array($selectedCharacters)) { + $selectedCharacters = array(); + } + if(!is_null($this->request->getPostParam('actions')) && count($this->request->getPostParam('actions')) > 0 && !is_null($this->request->getPostParam('characters')) && count($this->request->getPostParam('characters')) > 0) + { + $actions = $this->request->getPostParam('actions'); + $action = array_keys($actions)[0]; + + switch($action) + { + // Add/remove role to/from Characters + case 'addrole': + case 'removerole': + // Determine role and check permissions + $role = null; + switch($actions[$action]) + { + case _('Admin'): + if(count(array_intersect(array('admin', 'moderator'), \hhu\z\controllers\IntermediateController::$user['roles'])) <= 0 && !in_array('admin', \hhu\z\controllers\SeminaryController::$character['characterroles'])) { + throw new \nre\exceptions\AccessDeniedException(); + } + $role = 'admin'; + break; + case _('Moderator'): + if(count(array_intersect(array('admin', 'moderator'), \hhu\z\controllers\IntermediateController::$user['roles'])) <= 0 && !in_array('admin', \hhu\z\controllers\SeminaryController::$character['characterroles'])) { + throw new \nre\exceptions\AccessDeniedException(); + } + $role = 'moderator'; + break; + case _('User'): + if(count(array_intersect(array('admin', 'moderator'), \hhu\z\controllers\IntermediateController::$user['roles'])) <= 0 && count(array_intersect(array('admin', 'moderator'), \hhu\z\controllers\SeminaryController::$character['characterroles'])) <= 0) { + throw new \nre\exceptions\AccessDeniedException(); + } + $role = 'user'; + break; + } + + // Add role + if($action == 'addrole') { + foreach($selectedCharacters as &$characterId) { + $this->Characterroles->addCharacterroleToCharacter($characterId, $role); + } + } + // Remove role + else { + foreach($selectedCharacters as &$characterId) { + $this->Characterroles->removeCharacterroleFromCharacter($characterId, $role); + } + } + break; + } + } + } + + // Get Seminarycharacterfields + $characterfields = $this->Seminarycharacterfields->getFieldsForSeminary($seminary['id']); + + // Get registered Characters + $characters = $this->Characters->getCharactersForSeminary($seminary['id']); + foreach($characters as &$character) + { + $character['xplevel'] = $this->Characters->getXPLevelOfCharacters($character['id']); + $character['user'] = $this->Users->getUserById($character['user_id']); + $character['characterroles'] = array_map(function($r) { return $r['name']; }, $this->Characterroles->getCharacterrolesForCharacterById($character['id'])); + $character['characterfields'] = array(); + foreach($this->Seminarycharacterfields->getFieldsForCharacter($character['id']) as $value) { + $character['characterfields'][$value['url']] = $value; + } + } + + // Sort Characters + $sortorder = (!is_null($sortorder)) ? $sortorder : 'xps'; + $sortMethod = 'sortCharactersBy'.ucfirst(strtolower($sortorder)); + if(method_exists($this, $sortMethod)) { + usort($characters, array($this, $sortMethod)); + } + elseif(in_array($sortorder, array_map(function($f) { return $f['title']; }, $characterfields))) { + usort($characters, function($a, $b) { + global $sortorder; + return $this->sortCharactersByField($a, $b, $sortorder); + }); + } + else { + throw new \nre\exceptions\ParamsNotValidException($sortorder); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('characters', $characters); + $this->set('characterfields', $characterfields); + $this->set('selectedCharacters', $selectedCharacters); + $this->set('sortorder', $sortorder); + } + + + /** + * Acton: edit. + * + * Edit a new character for a Seminary. + * + * @throws IdNotFoundException + * @throws ParamsNotValidException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $characterUrl URL-name of a Charater + */ + public function edit($seminaryUrl, $characterUrl) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character + $character = $this->Characters->getCharacterByUrl($seminary['id'], $characterUrl); + + // Check permissions + if(count(array_intersect(array('admin','moderator'), \hhu\z\controllers\SeminaryController::$character['characterroles'])) == 0 && $character['id'] != \hhu\z\controllers\SeminaryController::$character['id']) { + throw new \nre\exceptions\AccessDeniedException(); + } + + // Get User + $user = $this->Users->getUserById($character['user_id']); + + // Character types + $types = $this->Charactertypes->getCharacterTypesForSeminary($seminary['id']); + foreach($types as &$type) { + $type['selected'] = ($type['url'] == $character['charactertype_url']); + } + + // Character fields + $fields = $this->Seminarycharacterfields->getFieldsForSeminary($seminary['id']); + foreach($fields as &$field) + { + $userValue = $this->Seminarycharacterfields->getSeminaryFieldOfCharacter($field['id'], $character['id']); + if(!empty($userValue)) { + $field['uservalue'] = $userValue['value']; + } + } + + // Values + $charactername = $character['name']; + $validation = array(); + $fieldsValidation = true; + + // Edit Character + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('edit'))) + { + // Validate Character properties + $validation = $this->Validation->validateParams($this->request->getPostParams(), array('charactername')); + $charactername = (count(array_intersect(array('admin','moderator'), \hhu\z\controllers\SeminaryController::$character['characterroles'])) > 0) ? $this->request->getPostParam('charactername') : $character['name']; + if($this->Characters->characterNameExists($charactername, $character['id'])) { + $validation = $this->Validation->addValidationResult($validation, 'charactername', 'exist', true); + } + + // Validate type + $typeIndex = null; + foreach($types as $index => &$type) + { + $type['selected'] = (count(array_intersect(array('admin','moderator'), \hhu\z\controllers\SeminaryController::$character['characterroles'])) > 0) ? ($type['url'] == $this->request->getPostParam('type')) : ($type['url'] == $character['charactertype_url']); + if($type['selected']) { + $typeIndex = $index; + } + } + if(is_null($typeIndex)) { + $validation = $this->Validation->addValidationResult($validation, 'type', 'exist', false); + } + + // Validate fields + $fieldsValues = $this->request->getPostParam('fields'); + foreach($fields as &$field) + { + if(!array_key_exists($field['url'], $fieldsValues)) { + throw new \nre\exceptions\ParamsNotValidException($index); + } + $field['uservalue'] = $fieldsValues[$field['url']]; + if($field['required']) + { + $fieldValidation = $this->Validation->validate($fieldsValues[$field['url']], array('regex'=>$field['regex'])); + if($fieldValidation !== true) + { + if(!is_array($fieldsValidation)) { + $fieldsValidation = array(); + } + $fieldsValidation[$field['url']] = $fieldValidation; + } + } + } + + // Edit + if($validation === true && $fieldsValidation === true) + { + $this->Characters->editCharacter( + $character['id'], + $types[$typeIndex]['id'], + $charactername + ); + + // Set Seminary fields + foreach($fields as &$field) { + if(!empty($fieldsValues[$field['url']])) { + $this->Seminarycharacterfields->setSeminaryFieldOfCharacter($field['id'], $character['id'], $fieldsValues[$field['url']]); + } + } + + // Redirect + $character = $this->Characters->getCharacterById($character['id']); + $this->redirect($this->linker->link(array('character', $seminary['url'], $character['url']), 1)); + } + } + + // Get XP-levels + $xplevels = $this->Characters->getXPLevelsForSeminary($seminary['id']); + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('types', $types); + $this->set('fields', $fields); + $this->set('charactername', $charactername); + $this->set('validation', $validation); + $this->set('fieldsValidation', $fieldsValidation); + $this->set('xplevels', $xplevels); + } + + + /** + * Action: delete. + * + * Delete a Character. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $characterUrl URL-name of a Charater + */ + public function delete($seminaryUrl, $characterUrl) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character + $character = $this->Characters->getCharacterByUrl($seminary['id'], $characterUrl); + + // Get User + $user = $this->Users->getUserById($character['user_id']); + + // Check request method + if($this->request->getRequestMethod() == 'POST') + { + // Check confirmation + if(!is_null($this->request->getPostParam('delete'))) + { + // Delete Character + $this->Characters->deleteCharacter($character['id']); + + // Redirect to overview + $this->redirect($this->linker->link(array('index', $seminary['url']), 1)); + } + + // Redirect to entry + $this->redirect($this->linker->link(array('index', $seminary['url'], $character['url']), 1)); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('character', $character); + $this->set('user', $user); + } + + + + + /** + * Send mail for new Character registration. + * + * @param string $charactername Name of newly registered Character + */ + private function sendRegistrationMail($charactername) + { + $sender = \nre\configs\AppConfig::$app['mailsender']; + if(empty($sender)) { + return; + } + + // Send notification mail to system moderators + $subject = sprintf('new Character registration: %s', $charactername); + $message = sprintf('User “%s” <%s> has registered a new Character “%s” for the Seminary “%s”', self::$user['username'], self::$user['email'], $charactername, self::$seminary['title']); + $characters = $this->Characters->getCharactersWithCharacterRole(self::$seminary['id'], 'moderator'); + foreach($characters as &$character) + { + $moderator = $this->Users->getUserById($character['user_id']); + \hhu\z\Utils::sendMail($sender, $moderator['email'], $subject, $message); + } + } + + + /** + * Compare two Characters by their name. + * + * @param array $a Character a + * @param array $b Character b + * @return int Result of comparison + */ + private function sortCharactersByCharactername($a, $b) + { + if($a['name'] == $b['name']) { + return 0; + } + + + return ($a['name'] < $b['name']) ? -1 : 1; + } + + + /** + * Compare two Characters by their XPs. + * + * @param array $a Character a + * @param array $b Character b + * @return int Result of comparison + */ + private function sortCharactersByXps($a, $b) + { + if($a['xps'] == $b['xps']) { + return 0; + } + + + return ($a['xps'] > $b['xps']) ? -1 : 1; + } + + + /** + * Compare two Characters by their Character roles. + * + * @param array $a Character a + * @param array $b Character b + * @return int Result of comparison + */ + private function sortCharactersByRole($a, $b) + { + if(in_array('admin', $a['characterroles'])) + { + if(in_array('admin', $b['characterroles'])) { + return 0; + } + return -1; + } + if(in_array('moderator', $a['characterroles'])) + { + if(in_array('admin', $b['characterroles'])) { + return 1; + } + if(in_array('moderator', $b['characterroles'])) { + return 0; + } + return -1; + } + if(in_array('user', $a['characterroles'])) + { + if(in_array('admin', $b['characterroles']) || in_array('moderator', $b['characterroles'])) { + return 1; + } + if(in_array('user', $b['characterroles'])) { + return 0; + } + return -1; + } + if(in_array('guest', $a['characterroles'])) + { + if(in_array('admin', $b['characterroles']) || in_array('moderator', $b['characterroles']) || in_array('user', $b['characterroles'])) { + return 1; + } + if(in_array('guest', $b['characterroles'])) { + return 0; + } + return -1; + } + + + return 1; + } + + + /** + * Compare two Characters by their registration date. + * + * @param array $a Character a + * @param array $b Character b + * @return int Result of comparison + */ + private function sortCharactersByDate($a, $b) + { + if($a['created'] == $b['created']) { + return 0; + } + + + return ($a['created'] > $b['created']) ? -1 : 1; + } + + + /** + * Compare two Characters by one of their Seminary fields. + * + * @param array $a Character a + * @param array $b Character b + * @param string $field Field to compare + * @return int Result of comparison + */ + private function sortCharactersByField($a, $b, $field) + { + if($a['characterfields'][$field] == $b['characterfields'][$field]) { + return 0; + } + + + return ($a['characterfields'][$field] < $b['characterfields'][$field]) ? -1 : 1; + } + + } + +?> diff --git a/controllers/ErrorController.inc b/controllers/ErrorController.inc new file mode 100644 index 00000000..a27e3afd --- /dev/null +++ b/controllers/ErrorController.inc @@ -0,0 +1,51 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to show an error page. + * + * @author Oliver Hanraths + */ + class ErrorController extends \hhu\z\Controller + { + + + + + + + /** + * Action: index. + * + * Set HTTP-header and print an error message. + * + * @param int $httpStatusCode HTTP-statuscode of the error that occurred + */ + public function index($httpStatusCode) + { + // Set HTTP-header + if(!array_key_exists($httpStatusCode, \nre\core\WebUtils::$httpStrings)) { + $httpStatusCode = 200; + } + $this->response->addHeader(\nre\core\WebUtils::getHttpHeader($httpStatusCode)); + + // Display statuscode and message + $this->set('code', $httpStatusCode); + $this->set('string', \nre\core\WebUtils::$httpStrings[$httpStatusCode]); + $this->set('userId', $this->Auth->getUserId()); + } + + } + +?> diff --git a/controllers/FaultController.inc b/controllers/FaultController.inc new file mode 100644 index 00000000..be01fea7 --- /dev/null +++ b/controllers/FaultController.inc @@ -0,0 +1,37 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to display a toplevel error page. + * + * @author Oliver Hanraths + */ + class FaultController extends \nre\core\Controller + { + + + + + /** + * Action: index. + * + * Show the error message. + */ + public function index() + { + } + + } + +?> diff --git a/controllers/HtmlController.inc b/controllers/HtmlController.inc new file mode 100644 index 00000000..4c85e074 --- /dev/null +++ b/controllers/HtmlController.inc @@ -0,0 +1,68 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the HtmlAgent to display a HTML-page. + * + * @author Oliver Hanraths + */ + class HtmlController extends \hhu\z\Controller + { + /** + * Required components + * + * @var array + */ + public $components = array('notification'); + + + + + /** + * Prefilter. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::preFilter($request, $response); + + // Set content-type + $this->response->addHeader("Content-type: text/html; charset=utf-8"); + } + + + /** + * Action: index. + * + * Create the HTML-structure. + */ + public function index() + { + // Set the name of the current IntermediateAgent as page title + $this->set('title', $this->request->getParam(1, 'intermediate')); + + // Set userdata + $this->set('loggedUser', IntermediateController::$user); + $this->set('loggedSeminary', SeminaryController::$seminary); + $this->set('loggedCharacter', SeminaryController::$character); + + // Set notifications + $this->set('notifications', $this->Notification->getNotifications()); + } + + } + +?> diff --git a/controllers/IntroductionController.inc b/controllers/IntroductionController.inc new file mode 100644 index 00000000..c1a07f41 --- /dev/null +++ b/controllers/IntroductionController.inc @@ -0,0 +1,37 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to show an introduction page. + * + * @author Oliver Hanraths + */ + class IntroductionController extends \hhu\z\controllers\IntermediateController + { + + + + + /** + * Action: index. + */ + public function index() + { + // Pass data to view + $this->set('userId', $this->Auth->getUserId()); + } + + } + +?> diff --git a/controllers/LibraryController.inc b/controllers/LibraryController.inc new file mode 100644 index 00000000..9cbfb24e --- /dev/null +++ b/controllers/LibraryController.inc @@ -0,0 +1,135 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the LibraryAgent to list Quest topics. + * + * @author Oliver Hanraths + */ + class LibraryController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('questtopics', 'seminaries', 'quests', 'questgroups'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'index' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'index' => array('admin', 'moderator', 'user') + ); + + + + + /** + * Action: index. + * + * List Questtopics of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of Seminary + */ + public function index($seminaryUrl) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character + $character = SeminaryController::$character; + + // Get Quest topics + $totalQuestcount = 0; + $totalCharacterQuestcount = 0; + $questtopics = $this->Questtopics->getQuesttopicsForSeminary($seminary['id']); + foreach($questtopics as &$questtopic) + { + // Get Quest count + $questtopic['questcount'] = $this->Questtopics->getQuestCountForQuesttopic($questtopic['id']); + $totalQuestcount += $questtopic['questcount']; + + // Get Character progress + $questtopic['characterQuestcount'] = $this->Questtopics->getCharacterQuestCountForQuesttopic($questtopic['id'], $character['id']); + $totalCharacterQuestcount += $questtopic['characterQuestcount']; + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('totalQuestcount', $totalQuestcount); + $this->set('totalCharacterQuestcount', $totalCharacterQuestcount); + $this->set('questtopics', $questtopics); + } + + + /** + * Action: topic. + * + * Show a Questtopic and its Quests with Questsubtopics. + * + * @throws IdNotFoundException + * @param string $eminaryUrl URL-Title of Seminary + * @param string $questtopicUrl URL-Title of Questtopic + */ + public function topic($seminaryUrl, $questtopicUrl) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character + $character = SeminaryController::$character; + + // Get Questtopic + $questtopic = $this->Questtopics->getQuesttopicByUrl($seminary['id'], $questtopicUrl); + $questtopic['questcount'] = $this->Questtopics->getQuestCountForQuesttopic($questtopic['id']); + $questtopic['characterQuestcount'] = $this->Questtopics->getCharacterQuestCountForQuesttopic($questtopic['id'], $character['id']); + + // Get Quests + $quests = array(); + foreach($this->Quests->getQuestsForQuesttopic($questtopic['id']) as $quest) + { + if($this->Quests->hasCharacterEnteredQuest($quest['id'], $character['id']) || count(array_intersect(array('admin', 'moderator'), self::$character['characterroles'])) > 0) + { + // Get Questgroup + $quest['questgroup'] = $this->Questgroups->getQuestgroupById($quest['questgroup_id']); + + // Get Subtopics + $quest['subtopics'] = $this->Questtopics->getQuestsubtopicsForQuest($quest['id']); + + $quests[] = $quest; + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('questtopic', $questtopic); + $this->set('quests', $quests); + } + + } + +?> diff --git a/controllers/MediaController.inc b/controllers/MediaController.inc new file mode 100644 index 00000000..8916be90 --- /dev/null +++ b/controllers/MediaController.inc @@ -0,0 +1,458 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the MediaAgent to process and show Media. + * + * @author Oliver Hanraths + */ + class MediaController extends \hhu\z\controllers\SeminaryController + { + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'index' => array('admin', 'moderator', 'user', 'guest'), + 'seminarymoodpic' => array('admin', 'moderator', 'user'), + 'seminary' => array('admin', 'moderator', 'user'), + 'avatar' => array('admin', 'moderator', 'user'), + 'achievement' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'seminary' => array('admin', 'moderator', 'user', 'guest'), + 'achievement' => array('admin', 'moderator', 'user', 'guest') + ); + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'achievements', 'media', 'avatars'); + + + + + /** + * Prefilter. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::preFilter($request, $response); + + // Set headers for caching control + $response->addHeader("Pragma: public"); + $response->addHeader("Cache-control: public, max-age=".(60*60*24)); + $response->addHeader("Expires: ".gmdate('r', time()+(60*60*24))); + $response->addHeader("Date: ".gmdate(\DateTime::RFC822)); + } + + + /** + * Action: index + * + * Display a medium. + * + * @param string $mediaUrl URL-name of the medium + * @param string $action Action for processing the media + */ + public function index($mediaUrl, $action=null) + { + // Get Media + $media = $this->Media->getMediaByUrl($mediaUrl); + + // Get file + $file = $this->getMediaFile($media, $action); + if(is_null($media)) { + return; + } + + + // Pass data to view + $this->set('media', $media); + $this->set('file', $file); + } + + + /** + * Action: seminarymoodpic + * + * Display the moodpic for a category of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-title of the Seminary + * @param string $category Category to show moodpic of + * @param string $action Action for processing the media + */ + public function seminarymoodpic($seminaryUrl, $category=null, $action=null) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Set index + switch($category) + { + case null: + $index = 'seminarymedia_id'; + break; + case 'charactergroups': + $index = 'charactergroups_seminarymedia_id'; + break; + case 'achievements': + $index = 'achievements_seminarymedia_id'; + break; + case 'library': + $index = 'library_seminarymedia_id'; + break; + } + + // Get media + $media = $this->Media->getSeminaryMediaById($seminary[$index]); + + // Get file + $file = $this->getMediaFile($media, $action); + if(is_null($file)) { + return; + } + + + // Pass data to view + $this->set('media', $media); + $this->set('file', $file); + } + + + /** + * Action: seminary. + * + * Display a Seminary medium. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-title of the Seminary + * @param string $mediaUrl URL-name of the medium + * @param string $action Action for processing the media + */ + public function seminary($seminaryUrl, $mediaUrl, $action=null) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Media + $media = $this->Media->getSeminaryMediaByUrl($seminary['id'], $mediaUrl); + + // Get file + $file = $this->getMediaFile($media, $action); + if(is_null($file)) { + return; + } + + + // Pass data to view + $this->set('media', $media); + $this->set('file', $file); + } + + + /** + * Action: avatar. + * + * Display an Avatar as full size or portrait. + * + * @throws ParamsNotValidException + * @throws IdNotFoundException + * @param string $seminaryUrl URL-title of the Seminary + * @param string $charactertypeUrl URL-title of Character type + * @param int $xplevel XP-level + * @param string $action Size to show (avatar or portrait) + */ + public function avatar($seminaryUrl, $charactertypeUrl, $xplevel, $action='avatar') + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Avatar + $avatar = $this->Avatars->getAvatarByTypeAndLevel($seminary['id'], $charactertypeUrl, $xplevel); + + // Get media + switch($action) + { + case null: + case 'avatar': + $media = $this->Media->getSeminaryMediaById($avatar['avatarpicture_id']); + $file = $this->getMediaFile($media, 'avatar'); + break; + case 'portrait': + $media = $this->Media->getSeminaryMediaById($avatar['small_avatarpicture_id']); + $file = $this->getMediaFile($media); + break; + default: + throw new \nre\exceptions\ParamsNotValidException($action); + break; + } + + // Get file + if(is_null($file)) { + return; + } + + + // Pass data to view + $this->set('media', $media); + $this->set('file', $file); + } + + + /** + * Action: achievement + * + * Display the achievement of a Seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-title of the Seminary + * @param string $achievementUrl URL-title of the Achievement + * @param string $action Action for processing the media + */ + public function achievement($seminaryUrl, $achievementUrl, $locked=null) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character + $character = SeminaryController::$character; + + // Get Achievement + $achievement = $this->Achievements->getAchievementByUrl($seminary['id'], $achievementUrl); + + // Get media + switch($locked) + { + case null: + if(is_null($character) || !$this->Achievements->hasCharacterAchievedAchievement($achievement['id'], $character['id'])) { + throw new \nre\exceptions\AccessDeniedException(); + } + $index = 'achieved_achievementsmedia_id'; + break; + case 'locked': + $index = 'unachieved_achievementsmedia_id'; + break; + default: + throw new \nre\exceptions\ParamsNotValidException($locked); + break; + } + if(is_null($achievement[$index])) { + throw new \nre\exceptions\IdNotFoundException($achievementUrl); + } + $media = $this->Media->getSeminaryMediaById($achievement[$index]); + + // Get file + $file = $this->getMediaFile($media, null); + if(is_null($file)) { + return; + } + + + // Pass data to view + $this->set('media', $media); + $this->set('file', $file); + } + + + + + /** + * Determine file information and set the HTTP-header for + * caching accordingly. + * + * @param string $fileName Filename + * @return boolean HTTP-status 304 was set (in cache) + */ + private function setCacheHeaders($fileName) + { + // Determine last change of file + $fileLastModified = gmdate('r', filemtime($fileName)); + + // Generate E-Tag + $fileEtag = hash('sha256', $fileLastModified.$fileName); + + + // Set header + $this->response->addHeader("Last-Modified: ".$fileLastModified); + $this->response->addHeader("Etag: ".$fileEtag); + // HTTP-status + $headerModifiedSince = $this->request->getServerParam('HTTP_IF_MODIFIED_SINCE'); + $headerNoneMatch = $this->request->getServerParam('HTTP_IF_NONE_MATCH'); + if( + !is_null($headerModifiedSince) && $fileLastModified < strtotime($headerModifiedSince) && + !is_null($headerNoneMatch) && $headerNoneMatch == $fileEtag + ) { + $this->response->setExit(true); + $this->response->addHeader(\nre\core\WebUtils::getHttpHeader(304)); + + return true; + } + + + return false; + } + + + /** + * Determine the file for a medium and process it if necessary. + * + * @throws IdNotFoundException + * @throws ParamsNotValidException + * @param array $media Medium to get file for + * @param string $action Action for processing the media + * @return object File for the medium (or null if medium is cached) + */ + private function getMediaFile($media, $action=null) + { + // Get format + $format = explode('/', $media['mimetype']); + $format = $format[1]; + + // Set content-type + $this->response->addHeader("Content-type: ".$media['mimetype'].""); + + // Set filename + $media['filename'] = ROOT.DS.\nre\configs\AppConfig::$dirs['seminarymedia'].DS.$media['id']; + if(!file_exists($media['filename'])) { + throw new \nre\exceptions\IdNotFoundException($mediaUrl); + } + + // Cache + if($this->setCacheHeaders($media['filename'])) { + return null; + } + + // Load and process file + $file = null; + switch($action) + { + // No action + case null: + // Do not process the file + $file = file_get_contents($media['filename']); + break; + case 'questgroup': + if(!in_array(strtoupper($format), self::getImageTypes())) { + $file = file_get_contents($media['filename']); + } + else + { + $file = self::resizeImage( + $media['filename'], + $format, + \nre\configs\AppConfig::$media['questgroup']['width'], + \nre\configs\AppConfig::$media['questgroup']['height'] + ); + } + break; + case 'avatar': + $file = self::resizeImage( + $media['filename'], + $format, + \nre\configs\AppConfig::$media['avatar']['width'], + \nre\configs\AppConfig::$media['avatar']['height'] + ); + break; + default: + throw new ParamsNotValidException($action); + break; + } + + + // Return file + return $file; + } + + + /** + * Get supported image types. + * + * @return array List of supported image types + */ + private static function getImageTypes() + { + $im = new \Imagick(); + + + return $im->queryFormats(); + } + + + /** + * Resize an image. + * + * @param string $fileName Absolute pathname of image to resize + * @param string $mimeType Mimetype of target image + * @param int $width Max. width to resize to + * @param int $height Max. height to resize to + * @return mixed Resized image + */ + private static function resizeImage($fileName, $mimeType, $width, $height) + { + // Read image from cache + $tempFileName = ROOT.DS.\nre\configs\AppConfig::$dirs['temporary'].DS.'media-'.basename($fileName).'-'.$width.'x'.$height; + if(file_exists($tempFileName)) + { + // Check age of file + if(date('r', filemtime($tempFileName)+(60*60*24)) > date('r', time())) { + // Too old, delete + unlink($tempFileName); + } + else { + // Valid, read and return + return file_get_contents($tempFileName); + } + } + + + // ImageMagick + $im = new \Imagick($fileName); + + // Calculate new size + $geometry = $im->getImageGeometry(); + if($geometry['width'] < $width) { + $width = $geometry['width']; + } + if($geometry['height'] < $height) { + $height = $geometry['width']; + } + + // Process + $im->thumbnailImage($width, $height, true); + $im->contrastImage(1); + $im->setImageFormat($mimeType); + + // Save temporary file + $im->writeImage($tempFileName); + + + // Return resized image + return $im; + } + + } + +?> diff --git a/controllers/MenuController.inc b/controllers/MenuController.inc new file mode 100644 index 00000000..6c6a7d58 --- /dev/null +++ b/controllers/MenuController.inc @@ -0,0 +1,52 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to display a menu. + * + * @author Oliver Hanraths + */ + class MenuController extends \hhu\z\Controller + { + + + + + /** + * Prefilter. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::preFilter($request, $response); + + // Set userdata + $this->set('loggedUser', IntermediateController::$user); + $this->set('loggedCharacter', SeminaryController::$character); + $this->set('loggedSeminary', SeminaryController::$seminary); + } + + + /** + * Action: index. + */ + public function index() + { + } + + } + +?> diff --git a/controllers/QuestgroupsController.inc b/controllers/QuestgroupsController.inc new file mode 100644 index 00000000..8e4d01c4 --- /dev/null +++ b/controllers/QuestgroupsController.inc @@ -0,0 +1,238 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the QuestgroupsAgent to display Questgroups. + * + * @author Oliver Hanraths + */ + class QuestgroupsController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'questgroupshierarchy', 'questgroups', 'quests', 'questtexts', 'media'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'questgroup' => array('admin', 'moderator', 'user'), + 'create' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'questgroup' => array('admin', 'moderator', 'user'), + 'create' => array('admin', 'moderator') + ); + + + + + /** + * Action: questgroup. + * + * Display a Questgroup and its data. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $questgroupUrl URL-Title of a Questgroup + */ + public function questgroup($seminaryUrl, $questgroupUrl) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Questgroup + $questgroup = $this->Questgroups->getQuestgroupByUrl($seminary['id'], $questgroupUrl); + + // Get Questgrouphierarchy + $questgroup['hierarchy'] = $this->Questgroupshierarchy->getHierarchyForQuestgroup($questgroup['id']); + + // Get Character + $character = $this->Characters->getCharacterForUserAndSeminary($this->Auth->getUserId(), $seminary['id']); + + // Check permission + if(count(array_intersect(array('admin','moderator'), SeminaryController::$character['characterroles'])) == 0) + { + $previousQuestgroup = $this->Questgroups->getPreviousQuestgroup($questgroup['id']); + if(!is_null($previousQuestgroup)) { + if(!$this->Questgroups->hasCharacterSolvedQuestgroup($previousQuestgroup['id'], $character['id'])) { + throw new \nre\exceptions\AccessDeniedException(); + } + } + } + + // Set status “entered” + $this->Questgroups->setQuestgroupEntered($questgroup['id'], $character['id']); + + // Get child Questgroupshierarchy + $childQuestgroupshierarchy = null; + if(!empty($questgroup['hierarchy'])) + { + $childQuestgroupshierarchy = $this->Questgroupshierarchy->getChildQuestgroupshierarchy($questgroup['hierarchy']['id']); + foreach($childQuestgroupshierarchy as &$hierarchy) + { + // Get Questgroups + $hierarchy['questgroups'] = $this->Questgroups->getQuestgroupsForHierarchy($hierarchy['id'], $questgroup['id']); + + // Get additional data + foreach($hierarchy['questgroups'] as $i => &$group) + { + $group['solved'] = $this->Questgroups->hasCharacterSolvedQuestgroup($group['id'], $character['id']); + + // Check permission of Questgroups + if($i >= 1 && count(array_intersect(array('admin','moderator'), SeminaryController::$character['characterroles'])) == 0) + { + if(!$hierarchy['questgroups'][$i-1]['solved']) + { + $hierarchy['questgroups'] = array_slice($hierarchy['questgroups'], 0, $i); + break; + } + } + + // Get cumulated data + $data = $this->Questgroups->getCumulatedDataForQuestgroup($group['id'], $character['id']); + $group['xps'] = $data['xps']; + $group['character_xps'] = $data['character_xps']; + + // Attach related Questgroups + $group['relatedQuestgroups'] = array(); + $relatedQuestgroups = $this->Questgroups->getRelatedQuestsgroupsOfQuestgroup($group['id']); + foreach($relatedQuestgroups as &$relatedQuestgroup) { + if($this->Questgroups->hasCharacterEnteredQuestgroup($relatedQuestgroup['id'], $character['id'])) { + $group['relatedQuestgroups'][] = $this->Questgroups->getQuestgroupById($relatedQuestgroup['id']); + } + } + } + } + } + + // Get texts + $questgroupTexts = $this->Questgroups->getQuestgroupTexts($questgroup['id']); + + // Get Character XPs + $questgroup['character_xps'] = $this->Questgroups->getAchievedXPsForQuestgroup($questgroup['id'], $character['id']); + + // Media + $picture = null; + if(!is_null($questgroup['questgroupspicture_id'])) + { + $picture = $this->Media->getSeminaryMediaById($questgroup['questgroupspicture_id']); + } + + + // Get Quests + $quests = array(); + if(count($childQuestgroupshierarchy) == 0) + { + $currentQuest = null; + do { + // Get next Quest + if(is_null($currentQuest)) { + $currentQuest = $this->Quests->getFirstQuestOfQuestgroup($questgroup['id']); + } + else { + $nextQuests = $this->Quests->getNextQuests($currentQuest['id']); + $currentQuest = null; + foreach($nextQuests as &$nextQuest) { + if($this->Quests->hasCharacterEnteredQuest($nextQuest['id'], $character['id'])) { + $currentQuest = $nextQuest; + break; + } + } + } + + // Add additional data + if(!is_null($currentQuest)) + { + // Set status + $currentQuest['solved'] = $this->Quests->hasCharacterSolvedQuest($currentQuest['id'], $character['id']); + + // Attach related Questgroups + $currentQuest['relatedQuestgroups'] = array(); + $relatedQuestgroups = $this->Questgroups->getRelatedQuestsgroupsOfQuest($currentQuest['id']); + foreach($relatedQuestgroups as &$relatedQuestgroup) + { + if($this->Questgroups->hasCharacterEnteredQuestgroup($relatedQuestgroup['id'], $character['id'])) { + $currentQuest['relatedQuestgroups'][] = $this->Questgroups->getQuestgroupById($relatedQuestgroup['id']); + } + } + + // Add Quest to Quests + $quests[] = $currentQuest; + } + } + while(!is_null($currentQuest) && ($currentQuest['solved'] || count(array_intersect(array('admin','moderator'), SeminaryController::$character['characterroles'])) > 0)); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('questgroup', $questgroup); + $this->set('childquestgroupshierarchy', $childQuestgroupshierarchy); + $this->set('texts', $questgroupTexts); + $this->set('picture', $picture); + $this->set('quests', $quests); + } + + + /** + * Action: create. + * + * Create a new Questgroup. + * + * @param string $seminaryUrl URL-Title of a Seminary + */ + public function create($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Create Questgroup + $validation = true; + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('create'))) + { + // TODO Validation + $title = $this->request->getPostParam('title'); + + // Create new Questgroup + if($validation === true) + { + $questgroupId = $this->Questgroups->createQuestgroup( + $this->Auth->getUserId(), + $seminary['id'], + $title + ); + + // Redirect + $this->redirect($this->linker->link(array('seminaries', 'seminary', $seminary['url']))); + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + } + + } + +?> diff --git a/controllers/QuestgroupshierarchypathController.inc b/controllers/QuestgroupshierarchypathController.inc new file mode 100644 index 00000000..fabf2bbb --- /dev/null +++ b/controllers/QuestgroupshierarchypathController.inc @@ -0,0 +1,90 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of QuestgroupshierarchypathAgent to display the + * Questgroups hierarchy path. + * + * @author Oliver Hanraths + */ + class QuestgroupshierarchypathController extends \hhu\z\Controller + { + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'questgroups', 'questgroupshierarchy', 'quests', 'questtexts'); + + + + + /** + * Action: index. + * + * Calculate and show the hierarchy path of a Questgroup. + * + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $questgroupUrl URL-Title of a Questgroup + * @param boolean $showGroup Show the current group itself + */ + public function index($seminaryUrl, $questgroupUrl, $showGroup=false) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Questgroup + $questgroup = $this->Questgroups->getQuestgroupByUrl($seminary['id'], $questgroupUrl); + $questgroup['hierarchy'] = $this->Questgroupshierarchy->getHierarchyForQuestgroup($questgroup['id']); + + // Get parent Questgrouphierarchy + $currentQuestgroup = $questgroup; + $parentQuestgroupshierarchy = array(); + if($showGroup) { + array_unshift($parentQuestgroupshierarchy, $currentQuestgroup); + } + if(is_null($questgroup['hierarchy'])) + { + // Get related Questgroup + $questtext = $this->Questtexts->getRelatedQuesttextForQuestgroup($currentQuestgroup['id']); + $quest = $this->Quests->getQuestById($questtext['quest_id']); + $currentQuestgroup = $this->Questgroups->getQuestgroupById($quest['questgroup_id']); + $currentQuestgroup['hierarchy'] = $this->Questgroupshierarchy->getHierarchyForQuestgroup($currentQuestgroup['id']); + $quest['questgroup'] = $currentQuestgroup; + + // Use Hierarchy name for optional Questgroup + if(!empty($parentQuestgroupshierarchy)) { + $parentQuestgroupshierarchy[0]['hierarchy'] = $currentQuestgroup['hierarchy']; + unset($parentQuestgroupshierarchy[0]['hierarchy']['questgroup_pos']); + } + + array_unshift($parentQuestgroupshierarchy, $quest); + array_unshift($parentQuestgroupshierarchy, $currentQuestgroup); + } + while(!empty($currentQuestgroup['hierarchy']) && !is_null($currentQuestgroup['hierarchy']['parent_questgroup_id'])) + { + $currentQuestgroup = $this->Questgroups->GetQuestgroupById($currentQuestgroup['hierarchy']['parent_questgroup_id']); + $currentQuestgroup['hierarchy'] = $this->Questgroupshierarchy->getHierarchyForQuestgroup($currentQuestgroup['id']); + array_unshift($parentQuestgroupshierarchy, $currentQuestgroup); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('parentquestgroupshierarchy', $parentQuestgroupshierarchy); + } + + } + +?> diff --git a/controllers/QuestsController.inc b/controllers/QuestsController.inc new file mode 100644 index 00000000..bdb9aa9c --- /dev/null +++ b/controllers/QuestsController.inc @@ -0,0 +1,819 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the QuestsAgent to display Quests. + * + * @author Oliver Hanraths + */ + class QuestsController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'questgroups', 'quests', 'questtexts', 'media', 'questtypes', 'questgroupshierarchy'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'index' => array('admin', 'moderator', 'user'), + 'quest' => array('admin', 'moderator', 'user'), + 'submissions' => array('admin', 'moderator', 'user'), + 'submission' => array('admin', 'moderator', 'user'), + 'create' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'index' => array('admin', 'moderator', 'user'), + 'quest' => array('admin', 'moderator', 'user'), + 'submissions' => array('admin', 'moderator'), + 'submission' => array('admin', 'moderator'), + 'create' => array('admin', 'moderator') + ); + + + + + /** + * Action: index. + * + * List all Quests for a Seminary. + * + * @param string $seminaryUrl URL-Title of Seminary + */ + public function index($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Prepare filters + $filters = array( + 'questgroups' => array(), + 'questtypes' => array() + ); + + // Get selected filters + $selectedFilters = array( + 'questgroup' => "0", + 'questtype' => "" + ); + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('filters'))) { + $selectedFilters = $this->request->getPostParam('filters'); + } + + // Get Quests + $quests = array(); + foreach($this->Quests->getQuestsForSeminary($seminary['id']) as $quest) + { + // Get Questgroup + $quest['questgroup'] = $this->Questgroups->getQuestgroupById($quest['questgroup_id']); + if($selectedFilters['questgroup'] != "0" && $selectedFilters['questgroup'] != $quest['questgroup']['id']) { + continue; + } + + // Get Questtype + $quest['questtype'] = $this->Questtypes->getQuesttypeById($quest['questtype_id']); + if($selectedFilters['questtype'] != "" && $selectedFilters['questtype'] != $quest['questtype']['classname']) { + continue; + } + + // Add filter values + $filters['questgroups'][$quest['questgroup']['id']] = $quest['questgroup']; + $filters['questtypes'][$quest['questtype']['classname']] = $quest['questtype']; + + // Add open submissions count + $quest['opensubmissionscount'] = count($this->Characters->getCharactersSubmittedQuest($quest['id'])); + + $quests[] = $quest; + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('quests', $quests); + $this->set('filters', $filters); + $this->set('selectedFilters', $selectedFilters); + } + + + /** + * Action: quest. + * + * Show a quest and its task. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of Seminary + * @param string $questgroupUrl URL-Title of Questgroup + * @param string $questUrl URL-Title of Quest + * @param string $questtexttypeUrl URL-Title of Questtexttype + * @param int $questtextPos Position of Questtext + */ + public function quest($seminaryUrl, $questgroupUrl, $questUrl, $questtexttypeUrl=null, $questtextPos=1) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Questgroup + $questgroup = $this->Questgroups->getQuestgroupByUrl($seminary['id'], $questgroupUrl); + $questgroup['picture'] = null; + if(!is_null($questgroup['questgroupspicture_id'])) { + $questgroup['picture'] = $this->Media->getSeminaryMediaById($questgroup['questgroupspicture_id']); + } + + // Get Quest + $quest = $this->Quests->getQuestByUrl($seminary['id'], $questgroup['id'], $questUrl); + + // Get Character + $character = $this->Characters->getCharacterForUserAndSeminary($this->Auth->getUserId(), $seminary['id']); + + // Check permissions + if(count(array_intersect(array('admin','moderator'), SeminaryController::$character['characterroles'])) == 0) + { + $previousQuests = $this->Quests->getPreviousQuests($quest['id']); + if(count($previousQuests) == 0) + { + // Previous Questgroup + $previousQuestgroup = $this->Questgroups->getPreviousQuestgroup($questgroup['id']); + if(!is_null($previousQuestgroup) && !$this->Questgroups->hasCharacterSolvedQuestgroup($previousQuestgroup['id'], $character['id'])) { + throw new \nre\exceptions\AccessDeniedException(); + } + } + else + { + // Previous Quests + // One previous Quest has to be solved and no other + // following Quests of ones has to be tried + $solved = false; + $tried = false; + foreach($previousQuests as &$previousQuest) + { + // // Check previous Quest + if($this->Quests->hasCharacterSolvedQuest($previousQuest['id'], $character['id'])) + { + $solved = true; + + // Check following Quests + $followingQuests = $this->Quests->getNextQuests($previousQuest['id']); + foreach($followingQuests as $followingQuest) + { + // Check following Quest + if($followingQuest['id'] != $quest['id'] && $this->Quests->hasCharacterTriedQuest($followingQuest['id'], $character['id'])) + { + $tried = true; + break; + } + } + + break; + } + } + if(!$solved || $tried) { + throw new \nre\exceptions\AccessDeniedException(); + } + } + } + + // Set status “entered” + $this->Quests->setQuestEntered($quest['id'], $character['id']); + + // Has Character solved quest? + $solved = $this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id']); + + // Get Questtexts + $questtexts = array(); + $questtexts['Prolog'] = $this->Questtexts->getQuesttextsOfQuest($quest['id'], 'Prolog'); + if($solved) { + $questtexts['Epilog'] = $this->Questtexts->getQuesttextsOfQuest($quest['id'], 'Epilog'); + } + foreach($questtexts as &$questtextList) + { + foreach($questtextList as &$questtext) + { + // Questtext media + if(!is_null($questtext['questsmedia_id'])) { + $questtext['media'] = $this->Media->getSeminaryMediaById($questtext['questsmedia_id']); + } + + // Related Questgroups + $questtext['relatedQuestsgroups'] = $this->Questgroups->getRelatedQuestsgroupsOfQuesttext($questtext['id']); + } + } + + // Quest status + $questStatus = $this->request->getGetParam('status'); + + // Quest media + $questmedia = null; + if(!is_null($quest['questsmedia_id'])) { + $questmedia = $this->Media->getSeminaryMediaById($quest['questsmedia_id']); + } + + // Task + $task = null; + $questtype = $this->Questtypes->getQuesttypeById($quest['questtype_id']); + if(!is_null($questtype['classname'])) { + $task = $this->renderTask($questtype['classname'], $seminary, $questgroup, $quest, $character); + } + else + { + // Mark Quest as solved + $this->Quests->setQuestSolved($quest['id'], $character['id']); + $solved = true; + } + + // Get (related) Questtext + $relatedQuesttext = $this->Questtexts->getRelatedQuesttextForQuestgroup($questgroup['id']); + if(!empty($relatedQuesttext)) { + $relatedQuesttext['quest'] = $this->Quests->getQuestById($relatedQuesttext['quest_id']); + if(!empty($relatedQuesttext['quest'])) { + $relatedQuesttext['quest']['questgroup_url'] = $this->Questgroups->getQuestgroupById($relatedQuesttext['quest']['questgroup_id'])['url']; + } + } + + // Next Quest/Questgroup + $nextQuests = null; + $charactedHasChoosenNextQuest = false; + $nextQuestgroup = null; + if($solved) + { + // Next Quest + $nextQuests = $this->Quests->getNextQuests($quest['id']); + foreach($nextQuests as &$nextQuest) + { + // Set entered status of Quest + $nextQuest['entered'] = $this->Quests->hasCharacterEnteredQuest($nextQuest['id'], $character['id']); + if($nextQuest['entered']) { + $charactedHasChoosenNextQuest = true; + } + } + + // Next Questgroup + if(empty($nextQuests)) + { + if(is_null($relatedQuesttext)) + { + $nextQuestgroup = $this->Questgroups->getNextQuestgroup($questgroup['id']); + $nextQuestgroup['hierarchy'] = $this->Questgroupshierarchy->getHierarchyForQuestgroup($nextQuestgroup['id']); + } + else + { + // Related (Main-) Quest + $nextQuest = $relatedQuesttext['quest']; + $nextQuest['entered'] = true; + $nextQuests = array($nextQuest); + } + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('questgroup', $questgroup); + //$this->set('questtexttype', $questtexttype); + $this->set('questtexts', $questtexts); + //$this->set('hasEpilog', $hasEpilog); + $this->set('quest', $quest); + $this->set('queststatus', $questStatus); + $this->set('relatedquesttext', $relatedQuesttext); + $this->set('nextquests', $nextQuests); + $this->set('charactedHasChoosenNextQuest', $charactedHasChoosenNextQuest); + $this->set('nextquestgroup', $nextQuestgroup); + $this->set('task', $task); + $this->set('media', $questmedia); + $this->set('solved', $solved); + } + + + /** + * List Character submissions for a Quest. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of Seminary + * @param string $questgroupUrl URL-Title of Questgroup + * @param string $questUrl URL-Title of Quest + */ + public function submissions($seminaryUrl, $questgroupUrl, $questUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Questgroup + $questgroup = $this->Questgroups->getQuestgroupByUrl($seminary['id'], $questgroupUrl); + $questgroup['picture'] = null; + if(!is_null($questgroup['questgroupspicture_id'])) { + $questgroup['picture'] = $this->Media->getSeminaryMediaById($questgroup['questgroupspicture_id']); + } + + // Get Quest + $quest = $this->Quests->getQuestByUrl($seminary['id'], $questgroup['id'], $questUrl); + + // Media + $questmedia = null; + if(!is_null($quest['questsmedia_id'])) { + $questmedia = $this->Media->getSeminaryMediaById($quest['questsmedia_id']); + } + + // Get submitted Character submissions waiting for approval + $submittedSubmissionCharacters = $this->Characters->getCharactersSubmittedQuest($quest['id']); + + // Get unsolved Character submissions + $unsolvedSubmissionCharacters = $this->Characters->getCharactersUnsolvedQuest($quest['id']); + + // Get solved Character submissions + $solvedSubmissionCharacters = $this->Characters->getCharactersSolvedQuest($quest['id']); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('questgroup', $questgroup); + $this->set('quest', $quest); + $this->set('media', $questmedia); + $this->set('submittedSubmissionCharacters', $submittedSubmissionCharacters); + $this->set('unsolvedSubmissionCharacters', $unsolvedSubmissionCharacters); + $this->set('solvedSubmissionCharacters', $solvedSubmissionCharacters); + } + + + /** + * Show and handle the submission of a Character for a Quest. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of Seminary + * @param string $questgroupUrl URL-Title of Questgroup + * @param string $questUrl URL-Title of Quest + * @param string $characterUrl URL-Title of Character + */ + public function submission($seminaryUrl, $questgroupUrl, $questUrl, $characterUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Questgroup + $questgroup = $this->Questgroups->getQuestgroupByUrl($seminary['id'], $questgroupUrl); + $questgroup['picture'] = null; + if(!is_null($questgroup['questgroupspicture_id'])) { + $questgroup['picture'] = $this->Media->getSeminaryMediaById($questgroup['questgroupspicture_id']); + } + + // Get Quest + $quest = $this->Quests->getQuestByUrl($seminary['id'], $questgroup['id'], $questUrl); + + // Character + $character = $this->Characters->getCharacterByUrl($seminary['id'], $characterUrl); + + // Media + $questmedia = null; + if(!is_null($quest['questsmedia_id'])) { + $questmedia = $this->Media->getSeminaryMediaById($quest['questsmedia_id']); + } + + // Questtype + $questtype = $this->Questtypes->getQuesttypeById($quest['questtype_id']); + + // Render Questtype output + $output = $this->renderTaskSubmission($questtype['classname'], $seminary, $questgroup, $quest, $character); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('questgroup', $questgroup); + $this->set('quest', $quest); + $this->set('character', $character); + $this->set('media', $questmedia); + $this->set('output', $output); + } + + + /** + * Action: create. + * + * Create a new Quest. + * + * @param string $seminaryUrl URL-Title of a Seminary + */ + public function create($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Quest groups + $questgroups = $this->Questgroups->getQuestgroupsForSeminary($seminary['id']); + + // Quest types + $questtypes = $this->Questtypes->getQuesttypes(); + + // Create Quest + $validation = true; + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('create'))) + { + // TODO Validation + $name = $this->request->getPostParam('name'); + $xps = $this->request->getPostParam('xps'); + $prolog = $this->request->getPostParam('prolog'); + $entrytext = $this->request->getPostParam('entrytext'); + $wrongtext = $this->request->getPostParam('wrongtext'); + $task = $this->request->getPostParam('task'); + + // Validate Questgroup + $questgroupIndex = null; + foreach($questgroups as $index => &$questgroup) + { + $questgroup['selected'] = ($questgroup['url'] == $this->request->getPostParam('questgroup')); + if($questgroup['selected']) { + $questgroupIndex = $index; + } + } + if(is_null($questgroupIndex)) { + throw new \nre\exceptions\ParamsNotValidException($questgroup); + } + + // Validate Questtype + $questtypeIndex = null; + foreach($questtypes as $index => &$questtype) + { + $questtype['selected'] = ($questtype['url'] == $this->request->getPostParam('questtype')); + if($questtype['selected']) { + $questtypeIndex = $index; + } + } + if(is_null($questtypeIndex)) { + throw new \nre\exceptions\ParamsNotValidException($questtype); + } + + // Questmedia + $questmedia = null; + if(array_key_exists('questmedia', $_FILES) && !empty($_FILES['questmedia']) && $_FILES['questmedia']['error'] == 0) { + $questmedia = $_FILES['questmedia']; + } + + // Process Prolog + if(!empty($prolog)) { + $prolog = preg_split('/\s*(_|=){5,}\s*/', $prolog, -1, PREG_SPLIT_NO_EMPTY); + } + + // Create new Quest + if($validation === true) + { + $questId = $this->Quests->createQuest( + $this->Auth->getUserId(), + $name, + $questgroups[$questgroupIndex]['id'], + $questtypes[$questtypeIndex]['id'], + $xps, + $entrytext, + $wrongtext, + $task + ); + + // Create Questmedia + if(!is_null($questmedia)) + { + $questsmediaId = $this->Media->createQuestMedia( + $this->Auth->getUserId(), + $seminary['id'], + $questmedia['name'], + $name, + $questmedia['type'], + $questmedia['tmp_name'] + ); + if($questsmediaId > 0) { + $this->Quests->setQuestmedia($questId, $questsmediaId); + } + } + + // Add Prolog-texts + if(!empty($prolog)) { + $this->Questtexts->addQuesttextsToQuest($this->Auth->getUserId(), $questId, 'Prolog', $prolog); + } + + + // Redirect + $this->redirect($this->linker->link(array('quests', 'index', $seminary['url']))); + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('questgroups', $questgroups); + $this->set('questtypes', $questtypes); + } + + + /** + * Action: createmedia. + * TODO only temporary for easier data import. + * + * Display a form for creating new Seminary media. + * + * @param string $seminaryUrl URL-title of Seminary + */ + public function createmedia($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Create media + $mediaId = null; + $error = null; + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('submit'))) + { + $file = $_FILES['file']; + $error = $file['error']; + if(empty($error)) + { + $mediaId = $this->Media->createQuestMedia( + $this->Auth->getUserId(), + $seminary['id'], + $file['name'], + $this->request->getPostParam('description'), + $file['type'], + $file['tmp_name'] + ); + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('mediaid', $mediaId); + } + + + + + /** + * Render and handle the task of a Quest. + * + * @param string $questtypeClassname Name of the class for the Questtype of a Quest + * @param array $seminary Seminary data + * @param array $questgroup Questgroup data + * @param array $quest Quest data + * @param array $character Character data + * @return string Rendered output + */ + private function renderTask($questtypeClassname, $seminary, $questgroup, $quest, $character) + { + $task = null; + try { + // Generate request and response + $request = clone $this->request; + $response = $this->createQuesttypeResponse('quest', $seminary, $questgroup, $quest, $character); + + // Load Questtype Agent + $questtypeAgent = $this->loadQuesttypeAgent($questtypeClassname, $request, $response); + + // Solve Quest + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('submit'))) + { + // Get user answers + $answers = $this->request->getPostParam('answers'); + + // Save answers in database + try { + if(!$this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id'])) { + $questtypeAgent->saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers); + } + + // Match answers with correct ones + $status = $questtypeAgent->matchAnswersofCharacter($seminary, $questgroup, $quest, $character, $answers); + if($status === true) + { + // Mark Quest as solved + $this->Quests->setQuestSolved($quest['id'], $character['id']); + + // Notify of XP-level change + $newXPLevel = $this->Characters->getXPLevelOfCharacters($character['id']); + if($newXPLevel['level'] > $character['xplevel']) { + $this->Notification->addNotification( + \hhu\z\controllers\components\NotificationComponent::TYPE_LEVELUP, + $newXPLevel['level'], + $this->linker->link(array('characters', 'character', $seminary['url'], $character['url'])) + ); + } + + // Redirect + $this->redirect($this->linker->link(array(), 5, true, array('status'=>'solved'), false, 'task')); + } + elseif($status === false) + { + // Mark Quest as unsolved + $this->Quests->setQuestUnsolved($quest['id'], $character['id']); + + // Redirect + $this->redirect($this->linker->link(array(), 5, true, array('status'=>'unsolved'), false, 'task')); + } + else { + // Mark Quest as submitted + $this->Quests->setQuestSubmitted($quest['id'], $character['id']); + + // Redirect + $this->redirect($this->linker->link(array(), 5, true, null, false, 'task')); + } + } + catch(\hhu\z\exceptions\SubmissionNotValidException $e) { + $response->addParam($e); + } + } + + // Render Task + $task = $this->runQuesttypeAgent($questtypeAgent, $request, $response); + } + catch(\nre\exceptions\ViewNotFoundException $e) { + $task = $e->getMessage(); + } + catch(\nre\exceptions\ActionNotFoundException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeModelNotValidException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeModelNotFoundException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeControllerNotValidException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeControllerNotFoundException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeAgentNotValidException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeAgentNotFoundException $e) { + $task = $e->getMessage(); + } + + + // Return rendered output + return $task; + } + + + /** + * Render and handle a Character submission for a Quest. + * + * @param string $questtypeClassname Name of the class for the Questtype of a Quest + * @param array $seminary Seminary data + * @param array $questgroup Questgroup data + * @param array $quest Quest data + * @param array $character Character data + * @return string Rendered output + */ + private function renderTaskSubmission($questtypeClassname, $seminary, $questgroup, $quest, $character) + { + $task = null; + try { + // Generate request and response + $request = clone $this->request; + $response = $this->createQuesttypeResponse('submission', $seminary, $questgroup, $quest, $character); + + // Load Questtype Agent + $questtypeAgent = $this->loadQuesttypeAgent($questtypeClassname, $request, $response); + + // Solve Quest + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('submit'))) + { + // Set status + if($this->request->getPostParam('submit') == _('solved')) + { + // Mark Quest as solved + $this->Quests->setQuestSolved($quest['id'], $character['id']); + } + else + { + // Mark Quest as unsolved + $this->Quests->setQuestUnsolved($quest['id'], $character['id']); + } + + // Save additional data for Character answers + $questtypeAgent->controller->saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $this->request->getPostParam('characterdata')); + + // Redirect + $this->redirect($this->linker->link(array('submissions', $seminary['url'], $questgroup['url'], $quest['url']), 1)); + } + + // Render task submissions + $task = $this->runQuesttypeAgent($questtypeAgent, $request, $response); + } + catch(\nre\exceptions\ViewNotFoundException $e) { + $task = $e->getMessage(); + } + catch(\nre\exceptions\ActionNotFoundException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeModelNotValidException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeModelNotFoundException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeControllerNotValidException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeControllerNotFoundException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeAgentNotValidException $e) { + $task = $e->getMessage(); + } + catch(\hhu\z\exceptions\QuesttypeAgentNotFoundException $e) { + $task = $e->getMessage(); + } + + + // Return rendered output + return $task; + } + + + /** + * Create a response for the Questtype rendering. + * + * @param string $action Action to run + * @param mixed $param Additional parameters to add to the response + * @return Response Generated response + */ + private function createQuesttypeResponse($action, $param1) + { + // Clone current response + $response = clone $this->response; + // Clear parameters + $response->clearParams(1); + + // Add Action + $response->addParams( + null, + $action + ); + + // Add additional parameters + foreach(array_slice(func_get_args(), 1) as $param) { + $response->addParam($param); + } + + + // Return response + return $response; + } + + + /** + * Load and construct the QuesttypeAgent for a Questtype. + * + * @param string $questtypeClassname Name of the class for the Questtype of a Quest + * @param Request $request Request + * @param Response $response Response + * @return QuesttypeAgent + */ + private function loadQuesttypeAgent($questtypeClassname, $request, $response) + { + // Load Agent + \hhu\z\QuesttypeAgent::load($questtypeClassname); + + + // Construct and return Agent + return \hhu\z\QuesttypeAgent::factory($questtypeClassname, $request, $response); + } + + + /** + * Run and render the Agent for a QuesttypeAgent and return ist output. + * + * @param Agent $questtypeAgent QuesttypeAgent to run and render + * @param Request $request Request + * @param Response $response Response + * @return string Rendered output + */ + private function runQuesttypeAgent($questtypeAgent, $request, $response) + { + // Run Agent + $questtypeAgent->run($request, $response); + + + // Render and return output + return $questtypeAgent->render(); + } + + } + +?> diff --git a/controllers/SeminariesController.inc b/controllers/SeminariesController.inc new file mode 100644 index 00000000..13259c75 --- /dev/null +++ b/controllers/SeminariesController.inc @@ -0,0 +1,253 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to list registered seminaries. + * + * @author Oliver Hanraths + */ + class SeminariesController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('seminaries', 'users', 'characterroles', 'questgroupshierarchy', 'questgroups', 'media'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'index' => array('admin', 'moderator', 'user'), + 'seminary' => array('admin', 'moderator', 'user'), + 'create' => array('admin', 'moderator'), + 'edit' => array('admin', 'moderator', 'user'), + 'delete' => array('admin', 'moderator', 'user') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'seminary' => array('admin', 'moderator', 'user', 'guest'), + 'edit' => array('admin'), + 'delete' => array('admin') + ); + + + + + /** + * Action: index. + * + * List registered seminaries. + */ + public function index() + { + // Get seminaries + $seminaries = $this->Seminaries->getSeminaries(); + + // Get additional data + foreach($seminaries as &$seminary) + { + $description = \hhu\z\Utils::shortenString($seminary['description'], 100, 120); + $seminary['description'] = $description.(strlen($description) < strlen($seminary['description']) ? ' …' : null); + $seminary['creator'] = $this->Users->getUserById($seminary['created_user_id']); + + // Character of currently logged-in user + try { + $seminary['usercharacter'] = $this->Characters->getCharacterForUserAndSeminary($this->Auth->getUserId(), $seminary['id']); + $seminary['usercharacter']['characterroles'] = $this->Characterroles->getCharacterrolesForCharacterById($seminary['usercharacter']['id']); + $seminary['xps'] = $this->Seminaries->getTotalXPs($seminary['id']); + } + catch(\nre\exceptions\IdNotFoundException $e) { + } + + } + + + // Pass data to view + $this->set('seminaries', $seminaries); + } + + + /** + * Action: seminary. + * + * Show a seminary and its details. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a seminary + */ + public function seminary($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Created user + $seminary['creator'] = $this->Users->getUserById($seminary['created_user_id']); + + // Get Character + $character = $this->Characters->getCharacterForUserAndSeminary($this->Auth->getUserId(), $seminary['id']); + + // Questgrouphierarchy and Questgroups + $questgroupshierarchy = $this->Questgroupshierarchy->getHierarchyOfSeminary($seminary['id']); + foreach($questgroupshierarchy as &$hierarchy) + { + // Get Questgroups + $hierarchy['questgroups'] = $this->Questgroups->getQuestgroupsForHierarchy($hierarchy['id']); + + // Get additional data + foreach($hierarchy['questgroups'] as $i => &$questgroup) + { + // Check permission of Questgroups + if($i >= 1 && count(array_intersect(array('admin','moderator'), SeminaryController::$character['characterroles'])) == 0) + { + if(!$this->Questgroups->hasCharacterSolvedQuestgroup($hierarchy['questgroups'][$i-1]['id'], $character['id'])) + { + $hierarchy['questgroups'] = array_slice($hierarchy['questgroups'], 0, $i); + break; + } + } + + // Get first Questgroup text + $text = $this->Questgroups->getFirstQuestgroupText($questgroup['id']); + if(!is_null($text)) + { + $questgroup['text'] = \hhu\z\Utils::shortenString($text, 100, 120).' …'; + } + + // Get cumulated data + $data = $this->Questgroups->getCumulatedDataForQuestgroup($questgroup['id'], $character['id']); + $questgroup['xps'] = $data['xps']; + $questgroup['character_xps'] = $data['character_xps']; + + // Get Media + $questgroup['picture'] = null; + try { + $questgroup['picture'] = $this->Media->getSeminaryMediaById($questgroup['questgroupspicture_id']); + } + catch(\nre\exceptions\IdNotFoundException $e) { + } + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('questgroupshierarchy', $questgroupshierarchy); + } + + + /** + * Action: create. + * + * Create a new seminary. + */ + public function create() + { + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('create'))) + { + // Create new seminary + $seminaryId = $this->Seminaries->createSeminary( + $this->request->getPostParam('title'), + $this->Auth->getUserId() + ); + + // Redirect to seminary + $user = $this->Seminaries->getSeminaryById($seminaryId); + $this->redirect($this->linker->link(array($seminary['url']), 1)); + } + } + + + /** + * Action: edit. + * + * Edit a seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a seminary + */ + public function edit($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Check request method + if($this->request->getRequestMethod() == 'POST') + { + // Save changes + if(!is_null($this->request->getPostParam('save'))) + { + // Edit seminary + $this->Seminaries->editSeminary( + $seminary['id'], + $this->request->getPostParam('title') + ); + $seminary = $this->Seminaries->getSeminaryById($seminary['id']); + } + + + // Redirect to entry + $this->redirect($this->linker->link(array($seminary['url']), 1)); + } + + + // Pass data to view + $this->set('seminary', $seminary); + } + + + /** + * Action: delete. + * + * Delete a seminary. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a seminary + */ + public function delete($seminaryUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Check request method + if($this->request->getRequestMethod() == 'POST') + { + // Check confirmation + if(!is_null($this->request->getPostParam('delete'))) + { + // Delete seminary + $this->Seminaries->deleteSeminary($seminary['id']); + + // Redirect to overview + $this->redirect($this->linker->link(null, 1)); + } + + // Redirect to entry + $this->redirect($this->linker->link(array('seminary', $seminary['url']), 1)); + } + + + // Show confirmation + $this->set('seminary', $seminary); + } + + } + +?> diff --git a/controllers/SeminarybarController.inc b/controllers/SeminarybarController.inc new file mode 100644 index 00000000..8b1e18bc --- /dev/null +++ b/controllers/SeminarybarController.inc @@ -0,0 +1,86 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to display a sidebar with Seminary related + * information. + * + * @author Oliver Hanraths + */ + class SeminarybarController extends \hhu\z\Controller + { + /** + * Required models + * + * @var array + */ + public $models = array('characters', 'quests', 'questgroups', 'achievements', 'charactergroups', 'avatars', 'media'); + + + + + /** + * Action: index. + */ + public function index() + { + if(is_null(SeminaryController::$seminary)) { + return; + } + + // Get Seminary + $seminary = SeminaryController::$seminary; + + // Get Character + $character = SeminaryController::$character; + if(is_null($character)) { + return; + } + $character['xplevel'] = $this->Characters->getXPLevelOfCharacters($character['id']); + $character['rank'] = $this->Characters->getXPRank($seminary['id'], $character['xps']); + + // Get “last” Quest + $lastQuest = $this->Quests->getLastQuestForCharacter($character['id']); + if(!is_null($lastQuest)) { + $lastQuest['questgroup'] = $this->Questgroups->getQuestgroupById($lastQuest['questgroup_id']); + } + + // Get last achieved Achievement + $achievements = $this->Achievements->getAchievedAchievementsForCharacter($character['id']); + $lastAchievement = array_shift($achievements); + + // Get Character group members + $characterGroups = array(); + foreach($this->Charactergroups->getGroupsForCharacter($character['id']) as $group) + { + $groupsgroup = $this->Charactergroups->getGroupsgroupById($group['charactergroupsgroup_id']); + if($groupsgroup['preferred']) + { + $group['members'] = $this->Characters->getCharactersForGroup($group['id']); + $characterGroups[] = $group; + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('character', $character); + $this->set('lastQuest', $lastQuest); + $this->set('lastAchievement', $lastAchievement); + $this->set('characterGroups', $characterGroups); + } + + } + +?> diff --git a/controllers/SeminarymenuController.inc b/controllers/SeminarymenuController.inc new file mode 100644 index 00000000..78c88082 --- /dev/null +++ b/controllers/SeminarymenuController.inc @@ -0,0 +1,53 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to display a menu with Seminary related + * links. + * + * @author Oliver Hanraths + */ + class SeminarymenuController extends \hhu\z\Controller + { + + + + + /** + * Prefilter. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::preFilter($request, $response); + + // Set userdata + $this->set('loggedUser', \hhu\z\controllers\IntermediateController::$user); + $this->set('loggedSeminary', \hhu\z\controllers\SeminaryController::$seminary); + $this->set('loggedCharacter', \hhu\z\controllers\SeminaryController::$character); + } + + + /** + * Action: index. + */ + public function index() + { + } + + } + +?> diff --git a/controllers/UploadsController.inc b/controllers/UploadsController.inc new file mode 100644 index 00000000..53cd1981 --- /dev/null +++ b/controllers/UploadsController.inc @@ -0,0 +1,312 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the UploadsAgent to process and show user uploads. + * + * @author Oliver Hanraths + */ + class UploadsController extends \hhu\z\controllers\SeminaryController + { + /** + * Required models + * + * @var array + */ + public $models = array('uploads', 'users', 'userroles', 'characterroles', 'seminaries', 'charactergroups'); + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'seminary' => array('admin', 'moderator', 'user', 'guest'), + 'charactergroup' => array('admin', 'moderator', 'user', 'guest') + ); + /** + * User seminary permissions + * + * @var array + */ + public $seminaryPermissions = array( + 'seminary' => array('admin', 'moderator', 'user', 'guest'), + 'charactergroup' => array('admin', 'moderator', 'user') + ); + + + + + /** + * Prefilter. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(\nre\core\Request $request, \nre\core\Response $response) + { + parent::preFilter($request, $response); + + // Set headers for caching control + $response->addHeader("Pragma: public"); + $response->addHeader("Cache-control: public, max-age=".(60*60*24)); + $response->addHeader("Expires: ".gmdate('r', time()+(60*60*24))); + $response->addHeader("Date: ".gmdate(\DateTime::RFC822)); + } + + + /** + * Action: seminary. + * + * Display a Seminary upload. + * + * @throws AccessDeniedException + * @throws IdNotFoundException + * @param string $seminaryUrl URL-title of Seminary + * @param string $uploadUrl URL-name of the upload + */ + public function seminary($seminaryUrl, $uploadUrl, $action=null) + { + // Get Seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Upload + $upload = $this->Uploads->getSeminaryuploadByUrl($seminary['id'], $uploadUrl); + + // Check permissions + if(!$upload['public']) + { + $user = $this->Users->getUserById($this->Auth->getUserId()); + $user['roles'] = array_map(function($r) { return $r['name']; }, $this->Userroles->getUserrolesForUserById($user['id'])); + + // System roles + if(count(array_intersect(array('admin', 'moderator'), $user['roles'])) == 0) + { + // Owner of file + if($upload['created_user_id'] != $user['id']) + { + // Seminary permissions + $characterRoles = array_map(function($r) { return $r['name']; }, $this->Characterroles->getCharacterrolesForCharacterById($character['id'])); + if(count(array_intersect(array('admin', 'moderator'), $characterRoles)) == 0) { + throw new \nre\exceptions\AccessDeniedException(); + } + } + } + } + + // Get file + switch($action) + { + case null: + $file = $this->getUploadFile($upload); + break; + case 'thumbnail': + $file = $this->createThumbnail($upload); + break; + default: + throw new \nre\exceptions\ParamsNotValidException($action); + break; + } + if(is_null($file)) { + return; + } + + + // Pass data to view + $this->set('upload', $upload); + $this->set('file', $file); + } + + + /** + * Action: charactergroup. + * + * Display the icon of a Character group. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a Seminary + * @param string $groupsgroupUrl URL-Title of a Character groups-group + * @param string $groupUrl URL-Title of a Character group + */ + public function charactergroup($seminaryUrl, $groupsgroupUrl, $groupUrl) + { + // Get seminary + $seminary = $this->Seminaries->getSeminaryByUrl($seminaryUrl); + + // Get Character groups-group + $groupsgroup = $this->Charactergroups->getGroupsgroupByUrl($seminary['id'], $groupsgroupUrl); + + // Get Character group + $group = $this->Charactergroups->getGroupByUrl($groupsgroup['id'], $groupUrl); + + // Get Upload + $upload = $this->Uploads->getSeminaryuploadById($group['seminaryupload_id']); + + // Get file + $file = $this->getUploadFile($upload); + if(is_null($file)) { + return; + } + + + // Pass data to view + $this->set('upload', $upload); + $this->set('file', $file); + } + + + + + /** + * Determine the file for an upload. + * + * @throws IdNotFoundException + * @param array $upload Upload to get file for + * @return object File for the upload (or null if upload is cached) + */ + private function getUploadFile($upload) + { + // Set content-type + $this->response->addHeader("Content-type: ".$upload['mimetype'].""); + + // Set filename + $upload['filename'] = ROOT.DS.\nre\configs\AppConfig::$dirs['seminaryuploads'].DS.$upload['url']; + if(!file_exists($upload['filename'])) { + throw new \nre\exceptions\IdNotFoundException($uploadUrl); + } + + // Cache + if($this->setCacheHeaders($upload['filename'])) { + return null; + } + + + return file_get_contents($upload['filename']); + } + + + /** + * Create a thumbnail from an upload. + * + * @param array $upload Upload to create thumbnail for + * @return object Thumbnail for the upload (or null if thumbnail is cached) + */ + private function createThumbnail($upload) + { + // Set filename + $upload['filename'] = ROOT.DS.\nre\configs\AppConfig::$dirs['seminaryuploads'].DS.$upload['url']; + + // Set content-type + $this->response->addHeader("Content-type: image/jpeg"); + + // Cache + if($this->setCacheHeaders($upload['filename'])) { + return null; + } + + // Set geometry + $width = 100; + $height = 100; + + switch($upload['mimetype']) + { + case 'image/jpeg': + case 'image/png': + // Read image from cache + $tempFileName = ROOT.DS.\nre\configs\AppConfig::$dirs['temporary'].DS.$upload['url'].'-'.$width.'x'.$height; + if(file_exists($tempFileName)) + { + // Check age of file + if(date('r', filemtime($tempFileName)+(60*60*24)) > date('r', time())) { + // Too old, delete + unlink($tempFileName); + } + else { + // Valid, read and return + return file_get_contents($tempFileName); + } + } + + // ImageMagick + $im = new \Imagick($upload['filename']); + + // Calculate new size + $geometry = $im->getImageGeometry(); + if($geometry['width'] < $width) { + $width = $geometry['width']; + } + if($geometry['height'] < $height) { + $height = $geometry['width']; + } + + // Process + $im->thumbnailImage($width, $height, true); + $im->contrastImage(1); + $im->setImageFormat('jpeg'); + + // Save temporary file + $im->writeImage($tempFileName); + + + // Return resized image + return $im; + break; + default: + throw new \nre\exceptions\ParamsNotValidException('thumbnail'); + break; + } + + + return $this->getUploadFile($upload); + } + + + /** + * Determine file information and set the HTTP-header for + * caching accordingly. + * + * @param string $fileName Filename + * @return boolean HTTP-status 304 was set (in cache) + */ + private function setCacheHeaders($fileName) + { + // Determine last change of file + $fileLastModified = gmdate('r', filemtime($fileName)); + + // Generate E-Tag + $fileEtag = hash('sha256', $fileLastModified.$fileName); + + + // Set header + $this->response->addHeader("Last-Modified: ".$fileLastModified); + $this->response->addHeader("Etag: ".$fileEtag); + // HTTP-status + $headerModifiedSince = $this->request->getServerParam('HTTP_IF_MODIFIED_SINCE'); + $headerNoneMatch = $this->request->getServerParam('HTTP_IF_NONE_MATCH'); + if( + !is_null($headerModifiedSince) && $fileLastModified < strtotime($headerModifiedSince) && + !is_null($headerNoneMatch) && $headerNoneMatch == $fileEtag + ) { + $this->response->setExit(true); + $this->response->addHeader(\nre\core\WebUtils::getHttpHeader(304)); + + return true; + } + + + return false; + } + + } + +?> diff --git a/controllers/UserrolesController.inc b/controllers/UserrolesController.inc new file mode 100644 index 00000000..80c00347 --- /dev/null +++ b/controllers/UserrolesController.inc @@ -0,0 +1,47 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to display and manage userroles. + * + * @author Oliver Hanraths + */ + class UserrolesController extends \hhu\z\Controller + { + + + + + /** + * Action: user. + * + * Show a user and its details. + * + * @throws IdNotFoundException + * @param string $userUrl URL-Username of an user + */ + public function user($userUrl) + { + // Get userroles + $roles = $this->Userroles->getUserrolesForUserByUrl($userUrl); + + + // Pass data to view + $this->set('roles', $roles); + } + + + } + +?> diff --git a/controllers/UsersController.inc b/controllers/UsersController.inc new file mode 100644 index 00000000..d7d5bd45 --- /dev/null +++ b/controllers/UsersController.inc @@ -0,0 +1,640 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers; + + + /** + * Controller of the Agent to list registered users and their data. + * + * @author Oliver Hanraths + */ + class UsersController extends \hhu\z\controllers\IntermediateController + { + /** + * User permissions + * + * @var array + */ + public $permissions = array( + 'index' => array('admin', 'moderator'), + 'user' => array('admin', 'moderator', 'user'), + 'create' => array('admin', 'moderator'), + 'edit' => array('admin', 'moderator', 'user'), + 'delete' => array('admin') + ); + /** + * Required models + * + * @var array + */ + public $models = array('users', 'userroles', 'characters', 'characterroles', 'avatars', 'media'); + /** + * Required components + * + * @var array + */ + public $components = array('validation'); + + + + + /** + * Action: index. + */ + public function index() + { + // Get registered users + $users = $this->Users->getUsers(); + + + // Pass data to view + $this->set('users', $users); + } + + + /** + * Action: user. + * + * Show a user and its details. + * + * @throws IdNotFoundException + * @throws AccessDeniedException + * @param string $userUrl URL-Username of an user + */ + public function user($userUrl) + { + // Get user + $user = $this->Users->getUserByUrl($userUrl); + + // Check permissions + if(count(array_intersect(array('admin','moderator'), \hhu\z\controllers\IntermediateController::$user['roles'])) == 0 && $user['id'] != IntermediateController::$user['id']) { + throw new \nre\exceptions\AccessDeniedException(); + } + + // Get Characters + $characters = $this->Characters->getCharactersForUser($user['id']); + + // Additional Character information + foreach($characters as &$character) + { + // Seminary roles + $character['characterroles'] = $this->Characterroles->getCharacterrolesForCharacterById($character['id']); + $character['characterroles'] = array_map(function($a) { return $a['name']; }, $character['characterroles']); + + // Level + $character['xplevel'] = $this->Characters->getXPLevelOfCharacters($character['id']); + + // Avatar + $avatar = $this->Avatars->getAvatarById($character['avatar_id']); + if(!is_null($avatar['small_avatarpicture_id'])) + { + //$character['seminary'] = + $character['small_avatar'] = $this->Media->getSeminaryMediaById($avatar['small_avatarpicture_id']); + } + } + + + // Pass data to view + $this->set('user', $user); + $this->set('characters', $characters); + } + + + /** + * Action: login. + * + * Log in a user. + */ + public function login() + { + $username = ''; + $referrer = null; + + // Log the user in + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('login'))) + { + $username = $this->request->getPostParam('username'); + $referrer = $this->request->getPostParam('referrer'); + $userId = $this->Users->login( + $username, + $this->request->getPostParam('password') + ); + + if(!is_null($userId)) + { + $this->Auth->setUserId($userId); + $user = $this->Users->getUserById($userId); + + if(!empty($referrer)) { + $this->redirect($referrer); + } + else { + $this->redirect($this->linker->link(array($user['url']), 1)); + } + } + } + + + // Pass data to view + $this->set('username', $username); + $this->set('referrer', $referrer); + $this->set('failed', ($this->request->getRequestMethod() == 'POST')); + } + + + /** + * Action: register. + * + * Register a new user. + */ + public function register() + { + $username = ''; + $prename = ''; + $surname = ''; + $email = ''; + + $fields = array('username', 'prename', 'surname', 'email', 'password'); + $validation = array(); + + // Register a new user + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('register'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $username = $this->request->getPostParam('username'); + if($this->Users->usernameExists($username)) { + $validation = $this->Validation->addValidationResult($validation, 'username', 'exist', true); + } + $prename = $this->request->getPostParam('prename'); + $surname = $this->request->getPostParam('surname'); + $email = $this->request->getPostParam('email'); + if($this->Users->emailExists($email)) { + $validation = $this->Validation->addValidationResult($validation, 'email', 'exist', true); + } + + + // Register + if($validation === true) + { + $userId = $this->Users->createUser( + $username, + $prename, + $surname, + $email, + $this->request->getPostParam('password') + ); + + // Send mail + $this->sendRegistrationMail($username, $email); + + // Login + $this->Auth->setUserId($userId); + $user = $this->Users->getUserById($userId); + + // Redirect to user page + $this->redirect($this->linker->link(array($user['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('username', $username); + $this->set('prename', $prename); + $this->set('surname', $surname); + $this->set('email', $email); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: logout. + * + * Log out a user. + */ + public function logout() + { + // Unset the currently logged in user + $this->Auth->setUserId(null); + + // Redirect + $this->redirect($this->linker->link(array())); + } + + + /** + * Action: manage. + * + * Manage users. + */ + public function manage() + { + $selectedUsers = array(); + global $sortorder; + + if($this->request->getRequestMethod() == 'POST') + { + // Set sortorder + $sortorder = $this->request->getPostParam('sortorder'); + + // Do action + $selectedUsers = $this->request->getPostParam('users'); + if(!is_array($selectedUsers)) { + $selectedUsers = array(); + } + if(!is_null($this->request->getPostParam('actions')) && count($this->request->getPostParam('actions')) > 0 && !is_null($this->request->getPostParam('users')) && count($this->request->getPostParam('users')) > 0) + { + $actions = $this->request->getPostParam('actions'); + $action = array_keys($actions)[0]; + + switch($action) + { + // Add/remove role to/from Characters + case 'addrole': + case 'removerole': + // Determine role and check permissions + $role = null; + switch($actions[$action]) + { + case _('Admin'): + if(!in_array('admin', \hhu\z\controllers\IntermediateController::$user['roles'])) { + throw new \nre\exceptions\AccessDeniedException(); + } + $role = 'admin'; + break; + case _('Moderator'): + if(!in_array('admin', \hhu\z\controllers\IntermediateController::$user['roles'])) { + throw new \nre\exceptions\AccessDeniedException(); + } + $role = 'moderator'; + break; + case _('User'): + if(count(array_intersect(array('admin', 'moderator'), \hhu\z\controllers\IntermediateController::$user['roles'])) <= 0) { + throw new \nre\exceptions\AccessDeniedException(); + } + $role = 'user'; + break; + } + + // Add role + if($action == 'addrole') { + foreach($selectedUsers as &$userId) { + $this->Userroles->addUserroleToUser($userId, $role); + } + } + // Remove role + else { + foreach($selectedUsers as &$userId) { + $this->Userroles->removeUserroleFromUser($userId, $role); + } + } + break; + } + } + } + + // Get registered users + $users = $this->Users->getUsers(); + foreach($users as &$user) { + $user['roles'] = array_map(function($r) { return $r['name']; }, $this->Userroles->getUserrolesForUserById($user['id'])); + } + + // Sort users + $sortorder = (!is_null($sortorder)) ? $sortorder : 'username'; + $sortMethod = 'sortUsersBy'.ucfirst(strtolower($sortorder)); + if(method_exists($this, $sortMethod)) { + usort($users, array($this, $sortMethod)); + } + else { + throw new \nre\exceptions\ParamsNotValidException($sortorder); + } + + + // Pass data to view + $this->set('users', $users); + $this->set('selectedUsers', $selectedUsers); + $this->set('sortorder', $sortorder); + } + + + /** + * Action: create. + * + * Create a new user. + */ + public function create() + { + // Values + $username = ''; + $prename = ''; + $surname = ''; + $email = ''; + $fields = array('username', 'prename', 'surname', 'email', 'password'); + $validation = array(); + + // Create new user + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('create'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $username = $this->request->getPostParam('username'); + if($this->Users->usernameExists($username)) { + $validation = $this->Validation->addValidationResult($validation, 'username', 'exist', true); + } + $prename = $this->request->getPostParam('prename'); + $surname = $this->request->getPostParam('surname'); + $email = $this->request->getPostParam('email'); + if($this->Users->emailExists($email)) { + $validation = $this->Validation->addValidationResult($validation, 'email', 'exist', true); + } + + // Create + if($validation === true) + { + $userId = $this->Users->createUser( + $this->request->getPostParam('username'), + $this->request->getPostParam('prename'), + $this->request->getPostParam('surname'), + $this->request->getPostParam('email'), + $this->request->getPostParam('password') + ); + + // Redirect to user + $user = $this->Users->getUserById($userId); + $this->redirect($this->linker->link(array($user['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('username', $username); + $this->set('prename', $prename); + $this->set('surname', $surname); + $this->set('email', $email); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: edit. + * + * Edit a user. + * + * @throws IdNotFoundException + * @param string $userUrl URL-Username of an user + */ + public function edit($userUrl) + { + // User + $user = $this->Users->getUserByUrl($userUrl); + + // Check permissions + if(count(array_intersect(array('admin','moderator'), \hhu\z\controllers\IntermediateController::$user['roles'])) == 0 && $user['id'] != \hhu\z\controllers\IntermediateController::$user['id']) { + throw new \nre\exceptions\AccessDeniedException(); + } + + // Values + $username = $user['username']; + $prename = $user['prename']; + $surname = $user['surname']; + $email = $user['email']; + $fields = array('username', 'prename', 'surname', 'email'); + $validation = array(); + + // Edit user + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('save'))) + { + // Get params and validate them + $validation = $this->Validation->validateParams($this->request->getPostParams(), $fields); + $username = $this->request->getPostParam('username'); + if($this->Users->usernameExists($username, $user['id'])) { + $validation = $this->Validation->addValidationResult($validation, 'username', 'exist', true); + } + $password = $this->request->getPostParam('password'); + if(!empty($password)) { + $validation = $this->Validation->addValidationResults($validation, + 'password', + $this->Validation->validateParam( + $this->request->getPostParams(), + 'password' + ) + ); + } + $prename = $this->request->getPostParam('prename'); + $surname = $this->request->getPostParam('surname'); + $email = $this->request->getPostParam('email'); + if($this->Users->emailExists($email, $user['id'])) { + $validation = $this->Validation->addValidationResult($validation, 'email', 'exist', true); + } + + // Save changes + if($validation === true) + { + // Edit user + $this->Users->editUser( + $user['id'], + (count(array_intersect(array('admin','moderator'),\hhu\z\controllers\IntermediateController::$user['roles'])) > 0) ? $this->request->getPostParam('username') : $user['username'], + $this->request->getPostParam('prename'), + $this->request->getPostParam('surname'), + $this->request->getPostParam('email'), + $this->request->getPostParam('password') + ); + + // Redirect to entry + $user = $this->Users->getUserById($user['id']); + $this->redirect($this->linker->link(array('user', $user['url']), 1)); + } + } + + // Get validation settings + $validationSettings = array(); + foreach($fields as &$field) { + $validationSettings[$field] = \nre\configs\AppConfig::$validation[$field]; + } + + + // Pass data to view + $this->set('username', $username); + $this->set('prename', $prename); + $this->set('surname', $surname); + $this->set('email', $email); + $this->set('validation', $validation); + $this->set('validationSettings', $validationSettings); + } + + + /** + * Action: delete. + * + * Delete a user. + * + * @throws IdNotFoundException + * @param string $userUrl URL-Username of an user + */ + public function delete($userUrl) + { + // User + $user = $this->Users->getUserByUrl($userUrl); + + // Check request method + if($this->request->getRequestMethod() == 'POST') + { + // Check confirmation + if(!is_null($this->request->getPostParam('delete'))) + { + // Delete user + $this->Users->deleteUser($user['id']); + + // Redirect to overview + $this->redirect($this->linker->link(null, 1)); + } + + // Redirect to entry + $this->redirect($this->linker->link(array('user', $user['url']), 1)); + } + + + // Show confirmation + $this->set('user', $user); + } + + + + + /** + * Send mail for new user registration. + * + * @param string $username Name of newly registered user + * @param string $email E‑mail address of newly registered user + */ + private function sendRegistrationMail($username, $email) + { + $sender = \nre\configs\AppConfig::$app['mailsender']; + if(empty($sender)) { + return; + } + + // Send notification mail to system moderators + $subject = sprintf('new user registration: %s', $username); + $message = sprintf('User “%s” <%s> has registered themself to %s', $username, $email, \nre\configs\AppConfig::$app['name']); + $moderators = $this->Users->getUsersWithRole('moderator'); + foreach($moderators as &$moderator) + { + \hhu\z\Utils::sendMail($sender, $moderator['email'], $subject, $message); + } + } + + + /** + * Compare two users by their username. + * + * @param array $a User a + * @param array $b User b + * @return int Result of comparison + */ + private function sortUsersByUsername($a, $b) + { + if($a['username'] == $b['username']) { + return 0; + } + + + return ($a['username'] < $b['username']) ? -1 : 1; + } + + + /** + * Compare two users by their userroles. + * + * @param array $a User a + * @param array $b User b + * @return int Result of comparison + */ + private function sortUsersByRole($a, $b) + { + if(in_array('admin', $a['roles'])) + { + if(in_array('admin', $b['roles'])) { + return 0; + } + return -1; + } + if(in_array('moderator', $a['roles'])) + { + if(in_array('admin', $b['roles'])) { + return 1; + } + if(in_array('moderator', $b['roles'])) { + return 0; + } + return -1; + } + if(in_array('user', $a['roles'])) + { + if(in_array('admin', $b['roles']) || in_array('moderator', $b['roles'])) { + return 1; + } + if(in_array('user', $b['roles'])) { + return 0; + } + return -1; + } + if(in_array('guest', $a['roles'])) + { + if(in_array('admin', $b['roles']) || in_array('moderator', $b['roles']) || in_array('user', $b['roles'])) { + return 1; + } + if(in_array('guest', $b['roles'])) { + return 0; + } + return -1; + } + + + return 1; + } + + + /** + * Compare two users by their registration date. + * + * @param array $a User a + * @param array $b User b + * @return int Result of comparison + */ + private function sortUsersByDate($a, $b) + { + if($a['created'] == $b['created']) { + return 0; + } + + + return ($a['created'] > $b['created']) ? -1 : 1; + } + + } + +?> diff --git a/controllers/components/AchievementComponent.inc b/controllers/components/AchievementComponent.inc new file mode 100644 index 00000000..70601543 --- /dev/null +++ b/controllers/components/AchievementComponent.inc @@ -0,0 +1,41 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers\components; + + + /** + * Component to handle achievements. + * + * @author Oliver Hanraths + */ + class AchievementComponent extends \nre\core\Component + { + /** + * Required models + * + * @var array + */ + public $models = array('achievements'); + + + + + /** + * Construct a new Achievements-component. + */ + public function __construct() + { + } + + } + +?> diff --git a/controllers/components/AuthComponent.inc b/controllers/components/AuthComponent.inc new file mode 100644 index 00000000..0db6c69d --- /dev/null +++ b/controllers/components/AuthComponent.inc @@ -0,0 +1,79 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers\components; + + + /** + * Component to handle authentication and authorization. + * + * @author Oliver Hanraths + */ + class AuthComponent extends \nre\core\Component + { + /** + * Key to save a user-ID as + * + * @var string + */ + const KEY_USER_ID = 'user_id'; + + + + + /** + * Construct a new Auth-component. + */ + public function __construct() + { + // Start session + if(session_id() === '') { + session_start(); + } + } + + + + + /** + * Set the ID of the user that is currently logged in. + * + * @param int $userId ID of the currently logged in user + */ + public function setUserId($userId) + { + if(is_null($userId)) { + unset($_SESSION[self::KEY_USER_ID]); + } + else { + $_SESSION[self::KEY_USER_ID] = $userId; + } + } + + + /** + * Get the ID of the user that is currently logged in. + * + * @return int ID of the currently logged in user + */ + public function getUserId() + { + if(array_key_exists(self::KEY_USER_ID, $_SESSION)) { + return $_SESSION[self::KEY_USER_ID]; + } + + + return null; + } + + } + +?> diff --git a/controllers/components/NotificationComponent.inc b/controllers/components/NotificationComponent.inc new file mode 100644 index 00000000..bddffc80 --- /dev/null +++ b/controllers/components/NotificationComponent.inc @@ -0,0 +1,108 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers\components; + + + /** + * Component to handle user notifications + * + * @author Oliver Hanraths + */ + class NotificationComponent extends \nre\core\Component + { + /** + * Type: Achievement + * + * @var string + */ + const TYPE_ACHIEVEMENT = 'achievement'; + /** + * Type: Level-up + * + * @var string + */ + const TYPE_LEVELUP = 'levelup'; + /** + * Key for Session-Array to store notifications in + * + * @var string + */ + const SESSION_KEY = 'notifications'; + + + + + /** + * Construct a new Notification-component. + */ + public function __construct() + { + // Start session + if(session_id() === '') { + session_start(); + } + + // Prepare array + if(!array_key_exists(self::SESSION_KEY, $_SESSION)) { + $_SESSION[self::SESSION_KEY] = array(); + } + } + + + + + /** + * Add a notification. + * + * @param string $type Type of notification + * @param string $message Message to display + * @param string $link Optional URL to link to + * @param string $image Optional URL of image to display + */ + public function addNotification($type, $message, $link=null, $image=null) + { + $_SESSION[self::SESSION_KEY][] = array( + 'type' => $type, + 'message' => $message, + 'link' => $link, + 'image' => $image + ); + } + + + /** + * Get all registered notifiactions and clear them. + * + * @return array List of existing notifications + */ + public function getNotifications() + { + $notifications = $_SESSION[self::SESSION_KEY]; + $this->clearNotifications(); + + + return $notifications; + } + + + /** + * Clear all notifications currently registered + */ + public function clearNotifications() + { + unset($_SESSION[self::SESSION_KEY]); + $_SESSION[self::SESSION_KEY] = array(); + } + + } + +?> diff --git a/controllers/components/ValidationComponent.inc b/controllers/components/ValidationComponent.inc new file mode 100644 index 00000000..33f7963d --- /dev/null +++ b/controllers/components/ValidationComponent.inc @@ -0,0 +1,183 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\controllers\components; + + + /** + * Component to validate user input. + * + * @author Oliver Hanraths + */ + class ValidationComponent extends \nre\core\Component + { + /** + * Validation settings + * + * @var array + */ + private $config; + + + + + /** + * Construct a new Validation-component. + */ + public function __construct() + { + // Get validation settings from configuration + $this->config = \nre\configs\AppConfig::$validation; + } + + + + + /** + * Validate an user input. + * + * @param mixed $input User input to validate + * @param array $settings Validation setting + * @return mixed True or the settings the validation fails on + */ + public function validate($input, $settings) + { + $validation = array(); + + // Min string length + if(array_key_exists('minlength', $settings) && strlen($input) < $settings['minlength']) { + $validation['minlength'] = $settings['minlength']; + } + // Max string length + if(array_key_exists('maxlength', $settings) && strlen($input) > $settings['maxlength']) { + $validation['maxlength'] = $settings['maxlength']; + } + + // Regex + if(array_key_exists('regex', $settings) && !preg_match($settings['regex'], $input)) { + $validation['regex'] = $settings['regex']; + } + + + // Return true or the failed fields + if(empty($validation)) { + return true; + } + return $validation; + } + + + /** + * Validate an user input parameter. + * + * @param array $params User input parameters + * @param array $index Names of parameter to validate and to validate against + * @return mixed True or the parameter with settings the validation failed on + */ + public function validateParam($params, $index) + { + // Check parameter + if(!array_key_exists($index, $params)) { + throw new \nre\exceptions\ParamsNotValidException($index); + } + // Check settings + if(!array_key_exists($index, $this->config)) { + return true; + } + + + // Validate parameter and return result + return $this->validate($params[$index], $this->config[$index]); + } + + + /** + * Validate user input parameters. + * + * @param array $params User input parameters + * @param array $indices Names of parameters to validate and to validate against + * @return mixed True or the parameters with settings the validation failed on + */ + public function validateParams($params, $indices) + { + // Validate parameters + $validation = true; + foreach($indices as $index) { + $validation = $this->addValidationResults($validation, $index, $this->validateParam($params, $index)); + } + + + // Return validation results + return $validation; + } + + + /** + * Add a custom determined validation result to a validation + * array. + * + * @param mixed $validation Validation array to add result to + * @param string $index Name of parameter of the custom validation result + * @param string $setting Name of setting of the custom validation result + * @param mixed $result Validation result + * @return mixed The altered validation array + */ + public function addValidationResult($validation, $index, $setting, $result) + { + // Create validation array + if(!is_array($validation)) { + $validation = array(); + } + + // Add validation results + if(!array_key_exists($index, $validation)) { + $validation[$index] = array(); + } + $validation[$index][$setting] = $result; + + + // Return new validation result + return $validation; + } + + + /** + * Add custom determined validation results to a validation + * arary. + * + * @param mixed $validation Validation array to add result to + * @param string $index Name of parameter of the custom validation result + * @param mixed $result Validation result + * @return mixed The altered validation array + */ + public function addValidationResults($validation, $index, $results) + { + // Create validation array + if(!is_array($validation)) { + $validation = array(); + } + + // Add validation results + if($results !== true) { + $validation[$index] = $results; + } + + + // Return new validation result + if(empty($validation)) { + return true; + } + return $validation; + } + + } + +?> diff --git a/core/Agent.inc b/core/Agent.inc new file mode 100644 index 00000000..00ec1a90 --- /dev/null +++ b/core/Agent.inc @@ -0,0 +1,607 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Abstract class for the implementation af an Agent. + * + * @author coderkun + */ + abstract class Agent + { + /** + * Name of BottomlevelAgent for showing inline errors + * + * @var string + */ + const INLINEERROR_AGENT = 'inlineerror'; + + /** + * Current request + * + * @var Request + */ + private $request; + /** + * Current response + * + * @var Response + */ + private $response; + /** + * Log-system + * + * @var Logger + */ + protected $log; + /** + * SubAgents + * + * @var array + */ + protected $subAgents = array(); + /** + * Controller of this Agent + * + * @var Controller + */ + public $controller = null; + + + + + /** + * Load the class of an Agent. + * + * @static + * @throws AgentNotFoundException + * @throws AgentNotValidException + * @param string $agentName Name of the Agent to load + */ + public static function load($agentName) + { + // Determine full classname + $agentType = self::getAgentType(); + $className = self::getClassName($agentName, $agentType); + + try { + // Load class + ClassLoader::load($className); + + // Validate class + $parentAgentClassName = ClassLoader::concatClassNames($agentType, 'agent'); + $parentAgentClassName = "\\nre\\agents\\$parentAgentClassName"; + ClassLoader::check($className, $parentAgentClassName); + } + catch(\nre\exceptions\ClassNotValidException $e) { + throw new \nre\exceptions\AgentNotValidException($e->getClassName()); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + throw new \nre\exceptions\AgentNotFoundException($e->getClassName()); + } + } + + + + /** + * Instantiate an Agent (Factory Pattern). + * + * @static + * @throws DatamodelException + * @throws DriverNotValidException + * @throws DriverNotFoundException + * @throws ViewNotFoundException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ControllerNotValidException + * @throws ControllerNotFoundException + * @param string $agentName Name of the Agent to instantiate + * @param Request $request Current request + * @param Response $respone Current respone + * @param Logger $log Log-system + */ + public static function factory($agentName, Request $request, Response $response, Logger $log=null) + { + // Determine full classname + $agentType = self::getAgentType(); + $className = self::getClassName($agentName, $agentType); + + + // Construct and return Agent + return new $className($request, $response, $log); + } + + + /** + * Determine the type of an Agent. + * + * @static + * @return string Agent type + */ + private static function getAgentType() + { + return strtolower(ClassLoader::getClassName(get_called_class())); + } + + + /** + * Determine the classname for the given Agent name. + * + * @static + * @param string $agentName Agent name to get classname of + * @param string $agentType Agent type of given Agent name + * @return string Classname for the Agent name + */ + private static function getClassName($agentName, $agentType) + { + $className = ClassLoader::concatClassNames($agentName, 'agent'); + + + return \nre\configs\AppConfig::$app['namespace']."agents\\$agentType\\$className"; + } + + + + + /** + * Construct a new Agent. + * + * @throws DatamodelException + * @throws DriverNotValidException + * @throws DriverNotFoundException + * @throws ViewNotFoundException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ControllerNotValidException + * @throws ControllerNotFoundException + * @param Request $request Current request + * @param Response $respone Current response + * @param Logger $log Log-system + */ + protected function __construct(Request $request, Response $response, Logger $log=null) + { + // Store values + $this->request = $request; + $this->response = $response; + $this->log = $log; + + // Construct SubAgent + $this->actionConstruct(); + + // Load corresponding Controller + $this->loadController(); + } + + + + + /** + * Run the Controller of this Agent and its SubAgents. + * + * @throws ParamsNotValidException + * @throws IdNotFoundException + * @throws DatamodelException + * @throws ActionNotFoundException + * @param Request $request Current request + * @param Response $response Current response + * @return Exception Last occurred exception of SubAgents + */ + public function run(Request $request, Response $response) + { + // Check Controller + if(!is_null($this->controller)) + { + // Call prefilter + $this->controller->preFilter($request, $response); + + // Run controller + $this->controller->run($request, $response); + + // Call postfilter + $this->controller->postFilter($request, $response); + } + + + // Run SubAgents + $exception = null; + foreach($this->subAgents as &$subAgent) + { + try { + $subAgent['object']->run( + $request, + $subAgent['response'] + ); + } + catch(ParamsNotValidException $e) { + $subAgent = $this->errorInline($subAgent, $request, $e); + } + catch(IdNotFoundException $e) { + $subAgent = $this->errorInline($subAgent, $request, $e); + } + catch(DatamodelException $e) { + $exception = $e; + $subAgent = $this->errorInline($subAgent, $request, $e); + } + catch(ActionNotFoundException $e) { + $subAgent = $this->errorInline($subAgent, $request, $e); + } + } + + + // Return last occurred exception + return $exception; + } + + + /** + * Generate output of the Controller of this Agent and its + * SubAgents. + * + * @param array $data View data + * @return string Generated output + */ + public function render($data=array()) + { + // Check Controller + if(!is_null($this->controller)) + { + // Render SubAgents + foreach($this->subAgents as $subAgent) + { + $label = array_key_exists('label', $subAgent) ? $subAgent['label'] : $subAgent['name']; + $data[$label] = $this->renderSubAgent($subAgent); + } + + // Render the Controller of this agent + return $this->controller->render($data); + } + } + + + + + /** + * Construct SubAgents (per Action). + */ + protected function actionConstruct() + { + // Action ermitteln + $action = $this->response->getParam(2); + if(is_null($action)) { + $action = $this->request->getParam(2, 'action'); + $this->response->addParam($action); + } + + // Initialisierungsmethode für diese Action ausführen + if(method_exists($this, $action)) + { + call_user_func_array( + array( + $this, + $action + ), + array( + $this->request, + $this->response + ) + ); + } + } + + + /** + * Load the Controller of this Agent. + * + * @throws DatamodelException + * @throws DriverNotValidException + * @throws DriverNotFoundException + * @throws ViewNotFoundException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ControllerNotValidException + * @throws ControllerNotFoundException + */ + protected function loadController() + { + // Determine Controller name + $controllerName = ClassLoader::getClassName(get_class($this)); + + // Determine ToplevelAgent + $toplevelAgentName = $this->response->getParam(0); + if(is_null($toplevelAgentName)) { + $toplevelAgentName = $this->request->getParam(0, 'toplevel'); + $this->response->addParam($toplevelAgentName); + } + + // Determine Action + $action = $this->response->getParam(2); + if(is_null($action)) { + $action = $this->request->getParam(2, 'action'); + $this->response->addParam($action); + } + + + // Load Controller + Controller::load($controllerName); + + // Construct Controller + $this->controller = Controller::factory($controllerName, $toplevelAgentName, $action, $this); + } + + + /** + * Log an error. + * + * @param Exception $exception Occurred exception + * @param int $logMode Log mode + */ + protected function log($exception, $logMode) + { + if(is_null($this->log)) { + return; + } + + $this->log->log( + $exception->getMessage(), + $logMode + ); + } + + + /** + * Load a SubAgent and add it. + * + * @throws ServiceUnavailableException + * @throws AgentNotFoundException + * @throws AgentNotValidException + * @param string $agentName Name of the Agent to load + * @param mixed … Additional parameters for the agent + */ + protected function addSubAgent($agentName) + { + try { + call_user_func_array( + array( + $this, + '_addSubAgent' + ), + func_get_args() + ); + } + catch(DatamodelException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + } + + + /** + * Load a SubAgent and add it. + * + * @throws ServiceUnavailableException + * @throws DatamodelException + * @throws AgentNotFoundException + * @throws AgentNotValidException + * @param string $agentName Name of the Agent to load + * @param mixed … Additional parameters for the agent + */ + protected function _addSubAgent($agentName) + { + try { + // Load Agent + \nre\agents\BottomlevelAgent::load($agentName); + + // Construct Agent + $this->subAgents[] = call_user_func_array( + array( + $this, + 'newSubAgent' + ), + func_get_args() + ); + } + catch(ViewNotFoundException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + catch(DriverNotValidException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + catch(DriverNotFoundException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + catch(ModelNotValidException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + catch(ModelNotFoundException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + catch(ControllerNotValidException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + catch(ControllerNotFoundException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + catch(AgentNotValidException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + catch(AgentNotFoundException $e) { + $this->subAgents[] = $this->newInlineError($agentName, $e); + } + } + + + + + /** + * Create a new SubAgent. + * + * @throws DatamodelException + * @param string $agentName Agent name + * @return array SubAgent + */ + private function newSubAgent($agentName) + { + // Response + $response = clone $this->response; + $response->clearParams(1); + $params = func_get_args(); + if(count($params) < 2 || empty($params[1])) { + $params[1] = \nre\configs\CoreConfig::$defaults['action']; + } + call_user_func_array( + array( + $response, + 'addParams' + ), + $params + ); + + return array( + 'name' => strtolower($agentName), + 'response' => $response, + 'object' => \nre\agents\BottomlevelAgent::factory( + $agentName, + $this->request, + $response, + $this->log + ) + ); + } + + + /** + * Render a SubAgent. + * + * @param array $subAgent SubAgent to render + * @return string Generated output + */ + private function renderSubAgent(&$subAgent) + { + // Check for InlineError + if(array_key_exists('inlineerror', $subAgent) && !empty($subAgent['inlineerror'])) { + return file_get_contents($subAgent['inlineerror']); + } + + + // Rendern SubAgent and return its output + return $subAgent['object']->render(); + } + + + /** + * Handle the exception of a SubAgent. + * + * @param string $label Name of the original Agent + * @param Excepiton $exception Occurred exception + * @return array InlineError-SubAgent + */ + private function errorInline($subAgent, $request, $exception) + { + // Create the SubAgent for the exception + $subAgent = $this->newInlineError($subAgent['name'], $exception); + + + // Run the InlineError-SubAgent + try { + $subAgent['object']->run( + $request, + $subAgent['response'] + ); + } + catch(ActionNotFoundException $e) { + $this->log($e, Logger::LOGMODE_AUTO); + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + + + // Return the InlineError-SubAgent + return $subAgent; + } + + + /** + * Create a new InlineError. + * + * @param string $label Name of the original Agent + * @param Exception $exception Occurred exception + */ + private function newInlineError($label, $exception) + { + // Log error + $this->log($exception, Logger::LOGMODE_AUTO); + + // Determine Agent name + $agentName = self::INLINEERROR_AGENT; + + // Create SugAgent + $subAgent = array(); + + + try { + // Load Agenten + \nre\agents\BottomlevelAgent::load($agentName); + + // Construct Agent + $subAgent = $this->newSubAgent($agentName); + $subAgent['label'] = $label; + $subAgent['response']->addParam($exception); + } + catch(ViewNotFoundException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(DatamodelException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(DriverNotValidException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(DriverNotFoundException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(ModelNotValidException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(ModelNotFoundException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(ControllerNotValidException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(ControllerNotFoundException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(AgentNotValidException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + catch(AgentNotFoundException $e) { + $subAgent['inlineerror'] = $this->newInlineErrorService(); + } + + + // Return SubAgent + return $subAgent; + } + + + /** + * Handle a hardcore error that could not be handled by the + * system. + */ + private function newInlineErrorService() + { + // Read and return static error file + return ROOT.DS.\nre\configs\CoreConfig::getClassDir('views').DS.\nre\configs\CoreConfig::$defaults['inlineErrorFile'].\nre\configs\Config::getFileExt('views'); + } + + } + +?> diff --git a/core/Api.inc b/core/Api.inc new file mode 100644 index 00000000..b89b12e7 --- /dev/null +++ b/core/Api.inc @@ -0,0 +1,163 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Abstract class to implement an API. + * + * The API is the center of each application and specifies how and what + * to run and render. + * + * @author coderkun + */ + abstract class Api + { + /** + * Die aktuelle Anfrage + * + * @var Request + */ + protected $request; + /** + * Der Toplevelagent + * + * @var ToplevelAgent + */ + private $toplevelAgent = null; + /** + * Die aktuelle Antwort + * + * @var Response + */ + protected $response; + /** + * Log-System + * + * @var Logger + */ + protected $log; + + + + + /** + * Construct a new API. + * + * @param Request $request Current request + * @param Response $respone Current response + */ + public function __construct(Request $request, Response $response) + { + // Store request + $this->request = $request; + + // Store response + $this->response = $response; + + // Init logging + $this->log = new \nre\core\Logger(); + } + + + + + /** + * Run the application. + * + * @throws DatamodelException + * @throws DriverNotValidException + * @throws DriverNotFoundException + * @throws ViewNotFoundException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ControllerNotValidException + * @throws ControllerNotFoundException + * @throws AgentNotValidException + * @throws AgentNotFoundException + * @return Exception Last occurred exception of an subagent + */ + public function run() + { + // Load ToplevelAgent + $this->loadToplevelAgent(); + + // Run ToplevelAgent + return $this->toplevelAgent->run($this->request, $this->response); + } + + + /** + * Render the output. + */ + public function render() + { + // Check exit-status + if($this->response->getExit()) { + return; + } + + // Render ToplevelAgent + $this->response->setOutput($this->toplevelAgent->render()); + } + + + + + /** + * Log an exception + * + * @param Exception $exception Occurred exception + * @param int $logMode Log-mode + */ + protected function log($exception, $logMode) + { + $this->log->log( + $exception->getMessage(), + $logMode + ); + } + + + + + /** + * Load the ToplevelAgent specified by the request. + * + * @throws ServiceUnavailableException + * @throws AgentNotValidException + * @throws AgentNotFoundException + */ + private function loadToplevelAgent() + { + // Determine agent + $agentName = $this->response->getParam(0); + if(is_null($agentName)) { + $agentName = $this->request->getParam(0, 'toplevel'); + $this->response->addParam($agentName); + } + + // Load agent + \nre\agents\ToplevelAgent::load($agentName); + + // Construct agent + $this->toplevelAgent = \nre\agents\ToplevelAgent::factory( + $agentName, + $this->request, + $this->response, + $this->log + ); + } + + } + +?> diff --git a/core/Autoloader.inc b/core/Autoloader.inc new file mode 100644 index 00000000..020b61f7 --- /dev/null +++ b/core/Autoloader.inc @@ -0,0 +1,98 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Autoloader. + * + * This class tries to load not yet used classes. + * + * @author coderkun + */ + class Autoloader + { + /** + * Private construct(). + */ + private function __construct() {} + + /** + * Private clone(). + */ + private function __clone() {} + + + + + /** + * Register load-method. + */ + public static function register() + { + spl_autoload_register( + array( + get_class(), + 'load' + ) + ); + } + + + /** + * Look for the given class and try to load it. + * + * @param string $fullClassName Die zu ladende Klasse + */ + public static function load($fullClassName) + { + $fullClassNameA = explode('\\', $fullClassName); + + if(strpos($fullClassName, \nre\configs\CoreConfig::$core['namespace']) !== 0) + { + // App + $className = array_slice($fullClassNameA, substr_count(\nre\configs\AppConfig::$app['namespace'], '\\')); + array_unshift($className, \nre\configs\CoreConfig::getClassDir('app')); + $filename = ROOT.DS.implode(DS, $className).\nre\configs\CoreConfig::getFileExt('includes'); + if(file_exists($filename)) { + require_once($filename); + } + } + else + { + // Core + $className = array_slice($fullClassNameA, substr_count(\nre\configs\CoreConfig::$core['namespace'], '\\')); + $filename = ROOT.DS.implode(DS, $className).\nre\configs\CoreConfig::getFileExt('includes'); + if(file_exists($filename)) { + require_once($filename); + } + } + + + } + + + /** + * Determine classtype of a class. + * + * @param string $className Name of the class to determine the classtype of + * @return string Classtype of the given class + */ + public static function getClassType($className) + { + // CamelCase + return strtolower(preg_replace('/^.*([A-Z][^A-Z]+)$/', '$1', $className)); + } + + } + +?> diff --git a/core/ClassLoader.inc b/core/ClassLoader.inc new file mode 100644 index 00000000..81cf537a --- /dev/null +++ b/core/ClassLoader.inc @@ -0,0 +1,129 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Class for safely loading classes. + * + * @author coderkun + */ + class ClassLoader + { + + + + + /** + * Load a class. + * + * @throws ClassNotFoundException + * @param string $className Name of the class to load + */ + public static function load($fullClassName) + { + // Determine folder to look in + $className = explode('\\', $fullClassName); + $className = array_slice($className, substr_count(\nre\configs\AppConfig::$app['namespace'], '\\')); + + // Determine filename + $fileName = ROOT.DS.implode(DS, $className). \nre\configs\CoreConfig::getFileExt('includes'); + + + // Check file + if(!file_exists($fileName)) + { + throw new \nre\exceptions\ClassNotFoundException( + $fullClassName + ); + } + + // Include file + include_once($fileName); + } + + + /** + * Check inheritance of a class. + * + * @throws ClassNotValidException + * @param string $className Name of the class to check + * @param string $parentClassName Name of the parent class + */ + public static function check($className, $parentClassName) + { + // Check if class is subclass of parent class + if(!is_subclass_of($className, $parentClassName)) { + throw new \nre\exceptions\ClassNotValidException( + $className + ); + } + } + + + /** + * Strip the namespace from a class name. + * + * @param string $class Name of a class including its namespace + * @return Name of the given class without its namespace + */ + public static function stripNamespace($class) + { + return array_slice(explode('\\', $class), -1)[0]; + } + + + /** + * Strip the class type from a class name. + * + * @param string $className Name of a class + * @return Name of the given class without its class type + */ + public static function stripClassType($className) + { + return preg_replace('/^(.*)[A-Z][^A-Z]+$/', '$1', $className); + } + + + /** + * Strip the namespace and the class type of a full class name + * to get only its name. + * + * @param string $class Full name of a class + * @return Only the name of the given class + */ + public static function getClassName($class) + { + return self::stripClassType(self::stripNamespace($class)); + } + + + /** + * Concatenate strings to a class name following the CamelCase + * pattern. + * + * @param string $className1 Arbitrary number of strings to concat + * @return string Class name as CamelCase + */ + public static function concatClassNames($className1) + { + return implode('', array_map( + function($arg) { + return ucfirst(strtolower($arg)); + }, + func_get_args() + )); + } + + } + +?> diff --git a/core/Component.inc b/core/Component.inc new file mode 100644 index 00000000..a93363dc --- /dev/null +++ b/core/Component.inc @@ -0,0 +1,85 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Abstract class to implement a (Controller) Component. + * + * @author coderkun + */ + abstract class Component + { + + + + + /** + * Load the class of a Component. + * + * @throws ComponentNotFoundException + * @throws ComponentNotValidException + * @param string $componentName Name of the Component to load + */ + public static function load($componentName) + { + // Determine full classname + $className = self::getClassName($componentName); + + try { + // Load class + ClassLoader::load($className); + + // Validate class + ClassLoader::check($className, get_class()); + } + catch(\nre\exceptions\ClassNotValidException $e) { + throw new \nre\exceptions\ComponentNotValidException($e->getClassName()); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + throw new \nre\exceptions\ComponentNotFoundException($e->getClassName()); + } + } + + + /** + * Instantiate a Component (Factory Pattern). + * + * @param string $componentName Name of the Component to instantiate + */ + public static function factory($componentName) + { + // Determine full classname + $className = self::getClassName($componentName); + + // Construct and return Controller + return new $className(); + } + + + /** + * Determine the classname for the given Component name. + * + * @param string $componentName Component name to get classname of + * @return string Classname for the Component name + */ + private static function getClassName($componentName) + { + $className = \nre\core\ClassLoader::concatClassNames($componentName, \nre\core\ClassLoader::stripNamespace(get_class())); + + + return \nre\configs\AppConfig::$app['namespace']."controllers\\components\\$className"; + } + + } + +?> diff --git a/core/Config.inc b/core/Config.inc new file mode 100644 index 00000000..b51f1e47 --- /dev/null +++ b/core/Config.inc @@ -0,0 +1,49 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Configuration. + * + * This class does not hold any configuration value but helps to + * determine values that can be hold by AppConfig or CoreConfig. + * + * @author coderkun + */ + final class Config + { + + + + + /** + * Get a default value. + * + * @param string $index Index of value to get + */ + public static function getDefault($index) + { + if(array_key_exists($index, \nre\configs\AppConfig::$defaults)) { + return \nre\configs\AppConfig::$defaults[$index]; + } + if(array_key_exists($index, \nre\configs\CoreConfig::$defaults)) { + return \nre\configs\CoreConfig::$defaults[$index]; + } + + + return null; + } + + } + +?> diff --git a/core/Controller.inc b/core/Controller.inc new file mode 100644 index 00000000..941e7523 --- /dev/null +++ b/core/Controller.inc @@ -0,0 +1,433 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Abstract class for implementing a Controller. + * + * @author coderkun + */ + abstract class Controller + { + /** + * Corresponding Agent + * + * @var Agent + */ + protected $agent; + /** + * View of the Controller + * + * @var View + */ + protected $view = null; + /** + * Data to pass to the View + * + * @var array + */ + protected $viewData = array(); + /** + * Current request + * + * @var Request + */ + protected $request = null; + /** + * Current response + * + * @var Response + */ + protected $response = null; + + + + + /** + * Load the class of a Controller. + * + * @throws ControllerNotFoundException + * @throws ControllerNotValidException + * @param string $controllerName Name of the Controller to load + */ + public static function load($controllerName) + { + // Determine full classname + $className = self::getClassName($controllerName); + + try { + // Load class + ClassLoader::load($className); + + // Validate class + ClassLoader::check($className, get_class()); + } + catch(\nre\exceptions\ClassNotValidException $e) { + throw new \nre\exceptions\ControllerNotValidException($e->getClassName()); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + throw new \nre\exceptions\ControllerNotFoundException($e->getClassName()); + } + } + + + /** + * Instantiate a Controller (Factory Pattern). + * + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ViewNotFoundException + * @param string $controllerName Name of the Controller to instantiate + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + */ + public static function factory($controllerName, $layoutName, $action, $agent) + { + // Determine full classname + $className = self::getClassName($controllerName); + + // Construct and return Controller + return new $className($layoutName, $action, $agent); + } + + + /** + * Determine the classname for the given Controller name. + * + * @param string $controllerName Controller name to get classname of + * @return string Classname for the Controller name + */ + private static function getClassName($controllerName) + { + $className = \nre\core\ClassLoader::concatClassNames($controllerName, \nre\core\ClassLoader::stripNamespace(get_class())); + + + return \nre\configs\AppConfig::$app['namespace']."controllers\\$className"; + } + + + + + /** + * Construct a new Controller. + * + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + * @throws ViewNotFoundException + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + * @param Agent $agent Corresponding Agent + */ + protected function __construct($layoutName, $action, $agent) + { + // Store values + $this->agent = $agent; + + // Load Components + $this->loadComponents(); + + // Load Models + $this->loadModels(); + + // Load View + $this->loadView($layoutName, $action); + } + + + + + /** + * Prefilter that is executed before running the Controller. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function preFilter(Request $request, Response $response) + { + // Request speichern + $this->request = $request; + + // Response speichern + $this->response = $response; + + + // Linker erstellen + $this->set('linker', new \nre\core\Linker($request)); + + } + + + /** + * Prefilter that is executed after running the Controller. + * + * @param Request $request Current request + * @param Response $response Current response + */ + public function postFilter(Request $request, Response $response) + { + } + + + /** + * Run the Controller. + * + * This method executes the Action of the Controller defined by + * the current Request. + * + * @throws ParamsNotValidException + * @throws IdNotFoundException + * @throws DatamodelException + * @throws ActionNotFoundException + * @param Request $request Current request + * @param Response $response Current response + */ + public function run(Request $request, Response $response) + { + // Determine Action + $action = $response->getParam(2, 'action'); + if(!method_exists($this, $action)) { + throw new \nre\exceptions\ActionNotFoundException($action); + } + + // Determine parameters + $params = $response->getParams(3); + if(empty($params)) { + $params = $request->getParams(3); + } + + // Fill missing parameters + $rc = new \ReflectionClass($this); + $nullParamsCount = $rc->getMethod($action)->getNumberOfParameters() - count($params); + $nullParams = ($nullParamsCount > 0 ? array_fill(0, $nullParamsCount, NULL) : array()); + + + // Call Action + call_user_func_array( + array( + $this, + $action + ), + array_merge( + $params, + $nullParams + ) + ); + } + + + /** + * Generate the output. + * + * @param array $viewData Data to pass to the View + * @return string Generated output + */ + public function render($viewData=null) + { + // Combine given data and data of this Controller + $data = $this->viewData; + if(!is_null($viewData)) { + $data = array_merge($viewData, $data); + } + + // Rendern and return output + return $this->view->render($data); + } + + + + + /** + * Set data for the View. + * + * @param string $name Key + * @param mixed $data Value + */ + protected function set($name, $data) + { + $this->viewData[$name] = $data; + } + + + /** + * Redirect to the given URL. + * + * @param string $url Relative URL + */ + protected function redirect($url) + { + $url = 'http://'.$_SERVER['HTTP_HOST'].$url; + header('Location: '.$url); + exit; + } + + + /** + * Check if Models of this Controller are loaded and available. + * + * @param string $modelName Arbitrary number of Models to check + * @return bool All given Models are loaded and available + */ + protected function checkModels($modelName) + { + foreach(func_get_args() as $modelName) + { + if(!isset($this->$modelName) || !is_subclass_of($this->$modelName, 'Model')) { + return false; + } + } + + + return true; + } + + + /** + * Get the View of the Controller + * + * @return View View of the Controller + */ + protected function getView() + { + return $this->view; + } + + + + + /** + * Load the Components of this Controller. + * + * @throws ComponentNotValidException + * @throws ComponentNotFoundException + */ + private function loadComponents() + { + // Determine components + $components = array(); + if(property_exists($this, 'components')) { + $components = $this->components; + } + if(!is_array($components)) { + $components = array($components); + } + // Components of parent classes + $parent = $this; + while($parent = get_parent_class($parent)) + { + $properties = get_class_vars($parent); + if(array_key_exists('components', $properties)) { + $components = array_merge($components, $properties['components']); + } + } + + // Load components + foreach($components as &$component) + { + // Load class + Component::load($component); + + // Construct component + $componentName = ucfirst(strtolower($component)); + $this->$componentName = Component::factory($component); + } + } + + + /** + * Load the Models of this Controller. + * + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + */ + protected function loadModels() + { + // Determine Models + $explicit = false; + $models = \nre\core\ClassLoader::stripClassType(\nre\core\ClassLoader::stripNamespace(get_class($this))); + if(property_exists($this, 'models')) + { + $models = $this->models; + $explicit = true; + } + if(!is_array($models)) { + $models = array($models); + } + // Models of parent classes + $parent = $this; + while($parent = get_parent_class($parent)) + { + $properties = get_class_vars($parent); + if(array_key_exists('models', $properties)) { + $models = array_merge($models, $properties['models']); + } + } + + // Load Models + foreach($models as &$model) + { + try { + // Load class + Model::load($model); + + // Construct Model + $modelName = ucfirst(strtolower($model)); + $this->$modelName = Model::factory($model); + } + catch(\nre\exceptions\ModelNotValidException $e) { + if($explicit) { + throw $e; + } + } + catch(\nre\exceptions\ModelNotFoundException $e) { + if($explicit) { + throw $e; + } + } + } + } + + + /** + * Load the View of this Controller. + * + * @throws ViewNotFoundException + * @param string $layoutName Name of the current Layout + * @param string $action Current Action + */ + protected function loadView($layoutName, $action) + { + // Check Layout name + if(is_null($layoutName)) { + return; + } + + // Determine controller name + $controllerName = \nre\core\ClassLoader::getClassName(get_class($this)); + + + // Load view + $isToplevel = is_subclass_of($this->agent, '\nre\agents\ToplevelAgent'); + $this->view = View::loadAndFactory($layoutName, $controllerName, $action, $isToplevel); + } + + } + +?> diff --git a/core/Driver.inc b/core/Driver.inc new file mode 100644 index 00000000..eec59143 --- /dev/null +++ b/core/Driver.inc @@ -0,0 +1,96 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Abstract class for implementing a Driver. + * + * @author coderkun + */ + abstract class Driver + { + + + + + /** + * Load the class of a Driver. + * + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @param string $driverName Name of the Driver to load + */ + public static function load($driverName) + { + // Determine full classname + $className = self::getClassName($driverName); + + try { + // Load class + ClassLoader::load($className); + + // Validate class + ClassLoader::check($className, get_class()); + } + catch(\nre\exceptions\ClassNotValidException $e) { + throw new \nre\exceptions\DriverNotValidException($e->getClassName()); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + throw new \nre\exceptions\DriverNotFoundException($e->getClassName()); + } + } + + + /** + * Instantiate a Driver (Factory Pattern). + * + * @param string $driverName Name of the Driver to instantiate + */ + public static function factory($driverName, $config) + { + // Determine full classname + $className = self::getClassName($driverName); + + + // Construct and return Driver + return $className::singleton($config); + } + + + /** + * Determine the classname for the given Driver name. + * + * @param string $driverName Driver name to get classname of + * @return string Classname fore the Driver name + */ + private static function getClassName($driverName) + { + $className = ClassLoader::concatClassNames($driverName, ClassLoader::stripNamespace(get_class())); + + + return "\\nre\\drivers\\$className"; + } + + + + + /** + * Construct a new Driver. + */ + protected function __construct() + { + } + + } + +?> diff --git a/core/Exception.inc b/core/Exception.inc new file mode 100644 index 00000000..a17a700f --- /dev/null +++ b/core/Exception.inc @@ -0,0 +1,65 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Exception class. + * + * @author coderkun + */ + class Exception extends \Exception + { + + + + + /** + * Construct a new exception. + * + * @param string $message Error message + * @param int $code Error code + * @param string $name Name to insert + */ + function __construct($message, $code, $name=null) + { + parent::__construct( + $this->concat( + $message, + $name + ), + $code + ); + } + + + + + /** + * Insert the name in a message + * + * @param string $message Error message + * @param string $name Name to insert + */ + private function concat($message, $name) + { + if(is_null($name)) { + return $message; + } + + + return "$message: $name"; + } + + } + +?> diff --git a/core/Linker.inc b/core/Linker.inc new file mode 100644 index 00000000..242bbe0e --- /dev/null +++ b/core/Linker.inc @@ -0,0 +1,313 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Class to create web links based on the current request. + * + * @author coderkun + */ + class Linker + { + /** + * Current request + * + * @var Request + */ + private $request; + + + + + /** + * Construct a new linker. + * + * @param Request $request Current request + */ + function __construct(\nre\requests\WebRequest $request) + { + $this->request = $request; + } + + + + + /** + * Mask parameters to be used in an URL. + * + * @param string $param1 First parameter + * @return string Masked parameters as string + */ + public static function createLinkParam($param1) + { + return implode( + \nre\configs\CoreConfig::$classes['linker']['url']['delimiter'], + call_user_func_array( + '\nre\core\Linker::createLinkParams', + func_get_args() + ) + ); + } + + + /** + * Mask parameters to be used in an URL. + * + * @param string $param1 First parameter + * @return string Masked parameters as array + */ + public static function createLinkParams($param1) + { + // Parameters + $linkParams = array(); + $params = func_get_args(); + + foreach($params as $param) + { + // Delete critical signs + $specials = array('/', '?', '&'); + foreach($specials as &$special) { + $param = str_replace($special, '', $param); + } + + // Process parameter + $param = str_replace( + ' ', + \nre\configs\CoreConfig::$classes['linker']['url']['delimiter'], + substr( + $param, + 0, + \nre\configs\CoreConfig::$classes['linker']['url']['length'] + ) + ); + + // Encode parameter + $linkParams[] = $param; + } + + + // Return link parameters + return $linkParams; + } + + + + + /** + * Create a web link. + * + * @param array $params Parameters to use + * @param int $offset Ignore first parameters + * @param bool $exclusiveParams Use only the given parameters + * @param array $getParams GET-parameter to use + * @param bool $exclusiveGetParams Use only the given GET-parameters + * @param string $anchor Target anchor + * @param bool $absolute Include hostname etc. for an absolute URL + * @return string Created link + */ + public function link($params=null, $offset=0, $exclusiveParams=true, $getParams=null, $exclusiveGetParams=true, $anchor=null, $absolute=false) + { + // Make current request to response + $response = new \nre\responses\WebResponse(); + + + // Check parameters + if(is_null($params)) { + $params = array(); + } + elseif(!is_array($params)) { + $params = array($params); + } + + // Set parameters from request + $reqParams = array_slice($this->request->getParams(), 1, $offset); + if(count($reqParams) < $offset && $offset > 0) { + $reqParams[] = $this->request->getParam(1, 'intermediate'); + } + if(count($reqParams) < $offset && $offset > 1) { + $reqParams[] = $this->request->getParam(2, 'action'); + } + $params = array_merge($reqParams, $params); + + // Use Layout + $layout = \nre\configs\AppConfig::$defaults['toplevel']; + if(!empty($getParams) && array_key_exists('layout', $getParams)) { + $layout = $getParams['layout']; + } + array_unshift($params, $layout); + + // Use parameters from request only + if(!$exclusiveParams) + { + $params = array_merge( + $params, + array_slice( + $this->request->getParams(), + count($params) + ) + ); + } + + // Encode parameters + $params = array_map('rawurlencode', $params); + + // Set parameters + call_user_func_array( + array( + $response, + 'addParams' + ), + $params + ); + + + // Check GET-parameters + if(is_null($getParams)) { + $getParams = array(); + } + elseif(!is_array($params)) { + $params = array($params); + } + if(!$exclusiveGetParams) + { + $getParams = array_merge( + $this->request->getGetParams(), + $getParams + ); + } + + // Set GET-parameters + $response->addGetParams($getParams); + + + // Create and return link + return self::createLink($this->request, $response, $anchor, $absolute); + } + + + /** + * Create a link from an URL. + * + * @param string $url URL to create link from + * @param bool $absolute Create absolute URL + * @return string Created link + */ + public function hardlink($url, $absolute=false) + { + return $this->createUrl($url, $this->request, $absolute); + } + + + + + /** + * Create a link. + * + * @param Request $request Current request + * @param Response $response Current response + * @param bool $absolute Create absolute link + * @param string $anchor Anchor on target + * @return string Created link + */ + private static function createLink(Request $request, Response $response, $anchor=null, $absolute=false) + { + // Check response + if(is_null($response)) { + return null; + } + + + // Get parameters + $params = $response->getParams(1); + + // Check Action + if(count($params) == 2 && $params[1] == \nre\configs\CoreConfig::$defaults['action']) { + array_pop($params); + } + + // Set parameters + $link = implode('/', $params); + + // Apply reverse-routes + $link = $request->applyReverseRoutes($link); + + + // Get GET-parameters + $getParams = $response->getGetParams(); + + // Layout überprüfen + if(array_key_exists('layout', $getParams) && $getParams['layout'] == \nre\configs\AppConfig::$defaults['toplevel']) { + unset($getParams['layout']); + } + + // Set GET-parameters + if(array_key_exists('url', $getParams)) { + unset($getParams['url']); + } + if(count($getParams) > 0) { + $link .= '?'.http_build_query($getParams); + } + + // Add anchor + if(!is_null($anchor)) { + $link .= "#$anchor"; + } + + + // Create URL + $url = self::createUrl($link, $request, $absolute); + + + return $url; + } + + + /** + * Adapt a link to the environment. + * + * @param string $url URL + * @param Request $request Current request + * @param bool $absolute Create absolute URL + * @return string Adapted URL + */ + private static function createUrl($url, Request $request, $absolute=false) + { + // Variables + $server = $_SERVER['SERVER_NAME']; + $uri = $_SERVER['REQUEST_URI']; + $prefix = ''; + + + // Determine prefix + $ppos = array(strlen($uri)); + if(($p = strpos($uri, '/'.$request->getParam(1, 'intermediate'))) !== false) { + $ppos[] = $p; + } + $prefix = substr($uri, 0, min($ppos)); + + // Create absolute URL + if($absolute) { + $prefix = "http://$server/".trim($prefix, '/'); + } + + // Put URL together + $url = rtrim($prefix, '/').'/'.ltrim($url, '/'); + + + // Return URL + return $url; + } + + } + +?> diff --git a/core/Logger.inc b/core/Logger.inc new file mode 100644 index 00000000..b5ac7dc9 --- /dev/null +++ b/core/Logger.inc @@ -0,0 +1,132 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Class to log messages to different targets. + * + * @author coderkun + */ + class Logger + { + /** + * Log mode: Detect automatic + * + * @var int + */ + const LOGMODE_AUTO = 0; + /** + * Log mode: Print to screen + * + * @var int + */ + const LOGMODE_SCREEN = 1; + /** + * Log mode: Use PHP-logging mechanism + * + * @var int + */ + const LOGMODE_PHP = 2; + + /** + * Do not auto-log to screen + * + * @var boolean + */ + private $autoLogToScreenDisabled = false; + + + + + /** + * Construct a new logger. + */ + public function __construct() + { + } + + + + + /** + * Log a message. + * + * @param string $message Message to log + * @param int $logMode Log mode to use + */ + public function log($message, $logMode=self::LOGMODE_SCREEN) + { + // Choose log mode automatically + if($logMode == self::LOGMODE_AUTO) { + $logMode = $this->getAutoLogMode(); + } + + // Print message to screen + if($logMode & self::LOGMODE_SCREEN) { + $this->logToScreen($message); + } + + // Use PHP-logging mechanism + if($logMode & self::LOGMODE_PHP) { + $this->logToPhp($message); + } + } + + + /** + * Disable logging to screen when the log mode is automatically + * detected. + */ + public function disableAutoLogToScreen() + { + $this->autoLogToScreenDisabled = true; + } + + + + /** + * Print a message to screen. + * + * @param string $message Message to print + */ + private function logToScreen($message) + { + echo "$message
\n"; + } + + + /** + * Log a message by using PHP-logging mechanism. + * + * @param string $message Message to log + */ + private function logToPhp($message) + { + error_log($message, 0); + } + + + /** + * Detect log mode automatically by distinguishing between + * production and test environment. + * + * @return int Automatically detected log mode + */ + private function getAutoLogMode() + { + return ($_SERVER['SERVER_ADDR'] == '127.0.0.1' && !$this->autoLogToScreenDisabled) ? self::LOGMODE_SCREEN : self::LOGMODE_PHP; + } + + } + +?> diff --git a/core/Model.inc b/core/Model.inc new file mode 100644 index 00000000..c0475b4a --- /dev/null +++ b/core/Model.inc @@ -0,0 +1,141 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Abstract class for implementing a Model. + * + * @author coderkun + */ + abstract class Model + { + + + + + /** + * Load the class of a Model. + * + * @throws ModelNotFoundException + * @throws ModelNotValidException + * @param string $modelName Name of the Model to load + */ + public static function load($modelName) + { + // Determine full classname + $className = self::getClassName($modelName); + + try { + // Load class + ClassLoader::load($className); + + // Validate class + ClassLoader::check($className, get_class()); + } + catch(\nre\exceptions\ClassNotValidException $e) { + throw new \nre\exceptions\ModelNotValidException($e->getClassName()); + } + catch(\nre\exceptions\ClassNotFoundException $e) { + throw new \nre\exceptions\ModelNotFoundException($e->getClassName()); + } + } + + + /** + * Instantiate a Model (Factory Pattern). + * + * @param string $modelName Name of the Model to instantiate + */ + public static function factory($modelName) + { + // Determine full classname + $className = self::getClassName($modelName); + + // Construct and return Model + return new $className(); + } + + + /** + * Determine the classname for the given Model name. + * + * @param string $modelName Model name to get classname of + * @return string Classname fore the Model name + */ + private static function getClassName($modelName) + { + $className = ClassLoader::concatClassNames($modelName, ClassLoader::stripNamespace(get_class())); + + + return \nre\configs\AppConfig::$app['namespace']."models\\$className"; + } + + + + + /** + * Construct a new Model. + * + * TODO Catch exception + * + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + */ + protected function __construct() + { + // Load Models + $this->loadModels(); + } + + + + + /** + * Load the Models of this Model. + * + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @throws ModelNotValidException + * @throws ModelNotFoundException + */ + private function loadModels() + { + // Determine Models + $models = array(); + if(property_exists($this, 'models')) { + $models = $this->models; + } + if(!is_array($models)) { + $models = array($models); + } + + + // Load Models + foreach($models as $model) + { + // Load class + Model::load($model); + + // Construct Model + $modelName = ucfirst(strtolower($model)); + $this->$modelName = Model::factory($model); + } + } + + } + +?> diff --git a/core/Request.inc b/core/Request.inc new file mode 100644 index 00000000..5fda187e --- /dev/null +++ b/core/Request.inc @@ -0,0 +1,64 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Base class to represent a request. + * + * @author coderkun + */ + class Request + { + /** + * Passed parameters + * + * @var array + */ + protected $params = array(); + + + + + /** + * Get a parameter. + * + * @param int $index Index of parameter + * @param string $defaultIndex Index of default configuration value for this parameter + * @return string Value of parameter + */ + public function getParam($index, $defaultIndex=null) + { + // Return parameter + if(count($this->params) > $index) { + return $this->params[$index]; + } + + // Return default value + return \nre\core\Config::getDefault($defaultIndex); + } + + + /** + * Get all parameters from index on. + * + * @param int $offset Offset-index + * @return array Parameter values + */ + public function getParams($offset=0) + { + return array_slice($this->params, $offset); + } + + } + +?> diff --git a/core/Response.inc b/core/Response.inc new file mode 100644 index 00000000..4781b2ab --- /dev/null +++ b/core/Response.inc @@ -0,0 +1,158 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Base class to represent a response. + * + * @author coderkun + */ + class Response + { + /** + * Applied parameters + * + * @var array + */ + protected $params = array(); + /** + * Generated output + * + * @var string + */ + private $output = ''; + /** + * Abort futher execution + * + * @var bool + */ + private $exit = false; + + + + + /** + * Add a parameter. + * + * @param mixed $value Value of parameter + */ + public function addParam($value) + { + $this->params[] = $value; + } + + + /** + * Add multiple parameters. + * + * @param mixed $value1 Value of first parameter + * @param mixed … Values of further parameters + */ + public function addParams($value1) + { + $this->params = array_merge( + $this->params, + func_get_args() + ); + } + + + /** + * Delete all stored parameters (from offset on). + * + * @param int $offset Offset-index + */ + public function clearParams($offset=0) + { + $this->params = array_slice($this->params, 0, $offset); + } + + + /** + * Get a parameter. + * + * @param int $index Index of parameter + * @param string $defaultIndex Index of default configuration value for this parameter + * @return string Value of parameter + */ + public function getParam($index, $defaultIndex=null) + { + // Return parameter + if(count($this->params) > $index) { + return $this->params[$index]; + } + + + // Return default value + return \nre\core\Config::getDefault($defaultIndex); + } + + + /** + * Get all parameters from index on. + * + * @param int $offset Offset-index + * @return array Parameter values + */ + public function getParams($offset=0) + { + return array_slice($this->params, $offset); + } + + + /** + * Set output. + * + * @param string $output Generated output + */ + public function setOutput($output) + { + $this->output = $output; + } + + + /** + * Get generated output. + * + * @return string Generated output + */ + public function getOutput() + { + return $this->output; + } + + + /** + * Set exit-state. + * + * @param bool $exit Abort further execution + */ + public function setExit($exit=true) + { + $this->exit = $exit; + } + + + /** + * Get exit-state. + * + * @return bool Abort further execution + */ + public function getExit() + { + return $this->exit; + } + + } + +?> diff --git a/core/View.inc b/core/View.inc new file mode 100644 index 00000000..546ddb6e --- /dev/null +++ b/core/View.inc @@ -0,0 +1,124 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * View. + * + * Class to encapsulate a template file and to provide rendering methods. + * + * @author coderkun + */ + class View + { + /** + * Template filename + * + * @var string + */ + protected $templateFilename; + + + + + /** + * Load and instantiate the View of an Agent. + * + * @throws ViewNotFoundException + * @param string $layoutName Name of Layout in use + * @param string $agentName Name of the Agent + * @param string $action Current Action + * @param bool $isToplevel Agent is a ToplevelAgent + */ + public static function loadAndFactory($layoutName, $agentName=null, $action=null, $isToplevel=false) + { + return new View($layoutName, $agentName, $action, $isToplevel); + } + + + + + /** + * Construct a new View. + * + * @throws ViewNotFoundException + * @param string $layoutName Name of Layout in use + * @param string $agentName Name of the Agent + * @param string $action Current Action + * @param bool $isToplevel Agent is a ToplevelAgent + */ + protected function __construct($layoutName, $agentName=null, $action=null, $isToplevel=false) + { + // Create template filename + // LayoutName + $fileName = ROOT.DS. \nre\configs\CoreConfig::getClassDir('views').DS. strtolower($layoutName).DS; + // AgentName and Action + if(strtolower($agentName) != $layoutName || !$isToplevel) { + $fileName .= strtolower($agentName).DS.strtolower($action); + } + else { + $fileName .= strtolower($layoutName); + } + // File extension + $fileName .= \nre\configs\CoreConfig::getFileExt('views'); + + + // Check template file + if(!file_exists($fileName)) { + throw new \nre\exceptions\ViewNotFoundException($fileName); + } + + // Save filename + $this->templateFilename = $fileName; + } + + + + + /** + * Generate output + * + * @param array $data Data to have available in the template file + */ + public function render($data) + { + // Extract data + if(!is_null($data)) { + extract($data, EXTR_SKIP); + } + + // Buffer output + ob_start(); + + // Include template + include($this->templateFilename); + + + // Return buffered output + return ob_get_clean(); + } + + + /** + * Get template filename. + * + * @return string Template filename + */ + public function getTemplateFilename() + { + return $this->templateFilename; + } + + } + +?> diff --git a/core/WebUtils.inc b/core/WebUtils.inc new file mode 100644 index 00000000..5a4bb6f0 --- /dev/null +++ b/core/WebUtils.inc @@ -0,0 +1,75 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\core; + + + /** + * Class that holds several web-specific methods and properties. + * + * @author coderkun + */ + class WebUtils + { + /** + * HTTP-statuscode 403: Forbidden + * + * @var int + */ + const HTTP_FORBIDDEN = 403; + /** + * HTTP-statuscode 404: Not Found + * + * @var int + */ + const HTTP_NOT_FOUND = 404; + /** + * HTTP-statuscode 503: Service Unavailable + * + * @var int + */ + const HTTP_SERVICE_UNAVAILABLE = 503; + + /** + * HTTP-header strings + * + * @var array + */ + public static $httpStrings = array( + 200 => 'OK', + 304 => 'Not Modified', + 403 => 'Forbidden', + 404 => 'Not Found', + 503 => 'Service Unavailable' + ); + + + + + /** + * Get the HTTP-header of a HTTP-statuscode + * + * @param int $httpStatusCode HTTP-statuscode + * @return string HTTP-header of HTTP-statuscode + */ + public static function getHttpHeader($httpStatusCode) + { + if(!array_key_exists($httpStatusCode, self::$httpStrings)) { + $httpStatusCode = 200; + } + + + return sprintf("HTTP/1.1 %d %s\n", $httpStatusCode, self::$httpStrings[$httpStatusCode]); + } + + } + +?> diff --git a/drivers/DatabaseDriver.inc b/drivers/DatabaseDriver.inc new file mode 100644 index 00000000..dfd5c0fd --- /dev/null +++ b/drivers/DatabaseDriver.inc @@ -0,0 +1,87 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\drivers; + + + /** + * Abstract class for implementing a database driver. + * + * @author coderkun + */ + abstract class DatabaseDriver extends \nre\core\Driver + { + /** + * Driver class instance + * + * @static + * @var DatabaseDriver + */ + protected static $driver = null; + /** + * Connection resource + * + * @var resource + */ + protected $connection = null; + + + + + /** + * Singleton-pattern. + * + * @param array $config Database driver configuration + * @return DatabaseDriver Singleton-instance of database driver class + */ + public static function singleton($config) + { + // Singleton + if(self::$driver !== null) { + return self::$driver; + } + + // Construct + $className = get_called_class(); + self::$driver = new $className($config); + + + return self::$driver; + } + + + /** + * Construct a new database driver. + * + * @param array $config Connection and login settings + */ + protected function __construct($config) + { + parent::__construct(); + + // Establish connection + $this->connect($config); + } + + + + + /** + * Establish a connect to a MqSQL-database. + * + * @throws DatamodelException + * @param array $config Connection and login settings + */ + protected abstract function connect($config); + + } + +?> diff --git a/drivers/MysqliDriver.inc b/drivers/MysqliDriver.inc new file mode 100644 index 00000000..fe4fa81a --- /dev/null +++ b/drivers/MysqliDriver.inc @@ -0,0 +1,169 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\drivers; + + + /** + * Implementation of a database driver for MySQL-databases. + * + * @author coderkun + */ + class MysqliDriver extends \nre\drivers\DatabaseDriver + { + + + + + /** + * Construct a MySQL-driver. + * + * @throws DatamodelException + * @param array $config Connection and login settings + */ + function __construct($config) + { + parent::__construct($config); + } + + + + + /** + * Execute a SQL-query. + * + * @throws DatamodelException + * @param string $query Query to run + * @param mixed … Params + * @return array Result + */ + public function query($query) + { + // Prepare statement + if(!($stmt = $this->connection->prepare($query))) { + throw new \nre\exceptions\DatamodelException($this->connection->error, $this->connection->errno); + } + + try { + // Prepare data + $data = array(); + + // Bind parameters + $p = array_slice(func_get_args(), 1); + $params = array(); + foreach($p as $key => $value) { + $params[$key] = &$p[$key]; + } + if(count($params) > 0) + { + if(!(call_user_func_array(array($stmt, 'bind_param'), $params))) { + throw new \nre\exceptions\DatamodelException($this->connection->error, $this->connection->errno); + } + } + + // Execute query + if(!$stmt->execute()) { + throw new \nre\exceptions\DatamodelException($this->connection->error, $this->connection->errno); + } + + // Fetch result + if($result = $stmt->get_result()) { + while($row = $result->fetch_assoc()) { + $data[] = $row; + } + } + + + $stmt->close(); + return $data; + } + catch(Exception $e) { + $stmt->close(); + throw $e; + } + } + + + /** + * Return the last insert id (of the last insert-query). + * + * @return int Last insert id + */ + public function getInsertId() + { + return $this->connection->insert_id; + } + + + /** + * Enable/disable autocommit feature. + * + * @param boolean $autocommit Enable/disable autocommit + */ + public function setAutocommit($autocommit) + { + $this->connection->autocommit($autocommit); + } + + + /** + * Rollback the current transaction. + */ + public function rollback() + { + $this->connection->rollback(); + } + + + /** + * Commit the current transaction. + */ + public function commit() + { + $this->connection->commit(); + } + + + + + /** + * Establish a connect to a MqSQL-database. + * + * @throws DatamodelException + * @param array $config Connection and login settings + */ + protected function connect($config) + { + // Connect + $con = @new \mysqli( + $config['host'], + $config['user'], + $config['password'], + $config['db'] + ); + + // Check connection + if($con->connect_error) { + throw new \nre\exceptions\DatamodelException($con->connect_error, $con->connect_errno); + } + + // Set character encoding + if(!$con->set_charset('utf8')) { + throw new \nre\exceptions\DatamodelException($con->connect_error, $con->connect_errno); + } + + // Save connection + $this->connection = $con; + } + + } + +?> diff --git a/exceptions/AccessDeniedException.inc b/exceptions/AccessDeniedException.inc new file mode 100644 index 00000000..31bba929 --- /dev/null +++ b/exceptions/AccessDeniedException.inc @@ -0,0 +1,51 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Access denied. + * + * @author coderkun + */ + class AccessDeniedException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 85; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'access denied'; + + + + + /** + * Consturct a new exception. + */ + function __construct() + { + parent::__construct( + self::MESSAGE, + self::CODE + ); + } + + } + +?> diff --git a/exceptions/ActionNotFoundException.inc b/exceptions/ActionNotFoundException.inc new file mode 100644 index 00000000..418dd49c --- /dev/null +++ b/exceptions/ActionNotFoundException.inc @@ -0,0 +1,77 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Action not found. + * + * @author coderkun + */ + class ActionNotFoundException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 70; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'action not found'; + + /** + * Name of the action that was not found + * + * @var string + */ + private $action; + + + + + /** + * Construct a new exception. + * + * @param string $action Name of the action that was not found + */ + function __construct($action) + { + parent::__construct( + self::MESSAGE, + self::CODE, + $action + ); + + // Store values + $this->action = $action; + } + + + + + /** + * Get the name of the action that was not found. + * + * @return string Name of the action that was not found + */ + public function getAction() + { + return $this->action; + } + + } + +?> diff --git a/exceptions/AgentNotFoundException.inc b/exceptions/AgentNotFoundException.inc new file mode 100644 index 00000000..f0b3a2a7 --- /dev/null +++ b/exceptions/AgentNotFoundException.inc @@ -0,0 +1,67 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Agent not found. + * + * @author coderkun + */ + class AgentNotFoundException extends \nre\exceptions\ClassNotFoundException + { + /** + * Error code + * + * @var int + */ + const CODE = 66; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'agent not found'; + + + + + /** + * Construct a new exception. + * + * @param string $agentName Name of the Agent that was not found + */ + function __construct($agentName) + { + parent::__construct( + $agentName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the Agent that was not found. + * + * @return string Name of the Agent that was not found + */ + public function getAgentName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/AgentNotValidException.inc b/exceptions/AgentNotValidException.inc new file mode 100644 index 00000000..1c752051 --- /dev/null +++ b/exceptions/AgentNotValidException.inc @@ -0,0 +1,67 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Agent not valid. + * + * @author coderkun + */ + class AgentNotValidException extends \nre\exceptions\ClassNotValidException + { + /** + * Error code + * + * @var int + */ + const CODE = 76; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'agent not valid'; + + + + + /** + * Construct a new exception. + * + * @param string $agentName Name of the invalid Agent + */ + function __construct($agentName) + { + parent::__construct( + $agentName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the invalid Agent. + * + * @return string Name of the invalid Agent + */ + public function getAgentName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/ClassNotFoundException.inc b/exceptions/ClassNotFoundException.inc new file mode 100644 index 00000000..070f383f --- /dev/null +++ b/exceptions/ClassNotFoundException.inc @@ -0,0 +1,77 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Class not found. + * + * @author coderkun + */ + class ClassNotFoundException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 64; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'class not found'; + + /** + * Name of the class that was not found + * + * @var string + */ + private $className; + + + + + /** + * Construct a new exception + * + * @param string $className Name of the class that was not found + */ + function __construct($className, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $className + ); + + // Store values + $this->className = $className; + } + + + + + /** + * Get the name of the class that was not found. + * + * @return string Name of the class that was not found + */ + public function getClassName() + { + return $this->className; + } + + } + +?> diff --git a/exceptions/ClassNotValidException.inc b/exceptions/ClassNotValidException.inc new file mode 100644 index 00000000..bdd36d5e --- /dev/null +++ b/exceptions/ClassNotValidException.inc @@ -0,0 +1,77 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Class not valid. + * + * @author coderkun + */ + class ClassNotValidException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 74; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'class not valid'; + + /** + * Name of the invalid class + * + * @var string + */ + private $className; + + + + + /** + * Construct a new exception. + * + * @param string $className Name of the invalid class + */ + function __construct($className, $message=self::MESSAGE, $code=self::CODE) + { + parent::__construct( + $message, + $code, + $className + ); + + // Store value + $this->className = $className; + } + + + + + /** + * Get the name of the invalid class. + * + * @return string Name of the invalid class + */ + public function getClassName() + { + return $this->className; + } + + } + +?> diff --git a/exceptions/ComponentNotFoundException.inc b/exceptions/ComponentNotFoundException.inc new file mode 100644 index 00000000..5e75de44 --- /dev/null +++ b/exceptions/ComponentNotFoundException.inc @@ -0,0 +1,67 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Component not found. + * + * @author coderkun + */ + class ComponentNotFoundException extends \nre\exceptions\ClassNotFoundException + { + /** + * Error code + * + * @var int + */ + const CODE = 67; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'component not found'; + + + + + /** + * Construct a new exception. + * + * @param string $componentName Name of the Component that was not found + */ + function __construct($componentName) + { + parent::__construct( + $componentName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the Component that was not found. + * + * @return string Name of the Component that was not found + */ + public function getComponentName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/ComponentNotValidException.inc b/exceptions/ComponentNotValidException.inc new file mode 100644 index 00000000..a03b0c0d --- /dev/null +++ b/exceptions/ComponentNotValidException.inc @@ -0,0 +1,67 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Component not valid. + * + * @author coderkun + */ + class ComponentNotValidException extends \nre\exceptions\ClassNotValidException + { + /** + * Error code + * + * @var int + */ + const CODE = 77; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'component not valid'; + + + + + /** + * Construct a new exception. + * + * @param string $componentName Name of the invalid Component + */ + function __construct($componentName) + { + parent::__construct( + $componentName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the invalid Component. + * + * @return string Name of the invalid Component + */ + public function getComponentName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/ControllerNotFoundException.inc b/exceptions/ControllerNotFoundException.inc new file mode 100644 index 00000000..90859c49 --- /dev/null +++ b/exceptions/ControllerNotFoundException.inc @@ -0,0 +1,67 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Controller not found. + * + * @author coderkun + */ + class ControllerNotFoundException extends \nre\exceptions\ClassNotFoundException + { + /** + * Error code + * + * @var int + */ + const CODE = 67; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'controller not found'; + + + + + /** + * Construct a new exception. + * + * @param string $controllerName Name of the Controller that was not found + */ + function __construct($controllerName) + { + parent::__construct( + $controllerName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the Controller that was not found. + * + * @return string Name of the Controller that was not found + */ + public function getControllerName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/ControllerNotValidException.inc b/exceptions/ControllerNotValidException.inc new file mode 100644 index 00000000..0c945bcb --- /dev/null +++ b/exceptions/ControllerNotValidException.inc @@ -0,0 +1,67 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Controller not valid. + * + * @author coderkun + */ + class ControllerNotValidException extends \nre\exceptions\ClassNotValidException + { + /** + * Error code + * + * @var int + */ + const CODE = 77; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'controller not valid'; + + + + + /** + * Construct a new exception. + * + * @param string $controllerName Name of the invalid Controller + */ + function __construct($controllerName) + { + parent::__construct( + $controllerName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the invalid Controller. + * + * @return string Name of the invalid Controller + */ + public function getControllerName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/DatamodelException.inc b/exceptions/DatamodelException.inc new file mode 100644 index 00000000..7785cd21 --- /dev/null +++ b/exceptions/DatamodelException.inc @@ -0,0 +1,99 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Datamodel. + * + * This exception is thrown when an error occurred during the execution + * of a datamodel. + * + * @author coderkun + */ + class DatamodelException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 84; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'datamodel error'; + + /** + * Error message of datamodel + * + * @var string + */ + private $datamodelMessage; + /** + * Error code of datamodel + * + * @var int + */ + private $datamodelErrorNumber; + + + + + /** + * Consturct a new exception. + * + * @param string $datamodelMessage Error message of datamodel + * @param int $datamodelErrorNumber Error code of datamodel + */ + function __construct($datamodelMessage, $datamodelErrorNumber) + { + parent::__construct( + self::MESSAGE, + self::CODE, + $datamodelMessage." ($datamodelErrorNumber)" + ); + + // Store values + $this->datamodelMessage = $datamodelMessage; + $this->datamodelErrorNumber = $datamodelErrorNumber; + } + + + + + /** + * Get the error message of datamodel. + * + * @return string Error message of datamodel + */ + public function getDatamodelMessage() + { + return $this->datamodelMessage; + } + + + /** + * Get the error code of datamodel. + * + * @return string Error code of datamodel + */ + public function getDatamodelErrorNumber() + { + return $this->datamodelErrorNumber; + } + + } + +?> diff --git a/exceptions/DriverNotFoundException.inc b/exceptions/DriverNotFoundException.inc new file mode 100644 index 00000000..9b218f29 --- /dev/null +++ b/exceptions/DriverNotFoundException.inc @@ -0,0 +1,68 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Driver not found. + * + * @author coderkun + */ + class DriverNotFoundException extends \nre\exceptions\ClassNotFoundException + { + /** + * Error code + * + * @var int + */ + const CODE = 71; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'driver not found'; + + + + + /** + * Construct a new exception. + * + * @param string $driverName Name of the driver that was not found + */ + function __construct($driverName) + { + // Elternkonstruktor aufrufen + parent::__construct( + $driverName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the driver that was not found. + * + * @return string Name of the driver that was not found + */ + public function getDriverName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/DriverNotValidException.inc b/exceptions/DriverNotValidException.inc new file mode 100644 index 00000000..fa9022e8 --- /dev/null +++ b/exceptions/DriverNotValidException.inc @@ -0,0 +1,68 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Driver not valid. + * + * @author coderkun + */ + class DriverNotValidException extends \nre\exceptions\ClassNotValidException + { + /** + * Error code + * + * @var int + */ + const CODE = 81; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'driver not valid'; + + + + + /** + * Konstruktor. + * + * @param string $driverName Name of the invalid driver + */ + function __construct($driverName) + { + // Elternkonstruktor aufrufen + parent::__construct( + $driverName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the invalid driver. + * + * @return string Name of the invalid driver + */ + public function getDriverName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/FatalDatamodelException.inc b/exceptions/FatalDatamodelException.inc new file mode 100644 index 00000000..afd80b86 --- /dev/null +++ b/exceptions/FatalDatamodelException.inc @@ -0,0 +1,96 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Datamodel exception that is fatal for the application. + * + * @author coderkun + */ + class FatalDatamodelException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 85; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'fatal datamodel error'; + + /** + * Error message of datamodel + * + * @var string + */ + private $datamodelMessage; + /** + * Error code of datamodel + * + * @var int + */ + private $datamodelErrorNumber; + + + + + /** + * Consturct a new exception. + * + * @param string $datamodelMessage Error message of datamodel + * @param int $datamodelErrorNumber Error code of datamodel + */ + function __construct($datamodelMessage, $datamodelErrorNumber) + { + parent::__construct( + self::MESSAGE, + self::CODE, + $datamodelMessage." ($datamodelErrorNumber)" + ); + + // Store values + $this->datamodelMessage = $datamodelMessage; + $this->datamodelErrorNumber = $datamodelErrorNumber; + } + + + + + /** + * Get the error message of datamodel. + * + * @return string Error message of datamodel + */ + public function getDatamodelMessage() + { + return $this->datamodelMessage; + } + + + /** + * Get the error code of datamodel. + * + * @return string Error code of datamodel + */ + public function getDatamodelErrorNumber() + { + return $this->datamodelErrorNumber; + } + + } + +?> diff --git a/exceptions/IdNotFoundException.inc b/exceptions/IdNotFoundException.inc new file mode 100644 index 00000000..fc3315c3 --- /dev/null +++ b/exceptions/IdNotFoundException.inc @@ -0,0 +1,77 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: ID not found. + * + * @author coderkun + */ + class IdNotFoundException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 85; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'id not found'; + + /** + * ID that was not found + * + * @var mixed + */ + private $id; + + + + + /** + * Consturct a new exception. + * + * @param mixed $id ID that was not found + */ + function __construct($id) + { + parent::__construct( + self::MESSAGE, + self::CODE, + $id + ); + + // Store values + $this->id = $id; + } + + + + + /** + * Get the ID that was not found. + * + * @return mixed ID that was not found + */ + public function getId() + { + return $this->id; + } + + } + +?> diff --git a/exceptions/LayoutNotFoundException.inc b/exceptions/LayoutNotFoundException.inc new file mode 100644 index 00000000..0eb8c89c --- /dev/null +++ b/exceptions/LayoutNotFoundException.inc @@ -0,0 +1,68 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Layout not found. + * + * @author coderkun + */ + class LayoutNotFoundException extends \nre\exceptions\AgentNotFoundException + { + /** + * Error code + * + * @var int + */ + const CODE = 65; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'layout not found'; + + + + + /** + * Construct a new exception. + * + * @param string $layoutName Name of the Layout that was not found + */ + function __construct($layoutName) + { + // Elternkonstruktor aufrufen + parent::__construct( + $layoutName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the Layout that was not found. + * + * @return string Name of the Layout that was not found + */ + public function getLayoutName() + { + return $this->getAgentName(); + } + + } + +?> diff --git a/exceptions/LayoutNotValidException.inc b/exceptions/LayoutNotValidException.inc new file mode 100644 index 00000000..b3af184f --- /dev/null +++ b/exceptions/LayoutNotValidException.inc @@ -0,0 +1,67 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Layout not valid. + * + * @author coderkun + */ + class LayoutNotValidException extends \nre\exceptions\AgentNotFoundException + { + /** + * Error code + * + * @var int + */ + const CODE = 75; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'layout not valid'; + + + + + /** + * Construct a new exception. + * + * @param string $layoutName Name of the invalid Layout + */ + function __construct($layoutName) + { + parent::__construct( + $layoutName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the invalid Layout. + * + * @return string Name of the invalid Layout + */ + public function getLayoutName() + { + return $this->getAgentName(); + } + + } + +?> diff --git a/exceptions/ModelNotFoundException.inc b/exceptions/ModelNotFoundException.inc new file mode 100644 index 00000000..48e8c0df --- /dev/null +++ b/exceptions/ModelNotFoundException.inc @@ -0,0 +1,67 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Action not found. + * + * @author coderkun + */ + class ModelNotFoundException extends \nre\exceptions\ClassNotFoundException + { + /** + * Error code + * + * @var int + */ + const CODE = 68; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'model not found'; + + + + + /** + * Construct a new exception. + * + * @param string $modelName Name of the Model that was not found + */ + function __construct($modelName) + { + parent::__construct( + $modelName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the Model that was not found + * + * @return string Name of the Model that was not found + */ + public function getModelName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/ModelNotValidException.inc b/exceptions/ModelNotValidException.inc new file mode 100644 index 00000000..267e460e --- /dev/null +++ b/exceptions/ModelNotValidException.inc @@ -0,0 +1,68 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Action not found. + * + * @author coderkun + */ + class ModelNotValidException extends \nre\exceptions\ClassNotValidException + { + /** + * Error code + * + * @var int + */ + const CODE = 78; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'model not valid'; + + + + + /** + * Construct a new exception. + * + * @param string $modelName Name of the invalid Model + */ + function __construct($modelName) + { + // Elternkonstruktor aufrufen + parent::__construct( + $modelName, + self::MESSAGE, + self::CODE + ); + } + + + + + /** + * Get the name of the invalid Model + * + * @return string Name of the invalid Model + */ + public function getModelName() + { + return $this->getClassName(); + } + + } + +?> diff --git a/exceptions/ParamsNotValidException.inc b/exceptions/ParamsNotValidException.inc new file mode 100644 index 00000000..be650ce2 --- /dev/null +++ b/exceptions/ParamsNotValidException.inc @@ -0,0 +1,77 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception Parameters not valid. + * + * @author coderkun + */ + class ParamsNotValidException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 86; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'parameters not valid'; + + /** + * Invalid parameters. + * + * @var array + */ + private $params; + + + + + /** + * Construct a new exception. + * + * @param mixed $param1 Invalid parameters as argument list + */ + function __construct($param1) + { + parent::__construct( + self::MESSAGE, + self::CODE, + implode(', ', func_get_args()) + ); + + // Store values + $this->params = func_get_args(); + } + + + + + /** + * Get invalid parameters. + * + * @return array Invalid parameters + */ + public function getParams() + { + return $this->params; + } + + } + +?> diff --git a/exceptions/ServiceUnavailableException.inc b/exceptions/ServiceUnavailableException.inc new file mode 100644 index 00000000..bafc297f --- /dev/null +++ b/exceptions/ServiceUnavailableException.inc @@ -0,0 +1,77 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: Service is unavailable. + * + * @author coderkun + */ + class ServiceUnavailableException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 84; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'service unavailable'; + + /** + * Throws exception + * + * @var Exception + */ + private $exception; + + + + + /** + * Construct a new exception. + * + * @param Exception $exception Exception that has occurred + */ + function __construct($exception) + { + parent::__construct( + self::MESSAGE, + self::CODE, + $exception->getMessage() + ); + + // Store values + $this->exception = $exception; + } + + + + + /** + * Get the exception that hat occurred + * + * @return Exception Exception that has occurred + */ + public function getException() + { + return $this->exception; + } + + } + +?> diff --git a/exceptions/ViewNotFoundException.inc b/exceptions/ViewNotFoundException.inc new file mode 100644 index 00000000..30366201 --- /dev/null +++ b/exceptions/ViewNotFoundException.inc @@ -0,0 +1,77 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\exceptions; + + + /** + * Exception: View not found. + * + * @author coderkun + */ + class ViewNotFoundException extends \nre\core\Exception + { + /** + * Error code + * + * @var int + */ + const CODE = 69; + /** + * Error message + * + * @var string + */ + const MESSAGE = 'view not found'; + + /** + * Filename of the view that was not found + * + * @var string + */ + private $fileName; + + + + + /** + * Construct a new exception. + * + * @param string $fileName Filename of the view that was not found + */ + function __construct($fileName) + { + parent::__construct( + self::MESSAGE, + self::CODE, + $fileName + ); + + // Save values + $this->fileName = $fileName; + } + + + + + /** + * Get the filename of the view that was not found. + * + * @return string Filename of the view that was not found + */ + public function getFileName() + { + return $this->fileName; + } + + } + +?> diff --git a/locale/de_DE/LC_MESSAGES/The Legend of Z.mo b/locale/de_DE/LC_MESSAGES/The Legend of Z.mo new file mode 100644 index 00000000..da5a4a38 Binary files /dev/null and b/locale/de_DE/LC_MESSAGES/The Legend of Z.mo differ diff --git a/locale/de_DE/LC_MESSAGES/The Legend of Z.po b/locale/de_DE/LC_MESSAGES/The Legend of Z.po new file mode 100644 index 00000000..1e6f69fa --- /dev/null +++ b/locale/de_DE/LC_MESSAGES/The Legend of Z.po @@ -0,0 +1,1208 @@ +msgid "" +msgstr "" +"Project-Id-Version: The Legend of Z\n" +"POT-Creation-Date: 2014-05-02 16:11+0100\n" +"PO-Revision-Date: 2014-05-02 16:11+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: de_DE\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 1.6.4\n" +"X-Poedit-Basepath: ../../../\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-SourceCharset: UTF-8\n" +"X-Poedit-SearchPath-0: views\n" +"X-Poedit-SearchPath-1: questtypes\n" + +#: questtypes/bossfight/html/quest.tpl:11 +#: questtypes/bossfight/html/quest.tpl:24 +#: questtypes/bossfight/html/submission.tpl:21 +#: questtypes/bossfight/html/submission.tpl:33 +msgid "lost" +msgstr "verloren" + +#: questtypes/bossfight/html/quest.tpl:41 views/html/quests/quest.tpl:120 +msgid "Choose" +msgstr "Wählen" + +#: questtypes/bossfight/html/quest.tpl:47 +#: questtypes/choiceinput/html/quest.tpl:14 +#: questtypes/crossword/html/quest.tpl:30 +#: questtypes/dragndrop/html/quest.tpl:16 +#: questtypes/multiplechoice/html/quest.tpl:19 +#: questtypes/submit/html/quest.tpl:23 questtypes/textinput/html/quest.tpl:10 +msgid "solve" +msgstr "lösen" + +#: questtypes/crossword/html/quest.tpl:22 +#: questtypes/crossword/html/submission.tpl:22 +msgid "vertical" +msgstr "vertikal" + +#: questtypes/crossword/html/quest.tpl:24 +#: questtypes/crossword/html/submission.tpl:24 +msgid "horizontal" +msgstr "horizontal" + +#: questtypes/multiplechoice/html/quest.tpl:3 +#, php-format +msgid "Question %d of %d" +msgstr "Frage %d von %d" + +#: questtypes/multiplechoice/html/quest.tpl:17 +msgid "solve Question" +msgstr "Frage lösen" + +#: questtypes/submit/html/quest.tpl:4 +#: views/html/charactergroupsquests/manage.tpl:39 +#, php-format +msgid "File has wrong type “%s”" +msgstr "Der Dateityp „%s“ ist nicht erlaubt" + +#: questtypes/submit/html/quest.tpl:6 +#: views/html/charactergroupsquests/manage.tpl:41 +msgid "File exceeds size maximum" +msgstr "Die Datei ist zu groß" + +#: questtypes/submit/html/quest.tpl:8 +#: views/html/charactergroupsquests/manage.tpl:37 +#, php-format +msgid "Error during file upload: %s" +msgstr "Fehler beim Dateiupload: %s" + +#: questtypes/submit/html/quest.tpl:17 +#: views/html/charactergroupsquests/manage.tpl:63 +msgid "Allowed file types" +msgstr "Erlaubte Dateiformate" + +#: questtypes/submit/html/quest.tpl:20 +#: views/html/charactergroupsquests/manage.tpl:66 +#, php-format +msgid "%s-files" +msgstr "%s-Dateien" + +#: questtypes/submit/html/quest.tpl:20 +#: views/html/charactergroupsquests/manage.tpl:66 +msgid "max." +msgstr "max." + +#: questtypes/submit/html/quest.tpl:28 +msgid "Past submissions" +msgstr "Vorherige Lösungen" + +#: questtypes/submit/html/quest.tpl:33 questtypes/submit/html/submission.tpl:6 +#: views/html/quests/submissions.tpl:15 views/html/quests/submissions.tpl:25 +#: views/html/quests/submissions.tpl:35 +#, php-format +msgid "submitted at %s on %s h" +msgstr "eingereicht am %s um %s Uhr" + +#: questtypes/submit/html/quest.tpl:35 +msgid "This submission is waiting for approval" +msgstr "Die Lösung wartet auf Bewertung" + +#: questtypes/submit/html/quest.tpl:42 +#, php-format +msgid "Approved on %s at %s" +msgstr "Bewertet am %s um %s Uhr" + +#: questtypes/submit/html/submission.tpl:12 +#, php-format +msgid "Approved by %s on %s at %s" +msgstr "Bewertet von %s am %s um %s Uhr" + +#: questtypes/submit/html/submission.tpl:27 +msgid "Comment" +msgstr "Kommentar" + +#: questtypes/submit/html/submission.tpl:30 views/html/quests/quest.tpl:45 +#: views/html/quests/submissions.tpl:30 +msgid "solved" +msgstr "Richtig!" + +#: questtypes/submit/html/submission.tpl:31 views/html/quests/quest.tpl:50 +#: views/html/quests/submissions.tpl:20 +msgid "unsolved" +msgstr "Leider falsch!" + +#: views/binary/error/index.tpl:1 views/html/error/index.tpl:1 +msgid "Error" +msgstr "Fehler" + +#: views/html/achievements/index.tpl:9 views/html/seminarymenu/index.tpl:5 +msgid "Achievements" +msgstr "Achievements" + +#: views/html/achievements/index.tpl:10 +msgid "Achievement description" +msgstr "" +"Achievements sind Auszeichnungen für deine Erfolge im Verlauf der Reise. Sie " +"dienen als historische Erinnerungen an Meilensteine, besondere Taten und " +"interessante oder lustige Ereignisse, die du erlebst." + +#: views/html/achievements/index.tpl:13 +msgid "Seldom Achievements" +msgstr "Die seltensten Achievements" + +#: views/html/achievements/index.tpl:26 views/html/achievements/index.tpl:68 +msgid "Secret Achievement" +msgstr "Geheimes Achievement" + +#: views/html/achievements/index.tpl:27 +#, php-format +msgid "Achievement has been achieved only %d times" +msgstr "wurde erst %d mal gefunden" + +#: views/html/achievements/index.tpl:33 +msgid "Most successful collectors" +msgstr "Die erfolgreichsten Sammler" + +#: views/html/achievements/index.tpl:39 +#, php-format +msgid "Character has achieved %d Achievements" +msgstr "hat %d Achievement erhalten" + +#: views/html/achievements/index.tpl:45 +msgid "Personal Achievements" +msgstr "Deine Achievements" + +#: views/html/achievements/index.tpl:47 +#, php-format +msgid "Own progress: %d %%" +msgstr "Persönlicher Fortschritt: %d%%" + +#: views/html/achievements/index.tpl:52 +#: views/html/charactergroups/group.tpl:30 +#: views/html/charactergroups/managegroup.tpl:22 +#: views/html/characters/character.tpl:46 views/html/seminarybar/index.tpl:7 +msgid "Rank" +msgstr "Platz" + +#: views/html/achievements/index.tpl:52 +#, php-format +msgid "You achieved %d of %d Achievements so far" +msgstr "Du hast bislang %d von insgesamt %d Achievements erreicht" + +#: views/html/achievements/index.tpl:59 views/html/seminarybar/index.tpl:28 +#, php-format +msgid "achieved at: %s" +msgstr "erhalten am: %s" + +#: views/html/achievements/index.tpl:72 +msgid "Continue playing to unlock this secret Achievement" +msgstr "Spiele weiter, um diesen geheimen Erfolg freizuschalten" + +#: views/html/charactergroups/creategroup.tpl:8 +#: views/html/charactergroups/creategroupsgroup.tpl:8 +#: views/html/charactergroups/deletegroup.tpl:8 +#: views/html/charactergroups/deletegroupsgroup.tpl:8 +#: views/html/charactergroups/editgroup.tpl:8 +#: views/html/charactergroups/editgroupsgroup.tpl:8 +#: views/html/charactergroups/group.tpl:8 +#: views/html/charactergroups/groupsgroup.tpl:8 +#: views/html/charactergroups/index.tpl:9 +#: views/html/charactergroups/managegroup.tpl:8 +#: views/html/charactergroupsquests/create.tpl:8 +#: views/html/charactergroupsquests/delete.tpl:8 +#: views/html/charactergroupsquests/edit.tpl:8 +#: views/html/charactergroupsquests/manage.tpl:8 +#: views/html/charactergroupsquests/quest.tpl:8 +#: views/html/characters/character.tpl:95 views/html/seminarymenu/index.tpl:4 +msgid "Character Groups" +msgstr "Gruppen" + +#: views/html/charactergroups/creategroup.tpl:12 +#, php-format +msgid "New %s Character group" +msgstr "Neue %s-Gruppe" + +#: views/html/charactergroups/creategroup.tpl:23 +#: views/html/charactergroups/creategroupsgroup.tpl:22 +#: views/html/charactergroups/editgroup.tpl:23 +#: views/html/charactergroups/editgroupsgroup.tpl:22 +#, php-format +msgid "Name is too short (min. %d chars)" +msgstr "Der Name ist zu kurz (min. %d Zeichen)" + +#: views/html/charactergroups/creategroup.tpl:25 +#: views/html/charactergroups/creategroupsgroup.tpl:24 +#: views/html/charactergroups/editgroup.tpl:25 +#: views/html/charactergroups/editgroupsgroup.tpl:24 +#, php-format +msgid "Name is too long (max. %d chars)" +msgstr "Der Name ist zu lang (max. %d Zeichen)" + +#: views/html/charactergroups/creategroup.tpl:27 +#: views/html/charactergroups/creategroupsgroup.tpl:26 +#: views/html/charactergroups/editgroup.tpl:27 +#: views/html/charactergroups/editgroupsgroup.tpl:26 +msgid "Name contains illegal characters" +msgstr "Der Name enthält ungültige Zeichen" + +#: views/html/charactergroups/creategroup.tpl:29 +#: views/html/charactergroups/creategroupsgroup.tpl:28 +#: views/html/charactergroups/editgroup.tpl:29 +#: views/html/charactergroups/editgroupsgroup.tpl:28 +msgid "Name already exists" +msgstr "Der Name existiert bereits" + +#: views/html/charactergroups/creategroup.tpl:31 +#: views/html/charactergroups/creategroupsgroup.tpl:30 +#: views/html/charactergroups/editgroup.tpl:31 +#: views/html/charactergroups/editgroupsgroup.tpl:30 +msgid "Name invalid" +msgstr "Der Name ist ungültig" + +#: views/html/charactergroups/creategroup.tpl:36 +#: views/html/charactergroups/editgroup.tpl:36 +#, php-format +msgid "Motto is too long (max. %d chars)" +msgstr "Das Motto ist zu lang (max. %d Zeichen)" + +#: views/html/charactergroups/creategroup.tpl:38 +#: views/html/charactergroups/editgroup.tpl:38 +msgid "Motto invalid" +msgstr "Das Motto ist ungültig" + +#: views/html/charactergroups/creategroup.tpl:54 +#: views/html/charactergroups/creategroup.tpl:55 +#: views/html/charactergroups/creategroupsgroup.tpl:46 +#: views/html/charactergroups/creategroupsgroup.tpl:47 +#: views/html/charactergroups/editgroup.tpl:54 +#: views/html/charactergroups/editgroup.tpl:55 +#: views/html/charactergroups/editgroupsgroup.tpl:46 +#: views/html/charactergroups/editgroupsgroup.tpl:47 +#: views/html/quests/create.tpl:15 views/html/quests/create.tpl:16 +#: views/html/users/user.tpl:23 +msgid "Name" +msgstr "Name" + +#: views/html/charactergroups/creategroup.tpl:56 +#: views/html/charactergroups/creategroup.tpl:57 +#: views/html/charactergroups/editgroup.tpl:56 +#: views/html/charactergroups/editgroup.tpl:57 +msgid "Motto" +msgstr "Motto" + +#: views/html/charactergroups/creategroup.tpl:59 +#: views/html/charactergroups/creategroupsgroup.tpl:50 +#: views/html/charactergroupsquests/create.tpl:75 +#: views/html/characters/register.tpl:94 views/html/seminaries/create.tpl:12 +#: views/html/users/create.tpl:96 +msgid "create" +msgstr "erstellen" + +#: views/html/charactergroups/creategroupsgroup.tpl:11 +msgid "New Character groups-group" +msgstr "Neue Gruppenart" + +#: views/html/charactergroups/creategroupsgroup.tpl:48 +#: views/html/charactergroups/editgroupsgroup.tpl:48 +msgid "preferred" +msgstr "bevorzugt" + +#: views/html/charactergroups/deletegroup.tpl:11 +#: views/html/charactergroups/group.tpl:15 +#, php-format +msgid "Delete %s Character group" +msgstr "%s-Gruppe löschen" + +#: views/html/charactergroups/deletegroup.tpl:13 +#, php-format +msgid "Should the %s Character group “%s” really be deleted?" +msgstr "Soll die %s-Gruppen „%s“ wirklich gelöscht werden?" + +#: views/html/charactergroups/deletegroup.tpl:15 +#: views/html/charactergroups/deletegroupsgroup.tpl:14 +#: views/html/charactergroupsquests/delete.tpl:15 +#: views/html/characters/delete.tpl:17 views/html/seminaries/delete.tpl:11 +#: views/html/users/delete.tpl:11 +msgid "delete" +msgstr "löschen" + +#: views/html/charactergroups/deletegroup.tpl:16 +#: views/html/charactergroups/deletegroupsgroup.tpl:15 +#: views/html/charactergroupsquests/delete.tpl:16 +#: views/html/characters/delete.tpl:18 views/html/seminaries/delete.tpl:12 +#: views/html/users/delete.tpl:12 +msgid "cancel" +msgstr "abbrechen" + +#: views/html/charactergroups/deletegroupsgroup.tpl:10 +#: views/html/charactergroups/groupsgroup.tpl:13 +msgid "Delete Character groups-group" +msgstr "Gruppenart löschen" + +#: views/html/charactergroups/deletegroupsgroup.tpl:12 +#, php-format +msgid "Should the Character groups-group “%s” really be deleted?" +msgstr "Soll die Gruppenart „%s“ wirklich gelöscht werden?" + +#: views/html/charactergroups/editgroup.tpl:12 +#: views/html/charactergroups/group.tpl:14 +#, php-format +msgid "Edit %s Character group" +msgstr "%s-Gruppe bearbeiten" + +#: views/html/charactergroups/editgroup.tpl:59 +#: views/html/charactergroups/editgroupsgroup.tpl:50 +#: views/html/charactergroupsquests/edit.tpl:75 +#: views/html/characters/edit.tpl:108 +msgid "edit" +msgstr "bearbeiten" + +#: views/html/charactergroups/editgroupsgroup.tpl:11 +#: views/html/charactergroups/groupsgroup.tpl:12 +msgid "Edit Character groups-group" +msgstr "Gruppenart bearbeiten" + +#: views/html/charactergroups/group.tpl:16 +#, php-format +msgid "Manage %s Character group" +msgstr "%s-Gruppe verwalten" + +#: views/html/charactergroups/group.tpl:31 +#: views/html/charactergroups/group.tpl:44 +#: views/html/charactergroupsquests/manage.tpl:21 +#: views/html/charactergroupsquests/manage.tpl:86 +#: views/html/charactergroupsquests/quest.tpl:29 +#: views/html/charactergroupsquests/quest.tpl:77 +#: views/html/characters/character.tpl:77 +#: views/html/characters/character.tpl:83 +#: views/html/characters/character.tpl:89 +#: views/html/characters/character.tpl:107 views/html/quests/index.tpl:37 +#: views/html/seminaries/index.tpl:29 views/html/seminarybar/index.tpl:6 +#: views/html/seminarybar/index.tpl:42 +#, php-format +msgid "%d XPs" +msgstr "%d XP" + +#: views/html/charactergroups/group.tpl:32 +#: views/html/charactergroups/managegroup.tpl:24 +msgid "Members" +msgstr "Mitglieder" + +#: views/html/charactergroups/group.tpl:32 +#: views/html/charactergroups/managegroup.tpl:24 +msgid "Member" +msgstr "Mitglied" + +#: views/html/charactergroups/group.tpl:36 +#: views/html/charactergroups/managegroup.tpl:28 +#: views/html/characters/character.tpl:11 +#: views/html/characters/character.tpl:13 views/html/characters/delete.tpl:10 +#: views/html/characters/edit.tpl:11 views/html/characters/edit.tpl:13 +#: views/html/characters/index.tpl:9 views/html/characters/manage.tpl:8 +#: views/html/characters/register.tpl:8 views/html/seminarymenu/index.tpl:3 +#: views/html/users/user.tpl:27 +msgid "Characters" +msgstr "Charaktere" + +#: views/html/charactergroups/group.tpl:51 +#: views/html/charactergroups/groupsgroup.tpl:31 +#, php-format +msgid "%s-Quests" +msgstr "%squests" + +#: views/html/charactergroups/group.tpl:58 +#: views/html/charactergroupsquests/create.tpl:58 +#: views/html/charactergroupsquests/create.tpl:59 +#: views/html/charactergroupsquests/edit.tpl:58 +#: views/html/charactergroupsquests/edit.tpl:59 +#: views/html/characters/index.tpl:22 views/html/characters/manage.tpl:17 +#: views/html/quests/create.tpl:29 views/html/quests/create.tpl:30 +msgid "XPs" +msgstr "XP" + +#: views/html/charactergroups/groupsgroup.tpl:21 +#, php-format +msgid "Create new %s Character group" +msgstr "Neue %s-Gruppe" + +#: views/html/charactergroups/groupsgroup.tpl:34 +#, php-format +msgid "Create new %s-Quest" +msgstr "Neue %s-Quest erstellen" + +#: views/html/charactergroups/index.tpl:13 +msgid "Create new Character groups-group" +msgstr "Neue Gruppenart" + +#: views/html/charactergroups/managegroup.tpl:45 +msgid "Remove Characters" +msgstr "Entferne Charaktere" + +#: views/html/charactergroups/managegroup.tpl:49 +msgid "Filter Characters" +msgstr "Filtere Charaktere" + +#: views/html/charactergroups/managegroup.tpl:55 +msgid "Add Characters" +msgstr "Füge Charaktere hinzu" + +#: views/html/charactergroups/managegroup.tpl:61 +#: views/html/questgroups/questgroup.tpl:57 views/html/quests/create.tpl:9 +#: views/html/quests/index.tpl:9 +msgid "Quests" +msgstr "Quests" + +#: views/html/charactergroupsquests/create.tpl:12 +#, php-format +msgid "New %s-Quest" +msgstr "Neue %s-Quest" + +#: views/html/charactergroupsquests/create.tpl:23 +#: views/html/charactergroupsquests/edit.tpl:23 +#, php-format +msgid "Title is too short (min. %d chars)" +msgstr "Der Titel ist zu kurz (min. %d Zeichen)" + +#: views/html/charactergroupsquests/create.tpl:25 +#: views/html/charactergroupsquests/edit.tpl:25 +#, php-format +msgid "Title is too long (max. %d chars)" +msgstr "Der Titel ist zu lang (max. %d Zeichen)" + +#: views/html/charactergroupsquests/create.tpl:27 +#: views/html/charactergroupsquests/edit.tpl:27 +msgid "Title contains illegal characters" +msgstr "Der Titel enthält ungültige Zeichen" + +#: views/html/charactergroupsquests/create.tpl:29 +#: views/html/charactergroupsquests/edit.tpl:29 +msgid "Title already exists" +msgstr "Der Titel existiert bereits" + +#: views/html/charactergroupsquests/create.tpl:31 +#: views/html/charactergroupsquests/edit.tpl:31 +msgid "Title invalid" +msgstr "Der Titel ist ungültig" + +#: views/html/charactergroupsquests/create.tpl:36 +#: views/html/charactergroupsquests/edit.tpl:36 +#, php-format +msgid "XPs not set" +msgstr "XP nicht angegeben" + +#: views/html/charactergroupsquests/create.tpl:38 +#: views/html/charactergroupsquests/edit.tpl:38 +msgid "XPs contain illegal characters" +msgstr "Die XP-Angabe enthält ungültige Zeichen" + +#: views/html/charactergroupsquests/create.tpl:40 +#: views/html/charactergroupsquests/edit.tpl:40 +msgid "XPs invalid" +msgstr "Die XP-Angabe ist ungültig" + +#: views/html/charactergroupsquests/create.tpl:56 +#: views/html/charactergroupsquests/create.tpl:57 +#: views/html/charactergroupsquests/edit.tpl:56 +#: views/html/charactergroupsquests/edit.tpl:57 +#: views/html/questgroups/create.tpl:14 views/html/questgroups/create.tpl:15 +#: views/html/seminaries/create.tpl:9 views/html/seminaries/create.tpl:10 +#: views/html/seminaries/edit.tpl:11 views/html/seminaries/edit.tpl:12 +msgid "Title" +msgstr "Titel" + +#: views/html/charactergroupsquests/create.tpl:60 +#: views/html/charactergroupsquests/edit.tpl:60 +#: views/html/quests/create.tpl:17 views/html/quests/index.tpl:14 +msgid "Questgroup" +msgstr "Questgruppe" + +#: views/html/charactergroupsquests/create.tpl:66 +#: views/html/charactergroupsquests/create.tpl:67 +#: views/html/charactergroupsquests/edit.tpl:66 +#: views/html/charactergroupsquests/edit.tpl:67 +#: views/html/charactergroupsquests/quest.tpl:49 +msgid "Description" +msgstr "Beschreibung" + +#: views/html/charactergroupsquests/create.tpl:68 +#: views/html/charactergroupsquests/create.tpl:69 +#: views/html/charactergroupsquests/edit.tpl:68 +#: views/html/charactergroupsquests/edit.tpl:69 +#: views/html/charactergroupsquests/quest.tpl:52 +msgid "Rules" +msgstr "Regeln" + +#: views/html/charactergroupsquests/create.tpl:70 +#: views/html/charactergroupsquests/create.tpl:71 +#: views/html/charactergroupsquests/edit.tpl:70 +#: views/html/charactergroupsquests/edit.tpl:71 +msgid "Won-text" +msgstr "Gewonnentext" + +#: views/html/charactergroupsquests/create.tpl:72 +#: views/html/charactergroupsquests/create.tpl:73 +#: views/html/charactergroupsquests/edit.tpl:72 +#: views/html/charactergroupsquests/edit.tpl:73 +msgid "Lost-text" +msgstr "Verlorentext" + +#: views/html/charactergroupsquests/delete.tpl:12 +#: views/html/charactergroupsquests/quest.tpl:15 +#, php-format +msgid "Delete %s-Quest" +msgstr "%s-Quest löschen" + +#: views/html/charactergroupsquests/delete.tpl:13 +#, php-format +msgid "Should the %s-Quest “%s” really be deleted?" +msgstr "Soll die %s-Quest „%s“ wirklich gelöscht werden?" + +#: views/html/charactergroupsquests/edit.tpl:12 +#: views/html/charactergroupsquests/quest.tpl:14 +#, php-format +msgid "Edit %s-Quest" +msgstr "%s-Quest bearbeiten" + +#: views/html/charactergroupsquests/manage.tpl:26 +#: views/html/charactergroupsquests/quest.tpl:35 +msgid "Media" +msgstr "Medien" + +#: views/html/charactergroupsquests/manage.tpl:43 +msgid "File invalid" +msgstr "Die Datei ist ungültig" + +#: views/html/charactergroupsquests/manage.tpl:71 +#: views/html/seminaries/edit.tpl:14 views/html/users/edit.tpl:103 +msgid "save" +msgstr "speichern" + +#: views/html/charactergroupsquests/manage.tpl:84 +msgid "Not attended" +msgstr "Nicht teilgenommen" + +#: views/html/charactergroupsquests/manage.tpl:93 +msgid "Set XPs" +msgstr "Setze XP" + +#: views/html/charactergroupsquests/quest.tpl:16 +#, php-format +msgid "Manage %s-Quest" +msgstr "%s-Quest Verwalten" + +#: views/html/charactergroupsquests/quest.tpl:59 +msgid "Won Quest" +msgstr "Gewonnene Quest" + +#: views/html/charactergroupsquests/quest.tpl:65 +msgid "Lost Quest" +msgstr "Verlorene Quest" + +#: views/html/characters/character.tpl:21 views/html/characters/edit.tpl:18 +msgid "Edit Character" +msgstr "Charakter bearbeiten" + +#: views/html/characters/character.tpl:24 views/html/characters/delete.tpl:14 +msgid "Delete Character" +msgstr "Charakter löschen" + +#: views/html/characters/character.tpl:34 +msgid "Total progress" +msgstr "Fortschritt" + +#: views/html/characters/character.tpl:38 +#: views/html/characters/character.tpl:77 +#: views/html/characters/character.tpl:83 +#: views/html/characters/character.tpl:89 views/html/seminarybar/index.tpl:42 +#: views/html/users/user.tpl:41 +msgid "Level" +msgstr "Level" + +#: views/html/characters/character.tpl:48 +msgid "Milestones" +msgstr "Meilensteine" + +#: views/html/characters/character.tpl:71 +msgid "Ranking" +msgstr "Ranking" + +#: views/html/characters/character.tpl:116 views/html/seminarybar/index.tpl:14 +msgid "Last Quest" +msgstr "Letzter Speicherpunkt" + +#: views/html/characters/character.tpl:122 +msgid "Topic progress" +msgstr "Thematischer Fortschritt" + +#: views/html/characters/delete.tpl:15 +#, php-format +msgid "Should the Character “%s” of user “%s” (%s) really be deleted?" +msgstr "Soll der Charakter „%s“ von Benutzer %s (%s) wirklich gelöscht werden?" + +#: views/html/characters/edit.tpl:30 views/html/characters/register.tpl:22 +#, php-format +msgid "Character name is too short (min. %d chars)" +msgstr "Der Charaktername ist zu kurz (min. %d Zeichen)" + +#: views/html/characters/edit.tpl:32 views/html/characters/register.tpl:24 +#, php-format +msgid "Character name is too long (max. %d chars)" +msgstr "Der Charaktername ist zu lang (max. %d Zeichen)" + +#: views/html/characters/edit.tpl:34 views/html/characters/register.tpl:26 +msgid "Character name contains illegal characters" +msgstr "Der Charaktername enthält ungültige Zeichen" + +#: views/html/characters/edit.tpl:36 views/html/characters/register.tpl:28 +msgid "Character name already exists" +msgstr "Der Charaktername existiert bereits" + +#: views/html/characters/edit.tpl:38 views/html/characters/register.tpl:30 +msgid "Character name invalid" +msgstr "Der Charaktername ist ungültig" + +#: views/html/characters/edit.tpl:50 views/html/characters/register.tpl:48 +msgid "Character properties" +msgstr "Charaktereigenschaften" + +#: views/html/characters/edit.tpl:51 views/html/characters/edit.tpl:53 +#: views/html/characters/edit.tpl:55 views/html/characters/index.tpl:21 +#: views/html/characters/manage.tpl:16 views/html/characters/register.tpl:49 +#: views/html/characters/register.tpl:50 +msgid "Character name" +msgstr "Charaktername" + +#: views/html/characters/edit.tpl:81 views/html/characters/register.tpl:67 +#, php-format +msgid "The Seminary field “%s” is invalid" +msgstr "Das Kursfeld „%s“ ist ungültig" + +#: views/html/characters/edit.tpl:86 views/html/characters/register.tpl:72 +msgid "Seminary fields" +msgstr "Kursfelder" + +#: views/html/characters/index.tpl:13 views/html/characters/manage.tpl:10 +#: views/html/users/index.tpl:7 views/html/users/manage.tpl:8 +msgid "Manage" +msgstr "Verwalten" + +#: views/html/characters/index.tpl:23 views/html/characters/manage.tpl:18 +#: views/html/users/manage.tpl:15 +msgid "Role" +msgstr "Rolle" + +#: views/html/characters/index.tpl:24 views/html/characters/manage.tpl:19 +#: views/html/users/manage.tpl:16 +msgid "Date of registration" +msgstr "Registrierungsdatum" + +#: views/html/characters/index.tpl:29 views/html/characters/manage.tpl:24 +#: views/html/users/manage.tpl:18 +msgid "Sort list" +msgstr "Liste sortieren" + +#: views/html/characters/index.tpl:39 views/html/characters/manage.tpl:35 +#: views/html/characters/manage.tpl:50 views/html/characters/manage.tpl:58 +#: views/html/users/manage.tpl:28 views/html/users/manage.tpl:41 +#: views/html/users/manage.tpl:49 +msgid "Admin" +msgstr "Administrator" + +#: views/html/characters/index.tpl:40 views/html/characters/manage.tpl:36 +#: views/html/characters/manage.tpl:51 views/html/characters/manage.tpl:59 +#: views/html/users/manage.tpl:29 views/html/users/manage.tpl:42 +#: views/html/users/manage.tpl:50 +msgid "Moderator" +msgstr "Moderator" + +#: views/html/characters/index.tpl:41 views/html/characters/manage.tpl:37 +#: views/html/characters/manage.tpl:53 views/html/characters/manage.tpl:61 +#: views/html/users/manage.tpl:30 views/html/users/manage.tpl:44 +#: views/html/users/manage.tpl:52 +msgid "User" +msgstr "Benutzer" + +#: views/html/characters/manage.tpl:48 views/html/users/manage.tpl:39 +msgid "Add role" +msgstr "Füge Rolle hinzu" + +#: views/html/characters/manage.tpl:56 views/html/users/manage.tpl:47 +msgid "Remove role" +msgstr "Entferne Rolle" + +#: views/html/characters/register.tpl:10 +msgid "Create Character" +msgstr "Charakter erstellen" + +#: views/html/characters/register.tpl:35 +msgid "Please choose an avatar" +msgstr "Bitte wähle einen Avatar aus" + +#: views/html/error/index.tpl:5 views/html/error/index.tpl:14 +#: views/html/introduction/index.tpl:9 views/html/menu/index.tpl:6 +#: views/html/users/login.tpl:6 views/html/users/login.tpl:20 +msgid "Login" +msgstr "Login" + +#: views/html/error/index.tpl:8 views/html/error/index.tpl:9 +#: views/html/introduction/index.tpl:7 views/html/users/create.tpl:84 +#: views/html/users/create.tpl:85 views/html/users/edit.tpl:87 +#: views/html/users/edit.tpl:89 views/html/users/edit.tpl:91 +#: views/html/users/login.tpl:12 views/html/users/login.tpl:13 +#: views/html/users/manage.tpl:14 views/html/users/register.tpl:83 +#: views/html/users/register.tpl:84 +msgid "Username" +msgstr "Benutzername" + +#: views/html/error/index.tpl:10 views/html/error/index.tpl:11 +#: views/html/introduction/index.tpl:8 views/html/users/create.tpl:92 +#: views/html/users/create.tpl:93 views/html/users/edit.tpl:100 +#: views/html/users/edit.tpl:101 views/html/users/login.tpl:14 +#: views/html/users/login.tpl:15 views/html/users/register.tpl:91 +#: views/html/users/register.tpl:92 +msgid "Password" +msgstr "Passwort" + +#: views/html/error/index.tpl:15 views/html/introduction/index.tpl:11 +msgid "or" +msgstr "oder" + +#: views/html/error/index.tpl:15 views/html/introduction/index.tpl:11 +msgid "register yourself" +msgstr "registriere dich" + +#: views/html/html.tpl:76 +msgid "Achievement" +msgstr "Achievement" + +#: views/html/html.tpl:86 +msgid "Level-up" +msgstr "Levelaufstieg" + +#: views/html/html.tpl:88 views/html/html.tpl:90 +#, php-format +msgid "You have reached level %d" +msgstr "Du hast Level %d erreicht" + +#: views/html/introduction/index.tpl:15 +msgid "Introduction" +msgstr "Einführung" + +#: views/html/library/index.tpl:9 views/html/library/topic.tpl:8 +#: views/html/seminarymenu/index.tpl:6 +msgid "Library" +msgstr "Bibliothek" + +#: views/html/library/index.tpl:10 +#, php-format +msgid "Library description, %s, %s" +msgstr "" +"Hier findest du alle Themen aus der Vorlesung „%s“ und die passenden Quests " +"zum Nachschlagen und Wiederholen. Dein Fortschritt in „%s“ beeinflusst den " +"Umfang der Bibliothek, spiele also regelmäßig weiter und schalte so Quest " +"für Quest alle Inhalte frei." + +#: views/html/library/index.tpl:12 +#, php-format +msgid "Total progress: %d %%" +msgstr "Gesamtfortschritt: %d%%" + +#: views/html/menu/index.tpl:2 views/html/users/create.tpl:5 +#: views/html/users/delete.tpl:5 views/html/users/edit.tpl:6 +#: views/html/users/edit.tpl:8 views/html/users/index.tpl:4 +#: views/html/users/login.tpl:4 views/html/users/manage.tpl:5 +#: views/html/users/register.tpl:5 views/html/users/user.tpl:6 +#: views/html/users/user.tpl:8 +msgid "Users" +msgstr "Benutzer" + +#: views/html/menu/index.tpl:3 views/html/seminaries/create.tpl:4 +#: views/html/seminaries/delete.tpl:6 views/html/seminaries/edit.tpl:6 +#: views/html/seminaries/index.tpl:4 +msgid "Seminaries" +msgstr "Kurse" + +#: views/html/menu/index.tpl:8 +msgid "Logout" +msgstr "Logout" + +#: views/html/questgroups/create.tpl:8 +msgid "Questgroups" +msgstr "Questgruppen" + +#: views/html/questgroups/create.tpl:10 +msgid "Create Questgroup" +msgstr "Questgruppe erstellen" + +#: views/html/questgroups/create.tpl:17 views/html/quests/create.tpl:43 +msgid "Create" +msgstr "Erstellen" + +#: views/html/questgroups/questgroup.tpl:45 +msgid "Found optional Questline" +msgstr "Optionale Questline gefunden" + +#: views/html/quests/create.tpl:11 +msgid "Create Quest" +msgstr "Quest erstellen" + +#: views/html/quests/create.tpl:37 +msgid "Entry text" +msgstr "Einstiegstext" + +#: views/html/quests/create.tpl:39 +msgid "Wrong text" +msgstr "Text für falsche Antwort" + +#: views/html/quests/create.tpl:40 views/html/quests/quest.tpl:41 +msgid "Task" +msgstr "Aufgabe" + +#: views/html/quests/index.tpl:21 +msgid "Questtype" +msgstr "Questtyp" + +#: views/html/quests/index.tpl:29 +msgid "Apply filters" +msgstr "Filter anwenden" + +#: views/html/quests/index.tpl:30 +msgid "Reset filters" +msgstr "Filter zurücksetzen" + +#: views/html/quests/quest.tpl:11 +msgid "Prolog" +msgstr "Prolog" + +#: views/html/quests/quest.tpl:46 +msgid "Quest completed." +msgstr "Quest abgeschlossen." + +#: views/html/quests/quest.tpl:46 +#, php-format +msgid "You have earned %d XPs." +msgstr "Du hast %d XP erhalten." + +#: views/html/quests/quest.tpl:62 +msgid "Task already successfully solved" +msgstr "Du hast die Aufgabe bereits erfolgreich gelöst" + +#: views/html/quests/quest.tpl:64 +msgid "Show answer" +msgstr "Lösung anzeigen" + +#: views/html/quests/quest.tpl:74 +msgid "Epilog" +msgstr "Epilog" + +#: views/html/quests/quest.tpl:100 +msgid "Continuation" +msgstr "Setze deine Reise fort" + +#: views/html/quests/quest.tpl:107 views/html/quests/quest.tpl:123 +msgid "Quest" +msgstr "Quest" + +#: views/html/quests/quest.tpl:125 +msgid "Go on" +msgstr "Fortfahren" + +#: views/html/quests/quest.tpl:134 views/html/seminaries/seminary.tpl:35 +msgid "Let’s go" +msgstr "Auf ins Abenteuer!" + +#: views/html/quests/submission.tpl:9 +#, php-format +msgid "Submission of %s" +msgstr "Lösung von %s" + +#: views/html/quests/submissions.tpl:10 +msgid "submitted" +msgstr "eingereicht" + +#: views/html/seminaries/create.tpl:5 +msgid "New seminary" +msgstr "Neuer Kurs" + +#: views/html/seminaries/delete.tpl:7 views/html/seminaries/seminary.tpl:10 +msgid "Delete seminary" +msgstr "Kurs löschen" + +#: views/html/seminaries/delete.tpl:9 +#, php-format +msgid "Should the seminary “%s” really be deleted?" +msgstr "Soll der Kurs „%s“ wirklich gelöscht werden?" + +#: views/html/seminaries/edit.tpl:7 views/html/seminaries/seminary.tpl:9 +msgid "Edit seminary" +msgstr "Kurs bearbeiten" + +#: views/html/seminaries/index.tpl:7 +msgid "Create new seminary" +msgstr "Neuen Kurs erstellen" + +#: views/html/seminaries/index.tpl:33 +#, php-format +msgid "created by %s on %s" +msgstr "erstellt von %s am %s" + +#: views/html/seminaries/index.tpl:35 +msgid "Create a Character" +msgstr "Erstelle einen Charakter" + +#: views/html/seminaries/index.tpl:37 +#, php-format +msgid "Your Character “%s” has not been activated yet" +msgstr "Dein Charakter „%s“ wurde noch nicht aktiviert" + +#: views/html/seminaries/seminary.tpl:11 +msgid "Show Quests" +msgstr "Quests anzeigen" + +#: views/html/seminarybar/index.tpl:21 +msgid "Last Achievement" +msgstr "Letztes Achievement" + +#: views/html/seminarybar/index.tpl:46 +#, php-format +msgid "Show %s-Profile" +msgstr "%s-Profil anzeigen" + +#: views/html/users/create.tpl:8 +msgid "New user" +msgstr "Neuer Benutzer" + +#: views/html/users/create.tpl:19 views/html/users/edit.tpl:23 +#: views/html/users/register.tpl:19 +#, php-format +msgid "Username is too short (min. %d chars)" +msgstr "Der Benutzername ist zu kurz (min. %d Zeichen)" + +#: views/html/users/create.tpl:21 views/html/users/edit.tpl:25 +#: views/html/users/register.tpl:21 +#, php-format +msgid "Username is too long (max. %d chars)" +msgstr "Der Benutzername ist zu lang (max. %d Zeichen)" + +#: views/html/users/create.tpl:23 views/html/users/edit.tpl:27 +#: views/html/users/register.tpl:23 +msgid "Username contains illegal characters" +msgstr "Der Benutzername enthält ungültige Zeichen" + +#: views/html/users/create.tpl:25 views/html/users/edit.tpl:29 +#: views/html/users/register.tpl:25 +msgid "Username already exists" +msgstr "Der Benutzername existiert bereits" + +#: views/html/users/create.tpl:27 views/html/users/edit.tpl:31 +#: views/html/users/register.tpl:27 +msgid "Username invalid" +msgstr "Der Benutzername ist ungültig" + +#: views/html/users/create.tpl:32 views/html/users/edit.tpl:36 +#: views/html/users/register.tpl:32 +#, php-format +msgid "Prename is too short (min. %d chars)" +msgstr "Der Vorname ist zu kurz (min. %d Zeichen)" + +#: views/html/users/create.tpl:34 views/html/users/edit.tpl:38 +#: views/html/users/register.tpl:34 +#, php-format +msgid "Prename is too long (max. %d chars)" +msgstr "Der Vorname ist zu lang (max. %d Zeichen)" + +#: views/html/users/create.tpl:36 views/html/users/edit.tpl:40 +#: views/html/users/register.tpl:36 +#, php-format +msgid "Prename contains illegal characters" +msgstr "Der Vorname enthält ungültige Zeichen" + +#: views/html/users/create.tpl:38 views/html/users/edit.tpl:42 +#: views/html/users/register.tpl:38 +msgid "Prename invalid" +msgstr "Der Vorname ist ungültig" + +#: views/html/users/create.tpl:43 views/html/users/edit.tpl:47 +#: views/html/users/register.tpl:43 +#, php-format +msgid "Surname is too short (min. %d chars)" +msgstr "Der Nachname ist zu kurz (min. %d Zeichen)" + +#: views/html/users/create.tpl:45 views/html/users/edit.tpl:49 +#: views/html/users/register.tpl:45 +#, php-format +msgid "Surname is too long (max. %d chars)" +msgstr "Der Nachname ist zu lang (max. %d Zeichen)" + +#: views/html/users/create.tpl:47 views/html/users/edit.tpl:51 +#: views/html/users/register.tpl:47 +#, php-format +msgid "Surname contains illegal characters" +msgstr "Der Nachname enthält ungültige Zeichen" + +#: views/html/users/create.tpl:49 views/html/users/edit.tpl:53 +#: views/html/users/register.tpl:49 +msgid "Surname invalid" +msgstr "Der Nachname ist ungültig" + +#: views/html/users/create.tpl:54 views/html/users/create.tpl:58 +#: views/html/users/edit.tpl:58 views/html/users/edit.tpl:62 +#: views/html/users/register.tpl:54 views/html/users/register.tpl:58 +msgid "E‑mail address invalid" +msgstr "Die E‑Mail-Adresse ist ungültig" + +#: views/html/users/create.tpl:56 views/html/users/edit.tpl:60 +#: views/html/users/register.tpl:56 +msgid "E‑mail address already exists" +msgstr "E‑Mail-Adresse existiert bereits" + +#: views/html/users/create.tpl:63 views/html/users/edit.tpl:67 +#: views/html/users/register.tpl:63 +#, php-format +msgid "Password is too short (min. %d chars)" +msgstr "Das Passwort ist zu kurz (min. %d Zeichen)" + +#: views/html/users/create.tpl:65 views/html/users/edit.tpl:69 +#: views/html/users/register.tpl:65 +#, php-format +msgid "Password is too long (max. %d chars)" +msgstr "Das Passwort ist zu lang (max. %d Zeichen)" + +#: views/html/users/create.tpl:67 views/html/users/edit.tpl:71 +#: views/html/users/register.tpl:67 +msgid "Password invalid" +msgstr "Das Passwort ist ungültig" + +#: views/html/users/create.tpl:86 views/html/users/create.tpl:87 +#: views/html/users/edit.tpl:94 views/html/users/edit.tpl:95 +#: views/html/users/register.tpl:85 views/html/users/register.tpl:86 +msgid "Prename" +msgstr "Vorname" + +#: views/html/users/create.tpl:88 views/html/users/create.tpl:89 +#: views/html/users/edit.tpl:96 views/html/users/edit.tpl:97 +#: views/html/users/register.tpl:87 views/html/users/register.tpl:88 +msgid "Surname" +msgstr "Nachname" + +#: views/html/users/create.tpl:90 views/html/users/create.tpl:91 +#: views/html/users/edit.tpl:98 views/html/users/edit.tpl:99 +#: views/html/users/register.tpl:89 views/html/users/register.tpl:90 +#: views/html/users/user.tpl:24 +msgid "E‑mail address" +msgstr "E‑Mail-Adresse" + +#: views/html/users/delete.tpl:8 views/html/users/user.tpl:18 +msgid "Delete user" +msgstr "Benutzer löschen" + +#: views/html/users/delete.tpl:9 +#, php-format +msgid "Should the user “%s” (%s) really be deleted?" +msgstr "Soll der Benutzer „%s“ (%s) wirklich gelöscht werden?" + +#: views/html/users/edit.tpl:12 views/html/users/user.tpl:15 +msgid "Edit user" +msgstr "Benutzer bearbeiten" + +#: views/html/users/index.tpl:6 +msgid "Create new user" +msgstr "Neuen Benutzer erstellen" + +#: views/html/users/index.tpl:11 views/html/users/manage.tpl:31 +#: views/html/users/user.tpl:22 +#, php-format +msgid "registered on %s" +msgstr "registriert am %s" + +#: views/html/users/login.tpl:8 +msgid "Login failed" +msgstr "Die Anmeldung war nicht korrekt" + +#: views/html/users/register.tpl:8 +msgid "Registration" +msgstr "Registrierung" + +#: views/html/users/register.tpl:94 +msgid "Register" +msgstr "Registrieren" + +#: views/html/users/user.tpl:46 +msgid "Roles" +msgstr "Rollen" + +#~ msgid "Edit Character group" +#~ msgstr "Gruppe bearbeiten" + +#~ msgid "Manage Character group" +#~ msgstr "Gruppe verwalten" + +#~ msgid "Delete Character group" +#~ msgstr "Gruppe löschen" + +#~ msgid "Maximum reward" +#~ msgstr "Maximale Belohnung" + +#~ msgid "Set properties" +#~ msgstr "Setze Eigenschaften" + +#~ msgid "Selection" +#~ msgstr "Auswahl" + +#~ msgid "Properties" +#~ msgstr "Eigenschaften" + +#, fuzzy +#~ msgid "%stQuests" +#~ msgstr "Quests" + +#~ msgid "Character Groups Quests" +#~ msgstr "Gruppenquests" + +#~ msgid "Skip task" +#~ msgstr "Aufgabe überspringen" + +#~ msgid "continue" +#~ msgstr "fortfahren" + +#~ msgid "Character type" +#~ msgstr "Charaktertyp" + +#~ msgid "Questtopics" +#~ msgstr "Themen" + +#~ msgid "Questname" +#~ msgstr "Questname" + +#, fuzzy +#~ msgid "achieved at %s" +#~ msgstr "erhalten am: %s" + +#~ msgid "Usergroups" +#~ msgstr "Benutzergruppen" + +#~ msgid "E‑Mail" +#~ msgstr "E‑Mail" + +#~ msgid "E‑Mail-Address" +#~ msgstr "E‑Mail-Adresse" + +#~ msgid "E‑mail address not valid" +#~ msgstr "Die E‑Mail-Adresse ist nicht gültig" + +#~ msgid "Username is too long" +#~ msgstr "Der Benutzername ist zu lang" + +#~ msgid "Registration failed: %s" +#~ msgstr "Registrierung fehlgeschlagen: %s" + +#~ msgid "Words" +#~ msgstr "Wörter" + +#~ msgid "locked" +#~ msgstr "gesperrt" + +#~ msgid "Group Leader" +#~ msgstr "Gruppenleiter" + +#~ msgid "as" +#~ msgstr "als" + +#~ msgid "This Quest is optional" +#~ msgstr "Diese Quest ist optional" diff --git a/logs/empty b/logs/empty new file mode 100644 index 00000000..e69de29b diff --git a/media/empty b/media/empty new file mode 100644 index 00000000..e69de29b diff --git a/models/AchievementsModel.inc b/models/AchievementsModel.inc new file mode 100644 index 00000000..1ce77070 --- /dev/null +++ b/models/AchievementsModel.inc @@ -0,0 +1,575 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Achievements-tables. + * + * @author Oliver Hanraths + */ + class AchievementsModel extends \hhu\z\Model + { + + + + + /** + * Construct a new AchievementsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get an Achievement by its URL. + * + * @param int $seminaryId ID of Seminary + * @param string $achievementUrl URL-title of Achievement + * @return array Achievement data + */ + public function getAchievementByUrl($seminaryId, $achievementUrl) + { + $data = $this->db->query( + 'SELECT achievements.id, achievementconditions.condition, title, url, description, progress, unachieved_achievementsmedia_id, achieved_achievementsmedia_id '. + 'FROM achievements '. + 'LEFT JOIN achievementconditions ON achievementconditions.id = achievements.achievementcondition_id '. + 'WHERE seminary_id = ? AND url = ?', + 'is', + $seminaryId, $achievementUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($achievementUrl); + } + + + return $data[0]; + } + + + /** + * Get all not yet achieved Achievements for a Seminary that can + * only be achieved once (only by one Character). + * + * @param int $seminaryId ID of Seminary + * @return array Achievements data + */ + public function getUnachievedOnlyOnceAchievementsForSeminary($seminaryId) + { + return $this->db->query( + 'SELECT achievements.id, achievementconditions.condition, title, url, description, progress, hidden, only_once, all_conditions, deadline, unachieved_achievementsmedia_id, achieved_achievementsmedia_id '. + 'FROM achievements '. + 'LEFT JOIN achievementconditions ON achievementconditions.id = achievements.achievementcondition_id '. + 'WHERE achievements.seminary_id = ? AND only_once = 1 AND NOT EXISTS ('. + 'SELECT character_id '. + 'FROM achievements_characters '. + 'WHERE achievements_characters.achievement_id = achievements.id'. + ')', + 'i', + $seminaryId + ); + } + + + /** + * Get all Achievements that have a deadline. + * + * @param int $seminaryId ID of Seminary + * @return array Achievements data + */ + public function getDeadlineAchievements($seminaryId) + { + return $this->db->query( + 'SELECT achievements.id, achievementconditions.condition, title, url, description, progress, hidden, only_once, all_conditions, deadline, unachieved_achievementsmedia_id, achieved_achievementsmedia_id '. + 'FROM achievements '. + 'LEFT JOIN achievementconditions ON achievementconditions.id = achievements.achievementcondition_id '. + 'WHERE achievements.seminary_id = ? AND deadline IS NOT NULL '. + 'ORDER BY deadline ASC', + 'i', + $seminaryId + ); + } + + + /** + * Get seldom Achievements. + * + * @param int $seminaryId ID of Seminary + * @param int $count Number of Achievements to retrieve + * @return array List of seldom Achievements + */ + public function getSeldomAchievements($seminaryId, $count, $alsoWithDeadline=true) + { + return $this->db->query( + 'SELECT achievements.id, achievements.title, achievements.url, achievements.description, achievements.progress, achievements.hidden, achievements.unachieved_achievementsmedia_id, achievements.achieved_achievementsmedia_id, count(DISTINCT achievements_characters.character_id) AS c '. + 'FROM achievements_characters '. + 'INNER JOIN characters_characterroles ON characters_characterroles.character_id = achievements_characters.character_id '. + 'INNER JOIN characterroles ON characterroles.id = characters_characterroles.characterrole_id AND characterroles.name = ? '. + 'LEFT JOIN achievements ON achievements.id = achievements_characters.achievement_id '. + 'WHERE achievements.seminary_id = ? AND achievements.only_once = 0 '. + (!$alsoWithDeadline ? 'AND achievements.deadline IS NULL ' : null). + 'GROUP BY achievements_characters.achievement_id '. + 'ORDER BY count(DISTINCT achievements_characters.character_id) ASC '. + 'LIMIT ?', + 'sii', + 'user', + $seminaryId, + $count + ); + } + + + /** + * Get all achieved Achievements for a Character. + * + * @param int $characterId ID of Character + * @return array Achievements data + */ + public function getAchievedAchievementsForCharacter($characterId, $alsoWithDeadline=true) + { + return $this->db->query( + 'SELECT achievements.id, achievements_characters.created, achievements.title, achievements.url, achievements.description, achievements.progress, unachieved_achievementsmedia_id, achieved_achievementsmedia_id '. + 'FROM achievements '. + 'INNER JOIN achievements_characters ON achievements_characters.achievement_id = achievements.id '. + 'WHERE achievements_characters.character_id = ? '. + (!$alsoWithDeadline ? 'AND achievements.deadline IS NULL ' : null). + 'ORDER BY achievements_characters.created DESC', + 'i', + $characterId + ); + } + + + /** + * Get all not yet achieved Achievements for a Character. + * + * @param int $seminaryId ID of Seminary + * @param int $characterId ID of Character + * @return array Achievements data + */ + public function getUnachhievedAchievementsForCharacter($seminaryId, $characterId, $includeOnlyOnce=false, $alsoWithDeadline=true) + { + return $this->db->query( + 'SELECT achievements.id, achievementconditions.condition, title, url, description, progress, hidden, only_once, all_conditions, deadline, unachieved_achievementsmedia_id, achieved_achievementsmedia_id '. + 'FROM achievements '. + 'LEFT JOIN achievementconditions ON achievementconditions.id = achievements.achievementcondition_id '. + 'WHERE achievements.seminary_id = ? AND only_once <= ? AND NOT EXISTS ('. + 'SELECT character_id '. + 'FROM achievements_characters '. + 'WHERE '. + 'achievements_characters.achievement_id = achievements.id AND '. + 'achievements_characters.character_id = ?'. + ') '. + (!$alsoWithDeadline ? 'AND achievements.deadline IS NULL ' : null). + 'ORDER BY achievements.pos ASC', + 'iii', + $seminaryId, + $includeOnlyOnce, + $characterId + ); + } + + + /** + * Get the rank for the number of achieved Achievements. + * + * @param int $seminaryId ID of Seminary + * @param int $xps Amount of achieved Achievements + * @return int Rank of Achievements count + */ + public function getCountRank($seminaryId, $count) + { + $data = $this->db->query( + 'SELECT count(*) AS c '. + 'FROM ('. + 'SELECT count(DISTINCT achievement_id) '. + 'FROM achievements_characters '. + 'LEFT JOIN achievements ON achievements.id = achievements_characters.achievement_id '. + 'WHERE achievements.seminary_id = ? '. + 'GROUP BY character_id '. + 'HAVING count(DISTINCT achievement_id) > ?'. + ') AS ranking', + 'ii', + $seminaryId, + $count + ); + if(!empty($data)) { + return $data[0]['c'] + 1; + } + + + return 1; + } + + + /** + * Get all date conditions for an Achievement. + * + * @param int $achievementId ID of Achievement + * @return array Date conditions + */ + public function getAchievementConditionsDate($achievementId) + { + return $this->db->query( + 'SELECT `select` '. + 'FROM achievementconditions_date '. + 'WHERE achievement_id = ?', + 'i', + $achievementId + ); + } + + + /** + * Check a date condition. + * + * @param string $select SELECT-string with date-functions + * @return boolean Result + */ + public function checkAchievementConditionDate($select) + { + $data = $this->db->query( + 'SELECT ('.$select.') AS got ' + ); + if(!empty($data)) { + return ($data[0]['got'] == 1); + } + + + return false; + } + + + /** + * Get all Character conditions for an Achievement. + * + * @param int $achievementId ID of Achievement + * @return array Character conditions + */ + public function getAchievementConditionsCharacter($achievementId) + { + return $this->db->query( + 'SELECT field, value '. + 'FROM achievementconditions_character '. + 'WHERE achievement_id = ?', + 'i', + $achievementId + ); + } + + + /** + * Check a Character condition. + * + * @param string $field Field to check + * @param int $value The value the field has to match + * @param int $characterId ID of Character + * @return boolean Result + */ + public function checkAchievementConditionCharacter($field, $value, $characterId) + { + $data = $this->db->query( + "SELECT ($field >= $value) AS got ". + 'FROM v_characters '. + 'WHERE user_id = ?', + 'i', + $characterId + ); + if(!empty($data)) { + return ($data[0]['got'] == 1); + } + + + return false; + } + + + /** + * Get the progress for a Character condition. + * + * @param string $field Field to check + * @param int $value The value the field has to match + * @param int $characterId ID of Character + * @return float Percentage progress + */ + public function getAchievementConditionCharacterProgress($field, $value, $characterId) + { + $data = $this->db->query( + "SELECT $field AS field ". + 'FROM v_characters '. + 'WHERE user_id = ?', + 'i', + $characterId + ); + if(!empty($data)) { + return $data[0]['field'] / $value; + } + + + return 0; + } + + + /** + * Get all Quest conditions for an Achievement. + * + * @param int $achievementId ID of Achievement + * @return array Quest conditions + */ + public function getAchievementConditionsQuest($achievementId) + { + return $this->db->query( + 'SELECT field, `count`, value, quest_id, status, groupby '. + 'FROM achievementconditions_quest '. + 'WHERE achievement_id = ?', + 'i', + $achievementId + ); + } + + + /** + * Check a Quest condition. + * + * @param string $field Field to check + * @param boolean $count Conut field-value + * @param int $value The value the field has to match + * @param int $status Status of Quest or NULL + * @param string $groupby Field to group or NULL + * @param int $questId ID of related Quest or NULL + * @param int $characterId ID of Character + * @return boolean Result + */ + public function checkAchievementConditionQuest($field, $count, $value, $status, $groupby, $questId, $characterId) + { + $data = $this->db->query( + 'SELECT ('.( + $count + ? "count($field) >= $value" + : "$field = $value" + ). ') AS got '. + 'FROM quests_characters '. + 'WHERE '. + 'character_id = ?'. + (!is_null($questId) ? " AND quest_id = $questId" : ''). + (!is_null($status) ? " AND status = $status" : ''). + (!is_null($groupby) ? " GROUP BY $groupby" : ''), + 'i', + $characterId + ); + if(!empty($data)) { + foreach($data as &$datum) { + if($datum['got'] == 1) { + return true; + } + } + } + + + return false; + } + + + /** + * Get the progress for a Quest condition. + * + * @param string $field Field to check + * @param boolean $count Conut field-value + * @param int $value The value the field has to match + * @param int $status Status of Quest or NULL + * @param string $groupby Field to group or NULL + * @param int $questId ID of related Quest or NULL + * @param int $characterId ID of Character + * @return float Percentage progress + */ + public function getAchievementConditionQuestProgress($field, $count, $value, $status, $groupby, $questId, $characterId) + { + $data = $this->db->query( + 'SELECT '.( + $count + ? "count($field)" + : "$field" + ). ' AS field '. + 'FROM quests_characters '. + 'WHERE '. + 'character_id = ?'. + (!is_null($questId) ? " AND quest_id = $questId" : ''). + (!is_null($status) ? " AND status = $status" : ''). + (!is_null($groupby) ? " GROUP BY $groupby" : ''), + 'i', + $characterId + ); + if(!empty($data)) + { + $maxField = 0; + foreach($data as &$datum) { + $maxField = max($maxField, intval($datum['field'])); + } + + return $maxField / $value; + } + + + return 0; + } + + + /** + * Get all Metaachievement conditions for an Achievement. + * + * @param int $achievementId ID of Achievement + * @return array Metaachievement conditions + */ + public function getAchievementConditionsAchievement($achievementId) + { + return $this->db->query( + 'SELECT field, `count`, value, meta_achievement_id, groupby '. + 'FROM achievementconditions_achievement '. + 'WHERE achievement_id = ?', + 'i', + $achievementId + ); + } + + + /** + * Check a Metaachievement condition. + * + * @param string $field Field to check + * @param boolean $count Conut field-value + * @param int $value The value the field has to match + * @param string $groupby Field to group or NULL + * @param int $metaAchievementId ID of related Achievement or NULL + * @param int $characterId ID of Character + * @return boolean Result + */ + public function checkAchievementConditionAchievement($field, $count, $value, $groupby, $metaAchievementId, $characterId) + { + $data = $this->db->query( + 'SELECT ('.( + $count + ? "count($field) >= $value" + : "$field = $value" + ). ') AS got '. + 'FROM achievements_characters '. + 'WHERE '. + 'character_id = ?'. + (!is_null($metaAchievementId) ? " AND achievement_id = $metaAchievementId" : ''). + (!is_null($groupby) ? " GROUP BY $groupby" : ''), + 'i', + $characterId + ); + if(!empty($data)) { + foreach($data as &$datum) { + if($datum['got'] == 1) { + return true; + } + } + } + + + return false; + } + + + /** + * Get the progress for a Metaachievement condition. + * + * @param string $field Field to check + * @param boolean $count Conut field-value + * @param int $value The value the field has to match + * @param string $groupby Field to group or NULL + * @param int $metaAchievementId ID of related Achievement or NULL + * @param int $characterId ID of Character + * @return float Percentage progress + */ + public function getAchievementConditionAchievementProgress($field, $count, $value, $groupby, $metaAchievementId, $characterId) + { + $data = $this->db->query( + 'SELECT '.( + $count + ? "count($field)" + : "$field" + ). ' AS field '. + 'FROM achievements_characters '. + 'WHERE '. + 'character_id = ?'. + (!is_null($metaAchievementId) ? " AND achievement_id = $metaAchievementId" : ''). + (!is_null($groupby) ? " GROUP BY $groupby" : ''), + 'i', + $characterId + ); + if(!empty($data)) + { + $maxField = 0; + foreach($data as &$datum) { + $maxField = max($maxField, intval($datum['field'])); + } + + return $maxField / $value; + } + + + return 0; + } + + + /** + * Set an Achievement as achieved for a Character. + * + * @param int $achievementId ID of Achievement + * @param int $characterId ID of Character + */ + public function setAchievementAchieved($achievementId, $characterId) + { + $this->db->query( + 'INSERT INTO achievements_characters '. + '(achievement_id, character_id) '. + 'VALUES '. + '(?, ?)', + 'ii', + $achievementId, $characterId + ); + } + + + /** + * Check if a Character has achieved an Achievement. + * + * @param int $achievementId ID of Achievement + * @param int $characterId ID of Character + * @return boolean Whether Character has achieved the Achievement or not + */ + public function hasCharacterAchievedAchievement($achievementId, $characterId) + { + $data = $this->db->query( + 'SELECT achievement_id, character_id, created '. + 'FROM achievements_characters '. + 'WHERE achievement_id = ? AND character_id = ?', + 'ii', + $achievementId, $characterId + ); + if(!empty($data)) { + return $data[0]; + } + + + return false; + } + + } + +?> diff --git a/models/AvatarsModel.inc b/models/AvatarsModel.inc new file mode 100644 index 00000000..9c3d3701 --- /dev/null +++ b/models/AvatarsModel.inc @@ -0,0 +1,92 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Avatars-tables. + * + * @author Oliver Hanraths + */ + class AvatarsModel extends \hhu\z\Model + { + + + + + /** + * Construct a new AvatarsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get an Avatar by its ID + * + * @param int $avatarId ID of Avatar + * @return array Avatar data + */ + public function getAvatarById($avatarId) + { + $data = $this->db->query( + 'SELECT id, charactertype_id, xplevel_id, avatarpicture_id, small_avatarpicture_id '. + 'FROM avatars '. + 'WHERE id = ?', + 'i', + $avatarId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get an Avatar by its Character type and XP-level. + * + * @param int $seminaryId ID of Seminary + * @param string $charactertypeUrl URL-title of Character type + * @param int $xplevel XP-level + * @return array Avatar data + */ + public function getAvatarByTypeAndLevel($seminaryId, $charactertypeUrl, $xplevel) + { + $data = $this->db->query( + 'SELECT avatars.id, charactertype_id, xplevel_id, avatarpicture_id, small_avatarpicture_id '. + 'FROM avatars '. + 'INNER JOIN charactertypes ON charactertypes.id = avatars.charactertype_id '. + 'INNER JOIN xplevels ON xplevels.id = avatars.xplevel_id AND xplevels.seminary_id = charactertypes.seminary_id '. + 'WHERE charactertypes.seminary_id = ? AND charactertypes.url = ? AND xplevels.level = ?', + 'isi', + $seminaryId, + $charactertypeUrl, + $xplevel + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($charactertypeUrl); + } + + + return $data[0]; + } + + } + +?> diff --git a/models/CharactergroupsModel.inc b/models/CharactergroupsModel.inc new file mode 100644 index 00000000..20a3e3a7 --- /dev/null +++ b/models/CharactergroupsModel.inc @@ -0,0 +1,458 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model of the CharactergroupsAgent to interact with + * Charactergroups-table. + * + * @author Oliver Hanraths + */ + class CharactergroupsModel extends \hhu\z\Model + { + + + + + /** + * Construct a new CharactergroupsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get Character groups-groups of a Seminary. + * + * @param int $seminaryId ID of the corresponding Seminary + * @return array Character groups-groups data + */ + public function getGroupsroupsForSeminary($seminaryId) + { + return $this->db->query( + 'SELECT id, name, url, preferred '. + 'FROM charactergroupsgroups '. + 'WHERE seminary_id = ?', + 'i', + $seminaryId + ); + } + + + /** + * Get a Character groups-group by its URL. + * + * @throws IdNotFoundException + * @param int $seminaryId ID of the corresponding Seminary + * @param string $groupsgroupUrl URL-name of the Character groups-group + * @return array Character groups-group data + */ + public function getGroupsgroupByUrl($seminaryId, $groupsgroupUrl) + { + $data = $this->db->query( + 'SELECT id, name, url, preferred '. + 'FROM charactergroupsgroups '. + 'WHERE seminary_id = ? AND url = ?', + 'is', + $seminaryId, $groupsgroupUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($groupsgroupUrl); + } + + + return $data[0]; + } + + + /** + * Get a Character groups-group by its ID. + * + * @throws IdNotFoundException + * @param string $groupsgroupId ID of the Character groups-group + * @return array Character groups-group data + */ + public function getGroupsgroupById($groupsgroupId) + { + $data = $this->db->query( + 'SELECT id, name, url, preferred '. + 'FROM charactergroupsgroups '. + 'WHERE id = ?', + 'i', + $groupsgroupId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($groupsgroupId); + } + + + return $data[0]; + } + + + /** + * Check if a Character groups-group name already exists. + * + * @param string $name Name to check + * @param int $groupsgroupId Do not check this ID (for editing) + * @return boolean Whether name exists or not + */ + public function characterGroupsgroupNameExists($name, $groupsgroupId=null) + { + $data = $this->db->query( + 'SELECT id '. + 'FROM charactergroupsgroups '. + 'WHERE name = ? OR url = ?', + 'ss', + $name, + \nre\core\Linker::createLinkParam($name) + ); + + + return (!empty($data) && (is_null($groupsgroupId) || $groupsgroupId != $data[0]['id'])); + } + + + /** + * Create a new Character groups-group. + * + * @param int $userId ID of user + * @param int $seminaryId ID of Seminary + * @param string $name Name of new groups-group + * @param boolean $preferred Whether groups-group is preferred or not + * @return int ID of newly created groups-group + */ + public function createGroupsgroup($userId, $seminaryId, $name, $preferred) + { + $this->db->query( + 'INSERT INTO charactergroupsgroups '. + '(created_user_id, seminary_id, name, url, preferred) '. + 'VALUES '. + '(?, ?, ?, ?, ?)', + 'iissd', + $userId, + $seminaryId, + $name, + \nre\core\Linker::createLinkParam($name), + $preferred + ); + + + return $this->db->getInsertId(); + } + + + /** + * Edit a Character groups-group. + * + * @param int $groupsgroupId ID of groups-group to edit + * @param string $name New name of groups-group + * @param boolean $preferred Whether groups-group is preferred or not + */ + public function editGroupsgroup($groupsgroupId, $name, $preferred) + { + $this->db->query( + 'UPDATE charactergroupsgroups '. + 'SET name = ?, url = ?, preferred = ? '. + 'WHERE id = ?', + 'ssdi', + $name, + \nre\core\Linker::createLinkParam($name), + $preferred, + $groupsgroupId + ); + } + + + /** + * Delete a Character groups-group. + * + * @param int $groupsgroupId ID of groups-group to delete + */ + public function deleteGroupsgroup($groupsgroupId) + { + $this->db->query('DELETE FROM charactergroupsgroups WHERE id = ?', 'i', $groupsgroupId); + } + + + /** + * Get Character groups for a Character groups-group. + * + * @param int $groupsgroupId ID of the Character groups-group + * @return array Character groups + */ + public function getGroupsForGroupsgroup($groupsgroupId) + { + return $this->db->query( + 'SELECT id, name, url, xps, motto, seminaryupload_id '. + 'FROM v_charactergroups '. + 'WHERE charactergroupsgroup_id = ? '. + 'ORDER BY name', + 'i', + $groupsgroupId + ); + } + + + /** + * Get Character groups for a Character. + * + * @param int $characterId ID of the Character + * @return array Character groups + */ + public function getGroupsForCharacter($characterId) + { + return $this->db->query( + 'SELECT charactergroups.id, charactergroups.charactergroupsgroup_id, charactergroups.name, charactergroups.url, charactergroups.seminaryupload_id, charactergroups.xps, charactergroupsgroups.id AS charactergroupsgroup_id, charactergroupsgroups.name AS charactergroupsgroup_name, charactergroupsgroups.url AS charactergroupsgroup_url '. + 'FROM characters_charactergroups '. + 'LEFT JOIN v_charactergroups AS charactergroups ON charactergroups.id = characters_charactergroups.charactergroup_id '. + 'LEFT JOIN charactergroupsgroups ON charactergroupsgroups.id = charactergroups.charactergroupsgroup_id '. + 'WHERE characters_charactergroups.character_id = ?', + 'i', + $characterId + ); + } + + + /** + * Get a Character group by its URL. + * + * @throws IdNotFoundException + * @param int $groupsgroupId ID of the Character groups-group + * @param string $groupUrl URL-name of the Character group + * @return array Character group data + */ + public function getGroupByUrl($groupsgroupId, $groupUrl) + { + $data = $this->db->query( + 'SELECT id, name, url, xps, motto, seminaryupload_id '. + 'FROM v_charactergroups '. + 'WHERE charactergroupsgroup_id = ? AND url = ?', + 'is', + $groupsgroupId, $groupUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($groupUrl); + } + + + return $data[0]; + } + + + /** + * Get a Character group by its ID. + * + * @throws IdNotFoundException + * @param int $groupsgroupId ID of the Character group + * @return array Character group data + */ + public function getGroupById($groupId) + { + $data = $this->db->query( + 'SELECT id, name, url, xps, motto, seminaryupload_id '. + 'FROM v_charactergroups '. + 'WHERE id = ?', + 'i', + $groupId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($groupId); + } + + + return $data[0]; + } + + + /** + * Get the Character groups for a Quest. + * + * @param int $questId ID of the Character groups Quest + * @return array Character groups + */ + public function getGroupsForQuest($questId) + { + $groups = $this->db->query( + 'SELECT charactergroups.id, charactergroups.name, charactergroups.url, charactergroupsquests_groups.created, charactergroupsquests_groups.xps_factor, charactergroupsquests.xps '. + 'FROM charactergroupsquests_groups '. + 'LEFT JOIN charactergroups ON charactergroups.id = charactergroupsquests_groups.charactergroup_id '. + 'LEFT JOIN charactergroupsquests ON charactergroupsquests.id = charactergroupsquests_groups.charactergroupsquest_id '. + 'WHERE charactergroupsquests_groups.charactergroupsquest_id = ? '. + 'ORDER BY xps_factor DESC', + 'i', + $questId + ); + foreach($groups as &$group) { + $group['xps'] = round($group['xps'] * $group['xps_factor'], 1); + } + + + return $groups; + } + + + /** + * Check if a Character group name already exists. + * + * @param string $name Name to check + * @param int $groupsgroupId Do not check this ID (for editing) + * @return boolean Whether name exists or not + */ + public function characterGroupNameExists($name, $groupId=null) + { + $data = $this->db->query( + 'SELECT id '. + 'FROM charactergroups '. + 'WHERE name = ? OR url = ?', + 'ss', + $name, + \nre\core\Linker::createLinkParam($name) + ); + + + return (!empty($data) && (is_null($groupId) || $groupId != $data[0]['id'])); + } + + + /** + * Create a new Character group. + * + * @param int $userId ID of user + * @param int $groupsgroupId ID of Character groups-group + * @param string $name Name of new group + * @param string $motto Motto of new group + * @return int ID of newly created group + */ + public function createGroup($userId, $groupsgroupId, $name, $motto) + { + $this->db->query( + 'INSERT INTO charactergroups '. + '(created_user_id, charactergroupsgroup_id, name, url, motto) '. + 'VALUES '. + '(?, ?, ?, ?, ?)', + 'iisss', + $userId, + $groupsgroupId, + $name, + \nre\core\Linker::createLinkParam($name), + $motto + ); + + + return $this->db->getInsertId(); + } + + + /** + * Edit a Character group. + * + * @param int $groupId ID of Character group to edit + * @param string $name New name of group + * @param string $motto New motto of group + */ + public function editGroup($groupId, $name, $motto) + { + $this->db->query( + 'UPDATE charactergroups '. + 'SET name = ?, url = ?, motto = ? '. + 'WHERE id = ?', + 'sssi', + $name, + \nre\core\Linker::createLinkParam($name), + $motto, + $groupId + ); + } + + + /** + * Delete a Character group. + * + * @param int $groupId ID of Character group to delete + */ + public function deleteGroup($groupId) + { + $this->db->query('DELETE FROM charactergroups WHERE id = ?', 'i', $groupId); + } + + + /** + * Get the rank of a XP-value of a Character. + * + * @param int $seminaryId ID of Seminary + * @param int $xps XP-value to get rank for + * @return int Rank of XP-value + */ + public function getXPRank($groupsgroupId, $xps) + { + $data = $this->db->query( + 'SELECT count(id) AS c '. + 'FROM v_charactergroups '. + 'WHERE charactergroupsgroup_id = ? AND xps > ?', + 'id', + $groupsgroupId, $xps + ); + if(!empty($data)) { + return $data[0]['c'] + 1; + } + + + return 1; + } + + + /** + * Add a Character to a Character group. + * + * @param int $groupId ID of Character group + * @param int $characterId ID of Character to add + */ + public function addCharacterToCharactergroup($groupId, $characterId) + { + $this->db->query( + 'INSERT INTO characters_charactergroups '. + '(character_id, charactergroup_id) '. + 'VALUES '. + '(?, ?)', + 'ii', + $characterId, + $groupId + ); + } + + + /** + * Remove a Character from a Character group. + * + * @param int $groupId ID of Character group + * @param int $characterId ID of Character to remove + */ + public function removeCharacterFromCharactergroup($groupId, $characterId) + { + $this->db->query( + 'DELETE FROM characters_charactergroups '. + 'WHERE charactergroup_id = ? AND character_id = ?', + 'ii', + $groupId, + $characterId + ); + } + + } + +?> diff --git a/models/CharactergroupsquestsModel.inc b/models/CharactergroupsquestsModel.inc new file mode 100644 index 00000000..5c9f1602 --- /dev/null +++ b/models/CharactergroupsquestsModel.inc @@ -0,0 +1,370 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model of the CharactergroupsquestsAgent to interact with + * Charactergroupsquests-table. + * + * @author Oliver Hanraths + */ + class CharactergroupsquestsModel extends \hhu\z\Model + { + /** + * Required models + * + * @var array + */ + public $models = array('uploads'); + + + + + /** + * Construct a new CharactergroupsquestsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get Character groups Quests of a Character groups-groups. + * + * @param int $groupsgroupId ID of the Character groups-group + * @return array Character groups Quest data + */ + public function getQuestsForCharactergroupsgroup($groupsgroupId) + { + return $this->db->query( + 'SELECT id, questgroups_id, title, url, xps '. + 'FROM charactergroupsquests '. + 'WHERE charactergroupsgroup_id = ?', + 'i', + $groupsgroupId + ); + } + + + /** + * Get a Character groups Quest by its URL. + * + * @throws IdNotFoundException + * @param int $groupsgroupId ID of the Character groups-group + * @param string $questUrl URL-title of the Character groups Quest + * @return array Character groups Quest data + */ + public function getQuestByUrl($groupsgroupId, $questUrl) + { + $data = $this->db->query( + 'SELECT id, questgroups_id, title, url, description, xps, rules, won_text, lost_text, questsmedia_id '. + 'FROM charactergroupsquests '. + 'WHERE charactergroupsgroup_id = ? AND url = ?', + 'is', + $groupsgroupId, + $questUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questUrl); + } + + + return $data[0]; + } + + + /** + * Get a Character groups Quest by its ID. + * + * @throws IdNotFoundException + * @param int $questId ID of the Character groups Quest + * @return array Character groups Quest data + */ + public function getQuestById($questId) + { + $data = $this->db->query( + 'SELECT id, questgroups_id, title, url, description, xps, rules, won_text, lost_text, questsmedia_id '. + 'FROM charactergroupsquests '. + 'WHERE id = ?', + 'i', + $questId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questUrl); + } + + + return $data[0]; + } + + + /** + * Get Character groups Quests for a Character group. + * + * @param int $groupId ID of the Character group + * @return array Character groups Quests + */ + public function getQuestsForGroup($groupId) + { + $quests = $this->db->query( + 'SELECT charactergroupsquests.id, charactergroupsquests_groups.created, charactergroupsquests.title, charactergroupsquests.url, charactergroupsquests.xps, charactergroupsquests_groups.xps_factor '. + 'FROM charactergroupsquests_groups '. + 'LEFT JOIN charactergroupsquests ON charactergroupsquests.id = charactergroupsquests_groups.charactergroupsquest_id '. + 'WHERE charactergroupsquests_groups.charactergroup_id = ?', + 'i', + $groupId + ); + foreach($quests as &$quest) { + $quest['group_xps'] = round($quest['xps'] * $quest['xps_factor'], 1); + } + + + return $quests; + } + + + /** + * Get XPs of a Character group for a Character groups Quest. + * + * @param int $questId ID of Character groups Quest + * @param int $groupId ID of Character group to get XPs of + * @return array XP-record + */ + public function getXPsOfGroupForQuest($questId, $groupId) + { + $data = $this->db->query( + 'SELECT charactergroupsquests_groups.created, charactergroupsquests_groups.xps_factor, charactergroupsquests.xps '. + 'FROM charactergroupsquests_groups '. + 'LEFT JOIN charactergroupsquests ON charactergroupsquests.id = charactergroupsquests_groups.charactergroupsquest_id '. + 'WHERE charactergroupsquests_groups.charactergroupsquest_id = ? AND charactergroupsquests_groups.charactergroup_id = ?', + 'ii', + $questId, + $groupId + ); + if(empty($data)) { + return null; + } + + $data = $data[0]; + $data['xps'] = round($data['xps'] * $data['xps_factor'], 1); + + + return $data; + } + + + /** + * Set XPs of a Character group for a Character groups Quest. + * + * @param int $questId ID of Character groups Quest + * @param int $groupId ID of Character group to set XPs of + * @param float $xpsFactor XPs-factor + */ + public function setXPsOfGroupForQuest($questId, $groupId, $xpsFactor) + { + $this->db->query( + 'INSERT INTO charactergroupsquests_groups '. + '(charactergroupsquest_id, charactergroup_id, xps_factor) '. + 'VALUES '. + '(?, ?, ?) '. + 'ON DUPLICATE KEY UPDATE '. + 'xps_factor = ?', + 'iidd', + $questId, + $groupId, + $xpsFactor, + $xpsFactor + ); + } + + + /** + * Remove a Character group from a Character groups Quest. + * + * @param int $questId ID of Character groups Quest + * @param int $groupId ID of Character group to remove + */ + public function deleteGroupForQuest($questId, $groupId) + { + $this->db->query( + 'DELETE FROM charactergroupsquests_groups '. + 'WHERE charactergroupsquest_id = ? AND charactergroup_id = ?', + 'ii', + $questId, $groupId + ); + } + + + /** + * Check if a Character groups Quest title already exists. + * + * @param string $name Character groups Quest title to check + * @param int $questId Do not check this ID (for editing) + * @return boolean Whether Character groups Quest title exists or not + */ + public function characterGroupsQuestTitleExists($title, $questId=null) + { + $data = $this->db->query( + 'SELECT id '. + 'FROM charactergroupsquests '. + 'WHERE title = ? OR url = ?', + 'ss', + $title, + \nre\core\Linker::createLinkParam($title) + ); + + return (!empty($data) && (is_null($questId) || $questId != $data[0]['id'])); + } + + + /** + * Upload a media for a Character groups Quest. + * + * @param int $userId ID of user that does the upload + * @param int $seminaryId ID of Seminary + * @param int $questId ID of Quest to upload media for + * @param array $file File-array of file to upload + * @param string $filename Filename for media + * @return boolean Whether upload succeeded or not + */ + public function uploadMediaForQuest($userId, $seminaryId, $questId, $file, $filename) + { + // Save file on harddrive + $uploadId = $this->Uploads->uploadSeminaryFile($userId, $seminaryId, $file['name'], $filename, $file['tmp_name'], $file['type']); + if($uploadId === false) { + return false; + } + + // Create database record + $this->db->query( + 'INSERT INTO charactergroupsquests_seminaryuploads '. + '(seminaryupload_id, charactergroupsquest_id, created_user_id) '. + 'VALUES '. + '(?, ?, ?) ', + 'iii', + $uploadId, $questId, $uploadId + ); + + + return true; + } + + + /** + * Get uploaded Medai for a Character groups Quest. + * + * @param int $questId ID of Quest to get media for + * @return array Seminary uploads + */ + public function getMediaForQuest($questId) + { + return $this->db->query( + 'SELECT seminaryupload_id, created, created_user_id '. + 'FROM charactergroupsquests_seminaryuploads '. + 'WHERE charactergroupsquest_id = ?', + 'i', + $questId + ); + } + + + /** + * Create a new Character groups Quest. + * + * @param int $userId ID of user + * @param int $groupsgroupId ID of Character groups-group + * @param int $questgroupId ID of Quest group + * @param string $title Title of new Quest + * @param string $description Description of new Quset + * @param int $xps Amount of XPs for new Quest + * @param string $rules Rules of new Quest + * @param string $wonText Won-text of new Quset + * @param string $lostText Lost-text of new Quest + * @return int ID of newly created Quest + */ + public function createQuest($userId, $groupsgroupId, $questgroupId, $title, $description, $xps, $rules, $wonText, $lostText) + { + $this->db->query( + 'INSERT INTO charactergroupsquests '. + '(created_user_id, charactergroupsgroup_id, questgroups_id, title, url, description, xps, rules, won_text, lost_text) '. + 'VALUES '. + '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'iiisssdsss', + $userId, + $groupsgroupId, + $questgroupId, + $title, + \nre\core\Linker::createLinkParam($title), + $description, + $xps, + $rules, + $wonText, + $lostText + ); + + + return $this->db->getInsertId(); + } + + + /** + * Edit a Character groups Quest. + * + * @param int $questId ID of Character groups Quest to edit + * @param int $groupsgroupId ID of Character groups-group + * @param int $questgroupId ID of Quest group + * @param string $title Title of new Quest + * @param string $description Description of new Quset + * @param int $xps Amount of XPs for new Quest + * @param string $rules Rules of new Quest + * @param string $wonText Won-text of new Quset + * @param string $lostText Lost-text of new Quest + */ + public function editQuest($questId, $groupsgroupId, $questgroupId, $title, $description, $xps, $rules, $wonText, $lostText) + { + $this->db->query( + 'UPDATE charactergroupsquests '. + 'SET charactergroupsgroup_id = ?, questgroups_id = ?, title = ?, url = ?, description = ?, xps = ?, rules = ?, won_text = ?, lost_text= ? '. + 'WHERE id = ?', + 'iisssdsssi', + $groupsgroupId, + $questgroupId, + $title, + \nre\core\Linker::createLinkParam($title), + $description, + $xps, + $rules, + $wonText, + $lostText, + $questId + ); + } + + + /** + * Delete a Character groups Quest. + * + * @param int $questId ID of Character groups Quest to delete + */ + public function deleteQuest($questId) + { + $this->db->query('DELETE FROM charactergroupsquests WHERE id = ?', 'i', $questId); + } + + + } + +?> diff --git a/models/CharacterrolesModel.inc b/models/CharacterrolesModel.inc new file mode 100644 index 00000000..752d4a0a --- /dev/null +++ b/models/CharacterrolesModel.inc @@ -0,0 +1,100 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with characterroles-table. + * + * @author Oliver Hanraths + */ + class CharacterrolesModel extends \hhu\z\Model + { + + + + + /** + * Construct a new CharacterrolesModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get all characterroles for a Character referenced by its ID. + * + * @param int $userId ID of an user + * @return array Characterroles for a Character + */ + public function getCharacterrolesForCharacterById($characterId) + { + return $this->db->query( + 'SELECT characterroles.id, characterroles.created, characterroles.name '. + 'FROM characters_characterroles '. + 'LEFT JOIN characterroles ON characterroles.id = characters_characterroles.characterrole_id '. + 'WHERE characters_characterroles.character_id = ?', + 'i', + $characterId + ); + } + + + /** + * Add a role to a Character. + * + * @param int $characterId ID of Character to add role to + * @param string $characterrole Role to add + */ + public function addCharacterroleToCharacter($characterId, $characterrole) + { + $this->db->query( + 'INSERT IGNORE INTO characters_characterroles '. + '(character_id, characterrole_id) '. + 'SELECT ?, id '. + 'FROM characterroles '. + 'WHERE name = ?', + 'is', + $characterId, + $characterrole + ); + } + + + /** + * Remove a role from a Character. + * + * @param int $characterId ID of Character to remove role from + * @param string $characterrole Role to remove + */ + public function removeCharacterroleFromCharacter($characterId, $characterrole) + { + $this->db->query( + 'DELETE FROM characters_characterroles '. + 'WHERE character_id = ? AND characterrole_id = ('. + 'SELECT id '. + 'FROM characterroles '. + 'WHERE name = ?'. + ')', + 'is', + $characterId, + $characterrole + ); + } + + } + +?> diff --git a/models/CharactersModel.inc b/models/CharactersModel.inc new file mode 100644 index 00000000..8f41bdb3 --- /dev/null +++ b/models/CharactersModel.inc @@ -0,0 +1,543 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Characters-table. + * + * @author Oliver Hanraths + */ + class CharactersModel extends \hhu\z\Model + { + + + + + /** + * Construct a new CharactersModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get all characters for an user. + * + * @param int $userId ID of the user + * @return array Characters + */ + public function getCharactersForUser($userId) + { + return $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url, seminaries.id AS seminary_id, seminaries.url AS seminary_url, seminaries.title AS seminary_title, seminaries.url AS seminary_url '. + 'FROM v_characters AS characters '. + 'LEFT JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'LEFT JOIN seminaries ON seminaries.id = charactertypes.seminary_id '. + 'WHERE user_id = ?', + 'i', + $userId + ); + } + + + /** + * Get Characters for a Seminary. + * + * @param int $seminaryId ID of the Seminary + * @return array Characters + */ + public function getCharactersForSeminary($seminaryId, $onlyWithRole=false) + { + return $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url, seminaries.id AS seminary_url, seminaries.title AS seminary_title, seminaries.url AS seminary_url '. + 'FROM v_characters AS characters '. + 'LEFT JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'LEFT JOIN seminaries ON seminaries.id = charactertypes.seminary_id '. + 'WHERE seminaries.id = ?'. + ($onlyWithRole ? ' AND EXISTS (SELECT character_id FROM characters_characterroles WHERE character_id = characters.id)' : null), + 'i', + $seminaryId + ); + } + + + /** + * Get Characters for a Character group. + * + * @param int $groupId ID of the Character group + * @return array Characters + */ + public function getCharactersForGroup($groupId) + { + return $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url '. + 'FROM v_characters AS characters '. + 'LEFT JOIN characters_charactergroups ON characters_charactergroups.character_id = characters.id '. + 'LEFT JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'WHERE characters_charactergroups.charactergroup_id = ?', + 'i', + $groupId + ); + } + + + /** + * Get the character of a user for a Seminary. + * + * @throws IdNotFoundException + * @param int $userId ID of the user + * @param int $seminaryId ID of the Seminary + * @return array Character data + */ + public function getCharacterForUserAndSeminary($userId, $seminaryId) + { + $data = $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url '. + 'FROM v_characters AS characters '. + 'LEFT JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'WHERE characters.user_id = ? AND charactertypes.seminary_id = ?', + 'ii', + $userId, $seminaryId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($userId); + } + + + return $data[0]; + } + + + /** + * Get a Character by its Url. + * + * @throws IdNotFoundException + * @param int $seminaryId ID of the Seminary + * @param string $characterUrl URL-name of the Character + * @return array Character data + */ + public function getCharacterByUrl($seminaryId, $characterUrl) + { + $data = $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url '. + 'FROM v_characters AS characters '. + 'LEFT JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'WHERE charactertypes.seminary_id = ? AND characters.url = ?', + 'is', + $seminaryId, $characterUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($characterUrl); + } + + + return $data[0]; + } + + + /** + * Get a Character by its Id. + * + * @throws IdNotFoundException + * @param string $characterId ID of the Character + * @return array Character data + */ + public function getCharacterById($characterId) + { + $data = $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url '. + 'FROM v_characters AS characters '. + 'LEFT JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'WHERE characters.id = ?', + 'i', + $characterId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($characterUrl); + } + + + return $data[0]; + } + + + /** + * Get Characters with the most amount of Achievements. + * + * @param int $seminaryId ID of Seminary + * @param int $conut Amount of Characters to retrieve + * @return array List of Characters + */ + public function getCharactersWithMostAchievements($seminaryId, $count, $alsoWithDeadline=true) + { + return $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url, count(DISTINCT achievement_id) AS c '. + 'FROM achievements_characters '. + 'INNER JOIN achievements ON achievements.id = achievements_characters.achievement_id '. + 'INNER JOIN v_characters AS characters ON characters.id = achievements_characters.character_id '. + 'INNER JOIN characters_characterroles ON characters_characterroles.character_id = characters.id '. + 'INNER JOIN characterroles ON characterroles.id = characters_characterroles.characterrole_id AND characterroles.name = ? '. + 'INNER JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'WHERE achievements.seminary_id = ? AND deadline IS NULL '. + (!$alsoWithDeadline ? 'AND achievements.deadline IS NULL ' : null). + 'GROUP BY achievements_characters.character_id '. + 'ORDER BY count(DISTINCT achievements_characters.achievement_id) DESC '. + 'LIMIT ?', + 'sii', + 'user', + $seminaryId, + $count + ); + } + + + /** + * Calculate only XPs for a Character achieved through Quests. + * + * @param int $characterId ID of Character + * @return int Quest-XPs for Character + */ + public function getQuestXPsOfCharacter($characterId) + { + $data = $this->db->query( + 'SELECT quest_xps '. + 'FROM v_charactersxps '. + 'WHERE character_id = ?', + 'i', + $characterId + ); + if(!empty($data)) { + return $data[0]['quest_xps']; + } + + + return 0; + } + + + /** + * Get the XP-level of a Character. + * + * @param string $characterId ID of the Character + * @return array XP-level of Character + */ + public function getXPLevelOfCharacters($characterId) + { + $data = $this->db->query( + 'SELECT xplevels.xps, xplevels.level, xplevels.name '. + 'FROM v_charactersxplevels '. + 'INNER JOIN xplevels ON xplevels.id = v_charactersxplevels.xplevel_id '. + 'WHERE v_charactersxplevels.character_id = ?', + 'i', + $characterId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get the rank of a XP-value of a Character. + * + * @param int $seminaryId ID of Seminary + * @param int $xps XP-value to get rank for + * @return int Rank of XP-value + */ + public function getXPRank($seminaryId, $xps) + { + $data = $this->db->query( + 'SELECT count(characters.id) AS c '. + 'FROM charactertypes '. + 'INNER JOIN v_characters AS characters ON characters.charactertype_id = charactertypes.id '. + 'INNER JOIN characters_characterroles ON characters_characterroles.character_id = characters.id '. + 'INNER JOIN characterroles ON characterroles.id = characters_characterroles.characterrole_id AND characterroles.name = ? '. + 'WHERE seminary_id = ? AND characters.xps > ?', + 'sid', + 'user', + $seminaryId, $xps + ); + if(!empty($data)) { + return $data[0]['c'] + 1; + } + + + return 1; + } + + + /** + * Get the superior $count Characters in the ranking. + * + * @param int $seminaryId ID of Seminary + * @param int $xps XP-value of Character + * @param int $count Count of Characters to determine + * @return array List of superior Characters + */ + public function getSuperiorCharacters($seminaryId, $xps, $count) + { + $data = $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url '. + 'FROM v_characters AS characters '. + 'INNER JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'INNER JOIN characters_characterroles ON characters_characterroles.character_id = characters.id '. + 'INNER JOIN characterroles ON characterroles.id = characters_characterroles.characterrole_id AND characterroles.name = ? '. + 'WHERE charactertypes.seminary_id = ? AND characters.xps > ? '. + 'ORDER BY characters.xps ASC, RAND() '. + 'LIMIT ?', + 'sidd', + 'user', + $seminaryId, $xps, $count + ); + $data = array_reverse($data); + + + return $data; + } + + + /** + * Get the inferior $count Characters in the ranking. + * + * @param int $seminaryId ID of Seminary + * @param int $xps XP-value of Character + * @param int $count Count of Characters to determine + * @return array List of inferior Characters + */ + public function getInferiorCharacters($seminaryId, $characterId, $xps, $count) + { + return $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url '. + 'FROM v_characters AS characters '. + 'INNER JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'INNER JOIN characters_characterroles ON characters_characterroles.character_id = characters.id '. + 'INNER JOIN characterroles ON characterroles.id = characters_characterroles.characterrole_id AND characterroles.name = ? '. + 'WHERE charactertypes.seminary_id = ? AND characters.xps <= ? AND characters.id <> ? '. + 'ORDER BY characters.xps DESC, RAND() '. + 'LIMIT ?', + 'sidid', + 'user', + $seminaryId, $xps, $characterId, $count + ); + } + + + /** + * Get Characters that solved a Quest. + * + * @param int $questId ID of Quest to get Characters for + * @return array Characters data + */ + public function getCharactersSolvedQuest($questId) + { + return $data = $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url, quests_characters.created AS submission_created '. + 'FROM quests_characters '. + 'INNER JOIN v_characters AS characters ON characters.id = quests_characters.character_id '. + 'INNER JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'WHERE quests_characters.quest_id = ? AND quests_characters.status = ? AND NOT EXISTS ('. + 'SELECT id '. + 'FROM quests_characters AS qc '. + 'WHERE qc.character_id = quests_characters.character_id AND qc.created > quests_characters.created'. + ') '. + 'ORDER BY quests_characters.created ASC', + 'ii', + $questId, QuestsModel::QUEST_STATUS_SOLVED + ); + } + + + /** + * Get Characters that did not solv a Quest. + * + * @param int $questId ID of Quest to get Characters for + * @return array Characters data + */ + public function getCharactersUnsolvedQuest($questId) + { + return $data = $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url, quests_characters.created AS submission_created '. + 'FROM quests_characters '. + 'INNER JOIN v_characters AS characters ON characters.id = quests_characters.character_id '. + 'INNER JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'WHERE quests_characters.quest_id = ? AND quests_characters.status = ? AND NOT EXISTS ('. + 'SELECT id '. + 'FROM quests_characters AS qc '. + 'WHERE qc.character_id = quests_characters.character_id AND qc.created > quests_characters.created'. + ') '. + 'ORDER BY quests_characters.created ASC', + 'ii', + $questId, QuestsModel::QUEST_STATUS_UNSOLVED + ); + } + + + /** + * Get Characters that sent a submission for a Quest. + * + * @param int $questId ID of Quest to get Characters for + * @return array Characters data + */ + public function getCharactersSubmittedQuest($questId) + { + return $data = $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url, quests_characters.created AS submission_created '. + 'FROM quests_characters '. + 'INNER JOIN v_characters AS characters ON characters.id = quests_characters.character_id '. + 'INNER JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'WHERE quests_characters.quest_id = ? AND quests_characters.status = ? AND NOT EXISTS ('. + 'SELECT id '. + 'FROM quests_characters AS qc '. + 'WHERE qc.character_id = quests_characters.character_id AND qc.created > quests_characters.created'. + ') '. + 'ORDER BY quests_characters.created ASC', + 'ii', + $questId, QuestsModel::QUEST_STATUS_SUBMITTED + ); + } + + + /** + * Get all XP-levels for a Seminary. + * + * @param int $seminaryId ID of Seminary + * @return array List of XP-levels + */ + public function getXPLevelsForSeminary($seminaryId) + { + return $this->db->query( + 'SELECT id, xps, level, name '. + 'FROM xplevels '. + 'WHERE seminary_id = ? '. + 'ORDER BY level ASC', + 'i', + $seminaryId + ); + } + + + /** + * Get Characters with the given Character role. + * + * @param int $seminaryId ID of Seminary + * @param string $characterrole Character role + * @return array List of users + */ + public function getCharactersWithCharacterRole($seminaryId, $characterrole) + { + return $this->db->query( + 'SELECT characters.id, characters.created, characters.charactertype_id, characters.name, characters.url, characters.user_id, characters.xps, characters.xplevel, characters.avatar_id, charactertypes.name AS charactertype_name, charactertypes.url AS charactertype_url, seminaries.id AS seminary_url, seminaries.title AS seminary_title, seminaries.url AS seminary_url '. + 'FROM v_characters AS characters '. + 'LEFT JOIN charactertypes ON charactertypes.id = characters.charactertype_id '. + 'LEFT JOIN seminaries ON seminaries.id = charactertypes.seminary_id '. + 'LEFT JOIN characters_characterroles ON characters_characterroles.character_id = characters.id '. + 'LEFT JOIN characterroles ON characterroles.id = characters_characterroles.characterrole_id '. + 'WHERE seminaries.id = ? AND characterroles.name = ?', + 'is', + $seminaryId, $characterrole + ); + } + + + /** + * Check if a Character name already exists. + * + * @param string $name Character name to check + * @param int $characterId Do not check this ID (for editing) + * @return boolean Whether Character name exists or not + */ + public function characterNameExists($name, $characterId=null) + { + $data = $this->db->query( + 'SELECT id '. + 'FROM characters '. + 'WHERE name = ? OR url = ?', + 'ss', + $name, + \nre\core\Linker::createLinkParam($name) + ); + + + return (!empty($data) && (is_null($characterId) || $characterId != $data[0]['id'])); + } + + + /** + * Create a new Character. + * + * @param int $userId User-ID that creates the new character + * @param int $charactertypeId ID of type of new Character + * @param string $characterName Name for the new Character + * @return int ID of Character + */ + public function createCharacter($userId, $charactertypeId, $characterName) + { + $this->db->query( + 'INSERT INTO characters '. + '(user_id, charactertype_id, name, url) '. + 'VALUES '. + '(?, ?, ?, ?)', + 'iiss', + $userId, + $charactertypeId, + $characterName, + \nre\core\Linker::createLinkParam($characterName) + ); + + + return $this->db->getInsertId(); + } + + + /** + * Edit a new Character. + * + * @param int $characterId ID of the Character to edit + * @param int $charactertypeId ID of new type of Character + * @param string $characterName New name for Character + */ + public function editCharacter($characterId, $charactertypeId, $characterName) + { + $this->db->query( + 'UPDATE characters '. + 'SET charactertype_id = ?, name = ?, url = ? '. + 'WHERE id = ?', + 'issi', + $charactertypeId, + $characterName, + \nre\core\Linker::createLinkParam($characterName), + $characterId + ); + } + + + /** + * Delete a Character. + * + * @param int $characterId ID of the Character to delete + */ + public function deleteCharacter($characterId) + { + $this->db->query('DELETE FROM characters WHERE id = ?', 'i', $characterId); + } + + } + +?> diff --git a/models/CharactertypesModel.inc b/models/CharactertypesModel.inc new file mode 100644 index 00000000..07c8d5e9 --- /dev/null +++ b/models/CharactertypesModel.inc @@ -0,0 +1,57 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Charactertypes-table. + * + * @author Oliver Hanraths + */ + class CharactertypesModel extends \hhu\z\Model + { + + + + + /** + * Construct a new CharactertypesModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get all Character types of a Seminary. + * + * @param int $seminaryId ID of Seminary to get types of + * @return array Character types + */ + public function getCharacterTypesForSeminary($seminaryId) + { + return $this->db->query( + 'SELECT id, name, url '. + 'FROM charactertypes '. + 'WHERE seminary_id = ? '. + 'ORDER BY name ASC', + 'i', + $seminaryId + ); + } + + } + +?> diff --git a/models/DatabaseModel.inc b/models/DatabaseModel.inc new file mode 100644 index 00000000..51259f4f --- /dev/null +++ b/models/DatabaseModel.inc @@ -0,0 +1,83 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\models; + + + /** + * Default implementation of a database model. + * + * @author coderkun + */ + class DatabaseModel extends \nre\core\Model + { + /** + * Database connection + * + * @static + * @var DatabaseDriver + */ + protected $db = NULL; + + + + + /** + * Construct a new datamase model. + * + * @throws DatamodelException + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @param string $type Database type + * @param array $config Connection settings + */ + function __construct($type, $config) + { + parent::__construct(); + + // Load database driver + $this->loadDriver($type); + + // Establish database connection + $this->connect($type, $config); + } + + + + + /** + * Load the database driver. + * + * @throws DriverNotFoundException + * @throws DriverNotValidException + * @param string $driverName Name of the database driver + */ + private function loadDriver($driverName) + { + \nre\core\Driver::load($driverName); + } + + + /** + * Establish a connection to the database. + * + * @throws DatamodelException + * @param string $driverName Name of the database driver + * @param array $config Connection settings + */ + private function connect($driverName, $config) + { + $this->db = \nre\core\Driver::factory($driverName, $config); + } + + } + +?> diff --git a/models/MediaModel.inc b/models/MediaModel.inc new file mode 100644 index 00000000..031df753 --- /dev/null +++ b/models/MediaModel.inc @@ -0,0 +1,195 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with the Media-tables. + * + * @author Oliver Hanraths + */ + class MediaModel extends \hhu\z\Model + { + + + + + /** + * Construct a new MediaModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get a medium by its URL. + * + * @throws IdNotFoundException + * @param string $mediaURL URL-name of the Medium + * @return array Medium data + */ + public function getMediaByUrl($mediaUrl) + { + $data = $this->db->query( + 'SELECT id, name, url, description, mimetype '. + 'FROM media '. + 'WHERE url = ?', + 's', + $mediaUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($mediaUrl); + } + + + return $data[0]; + } + + + /** + * Get a medium by its ID. + * + * @throws IdNotFoundException + * @param int $mediaId ID of the Medium + * @return array Medium data + */ + public function getMediaById($mediaId) + { + $data = $this->db->query( + 'SELECT id, name, url, description, mimetype '. + 'FROM media '. + 'WHERE id = ?', + 'i', + $mediaId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($mediaId); + } + + + return $data[0]; + } + + + /** + * Get a Seminary medium by its URL. + * + * @throws IdNotFoundException + * @param int $seminaryId ID of the seminary + * @param string $seminaryMediaUrl URL-name of the Seminary medium + * @return array Seminary medium data + */ + public function getSeminaryMediaByUrl($seminaryId, $seminaryMediaUrl) + { + $data = $this->db->query( + 'SELECT id, name, url, description, mimetype '. + 'FROM seminarymedia '. + 'WHERE url = ?', + 's', + $seminaryMediaUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($seminaryMediaUrl); + } + + + return $data[0]; + } + + + /** + * Get a Seminary medium by its ID. + * + * @throws IdNotFoundException + * @param int $seminaryMediaId ID of the Seminary medium + * @return array Seminary medium data + */ + public function getSeminaryMediaById($mediaId) + { + $data = $this->db->query( + 'SELECT id, name, url, description, mimetype '. + 'FROM seminarymedia '. + 'WHERE id = ?', + 'i', + $mediaId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($mediaId); + } + + + return $data[0]; + } + + + /** + * Create a new Questsmedia by creating a new Seminarymedia and + * adding it to the list of Questsmedia. + * TODO Currently only temporary for easier data import. + */ + public function createQuestMedia($userId, $seminaryId, $filename, $description, $mimetype, $tmpFilename) + { + $uploadId = false; + $this->db->setAutocommit(false); + + try { + // Create database record + $this->db->query( + 'INSERT INTO seminarymedia '. + '(created_user_id, seminary_id, name, url, description, mimetype) '. + 'VALUES '. + '(?, ? ,? ,?, ?, ?)', + 'iissss', + $userId, + $seminaryId, + $filename, + \nre\core\Linker::createLinkParam($filename), + $description, + $mimetype + ); + $uploadId = $this->db->getInsertId(); + + $this->db->query( + 'INSERT INTO questsmedia '. + '(media_id, created_user_id) '. + 'VALUES '. + '(?, ?)', + 'ii', + $uploadId, + $userId + ); + + // Create filename + $filename = ROOT.DS.'seminarymedia'.DS.$uploadId; + if(!move_uploaded_file($tmpFilename, $filename)) + { + $this->db->rollback(); + $uploadId = false; + } + } + catch(\nre\exceptions\DatamodelException $e) { + $this->db->rollback(); + $this->db->setAutocommit(true); + } + + + $this->db->setAutocommit(true); + return $uploadId; + } + + } + +?> diff --git a/models/QuestgroupsModel.inc b/models/QuestgroupsModel.inc new file mode 100644 index 00000000..0f5c81a8 --- /dev/null +++ b/models/QuestgroupsModel.inc @@ -0,0 +1,787 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Questgroups-table. + * + * @author Oliver Hanraths + */ + class QuestgroupsModel extends \hhu\z\Model + { + /** + * Questgroup-status: Entered + * + * @var int; + */ + const QUESTGROUP_STATUS_ENTERED = 0; + + /** + * Required models + * + * @var array + */ + public $models = array('questgroupshierarchy', 'quests', 'questtexts'); + + + + + /** + * Construct a new QuestgroupsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get all Questgroups for a Questgroup hierarchy. + * + * @param int $hierarchyId ID of the Questgroup hierarchy to get Questgroups for + * @param int $parentQuestgroupId ID of the parent Questgroup hierarchy + * @return array Questgroups for the given hierarchy + */ + public function getQuestgroupsForHierarchy($hierarchyId, $parentQuestgroupId=null) + { + // Get Questgroups + $questgroups = array(); + if(is_null($parentQuestgroupId)) + { + $questgroups = $this->db->query( + 'SELECT questgroups.id, questgroups_questgroupshierarchy.questgroupshierarchy_id, questgroups_questgroupshierarchy.pos, questgroups.title, questgroups.url, questgroups.questgroupspicture_id '. + 'FROM questgroups_questgroupshierarchy '. + 'INNER JOIN questgroups ON questgroups.id = questgroups_questgroupshierarchy.questgroup_id '. + 'WHERE questgroups_questgroupshierarchy.questgroupshierarchy_id = ? AND questgroups_questgroupshierarchy.parent_questgroup_id IS NULL '. + 'ORDER BY questgroups_questgroupshierarchy.pos ASC', + 'i', + $hierarchyId + ); + } + else + { + $questgroups = $this->db->query( + 'SELECT questgroups.id, questgroups_questgroupshierarchy.questgroupshierarchy_id, questgroups_questgroupshierarchy.pos, questgroups.title, questgroups.url, questgroups.questgroupspicture_id '. + 'FROM questgroups_questgroupshierarchy '. + 'INNER JOIN questgroups ON questgroups.id = questgroups_questgroupshierarchy.questgroup_id '. + 'WHERE questgroups_questgroupshierarchy.questgroupshierarchy_id = ? AND questgroups_questgroupshierarchy.parent_questgroup_id = ? '. + 'ORDER BY questgroups_questgroupshierarchy.pos ASC', + 'ii', + $hierarchyId, $parentQuestgroupId + ); + } + + + // Return Questgroups + return $questgroups; + } + + + /** + * Get all Questgroups for a Seminary. + * + * @param int $seminaryId ID of Seminary + * @return array List of Questgroups + */ + public function getQuestgroupsForSeminary($seminaryId) + { + return $this->db->query( + 'SELECT id, title, url '. + 'FROM questgroups '. + 'WHERE seminary_id = ? '. + 'ORDER BY title ASC', + 'i', + $seminaryId + ); + } + + + /** + * Get a Questgroup by its ID. + * + * @throws IdNotFoundException + * @param int $questgroupId ID of a Questgroup + * @return array Questgroup data + */ + public function getQuestgroupById($questgroupId) + { + $data = $this->db->query( + 'SELECT id, title, url, questgroupspicture_id '. + 'FROM questgroups '. + 'WHERE questgroups.id = ?', + 'i', + $questgroupId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questgroupId); + } + + + return $data[0]; + } + + + /** + * Get a Questgroup by its URL. + * + * @throws IdNotFoundException + * @param int $seminaryId ID of the corresponding seminary + * @param string $questgroupURL URL-title of a Questgroup + * @return array Questgroup data + */ + public function getQuestgroupByUrl($seminaryId, $questgroupUrl) + { + $data = $this->db->query( + 'SELECT id, title, url, questgroupspicture_id '. + 'FROM questgroups '. + 'WHERE seminary_id = ? AND url = ?', + 'is', + $seminaryId, $questgroupUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questgroupUrl); + } + + + return $data[0]; + } + + + /** + * Get texts of a Questgroup. + * + * @param int $questgroupId ID of a Questgroup + * @return array Texts of this Questgroup + */ + public function getQuestgroupTexts($questgroupId) + { + return $this->db->query( + 'SELECT id, pos, text '. + 'FROM questgrouptexts '. + 'WHERE questgroup_id = ? '. + 'ORDER BY pos ASC', + 'i', + $questgroupId + ); + } + + + /** + * Get the first text of a Questgroup. + * + * @param int $questgroupId ID of a Questgroup + * @return string First text of this Questgroup or NULL + */ + public function getFirstQuestgroupText($questgroupId) + { + // Text of Questgroup itself + $questgroupTexts = $this->getQuestgroupTexts($questgroupId); + if(!empty($questgroupTexts)) { + return $questgroupTexts[0]['text']; + } + + // Text of first Quest + $quest = $this->Quests->getFirstQuestOfQuestgroup($questgroupId); + if(!is_null($quest)) + { + $questText = $this->Questtexts->getFirstQuestText($quest['id']); + if(!is_null($questText)) { + return $questText; + } + } + + // Text of ChildQuestgroups + $questgroupHierarchy = $this->Questgroupshierarchy->getHierarchyForQuestgroup($questgroupId); + $childQuestgroupshierarchy = $this->Questgroupshierarchy->getChildQuestgroupshierarchy($questgroupHierarchy['id']); + foreach($childQuestgroupshierarchy as &$hierarchy) + { + // Get Questgroups + $questgroups = $this->getQuestgroupsForHierarchy($hierarchy['id'], $questgroupId); + foreach($questgroups as &$group) + { + $childQuestgroupText = $this->getFirstQuestgroupText($group['id']); + if(!is_null($childQuestgroupText)) { + return $childQuestgroupText; + } + } + } + + + // No text found + return null; + } + + + /** + * Get the next Questgroup. + * + * Determine the next Questgroup. If there is no next Questgroup + * on the same level as the given Quest then the followed-up + * Questgroup from a higher hierarchy level is returned. + * + * @param int $questgroupId ID of Questgroup to get next Questgroup of + * @return array Questgroup data + */ + public function getNextQuestgroup($questgroupId) + { + $currentQuestgroup = $this->getQuestgroupById($questgroupId); + $currentQuestgroup['hierarchy'] = $this->Questgroupshierarchy->getHierarchyForQuestgroup($currentQuestgroup['id']); + if(empty($currentQuestgroup['hierarchy'])) { + return null; + } + + $nextQuestgroup = $this->_getNextQuestgroup($currentQuestgroup['hierarchy']['parent_questgroup_id'], $currentQuestgroup['hierarchy']['questgroup_pos']); + if(is_null($nextQuestgroup) && !is_null($currentQuestgroup['hierarchy']['parent_questgroup_id'])) { + $nextQuestgroup = $this->getNextQuestgroup($currentQuestgroup['hierarchy']['parent_questgroup_id']); + } + + + return $nextQuestgroup; + } + + + /** + * Get the previous Questgroup. + * + * Determine the previous Questgroup. If there is no previous + * Questgroup on the same level as the given Quest then the + * followed-up Questgroup from a higher hierarchy level is + * returned. + * + * @param int $questgroupId ID of Questgroup to get previous Questgroup of + * @return array Questgroup data + */ + public function getPreviousQuestgroup($questgroupId) + { + $currentQuestgroup = $this->getQuestgroupById($questgroupId); + $currentQuestgroup['hierarchy'] = $this->Questgroupshierarchy->getHierarchyForQuestgroup($currentQuestgroup['id']); + if(empty($currentQuestgroup['hierarchy'])) { + return null; + } + + $previousQuestgroup = $this->_getPreviousQuestgroup($currentQuestgroup['hierarchy']['parent_questgroup_id'], $currentQuestgroup['hierarchy']['questgroup_pos']); + if(is_null($previousQuestgroup) && !is_null($currentQuestgroup['hierarchy']['parent_questgroup_id'])) { + $previousQuestgroup = $this->getPreviousQuestgroup($currentQuestgroup['hierarchy']['parent_questgroup_id']); + } + + + return $previousQuestgroup; + } + + + /** + * Mark a Questgroup as entered for a Character. + * + * @param int $questId ID of Quest to mark as entered + * @param int $characterId ID of Character that entered the Quest + */ + public function setQuestgroupEntered($questgroupId, $characterId) + { + $this->setQuestgroupStatus($questgroupId, $characterId, self::QUESTGROUP_STATUS_ENTERED, false); + } + + + /** + * Determine if the given Character has entered a Questgroup. + * + * @param int $questgroupId ID of Questgroup to check + * @param int $characterId ID of Character to check + * @result boolean Whether Character has entered the Questgroup or not + */ + public function hasCharacterEnteredQuestgroup($questgroupId, $characterId) + { + $count = $this->db->query( + 'SELECT count(id) AS c '. + 'FROM questgroups_characters '. + 'WHERE questgroup_id = ? AND character_id = ? AND status IN (?)', + 'iii', + $questgroupId, + $characterId, + self::QUESTGROUP_STATUS_ENTERED + ); + + + return (!empty($count) && intval($count[0]['c']) > 0); + } + + + /** + * Determine if the given Character has solved the Quests form + * this Questgroup. + * + * @param int $questgroupId ID of Questgroup to check + * @param int $characterId ID of Character to check + * @result boolean Whether Character has solved the Questgroup or not + */ + public function hasCharacterSolvedQuestgroup($questgroupId, $characterId) + { + // Get data of Questgroup + $questgroup = $this->getQuestgroupById($questgroupId); + + // Chack all Quests + $currentQuest = $this->Quests->getFirstQuestOfQuestgroup($questgroup['id']); + if(!is_null($currentQuest)) + { + if(!$this->Quests->hasCharacterSolvedQuest($currentQuest['id'], $characterId)) { + return false; + } + + // Get next Quests + $nextQuests = !is_null($currentQuest) ? $this->Quests->getNextQuests($currentQuest['id']) : null; + while(!is_null($currentQuest) && !empty($nextQuests)) + { + // Get choosed Quest + $currentQuest = null; + foreach($nextQuests as &$nextQuest) { + if($this->Quests->hasCharacterEnteredQuest($nextQuest['id'], $characterId)) { + $currentQuest = $nextQuest; + } + } + + // Check Quest + if(is_null($currentQuest)) { + return false; + } + + // Check status + if(!$this->Quests->hasCharacterSolvedQuest($currentQuest['id'], $characterId)) { + return false; + } + + $nextQuests = !is_null($currentQuest) ? $this->Quests->getNextQuests($currentQuest['id']) : null; + } + } + + // Check all child Questgroups + $questgroup['hierarchy'] = $this->Questgroupshierarchy->getHierarchyForQuestgroup($questgroup['id']); + if(!empty($questgroup['hierarchy'])) + { + $childQuestgroupshierarchy = $this->Questgroupshierarchy->getChildQuestgroupshierarchy($questgroup['hierarchy']['id']); + foreach($childQuestgroupshierarchy as &$hierarchy) + { + // Get Questgroups + $questgroups = $this->getQuestgroupsForHierarchy($hierarchy['id'], $questgroup['id']); + foreach($questgroups as &$group) { + if(!$this->hasCharacterSolvedQuestgroup($group['id'], $characterId)) { + return false; + } + } + } + } + + + return true; + } + + + /** + * Get all related Questgroups of a Questtext. + * + * @param int $questtextId ID of the Questtext + * @return array Related Questgroups for the Questtext + */ + public function getRelatedQuestsgroupsOfQuesttext($questtextId) + { + return $this->db->query( + 'SELECT questgroups.id, questgroups_questtexts.questtext_id, questgroups.title, questgroups.url, questgroups_questtexts.entry_text '. + 'FROM questgroups_questtexts '. + 'INNER JOIN questgroups ON questgroups.id = questgroups_questtexts.questgroup_id '. + 'WHERE questgroups_questtexts.questtext_id = ?', + 'i', + $questtextId + ); + } + + + /** + * Get all related Questgroups of a Quest. + * + * @param int $questId ID of the Quest + * @return array Related Quests for the Quest + */ + public function getRelatedQuestsgroupsOfQuest($questId) + { + return $this->db->query( + 'SELECT questgroups_questtexts.questgroup_id AS id '. + 'FROM quests '. + 'INNER JOIN questtexts ON questtexts.quest_id = quests.id '. + 'INNER JOIN questgroups_questtexts ON questgroups_questtexts.questtext_id = questtexts.id '. + 'WHERE quests.id = ?', + 'i', + $questId + ); + + } + + + /** + * Get all related Questgroups of a Questgroup. + * + * @param int $questgroupId ID of the Questgroup + * @return array Related Questgroups for the Questgroup + */ + public function getRelatedQuestsgroupsOfQuestgroup($questgroupId) + { + return $this->db->query( + 'SELECT DISTINCT questgroups_questtexts.questgroup_id AS id '. + 'FROM questgroups '. + 'INNER JOIN quests ON quests.questgroup_id = questgroups.id '. + 'INNER JOIN questtexts ON questtexts.quest_id = quests.id '. + 'INNER JOIN questgroups_questtexts ON questgroups_questtexts.questtext_id = questtexts.id '. + 'WHERE questgroups.id = ?', + 'i', + $questgroupId + ); + } + + + /** + * Calculate cumulated data for a Questgroup, its + * sub-Questgroups and all its Quests. + * + * @param int $questgroupId ID of Questgroup + * @param int $characterId ID of Character + * @param array $calculatedQuests IDs of already calculated Quests + * @return array Cumulated data for Questgroup + */ + public function getCumulatedDataForQuestgroup($questgroupId, $characterId=null, &$calculatedQuests=array()) + { + // Cumulated data + $data = array( + 'xps' => 0, + 'character_xps' => 0 + ); + + // Current Questgroup + $questgroup = $this->getQuestgroupById($questgroupId); + + // Quests of current Questgroup + $quest = $this->Quests->getFirstQuestOfQuestgroup($questgroup['id']); + if(!is_null($quest)) + { + $questData = $this->getCumulatedDataForQuest($quest, $characterId, $calculatedQuests); + $data['xps'] += $questData['xps']; + $data['character_xps'] += $questData['character_xps']; + } + + // XPs of child Questgroups + $questgroupHierarchy = $this->Questgroupshierarchy->getHierarchyForQuestgroup($questgroup['id']); + if(!empty($questgroupHierarchy)) + { + $childQuestgroupshierarchy = $this->Questgroupshierarchy->getChildQuestgroupshierarchy($questgroupHierarchy['id']); + foreach($childQuestgroupshierarchy as &$hierarchy) + { + $questgroups = $this->getQuestgroupsForHierarchy($hierarchy['id'], $questgroup['id']); + foreach($questgroups as &$questgroup) + { + $childData = $this->getCumulatedDataForQuestgroup($questgroup['id'], $characterId, $calculatedQuests); + $data['xps'] += $childData['xps']; + $data['character_xps'] += $childData['character_xps']; + } + } + } + + + // Return cumulated data + return $data; + } + + + /** + * Calculate cumulated data of the given Quest, its following + * Quests and its related Questgroups. + * + * @param array $quest Quest data + * @param int $characterId ID of Character + * @param array $calculatedQuests IDs of already calculated Quests + * @return array Cumulated data for Quest + */ + public function getCumulatedDataForQuest($quest, $characterId=null, &$calculatedQuests=array()) + { + // Cumulated data + $data = array( + 'xps' => $quest['xps'], + 'character_xps' => (!is_null($characterId) && $this->Quests->hasCharacterSolvedQuest($quest['id'], $characterId)) ? $quest['xps'] : 0 + ); + + // Related Questgroups + $relatedQuestgroups = $this->getRelatedQuestsgroupsOfQuest($quest['id']); + foreach($relatedQuestgroups as &$relatedQuestgroup) + { + $relatedData = $this->getCumulatedDataForQuestgroup($relatedQuestgroup['id'], $characterId, $calculatedQuests); + $data['xps'] += $relatedData['xps']; + $data['character_xps'] += $relatedData['character_xps']; + } + + // Next Quests + $nextQuests = $this->Quests->getNextQuests($quest['id']); + $allNextData = array( + 'xps' => array(0), + 'character_xps' => array(0), + ); + foreach($nextQuests as &$nextQuest) + { + if(!in_array($nextQuest['id'], $calculatedQuests)) + { + $nextData = $this->getCumulatedDataForQuest($nextQuest, $characterId, $calculatedQuests); + $allNextData['xps'][] = $nextData['xps']; + $allNextData['character_xps'][] = $nextData['character_xps']; + $calculatedQuests[] = $nextQuest['id']; + } + } + $data['xps'] += max($allNextData['xps']); + $data['character_xps'] += max($allNextData['character_xps']); + + + // Return cumulated data + return $data; + } + + + /** + * Summarize XPs of all Quests for a Questgroup and its + * sub-Questgroups solved by a Character. + * + * @param int $questgroupId ID of Questgroup + * @param int $characterId ID of Character + * @return int Sum of XPs + */ + public function getAchievedXPsForQuestgroup($questgroupId, $characterId) + { + // Sum of XPs + $xps = 0; + + // Current Questgroup + $questgroup = $this->getQuestgroupById($questgroupId); + + // Quests of current Questgroup + $quest = $this->Quests->getFirstQuestOfQuestgroup($questgroup['id']); + if(!is_null($quest)) { + $xps += $this->getAchievedXPsForQuest($quest, $characterId); + } + + // XPs of child Questgroups + $questgroupHierarchy = $this->Questgroupshierarchy->getHierarchyForQuestgroup($questgroup['id']); + if(empty($questgroupHierarchy)) { + return $xps; + } + $childQuestgroupshierarchy = $this->Questgroupshierarchy->getChildQuestgroupshierarchy($questgroupHierarchy['id']); + foreach($childQuestgroupshierarchy as &$hierarchy) + { + $questgroups = $this->getQuestgroupsForHierarchy($hierarchy['id'], $questgroup['id']); + foreach($questgroups as &$questgroup) { + $xps += $this->getAchievedXPsForQuestgroup($questgroup['id'], $characterId); + } + } + + + // Return summarized XPs + return $xps; + } + + + /** + * Summarize XPs of the given Quest, its following Quests and + * its related Questgroups solved by a Character. + * + * @param int $quest Quest to summarize XPs for + * @param int $characterId ID of Character + * @return int Sum of XPs + */ + public function getAchievedXPsForQuest($quest, $characterId) + { + $xps = 0; + + // XPs for the given Quest + if($this->Quests->hasCharacterSolvedQuest($quest['id'], $characterId)) + { + $xps += $quest['xps']; + + // Next Quests + $nextQuests = $this->Quests->getNextQuests($quest['id']); + foreach($nextQuests as &$nextQuest) + { + if($this->Quests->hasCharacterEnteredQuest($nextQuest['id'], $characterId)) + { + $xps += $this->getAchievedXPsForQuest($nextQuest, $characterId); + break; + } + } + } + + // Related Questgroups + $relatedQuestgroups = $this->getRelatedQuestsgroupsOfQuest($quest['id']); + foreach($relatedQuestgroups as &$relatedQuestgroup) { + $xps += $this->getAchievedXPsForQuestgroup($relatedQuestgroup['id'], $characterId); + } + + + // Return summarized XPs + return $xps; + } + + + /** + * Create a new Questgroup. + * + * @param int $userId User-ID that creates the new character + * @param int $seminaryId ID of Seminary + * @param string $title Title for new Questgroup + * @return int ID of new Questgroup + */ + public function createQuestgroup($userId, $seminaryId, $title) + { + $this->db->query( + 'INSERT INTO questgroups '. + '(created_user_id, seminary_id, title, url) '. + 'VALUES '. + '(?, ?, ?, ?)', + 'iiss', + $userId, + $seminaryId, + $title, + \nre\core\Linker::createLinkParam($title) + ); + + + return $this->db->getInsertId(); + } + + + + + /** + * Get the next (direct) Questgroup. + * + * @param int $parentQuestgroupId ID of parent Questgroup to get next Questgroup of + * @param int $questgroupPos Position of Questgroup to get next Questgroup of + * @return array Data of next Questgroup or NULL + */ + private function _getNextQuestgroup($parentQuestgroupId, $questgroupPos) + { + if(!is_null($parentQuestgroupId)) + { + $data = $this->db->query( + 'SELECT * '. + 'FROM questgroups_questgroupshierarchy '. + 'INNER JOIN questgroups ON questgroups.id = questgroups_questgroupshierarchy.questgroup_id '. + 'WHERE parent_questgroup_id = ? AND pos = ? + 1', + 'ii', + $parentQuestgroupId, $questgroupPos + ); + } + else + { + $data = $this->db->query( + 'SELECT * '. + 'FROM questgroups_questgroupshierarchy '. + 'INNER JOIN questgroups ON questgroups.id = questgroups_questgroupshierarchy.questgroup_id '. + 'WHERE parent_questgroup_id IS NULL AND pos = ? + 1', + 'i', + $questgroupPos + ); + } + if(empty($data)) { + return null; + } + + + return $data[0]; + } + + + /** + * Get the previous (direct) Questgroup. + * + * @param int $parentQuestgroupId ID of parent Questgroup to get previous Questgroup of + * @param int $questgroupPos Position of Questgroup to get previous Questgroup of + * @return array Data of previous Questgroup or NULL + */ + private function _getPreviousQuestgroup($parentQuestgroupId, $questgroupPos) + { + if(!is_null($parentQuestgroupId)) + { + $data = $this->db->query( + 'SELECT * '. + 'FROM questgroups_questgroupshierarchy '. + 'INNER JOIN questgroups ON questgroups.id = questgroups_questgroupshierarchy.questgroup_id '. + 'WHERE parent_questgroup_id = ? AND pos = ? - 1', + 'ii', + $parentQuestgroupId, $questgroupPos + ); + } + else + { + $data = $this->db->query( + 'SELECT * '. + 'FROM questgroups_questgroupshierarchy '. + 'INNER JOIN questgroups ON questgroups.id = questgroups_questgroupshierarchy.questgroup_id '. + 'WHERE parent_questgroup_id IS NULL AND pos = ? - 1', + 'i', + $questgroupPos + ); + } + if(empty($data)) { + return null; + } + + + return $data[0]; + } + + + /** + * Mark a Questgroup for a Character. + * + * @param int $questgroupId ID of Questgroup to mark + * @param int $characterId ID of Character to mark the Questgroup for + * @param int $status Questgroup status to mark + * @param boolean $repeated Insert although status is already set for this Questgroup and Character + */ + private function setQuestgroupStatus($questgroupId, $characterId, $status, $repeated=true) + { + // Check if status is already set + if(!$repeated) + { + $count = $this->db->query( + 'SELECT count(*) AS c '. + 'FROM questgroups_characters '. + 'WHERE questgroup_id = ? AND character_id = ? AND status = ?', + 'iii', + $questgroupId, + $characterId, + $status + ); + if(!empty($count) && intval($count[0]['c']) > 0) { + return; + } + } + + // Set status + $this->db->query( + 'INSERT INTO questgroups_characters '. + '(questgroup_id, character_id, status) '. + 'VALUES '. + '(?, ?, ?) ', + 'iii', + $questgroupId, + $characterId, + $status + ); + } + + } + +?> diff --git a/models/QuestgroupshierarchyModel.inc b/models/QuestgroupshierarchyModel.inc new file mode 100644 index 00000000..c1dae116 --- /dev/null +++ b/models/QuestgroupshierarchyModel.inc @@ -0,0 +1,128 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Questgroupshierarchy-table. + * + * @author Oliver Hanraths + */ + class QuestgroupshierarchyModel extends \hhu\z\Model + { + + + + + /** + * Construct a new QuestgroupshierarchyModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get a Questgroup hierarchy by its ID. + * + * throws IdNotFoundException + * @param int $questgroupshierarchyId ID of a Questgroup hierarchy + * @return array Questgroup hierarchy + */ + public function getHierarchyById($questgroupshierarchyId) + { + $data = $this->db->query( + 'SELECT id, seminary_id, parent_questgroupshierarchy_id, pos, title_singular, title_plural, url '. + 'FROM questgroupshierarchy '. + 'WHERE questgroupshierarchy.id = ?', + 'i', + $questgroupshierarchyId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questgroupshierarchyId); + } + + + return $data[0]; + } + + + /** + * Get the toplevel hierarchy entries of a Seminary. + * + * @param int $seminaryId ID of the seminary to get hierarchy for + * @return array Toplevel hierarchy + */ + public function getHierarchyOfSeminary($seminaryId) + { + return $this->db->query( + 'SELECT id, seminary_id, parent_questgroupshierarchy_id, pos, title_singular, title_plural, url '. + 'FROM questgroupshierarchy '. + 'WHERE '. + 'questgroupshierarchy.seminary_id = ? AND '. + 'questgroupshierarchy.parent_questgroupshierarchy_id IS NULL '. + 'ORDER BY questgroupshierarchy.pos ASC', + 'i', + $seminaryId + ); + } + + + /** + * Get the Questgroup-Hierarchy for a Questgroup. + * + * @param int $questgroupId ID of Questgroup + * @return array Hierarchy for Questgroup + */ + public function getHierarchyForQuestgroup($questgroupId) + { + $data = $this->db->query( + 'SELECT questgroups_questgroupshierarchy.parent_questgroup_id, questgroups_questgroupshierarchy.pos AS questgroup_pos, questgroupshierarchy.id, questgroupshierarchy.seminary_id, questgroupshierarchy.parent_questgroupshierarchy_id, questgroupshierarchy.pos, questgroupshierarchy.title_singular, questgroupshierarchy.title_plural, questgroupshierarchy.url '. + 'FROM questgroups_questgroupshierarchy '. + 'INNER JOIN questgroupshierarchy ON questgroupshierarchy.id = questgroups_questgroupshierarchy.questgroupshierarchy_id '. + 'WHERE questgroups_questgroupshierarchy.questgroup_id = ?', + 'i', + $questgroupId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get the child hierarchy entries of a Questgroup hierarchy. + * + * @param int $questgroupshierarchyId ID of a Questgroup hierarchy + * @return array Child Questgroup hierarchy entries + */ + public function getChildQuestgroupshierarchy($questgroupshierarchyId) + { + return $this->db->query( + 'SELECT id, seminary_id, parent_questgroupshierarchy_id, pos, title_singular, title_plural, url '. + 'FROM questgroupshierarchy '. + 'WHERE questgroupshierarchy.parent_questgroupshierarchy_id = ? '. + 'ORDER BY questgroupshierarchy.pos ASC', + 'i', + $questgroupshierarchyId + ); + } + + } + +?> diff --git a/models/QuestsModel.inc b/models/QuestsModel.inc new file mode 100644 index 00000000..f7141efc --- /dev/null +++ b/models/QuestsModel.inc @@ -0,0 +1,488 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Quests-table. + * + * @author Oliver Hanraths + */ + class QuestsModel extends \hhu\z\Model + { + /** + * Quest-status: Entered + * + * @var int; + */ + const QUEST_STATUS_ENTERED = 0; + /** + * Quest-status: submitted + * + * @var int; + */ + const QUEST_STATUS_SUBMITTED = 1; + /** + * Quest-status: Unsolved + * + * @var int; + */ + const QUEST_STATUS_UNSOLVED = 2; + /** + * Quest-status: Solved + * + * @var int; + */ + const QUEST_STATUS_SOLVED = 3; + + + + + /** + * Construct a new QuestsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get a Quest and its data by its URL. + * + * @throws IdNotFoundException + * @param int $seminaryId ID of the corresponding Seminary + * @param int $questgroupId ID of the corresponding Questgroup + * @param string $questURL URL-title of a Quest + * @return array Quest data + */ + public function getQuestByUrl($seminaryId, $questgroupId, $questUrl) + { + $data = $this->db->query( + 'SELECT quests.id, quests.questgroup_id, quests.questtype_id, quests.title, quests.url, quests.xps, quests.entry_text, quests.task, quests.wrong_text, quests.questsmedia_id '. + 'FROM quests '. + 'LEFT JOIN questgroups ON questgroups.id = quests.questgroup_id '. + 'WHERE questgroups.seminary_id = ? AND questgroups.id = ? AND quests.url = ?', + 'iis', + $seminaryId, $questgroupId, $questUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questUrl); + } + + + return $data[0]; + } + + + /** + * Get a Quest and its data by its ID. + * + * @throws IdNotFoundException + * @param string $questId ID of a Quest + * @return array Quest data + */ + public function getQuestById($questId) + { + $data = $this->db->query( + 'SELECT quests.id, quests.questgroup_id, quests.questtype_id, quests.title, quests.url, quests.xps, quests.entry_text, quests.task, quests.wrong_text, quests.questsmedia_id '. + 'FROM quests '. + 'LEFT JOIN questgroups ON questgroups.id = quests.questgroup_id '. + 'WHERE quests.id = ?', + 'i', + $questId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questId); + } + + + return $data[0]; + } + + + /** + * Get the first Quest of a Qusetgroup. + * + * @param int $questId ID of Questgroup + * @return array Data of first Quest + */ + public function getFirstQuestOfQuestgroup($questgroupId) + { + $data = $this->db->query( + 'SELECT id, questtype_id, title, url, xps, task '. + 'FROM quests '. + 'LEFT JOIN quests_previousquests ON quests_previousquests.quest_id = quests.id '. + 'WHERE questgroup_id = ? AND quests_previousquests.previous_quest_id IS NULL', + 'i', + $questgroupId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get Quests that follow-up a Quest. + * + * @param int $questId ID of Quest to get next Quests of + * @return array Quests data + */ + public function getNextQuests($questId) + { + return $this->db->query( + 'SELECT quests.id, quests.questtype_id, quests.title, quests.url, quests.xps, quests.entry_text, quests.task, questgroups.title AS questgroup_title, questgroups.url AS questgroup_url '. + 'FROM quests_previousquests '. + 'INNER JOIN quests ON quests.id = quests_previousquests.quest_id '. + 'INNER JOIN questgroups ON questgroups.id = quests.questgroup_id '. + 'WHERE quests_previousquests.previous_quest_id = ?', + 'i', + $questId + ); + } + + + /** + * Get Quests that the given Quests follows-up to. + * + * @param int $questId ID of Quest to get previous Quests of + * @return array Quests data + */ + public function getPreviousQuests($questId) + { + return $this->db->query( + 'SELECT quests.id, quests.title, quests.url, quests.entry_text, questgroups.title AS questgroup_title, questgroups.url AS questgroup_url '. + 'FROM quests_previousquests '. + 'INNER JOIN quests ON quests.id = quests_previousquests.previous_quest_id '. + 'INNER JOIN questgroups ON questgroups.id = quests.questgroup_id '. + 'WHERE quests_previousquests.quest_id = ?', + 'i', + $questId + ); + } + + + /** + * Mark a Quest as entered for a Character. + * + * @param int $questId ID of Quest to mark as entered + * @param int $characterId ID of Character that entered the Quest + */ + public function setQuestEntered($questId, $characterId) + { + $this->setQuestStatus($questId, $characterId, self::QUEST_STATUS_ENTERED, false); + } + + + /** + * Mark a Quest as submitted for a Character. + * + * @param int $questId ID of Quest to mark as unsolved + * @param int $characterId ID of Character that unsolved the Quest + */ + public function setQuestSubmitted($questId, $characterId) + { + $this->setQuestStatus($questId, $characterId, self::QUEST_STATUS_SUBMITTED); + } + + + /** + * Mark a Quest as unsolved for a Character. + * + * @param int $questId ID of Quest to mark as unsolved + * @param int $characterId ID of Character that unsolved the Quest + */ + public function setQuestUnsolved($questId, $characterId) + { + $this->setQuestStatus($questId, $characterId, self::QUEST_STATUS_UNSOLVED); + } + + + /** + * Mark a Quest as solved for a Character. + * + * @param int $questId ID of Quest to mark as solved + * @param int $characterId ID of Character that solved the Quest + */ + public function setQuestSolved($questId, $characterId) + { + $this->setQuestStatus($questId, $characterId, self::QUEST_STATUS_SOLVED, false); + } + + + /** + * Determine if the given Character has entered a Quest. + * + * @param int $questId ID of Quest to check + * @param int $characterId ID of Character to check + * @result boolean Whether Character has entered the Quest or not + */ + public function hasCharacterEnteredQuest($questId, $characterId) + { + $count = $this->db->query( + 'SELECT count(id) AS c '. + 'FROM quests_characters '. + 'WHERE quest_id = ? AND character_id = ? AND status IN (?,?,?)', + 'iiiii', + $questId, + $characterId, + self::QUEST_STATUS_ENTERED, self::QUEST_STATUS_SOLVED, self::QUEST_STATUS_UNSOLVED + ); + + + return (!empty($count) && intval($count[0]['c']) > 0); + } + + + /** + * Determine if the given Character has tried to solve a Quest. + * + * @param int $questId ID of Quest to check + * @param int $characterId ID of Character to check + * @result boolean Whether Character has tried to solved the Quest or not + */ + public function hasCharacterTriedQuest($questId, $characterId) + { + $count = $this->db->query( + 'SELECT count(id) AS c '. + 'FROM quests_characters '. + 'WHERE quest_id = ? AND character_id = ? AND status IN (?,?)', + 'iiii', + $questId, + $characterId, + self::QUEST_STATUS_SOLVED, self::QUEST_STATUS_UNSOLVED + ); + + + return (!empty($count) && intval($count[0]['c']) > 0); + } + + + /** + * Determine if the given Character has solved the given Quest. + * + * @param int $questId ID of Quest to check + * @param int $characterId ID of Character to check + * @result boolean Whether Character has solved the Quest or not + */ + public function hasCharacterSolvedQuest($questId, $characterId) + { + $count = $this->db->query( + 'SELECT count(id) AS c '. + 'FROM quests_characters '. + 'WHERE quest_id = ? AND character_id = ? AND status = ?', + 'iii', + $questId, + $characterId, + self::QUEST_STATUS_SOLVED + ); + + + return (!empty($count) && intval($count[0]['c']) > 0); + } + + + /** + * Get the last Quests for a Character. + * + * @param int $characterId ID of Character + * @retrun array Quest data + */ + public function getLastQuestForCharacter($characterId) + { + $data = $this->db->query( + 'SELECT quests.id, quests.questgroup_id, quests.questtype_id, quests.title, quests.url, quests.xps, quests.task, quests.wrong_text, quests.questsmedia_id '. + 'FROM quests_characters '. + 'LEFT JOIN quests ON quests.id = quests_characters.quest_id '. + 'WHERE quests_characters.character_id = ? AND quests_characters.status IN (?, ?, ?) '. + 'ORDER BY quests_characters.created desc '. + 'LIMIT 1', + 'iiii', + $characterId, + self::QUEST_STATUS_ENTERED, self::QUEST_STATUS_SUBMITTED, self::QUEST_STATUS_SOLVED + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get all Quests for a Seminary. + * + * @param int $seminaryId ID of Seminary + * @return array Quests for this Seminary + */ + public function getQuestsForSeminary($seminaryId) + { + return $this->db->query( + 'SELECT DISTINCT quests.id, quests.questgroup_id, quests.questtype_id, quests.title, quests.url, quests.xps, quests.task, quests.wrong_text, quests.questsmedia_id '. + 'FROM questgroups '. + 'INNER JOIN quests ON quests.questgroup_id = questgroups.id '. + 'WHERE questgroups.seminary_id = ?', + 'i', + $seminaryId + ); + } + + + /** + * Get all Quests that are linked to a Questtopic. + * + * @param int $questtopicId ID of Questtopic + * @return array Quests for this Questtopic + */ + public function getQuestsForQuesttopic($questtopicId) + { + return $this->db->query( + 'SELECT DISTINCT quests.id, quests.questgroup_id, quests.questtype_id, quests.title, quests.url, quests.xps, quests.task, quests.wrong_text, quests.questsmedia_id '. + 'FROM quests_questsubtopics '. + 'INNER JOIN questsubtopics ON questsubtopics.id = quests_questsubtopics.questsubtopic_id '. + 'INNER JOIN quests ON quests.id = quests_questsubtopics.quest_id '. + 'WHERE questsubtopics.questtopic_id = ?', + 'i', + $questtopicId + ); + } + + + + + /** + * Mark a Quest for a Character. + * + * @param int $questId ID of Quest to mark + * @param int $characterId ID of Character to mark the Quest for + * @param int $status Quest status to mark + * @param boolean $repeated Insert although status is already set for this Quest and Character + */ + private function setQuestStatus($questId, $characterId, $status, $repeated=true) + { + // Check if status is already set + if(!$repeated) + { + $count = $this->db->query( + 'SELECT count(*) AS c '. + 'FROM quests_characters '. + 'WHERE quest_id = ? AND character_id = ? AND status = ?', + 'iii', + $questId, + $characterId, + $status + ); + if(!empty($count) && intval($count[0]['c']) > 0) { + return; + } + } + + // Set status + $this->db->query( + 'INSERT INTO quests_characters '. + '(quest_id, character_id, status) '. + 'VALUES '. + '(?, ?, ?) ', + 'iii', + $questId, + $characterId, + $status + ); + } + + + /** + * Get the last status of a Quest for a Character. + * + * @param int $questId ID of Quest + * @param int $characterId ID of Character to get status for + * @return int Last status + */ + public function getLastQuestStatus($questId, $characterId) + { + $data = $this->db->query( + 'SELECT id, created, status '. + 'FROM quests_characters '. + 'WHERE quest_id = ? AND character_id = ? '. + 'ORDER BY created DESC '. + 'LIMIT 1', + 'ii', + $questId, $characterId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Create a new Quest. + * + * @param int $userId User-ID that creates the new character + * @param string $name Name for new Quest + * @param int $questgroupId ID of Questgroup + * @param int $questtypeId ID of Questtype + * @param int $xps XPs for new Quest + * @param string $entrytext Entrytext for new Quest + * @param string $wrongtext Wrongtext for new Quest + * @param string $task Task for new Quest + * @return int ID of new Quest + */ + public function createQuest($userId, $name, $questgroupId, $questtypeId, $xps, $entrytext, $wrongtext, $task) + { + $this->db->query( + 'INSERT INTO quests '. + '(created_user_id, questgroup_id, questtype_id, title, url, xps, entry_text, wrong_text, task) '. + 'VALUES '. + '(?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'iiississs', + $userId, $questgroupId, $questtypeId, + $name, \nre\core\Linker::createLinkParam($name), + $xps, $entrytext, $wrongtext, $task + ); + + + return $this->db->getInsertId(); + } + + + /** + * Set the media for a Quest. + * + * @param int $questId ID of Quest to set media for + * @param int $questmediaId ID of Questsmedia to set + */ + public function setQuestmedia($questId, $questsmediaId) + { + $this->db->query( + 'UPDATE quests '. + 'SET questsmedia_id = ? '. + 'WHERE id = ?', + 'ii', + $questsmediaId, + $questId + ); + } + + } + +?> diff --git a/models/QuesttextsModel.inc b/models/QuesttextsModel.inc new file mode 100644 index 00000000..0cd931f1 --- /dev/null +++ b/models/QuesttextsModel.inc @@ -0,0 +1,238 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Questtexts-table. + * + * @author Oliver Hanraths + */ + class QuesttextsModel extends \hhu\z\Model + { + + + + + /** + * Construct a new QuesttextsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get the first text of a Quest. + * + * @param int $questId ID of a Quest + * @return string First text of this Quest or NULL + */ + public function getFirstQuestText($questId) + { + $prolog = $this->getQuesttextsOfQuest($questId, 'Prolog'); + if(!empty($prolog)) { + return $prolog[0]['text']; + } + + + return null; + } + + + /** + * Get all Questtexts for a Quest. + * + * @param int $questId ID of the Quest + * @param string $questtexttypeUrl URL of the Questtexttype + * @return array All Questtexts for a Quest + */ + public function getQuesttextsOfQuest($questId, $questtexttypeUrl=null) + { + if(is_null($questtexttypeUrl)) + { + return $this->db->query( + 'SELECT questtexts.id, questtexts.text, questtexts.pos, questtexts.out_text, questtexts.abort_text, questtexts.questsmedia_id, questtexttypes.id AS type_id, questtexttypes.type, questtexttypes.url AS type_url '. + 'FROM questtexts '. + 'LEFT JOIN questtexttypes ON questtexttypes.id = questtexts.questtexttype_id '. + 'WHERE questtexts.quest_id = ?', + 'i', + $questId + ); + } + else + { + return $this->db->query( + 'SELECT questtexts.id, questtexts.text, questtexts.pos, questtexts.out_text, questtexts.abort_text, questtexts.questsmedia_id, questtexttypes.id AS type_id, questtexttypes.type, questtexttypes.url AS type_url '. + 'FROM questtexts '. + 'LEFT JOIN questtexttypes ON questtexttypes.id = questtexts.questtexttype_id '. + 'WHERE questtexts.quest_id = ? and questtexttypes.url = ?', + 'is', + $questId, + $questtexttypeUrl + ); + } + } + + + /** + * Get count of Questtexts for a Quest. + * + * @param int $questId ID of the Quest + * @param string $questtexttypeUrl URL of the Questtexttype + * @return int Amount of Questtexts for a Quest + */ + public function getQuesttextCountOfQuest($questId, $questtexttypeUrl=null) + { + if(is_null($questtexttypeUrl)) + { + $data = $this->db->query( + 'SELECT count(questtexts.id) AS c '. + 'FROM questtexts '. + 'LEFT JOIN questtexttypes ON questtexttypes.id = questtexts.questtexttype_id '. + 'WHERE questtexts.quest_id = ?', + 'i', + $questId + ); + } + else + { + $data = $this->db->query( + 'SELECT count(questtexts.id) AS c '. + 'FROM questtexts '. + 'LEFT JOIN questtexttypes ON questtexttypes.id = questtexts.questtexttype_id '. + 'WHERE questtexts.quest_id = ? and questtexttypes.url = ?', + 'is', + $questId, + $questtexttypeUrl + ); + } + if(!empty($data)) { + return $data[0]['c']; + } + + + return 0; + } + + + /** + * Get corresponding Questtext for a Sidequest. + * + * @throws IdNotFoundException + * @param int $sidequestId ID of the Sidequest to get the Questtext for + * @param array Questtext data + */ + public function getRelatedQuesttextForQuestgroup($questgroupId) + { + $data = $this->db->query( + 'SELECT questtexts.id, questtexts.text, questtexts.pos, questtexts.quest_id, questtexttypes.id AS type_id, questtexttypes.type, questtexttypes.url AS type_url '. + 'FROM questgroups_questtexts '. + 'LEFT JOIN questtexts ON questtexts.id = questgroups_questtexts.questtext_id '. + 'LEFT JOIN questtexttypes ON questtexttypes.id = questtexts.questtexttype_id '. + 'WHERE questgroups_questtexts.questgroup_id = ?', + 'i', + $questgroupId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get all registered Questtexttypes. + * + * @return array Registered Questtexttypes + */ + public function getQuesttexttypes() + { + return $this->db->query( + 'SELECT id, type, url '. + 'FROM questtexttypes' + ); + } + + + /** + * Get a Questtexttype by its URL. + * + * @param string $questtexttypeUrl URL-type of Questtexttype + * @return array Questtexttype data + */ + public function getQuesttexttypeByUrl($questtexttypeUrl) + { + $data = $this->db->query( + 'SELECT id, type, url '. + 'FROM questtexttypes '. + 'WHERE url = ?', + 's', + $questtexttypeUrl + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Add a list of Questtexts to a Quest. + * + * @param int $userId ID of user + * @param int $questId ID of Quest to add texts to + * @param string $questtexttypeUrl URL-type of Questtexttype of texts + * @param array $texts List of texts to add. + */ + public function addQuesttextsToQuest($userId, $questId, $questtexttypeUrl, $texts) + { + $questtexttype = $this->getQuesttexttypeByUrl($questtexttypeUrl); + if(is_null($questtexttype)) { + return; + } + foreach($texts as &$text) + { + $pos = $this->db->query( + 'SELECT COALESCE(MAX(pos),0)+1 AS pos '. + 'FROM questtexts '. + 'WHERE quest_id = ?', + 'i', + $questId + ); + $pos = $pos[0]['pos']; + + $this->db->query( + 'INSERT INTO questtexts '. + '(created_user_id, quest_id, questtexttype_id, pos, text) '. + 'VALUES '. + '(?, ?, ?, ?, ?)', + 'iiiis', + $userId, $questId, $questtexttype['id'], $pos, + $text + ); + } + } + + + + + } + +?> diff --git a/models/QuesttopicsModel.inc b/models/QuesttopicsModel.inc new file mode 100644 index 00000000..abd246f7 --- /dev/null +++ b/models/QuesttopicsModel.inc @@ -0,0 +1,154 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Questtopics-table. + * + * @author Oliver Hanraths + */ + class QuesttopicsModel extends \hhu\z\Model + { + + + + + /** + * Construct a new QuesttopicsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get a Questtopic by its URL. + * + * @param int $seminaryId ID of Seminary + * @param string $questtopicUrl URL-Title of Questtopic + * @return array Questtopic data + */ + public function getQuesttopicByUrl($seminaryId, $questtopicUrl) + { + $data = $this->db->query( + 'SELECT id, title, url '. + 'FROM questtopics '. + 'WHERE seminary_id = ? AND url = ?', + 'is', + $seminaryId, $questtopicUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questtopicUrl); + } + + + return $data[0]; + } + + + /** + * Get all Questtopics for a Seminary. + * + * @param int $seminaryId ID of Seminary + * @return array List of Questtopics + */ + public function getQuesttopicsForSeminary($seminaryId) + { + return $this->db->query( + 'SELECT id, title, url '. + 'FROM questtopics '. + 'WHERE seminary_id = ?', + 'i', + $seminaryId + ); + } + + + /** + * Get count of Quests that are linked to a Questtopic. + * + * @param int $questtopicId ID of Questtopic + * @return int Count of Quests + */ + public function getQuestCountForQuesttopic($questtopicId) + { + $data = $this->db->query( + 'SELECT count(DISTINCT quests_questsubtopics.quest_id) AS c ' . + 'FROM questsubtopics '. + 'LEFT JOIN quests_questsubtopics ON quests_questsubtopics.questsubtopic_id = questsubtopics.id '. + 'WHERE questsubtopics.questtopic_id = ?', + 'i', + $questtopicId + ); + if(!empty($data)) { + return $data[0]['c']; + } + + + return 0; + } + + + /** + * Get count of Quests that are linked to a Questtopic and are + * unlocked by a Character. + * + * @param int $questtopicId ID of Questtopic + * @param int $characterId ID of Character + * @return int Count of Quests + */ + public function getCharacterQuestCountForQuesttopic($questtopicId, $characterId) + { + $data = $this->db->query( + 'SELECT count(DISTINCT quests_characters.quest_id) AS c '. + 'FROM questsubtopics '. + 'LEFT JOIN quests_questsubtopics ON quests_questsubtopics.questsubtopic_id = questsubtopics.id '. + 'INNER JOIN quests_characters ON quests_characters.quest_id = quests_questsubtopics.quest_id AND quests_characters.character_id = ? AND quests_characters.status = 3 '. + 'WHERE questsubtopics.questtopic_id = ?', + 'ii', + $characterId, + $questtopicId + ); + if(!empty($data)) { + return $data[0]['c']; + } + + + return 0; + } + + + /** + * Get all Questsubtopics for a Quest. + * + * @param int $questId ID of Quest + * @return array List of Questsubtopics + */ + public function getQuestsubtopicsForQuest($questId) + { + return $this->db->query( + 'SELECT DISTINCT id, questtopic_id, title, url '. + 'FROM quests_questsubtopics '. + 'INNER JOIN questsubtopics ON questsubtopics.id = quests_questsubtopics.questsubtopic_id '. + 'WHERE quests_questsubtopics.quest_id = ?', + 'i', + $questId + ); + } + + } + +?> diff --git a/models/QuesttypesModel.inc b/models/QuesttypesModel.inc new file mode 100644 index 00000000..58b36648 --- /dev/null +++ b/models/QuesttypesModel.inc @@ -0,0 +1,77 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with Questtypes-table. + * + * @author Oliver Hanraths + */ + class QuesttypesModel extends \hhu\z\Model + { + + + + + /** + * Construct a new QuesttypesModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get all registered Questtypes. + * + * @return array List of registered Questtypes + */ + public function getQuesttypes() + { + return $this->db->query( + 'SELECT id, title, url, classname '. + 'FROM questtypes '. + 'ORDER BY title ASC' + ); + } + + + /** + * Get a Questtype by its ID + * + * @param int $questtypeId ID of Questtype + * @return array Questtype data + */ + public function getQuesttypeById($questtypeId) + { + $data = $this->db->query( + 'SELECT title, classname '. + 'FROM questtypes '. + 'WHERE id = ?', + 'i', + $questtypeId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questtypeId); + } + + + return $data = $data[0]; + } + + } + +?> diff --git a/models/SeminariesModel.inc b/models/SeminariesModel.inc new file mode 100644 index 00000000..494aadc3 --- /dev/null +++ b/models/SeminariesModel.inc @@ -0,0 +1,195 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model of the SeminariesAgent to list registered seminaries. + * + * @author Oliver Hanraths + */ + class SeminariesModel extends \hhu\z\Model + { + /** + * Required models + * + * @var array + */ + public $models = array('questgroupshierarchy', 'questgroups'); + + + + + /** + * Construct a new SeminariesModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get registered seminaries. + * + * @return array Seminaries + */ + public function getSeminaries() + { + // Get seminaries + return $this->db->query( + 'SELECT id, created, created_user_id, title, url, course, description, seminarymedia_id, charactergroups_seminarymedia_id, achievements_seminarymedia_id, library_seminarymedia_id '. + 'FROM seminaries '. + 'ORDER BY created DESC' + ); + } + + + /** + * Get a seminary and its data by its ID. + * + * @throws IdNotFoundException + * @param string $seminaryId ID of a seminary + * @return array Seminary + */ + public function getSeminaryById($seminaryId) + { + $seminary = $this->db->query( + 'SELECT id, created, created_user_id, title, url, course, description, seminarymedia_id, charactergroups_seminarymedia_id, achievements_seminarymedia_id, library_seminarymedia_id '. + 'FROM seminaries '. + 'WHERE id = ?', + 'i', + $seminaryId + ); + if(empty($seminary)) { + throw new \nre\exceptions\IdNotFoundException($seminaryId); + } + + + return $seminary[0]; + } + + + /** + * Get a seminary and its data by its URL-title. + * + * @throws IdNotFoundException + * @param string $seminaryUrl URL-Title of a seminary + * @return array Seminary + */ + public function getSeminaryByUrl($seminaryUrl) + { + $seminary = $this->db->query( + 'SELECT id, created, created_user_id, title, url, course, description, seminarymedia_id, charactergroups_seminarymedia_id, achievements_seminarymedia_id, library_seminarymedia_id '. + 'FROM seminaries '. + 'WHERE url = ?', + 's', + $seminaryUrl + ); + if(empty($seminary)) { + throw new \nre\exceptions\IdNotFoundException($seminaryUrl); + } + + + return $seminary[0]; + } + + + /* + * Calculate sum of XPs for a Seminary. + * + * @param int $seminaryId ID of Seminary + * @return int Total sum of XPs + */ + public function getTotalXPs($seminaryId) + { + $xps = 0; + + // Questgroups + $questgroupshierarchy = $this->Questgroupshierarchy->getHierarchyOfSeminary($seminaryId); + foreach($questgroupshierarchy as &$hierarchy) + { + // Get Questgroups + $questgroups = $this->Questgroups->getQuestgroupsForHierarchy($hierarchy['id']); + foreach($questgroups as &$questgroup) + { + $data = $this->Questgroups->getCumulatedDataForQuestgroup($questgroup['id']); + $xps += $data['xps']; + } + } + + + return $xps; + } + + + /** + * Create a new seminary. + * + * @param string $title Title of seminary to create + * @param int $userId ID of creating user + * @return int ID of the newly created seminary + */ + public function createSeminary($title, $userId) + { + $this->db->query( + 'INSERT INTO seminaries '. + '(created_user_id, title, url) '. + 'VALUES '. + '(?, ?, ?)', + 'iss', + $userId, + $title, + \nre\core\Linker::createLinkParam($title) + ); + + + return $this->db->getInsertId(); + } + + + /** + * Edit a seminary. + * + * @throws DatamodelException + * @param int $seminaryId ID of the seminary to delete + * @param string $title New title of seminary + */ + public function editSeminary($seminaryId, $title) + { + $this->db->query( + 'UPDATE seminaries '. + 'SET title = ?, url = ? '. + 'WHERE id = ?', + 'ssi', + $title, + \nre\core\Linker::createLinkParam($title), + $seminaryId + ); + } + + + /** + * Delete a seminary. + * + * @param int $seminaryId ID of the seminary to delete + */ + public function deleteSeminary($seminaryId) + { + $this->db->query('DELETE FROM seminaries WHERE id = ?', 'i', $seminaryId); + } + + } + +?> diff --git a/models/SeminarycharacterfieldsModel.inc b/models/SeminarycharacterfieldsModel.inc new file mode 100644 index 00000000..1a17706d --- /dev/null +++ b/models/SeminarycharacterfieldsModel.inc @@ -0,0 +1,128 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with the Seminarycharacterfields-tables. + * + * @author Oliver Hanraths + */ + class SeminarycharacterfieldsModel extends \hhu\z\Model + { + + + + + /** + * Construct a new SeminarycharacterfieldsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get all Character fields of a Seminary. + * + * @param int $seminaryId ID of Seminary to get fields of + * @param array Seminary Character fields + */ + public function getFieldsForSeminary($seminaryId) + { + return $this->db->query( + 'SELECT seminarycharacterfields.id, seminarycharacterfields.title, seminarycharacterfields.url, seminarycharacterfields.regex, seminarycharacterfields.required, seminarycharacterfieldtypes.id AS type_id, seminarycharacterfieldtypes.title AS type_title, seminarycharacterfieldtypes.url AS type_url '. + 'FROM seminarycharacterfields '. + 'LEFT JOIN seminarycharacterfieldtypes ON seminarycharacterfieldtypes.id = seminarycharacterfields.seminarycharacterfieldtype_id '. + 'WHERE seminarycharacterfields.seminary_id = ? '. + 'ORDER BY pos ASC', + 'i', + $seminaryId + ); + } + + + /** + * Set the value of a Seminary field for a Character. + * + * @param int $characterId ID of Character + * @param int $seminarycharacterfieldId ID of seminarycharacterfield to set value of + * @param string $value Value to set + */ + public function setSeminaryFieldOfCharacter($seminarycharacterfieldId, $characterId, $value) + { + $this->db->query( + 'INSERT INTO characters_seminarycharacterfields '. + '(character_id, seminarycharacterfield_id, value) '. + 'VALUES '. + '(?, ?, ?) '. + 'ON DUPLICATE KEY UPDATE '. + 'value = ?', + 'iiss', + $characterId, + $seminarycharacterfieldId, + $value, + $value + ); + } + + + /** + * Get Seminary Character fields of a Character. + * + * @param int $characterId ID of the Character + * @return array Seminary Character fields + */ + public function getSeminaryFieldOfCharacter($fieldId, $characterId) + { + $data = $this->db->query( + 'SELECT created, value '. + 'FROM characters_seminarycharacterfields '. + 'WHERE seminarycharacterfield_id = ? AND character_id = ?', + 'ii', + $fieldId, + $characterId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get Seminary Character fields of a Character. + * + * @param int $characterId ID of the Character + * @return array Seminary Character fields + */ + public function getFieldsForCharacter($characterId) + { + return $this->db->query( + 'SELECT seminarycharacterfields.id, seminarycharacterfields.title, seminarycharacterfields.url, seminarycharacterfields.regex, seminarycharacterfields.required, seminarycharacterfieldtypes.id AS type_id, seminarycharacterfieldtypes.title AS type_title, seminarycharacterfieldtypes.url AS type_url, characters_seminarycharacterfields.value '. + 'FROM characters_seminarycharacterfields '. + 'LEFT JOIN seminarycharacterfields ON seminarycharacterfields.id = characters_seminarycharacterfields.seminarycharacterfield_id '. + 'LEFT JOIN seminarycharacterfieldtypes ON seminarycharacterfieldtypes.id = seminarycharacterfields.seminarycharacterfieldtype_id '. + 'WHERE characters_seminarycharacterfields.character_id = ?', + 'i', + $characterId + ); + } + + } + +?> diff --git a/models/UploadsModel.inc b/models/UploadsModel.inc new file mode 100644 index 00000000..fc4ec2fd --- /dev/null +++ b/models/UploadsModel.inc @@ -0,0 +1,141 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to handle files to upload. + * + * @author Oliver Hanraths + */ + class UploadsModel extends \hhu\z\Model + { + + + + + /** + * Construct a new UploadsModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Upload a file and create a database record. + * + * @param int $userId ID of user that uploads the file + * @param int $seminaryId ID of Seminary + * @param string $name Name of file to upload + * @param string $filename Filename of file to upload + * @param string $tmpFilename Name of temporary uploaded file + * @param string $mimetype Mimetype of file to upload + * @return mixed ID of database record or false + */ + public function uploadSeminaryFile($userId, $seminaryId, $name, $filename, $tmpFilename, $mimetype) + { + $uploadId = false; + $this->db->setAutocommit(false); + + try { + // Create database record + $this->db->query( + 'INSERT INTO seminaryuploads '. + '(created_user_id, seminary_id, name, url, mimetype) '. + 'VALUES '. + '(?, ? ,? ,?, ?)', + 'iisss', + $userId, + $seminaryId, + $name, + \nre\core\Linker::createLinkParam($filename), + $mimetype + ); + $uploadId = $this->db->getInsertId(); + + // Create filename + $filename = ROOT.DS.\nre\configs\AppConfig::$dirs['seminaryuploads'].DS.$filename; + if(!move_uploaded_file($tmpFilename, $filename)) + { + $this->db->rollback(); + $uploadId = false; + } + } + catch(\nre\exceptions\DatamodelException $e) { + $this->db->rollback(); + $this->db->setAutocommit(true); + } + + + $this->db->setAutocommit(true); + return $uploadId; + } + + + /** + * Get an upload by its ID. + * + * @throws IdNotFoundException + * @param int $uploadId ID of the uploaded file + * @return array Upload data + */ + public function getSeminaryuploadById($seminaryuploadId) + { + $data = $this->db->query( + 'SELECT id, created, created_user_id, seminary_id, name, url, mimetype, public '. + 'FROM seminaryuploads '. + 'WHERE id = ?', + 'i', + $seminaryuploadId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($seminaryuploadId); + } + + + return $data[0]; + } + + + /** + * Get an upload by its URL. + * + * @throws IdNotFoundException + * @param int $seminaryId ID of Seminary + * @param int $uploadId ID of the uploaded file + * @return array Upload data + */ + public function getSeminaryuploadByUrl($seminaryId, $seminaryuploadUrl) + { + $data = $this->db->query( + 'SELECT id, created, created_user_id, seminary_id, name, url, mimetype, public '. + 'FROM seminaryuploads '. + 'WHERE seminary_id = ? AND url = ?', + 'is', + $seminaryId, + $seminaryuploadUrl + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($seminaryuploadUrl); + } + + + return $data[0]; + } + + } + +?> diff --git a/models/UserrolesModel.inc b/models/UserrolesModel.inc new file mode 100644 index 00000000..ebdd00f1 --- /dev/null +++ b/models/UserrolesModel.inc @@ -0,0 +1,120 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model to interact with userroles-table. + * + * @author Oliver Hanraths + */ + class UserrolesModel extends \hhu\z\Model + { + + + + + /** + * Construct a new UserrolesModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get all userroles for an user referenced by its ID. + * + * @param int $userId ID of an user + * @return array Userroles for an user + */ + public function getUserrolesForUserById($userId) + { + return $this->db->query( + 'SELECT userroles.id, userroles.created, userroles.name '. + 'FROM users_userroles '. + 'LEFT JOIN userroles ON userroles.id = users_userroles.userrole_id '. + 'WHERE users_userroles.user_id = ?', + 'i', + $userId + ); + } + + + /** + * Get all userroles for an user referenced by its URL-username. + * + * @param string $userUrl URL-Username of an user + * @return array Userroles for an user + */ + public function getUserrolesForUserByUrl($userUrl) + { + return $this->db->query( + 'SELECT userroles.id, userroles.created, userroles.name '. + 'FROM users '. + 'LEFT JOIN users_userroles ON users_userroles.user_id = users.id '. + 'LEFT JOIN userroles ON userroles.id = users_userroles.userrole_id '. + 'WHERE users.url = ?', + 's', + $userUrl + ); + } + + + /** + * Add a role to a user. + * + * @param int $userId ID of user to add role to + * @param string $userrole Role to add + */ + public function addUserroleToUser($userId, $userrole) + { + $this->db->query( + 'INSERT IGNORE INTO users_userroles '. + '(user_id, userrole_id) '. + 'SELECT ?, id '. + 'FROM userroles '. + 'WHERE name = ?', + 'is', + $userId, + $userrole + ); + } + + + /** + * Remove a role from a user. + * + * @param int $userId ID of user to remove role from + * @param string $userrole Role to remove + */ + public function removeUserroleFromUser($userId, $userrole) + { + $this->db->query( + 'DELETE FROM users_userroles '. + 'WHERE user_id = ? AND userrole_id = ('. + 'SELECT id '. + 'FROM userroles '. + 'WHERE name = ?'. + ')', + 'is', + $userId, + $userrole + ); + } + + } + +?> diff --git a/models/UsersModel.inc b/models/UsersModel.inc new file mode 100644 index 00000000..2181586a --- /dev/null +++ b/models/UsersModel.inc @@ -0,0 +1,344 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\models; + + + /** + * Model of the UsersAgent to list users and get their data. + * + * @author Oliver Hanraths + */ + class UsersModel extends \hhu\z\Model + { + + + + + /** + * Construct a new UsersModel. + */ + public function __construct() + { + parent::__construct(); + } + + + + + /** + * Get registered users. + * + * @return array Users + */ + public function getUsers() + { + return $this->db->query( + 'SELECT id, created, username, url, surname, prename, email '. + 'FROM users '. + 'ORDER BY username ASC' + ); + } + + + /** + * Get users with the given user role. + * + * @param string $userrole User role + * @return array List of users + */ + public function getUsersWithRole($userrole) + { + return $this->db->query( + 'SELECT users.id, users.created, users.username, users.url, users.surname, users.prename, users.email '. + 'FROM users '. + 'LEFT JOIN users_userroles ON users_userroles.user_id = users.id '. + 'LEFT JOIN userroles ON userroles.id = users_userroles.userrole_id '. + 'WHERE userroles.name = ? '. + 'ORDER BY username ASC', + 's', + $userrole + ); + } + + + /** + * Get a user and its data by its ID. + * + * @throws IdNotFoundException + * @param int $userId ID of an user + * @return array Userdata + */ + public function getUserById($userId) + { + // Get user + $user = $this->db->query( + 'SELECT id, created, username, url, surname, prename, email '. + 'FROM users '. + 'WHERE id = ?', + 'i', + $userId + ); + if(empty($user)) { + throw new \nre\exceptions\IdNotFoundException($userId); + } + + + return $user[0]; + } + + + /** + * Get a user and its data by its URL-username. + * + * @throws IdNotFoundException + * @param string $userUrl URL-Username of an user + * @return array Userdata + */ + public function getUserByUrl($userUrl) + { + // Get user + $user = $this->db->query( + 'SELECT id, created, username, url, surname, prename, email '. + 'FROM users '. + 'WHERE url = ?', + 's', + $userUrl + ); + if(empty($user)) { + throw new \nre\exceptions\IdNotFoundException($userUrl); + } + + + return $user[0]; + } + + + /** + * Log a user in if its credentials are valid. + * + * @throws DatamodelException + * @param string $username The name of the user to log in + * @param string $password Plaintext password of the user to log in + */ + public function login($username, $password) + { + $data = $this->db->query('SELECT id, password FROM users WHERE username = ?', 's', $username); + if(!empty($data)) + { + $data = $data[0]; + if($this->verify($password, $data['password'])) { + return $data['id']; + } + } + + + return null; + } + + + /** + * Check if an username already exists. + * + * @param string $username Username to check + * @param int $userId Do not check this ID (for editing) + * @return boolean Whether username exists or not + */ + public function usernameExists($username, $userId=null) + { + $data = $this->db->query( + 'SELECT id '. + 'FROM users '. + 'WHERE username = ? OR url = ?', + 'ss', + $username, + \nre\core\Linker::createLinkParam($username) + ); + + + return (!empty($data) && (is_null($userId) || $userId != $data[0]['id'])); + } + + + /** + * Check if an e‑mail address already exists. + * + * @param string $email E‑mail address to check + * @param int $userId Do not check this ID (for editing) + * @return boolean Whether e‑mail address exists or not + */ + public function emailExists($email, $userId=null) + { + $data = $this->db->query( + 'SELECT id '. + 'FROM users '. + 'WHERE email = ?', + 's', + $email + ); + + + return (!empty($data) && (is_null($userId) || $userId != $data[0]['id'])); + } + + + /** + * Create a new user. + * + * @param string $username Username of the user to create + * @param string $email E‑Mail-Address of the user to create + * @param string $password Password of the user to create + * @return int ID of the newly created user + */ + public function createUser($username, $prename, $surname, $email, $password) + { + $userId = null; + $this->db->setAutocommit(false); + try { + // Create user + $this->db->query( + 'INSERT INTO users '. + '(username, url, surname, prename, email, password) '. + 'VALUES '. + '(?, ?, ?, ?, ?, ?)', + 'ssssss', + $username, + \nre\core\Linker::createLinkParam($username), + $surname, + $prename, + $email, + $this->hash($password) + ); + $userId = $this->db->getInsertId(); + + // Add role “user” + $this->db->query( + 'INSERT INTO users_userroles '. + '(user_id, userrole_id) '. + 'SELECT ?, userroles.id '. + 'FROM userroles '. + 'WHERE userroles.name = ?', + 'is', + $userId, + 'user' + ); + } + catch(Exception $e) { + $this->db->rollback(); + $this->db->setAutocommit(true); + throw $e; + } + $this->db->setAutocommit(true); + + + return $userId; + } + + + /** + * Edit a user. + * + * @throws DatamodelException + * @param int $userId ID of the user to delete + * @param string $username New name of user + * @param string $email Changed e‑mail-address of user + * @param string $password Changed plaintext password of user + */ + public function editUser($userId, $username, $prename, $surname, $email, $password) + { + $this->db->setAutocommit(false); + try { + // Update user data + $this->db->query( + 'UPDATE users '. + 'SET username = ?, url = ?, prename = ?, surname = ?, email = ? '. + 'WHERE id = ?', + 'sssssi', + $username, + \nre\core\Linker::createLinkParam($username), + $prename, + $surname, + $email, + $userId + ); + + // Set new password + if(!empty($password)) + { + $this->db->query( + 'UPDATE users '. + 'SET password = ? '. + 'WHERE id = ?', + 'si', + $this->hash($password), + $userId + ); + } + } + catch(Exception $e) { + $this->db->rollback(); + $this->db->setAutocommit(true); + throw $e; + } + $this->db->setAutocommit(true); + } + + + /** + * Delete a user. + * + * @param int $userId ID of the user to delete + */ + public function deleteUser($userId) + { + $this->db->query('DELETE FROM users WHERE id = ?', 'i', $userId); + } + + + + + /** + * Hash a password. + * + * @param string $password Plaintext password + * @return string Hashed password + */ + public function hash($password) + { + if(!function_exists('password_hash')) { + \hhu\z\lib\Password::load(); + } + + + return password_hash($password, PASSWORD_DEFAULT); + } + + + /** + * Verify a password. + * + * @param string $password Plaintext password to verify + * @param string $hash Hashed password to match with + * @return boolean Verified + */ + private function verify($password, $hash) + { + if(!function_exists('password_verify')) { + \hhu\z\lib\Password::load(); + } + + + return password_verify($password, $hash); + } + + } + +?> diff --git a/questtypes/bossfight/BossfightQuesttypeAgent.inc b/questtypes/bossfight/BossfightQuesttypeAgent.inc new file mode 100644 index 00000000..6d99ec44 --- /dev/null +++ b/questtypes/bossfight/BossfightQuesttypeAgent.inc @@ -0,0 +1,24 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * QuesttypeAgent for a boss-fight. + * + * @author Oliver Hanraths + */ + class BossfightQuesttypeAgent extends \hhu\z\QuesttypeAgent + { + } + +?> diff --git a/questtypes/bossfight/BossfightQuesttypeController.inc b/questtypes/bossfight/BossfightQuesttypeController.inc new file mode 100644 index 00000000..9495c073 --- /dev/null +++ b/questtypes/bossfight/BossfightQuesttypeController.inc @@ -0,0 +1,240 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Controller of the BossfightQuesttypeAgent for a boss-fight. + * + * @author Oliver Hanraths + */ + class BossfightQuesttypeController extends \hhu\z\QuesttypeController + { + /** + * Required models + * + * @var array + */ + public $models = array('media'); + + + + + /** + * Save the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Prepare session + $this->prepareSession($quest['id']); + + // Remove previous answers + $this->Bossfight->clearCharacterSubmissions($quest['id'], $character['id']); + + // Save answers + foreach($_SESSION['quests'][$quest['id']]['stages'] as &$stage) { + $this->Bossfight->setCharacterSubmission($stage['id'], $character['id']); + } + } + + + /** + * Save additional data for the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $data Additional (POST-) data + */ + public function saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $data) + { + } + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @return boolean True/false for a right/wrong answer or null for moderator evaluation + */ + public function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + return true; + } + + + /** + * Action: quest. + * + * Display a stage with a text and the answers for the following + * stages. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param Exception $exception Character submission exception + */ + public function quest($seminary, $questgroup, $quest, $character, $exception) + { + // Get Boss-Fight + $fight = $this->Bossfight->getBossFight($quest['id']); + if(!is_null($fight['boss_seminarymedia_id'])) { + $fight['bossmedia'] = $this->Media->getSeminaryMediaById($fight['boss_seminarymedia_id']); + } + + // Prepare session + $this->prepareSession($quest['id']); + + // Get Stage + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('submit_stages'))) + { + $stages = $this->request->getPostParam('submit_stages'); + $stageId = array_keys($stages)[0]; + $stage = $this->Bossfight->getStageById($stageId); + } + else + { + $_SESSION['quests'][$quest['id']]['stages'] = array(); + $stage = $this->Bossfight->getFirstStage($quest['id']); + } + + // Store Stage in session + if(count($_SESSION['quests'][$quest['id']]['stages']) == 0 || $_SESSION['quests'][$quest['id']]['stages'][count($_SESSION['quests'][$quest['id']]['stages'])-1]['id'] != $stage['id']) { + $_SESSION['quests'][$quest['id']]['stages'][] = $stage; + } + + // Calculate lives + $lives = array( + 'character' => $fight['lives_character'], + 'boss' => $fight['lives_boss'] + ); + foreach($_SESSION['quests'][$quest['id']]['stages'] as &$stage) + { + $lives['character'] += $stage['livedrain_character']; + $lives['boss'] += $stage['livedrain_boss']; + } + + // Get Child-Stages + $childStages = $this->Bossfight->getChildStages($stage['id']); + + // Get answer of Character + if($this->request->getGetParam('show-answer') == 'true') { + foreach($childStages as &$childStage) { + $childStage['answer'] = $this->Bossfight->getCharacterSubmission($childStage['id'], $character['id']); + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('character', $character); + $this->set('fight', $fight); + $this->set('stage', $stage); + $this->set('lives', $lives); + $this->set('childStages', $childStages); + } + + + /** + * Action: submission. + * + * Display all stages with the answers the character has + * choosen. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + */ + public function submission($seminary, $questgroup, $quest, $character) + { + // Get Boss-Fight + $fight = $this->Bossfight->getBossFight($quest['id']); + if(!is_null($fight['boss_seminarymedia_id'])) { + $fight['bossmedia'] = $this->Media->getSeminaryMediaById($fight['boss_seminarymedia_id']); + } + + // Get stages + $stages = array(); + $stage = $this->Bossfight->getFirstStage($quest['id']); + while(!is_null($stage)) + { + $stages[] = $stage; + + $childStages = $this->Bossfight->getChildStages($stage['id']); + $stage = null; + foreach($childStages as &$childStage) + { + if($this->Bossfight->getCharacterSubmission($childStage['id'], $character['id'])) + { + $stage = $childStage; + break; + } + } + } + + // Calculate lives + $stages[0]['lives'] = array( + 'character' => $fight['lives_character'], + 'boss' => $fight['lives_boss'] + ); + for($i=1; $i $stages[$i-1]['lives']['character'] + $stages[$i]['livedrain_character'], + 'boss' => $stages[$i-1]['lives']['boss'] + $stages[$i]['livedrain_boss'], + ); + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('character', $character); + $this->set('fight', $fight); + $this->set('stages', $stages); + } + + + + + /** + * Prepare the session to store stage information in + * + * @param int $questId ID of Quest + */ + private function prepareSession($questId) + { + if(!array_key_exists('quests', $_SESSION)) { + $_SESSION['quests'] = array(); + } + if(!array_key_exists($questId, $_SESSION['quests'])) { + $_SESSION['quests'][$questId] = array(); + } + if(!array_key_exists('stages', $_SESSION['quests'][$questId])) { + $_SESSION['quests'][$questId]['stages'] = array(); + } + } + + } + +?> diff --git a/questtypes/bossfight/BossfightQuesttypeModel.inc b/questtypes/bossfight/BossfightQuesttypeModel.inc new file mode 100644 index 00000000..a05927f0 --- /dev/null +++ b/questtypes/bossfight/BossfightQuesttypeModel.inc @@ -0,0 +1,183 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Model of the BossfightQuesttypeAgent for a boss-fight. + * + * @author Oliver Hanraths + */ + class BossfightQuesttypeModel extends \hhu\z\QuesttypeModel + { + + + + + /** + * Get a Boss-Fight. + * + * @throws IdNotFoundException + * @param int $questId ID of Quest + * @return array Boss-Fight data + */ + public function getBossFight($questId) + { + $data = $this->db->query( + 'SELECT bossname, boss_seminarymedia_id, lives_character, lives_boss '. + 'FROM questtypes_bossfight '. + 'WHERE quest_id = ?', + 'i', + $questId + ); + if(empty($data)) { + throw new \nre\exceptions\IdNotFoundException($questId); + } + + + return $data[0]; + } + + + /** + * Get the first Stage to begin the Boss-Fight with. + * + * @param int $questId ID of Quest + * @return array Data of first Stage + */ + public function getFirstStage($questId) + { + $data = $this->db->query( + 'SELECT id, text, question, livedrain_character, livedrain_boss '. + 'FROM questtypes_bossfight_stages '. + 'WHERE questtypes_bossfight_quest_id = ? AND parent_stage_id IS NULL', + 'i', + $questId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get a Stage by its ID. + * + * @param int $stageId ID of Stage + * @return array Stage data or null + */ + public function getStageById($stageId) + { + $data = $this->db->query( + 'SELECT id, text, question, livedrain_character, livedrain_boss '. + 'FROM questtypes_bossfight_stages '. + 'WHERE id = ?', + 'i', + $stageId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get the follow-up Stages for a Stage. + * + * @param int $parentStageId ID of Stage to get follow-up Stages for + * @return array List of follow-up Stages + */ + public function getChildStages($parentStageId) + { + return $this->db->query( + 'SELECT id, text, question, livedrain_character, livedrain_boss '. + 'FROM questtypes_bossfight_stages '. + 'WHERE parent_stage_id = ?', + 'i', + $parentStageId + ); + } + + + /** + * Reset all Character submissions of a Boss-Fight. + * + * @param int $questId ID of Quest + * @param int $characterId ID of Character + */ + public function clearCharacterSubmissions($questId, $characterId) + { + $this->db->query( + 'DELETE FROM questtypes_bossfight_stages_characters '. + 'WHERE questtypes_bossfight_stage_id IN ('. + 'SELECT id '. + 'FROM questtypes_bossfight_stages '. + 'WHERE questtypes_bossfight_quest_id = ?'. + ') AND character_id = ?', + 'ii', + $questId, + $characterId + ); + } + + + /** + * Save Character’s submitted answer for one Boss-Fight-Stage. + * + * @param int $regexId ID of list + * @param int $characterId ID of Character + */ + public function setCharacterSubmission($stageId, $characterId) + { + $this->db->query( + 'INSERT INTO questtypes_bossfight_stages_characters '. + '(questtypes_bossfight_stage_id, character_id) '. + 'VALUES '. + '(?, ?) '. + 'ON DUPLICATE KEY UPDATE '. + 'questtypes_bossfight_stage_id = ?', + 'iii', + $stageId, $characterId, $stageId + ); + } + + + /** + * Get answer of one Boss-Fight-Stage submitted by Character. + * + * @param int $regexId ID of list + * @param int $characterId ID of Character + * @return boolean Stage taken + */ + public function getCharacterSubmission($stageId, $characterId) + { + $data = $this->db->query( + 'SELECT questtypes_bossfight_stage_id '. + 'FROM questtypes_bossfight_stages_characters '. + 'WHERE questtypes_bossfight_stage_id = ? AND character_id = ? ', + 'ii', + $stageId, $characterId + ); + + + return (!empty($data)); + } + + } + +?> diff --git a/questtypes/bossfight/html/quest.tpl b/questtypes/bossfight/html/quest.tpl new file mode 100644 index 00000000..9c6e309a --- /dev/null +++ b/questtypes/bossfight/html/quest.tpl @@ -0,0 +1,51 @@ +

+
+

+

+

+ 0) : ?> + + + + + + +

+
+
+

+

+

+ 0) : ?> + + + + + + +

+
+
+ +

+ +
+ +
    + +
  • +

    + + +

    +

    +
  • + + +
  • + + +
  • + +
+
diff --git a/questtypes/bossfight/html/submission.tpl b/questtypes/bossfight/html/submission.tpl new file mode 100644 index 00000000..3696964e --- /dev/null +++ b/questtypes/bossfight/html/submission.tpl @@ -0,0 +1,38 @@ +
+
+

+
+
+

+
+
+ + +

+
+
+

+

+ 0) : ?> + + + + + + +

+
+
+

+

+ 0) : ?> + + + + + + +

+
+
+ diff --git a/questtypes/choiceinput/ChoiceinputQuesttypeAgent.inc b/questtypes/choiceinput/ChoiceinputQuesttypeAgent.inc new file mode 100644 index 00000000..b418f01e --- /dev/null +++ b/questtypes/choiceinput/ChoiceinputQuesttypeAgent.inc @@ -0,0 +1,24 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * QuesttypeAgent for choosing between predefined input values. + * + * @author Oliver Hanraths + */ + class ChoiceinputQuesttypeAgent extends \hhu\z\QuesttypeAgent + { + } + +?> diff --git a/questtypes/choiceinput/ChoiceinputQuesttypeController.inc b/questtypes/choiceinput/ChoiceinputQuesttypeController.inc new file mode 100644 index 00000000..a7edf905 --- /dev/null +++ b/questtypes/choiceinput/ChoiceinputQuesttypeController.inc @@ -0,0 +1,171 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Controller of the ChoiceinputQuesttypeAgent for choosing between + * predefined input values. + * + * @author Oliver Hanraths + */ + class ChoiceinputQuesttypeController extends \hhu\z\QuesttypeController + { + + + + + /** + * Save the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Get lists + $choiceLists = $this->Choiceinput->getChoiceinputLists($quest['id']); + + // Save answers + foreach($choiceLists as &$list) + { + $pos = intval($list['number']) - 1; + $answer = (array_key_exists($pos, $answers)) ? $answers[$pos] : null; + $this->Choiceinput->setCharacterSubmission($list['id'], $character['id'], $answer); + } + } + + + /** + * Save additional data for the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $data Additional (POST-) data + */ + public function saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $data) + { + } + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @return boolean True/false for a right/wrong answer or null for moderator evaluation + */ + public function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Get lists + $choiceLists = $this->Choiceinput->getChoiceinputLists($quest['id']); + + // Match lists with user answers + foreach($choiceLists as $i => &$list) + { + if(!array_key_exists($i, $answers)) { + return false; + } + if($list['questtypes_choiceinput_choice_id'] != $answers[$i]) { + return false; + } + } + + + // All answer right + return true; + } + + + /** + * Action: quest. + * + * Display a text with lists with predefined values. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param Exception $exception Character submission exception + */ + public function quest($seminary, $questgroup, $quest, $character, $exception) + { + // Get Task + $task = $this->Choiceinput->getChoiceinputQuest($quest['id']); + + // Process text + $textParts = preg_split('/(\$\$)/', ' '.$task['text'].' '); + + // Get lists + $choiceLists = $this->Choiceinput->getChoiceinputLists($quest['id']); + foreach($choiceLists as &$list) { + $list['values'] = $this->Choiceinput->getChoiceinputChoices($list['id']); + } + + // Get Character answers + if($this->request->getGetParam('show-answer') == 'true' || !$this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id']) || $this->request->getGetParam('status') == 'solved') + { + foreach($choiceLists as &$list) { + $list['answer'] = $this->Choiceinput->getCharacterSubmission($list['id'], $character['id']); + } + } + + + // Pass data to view + $this->set('texts', $textParts); + $this->set('choiceLists', $choiceLists); + } + + + /** + * Action: submission. + * + * Show the submission of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + */ + public function submission($seminary, $questgroup, $quest, $character) + { + // Get Task + $task = $this->Choiceinput->getChoiceinputQuest($quest['id']); + + // Process text + $textParts = preg_split('/(\$\$)/', ' '.$task['text'].' '); + + // Get lists + $choiceLists = $this->Choiceinput->getChoiceinputLists($quest['id']); + foreach($choiceLists as &$list) + { + $list['values'] = $this->Choiceinput->getChoiceinputChoices($list['id']); + $list['answer'] = $this->Choiceinput->getCharacterSubmission($list['id'], $character['id']); + $list['right'] = ($list['questtypes_choiceinput_choice_id'] == $list['answer']); + } + + + // Pass data to view + $this->set('texts', $textParts); + $this->set('choiceLists', $choiceLists); + } + + } + +?> diff --git a/questtypes/choiceinput/ChoiceinputQuesttypeModel.inc b/questtypes/choiceinput/ChoiceinputQuesttypeModel.inc new file mode 100644 index 00000000..e908600f --- /dev/null +++ b/questtypes/choiceinput/ChoiceinputQuesttypeModel.inc @@ -0,0 +1,153 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Model of the ChoiceinputQuesttypeAgent for choosing between + * predefined input values. + * + * @author Oliver Hanraths + */ + class ChoiceinputQuesttypeModel extends \hhu\z\QuesttypeModel + { + + + + + /** + * Get choiceinput-text for a Quest. + * + * @param int $questId ID of Quest + * @return array Choiceinput-text + */ + public function getChoiceinputQuest($questId) + { + $data = $this->db->query( + 'SELECT text '. + 'FROM questtypes_choiceinput '. + 'WHERE quest_id = ?', + 'i', + $questId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get all lists of input values for a choiceinput-text. + * + * @param int $questId ID of Quest + * @return array List + */ + public function getChoiceinputLists($questId) + { + return $this->db->query( + 'SELECT id, number, questtypes_choiceinput_choice_id '. + 'FROM questtypes_choiceinput_lists '. + 'WHERE questtypes_choiceinput_quest_id = ? '. + 'ORDER BY number ASC', + 'i', + $questId + ); + } + + + /** + * Get the list of values for a choiceinput-list. + * + * @param int $listId ID of list + * @return array Input values + */ + public function getChoiceinputChoices($listId) + { + return $this->db->query( + 'SELECT id, pos, text '. + 'FROM questtypes_choiceinput_choices '. + 'WHERE questtypes_choiceinput_list_id = ? '. + 'ORDER BY pos ASC', + 'i', + $listId + ); + } + + + /** + * Save Character’s submitted answer for one choiceinput-list. + * + * @param int $regexId ID of list + * @param int $characterId ID of Character + * @param string $answer Submitted answer for this list + */ + public function setCharacterSubmission($listId, $characterId, $answer) + { + if(is_null($answer)) + { + $this->db->query( + 'INSERT INTO questtypes_choiceinput_lists_characters '. + '(questtypes_choiceinput_list_id, character_id, questtypes_choiceinput_choice_id) '. + 'VALUES '. + '(?, ?, NULL) '. + 'ON DUPLICATE KEY UPDATE '. + 'questtypes_choiceinput_choice_id = NULL', + 'ii', + $listId, $characterId + ); + } + else + { + $this->db->query( + 'INSERT INTO questtypes_choiceinput_lists_characters '. + '(questtypes_choiceinput_list_id, character_id, questtypes_choiceinput_choice_id) '. + 'VALUES '. + '(?, ?, ?) '. + 'ON DUPLICATE KEY UPDATE '. + 'questtypes_choiceinput_choice_id = ?', + 'iiii', + $listId, $characterId, $answer, $answer + ); + } + } + + + /** + * Get answer of one choiceinput-list submitted by Character. + * + * @param int $regexId ID of list + * @param int $characterId ID of Character + * @return int Submitted answer for this list or null + */ + public function getCharacterSubmission($listId, $characterId) + { + $data = $this->db->query( + 'SELECT questtypes_choiceinput_choice_id '. + 'FROM questtypes_choiceinput_lists_characters '. + 'WHERE questtypes_choiceinput_list_id = ? AND character_id = ? ', + 'ii', + $listId, $characterId + ); + if(!empty($data)) { + return $data[0]['questtypes_choiceinput_choice_id']; + } + + + return null; + } + + } + +?> diff --git a/questtypes/choiceinput/html/quest.tpl b/questtypes/choiceinput/html/quest.tpl new file mode 100644 index 00000000..43a7d2df --- /dev/null +++ b/questtypes/choiceinput/html/quest.tpl @@ -0,0 +1,15 @@ +
+ &$text) : ?> + 0) : ?> + + + t($text)?> + + +

+ +
diff --git a/questtypes/choiceinput/html/submission.tpl b/questtypes/choiceinput/html/submission.tpl new file mode 100644 index 00000000..40f60505 --- /dev/null +++ b/questtypes/choiceinput/html/submission.tpl @@ -0,0 +1,12 @@ +
+ &$text) : ?> + 0) : ?> + + + t($text)?> + +
diff --git a/questtypes/crossword/CrosswordQuesttypeAgent.inc b/questtypes/crossword/CrosswordQuesttypeAgent.inc new file mode 100644 index 00000000..9b137fe9 --- /dev/null +++ b/questtypes/crossword/CrosswordQuesttypeAgent.inc @@ -0,0 +1,24 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * QuesttypeAgent for solving a crossword. + * + * @author Oliver Hanraths + */ + class CrosswordQuesttypeAgent extends \hhu\z\QuesttypeAgent + { + } + +?> diff --git a/questtypes/crossword/CrosswordQuesttypeController.inc b/questtypes/crossword/CrosswordQuesttypeController.inc new file mode 100644 index 00000000..e5510786 --- /dev/null +++ b/questtypes/crossword/CrosswordQuesttypeController.inc @@ -0,0 +1,365 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Controller of the CrosswordQuesttypeAgent for solving a crossword. + * + * @author Oliver Hanraths + */ + class CrosswordQuesttypeController extends \hhu\z\QuesttypeController + { + + + + + /** + * Save the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Get words + $words = $this->Crossword->getWordsForQuest($quest['id']); + + // Iterate words + foreach($words as &$word) + { + // Assemble answer for word + $answer = ''; + if($word['vertical']) + { + $x = $word['pos_x']; + $startY = $word['pos_y']; + $endY = $startY + mb_strlen($word['word'], 'UTF-8') - 1; + + foreach(range($startY, $endY) as $y) + { + if(array_key_exists($x, $answers) && array_key_exists($y, $answers[$x]) && !empty($answers[$x][$y])) { + $answer .= $answers[$x][$y]; + } + else { + $answer .= ' '; + } + } + } + else + { + $startX = $word['pos_x']; + $endX = $startX + mb_strlen($word['word'], 'UTF-8') - 1; + $y = $word['pos_y']; + + foreach(range($startX, $endX) as $x) + { + if(array_key_exists($x, $answers) && array_key_exists($y, $answers[$x]) && !empty($answers[$x][$y])) { + $answer .= $answers[$x][$y]; + } + else { + $answer .= ' '; + } + } + } + + // Save answer + $this->Crossword->setCharacterSubmission($word['id'], $character['id'], $answer); + } + } + + + /** + * Save additional data for the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $data Additional (POST-) data + */ + public function saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $data) + { + } + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @return boolean True/false for a right/wrong answer or null for moderator evaluation + */ + public function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Get words + $words = $this->Crossword->getWordsForQuest($quest['id']); + + // Iterate words + foreach($words as &$word) + { + // Assemble answer for word + $answer = ''; + if($word['vertical']) + { + $x = $word['pos_x']; + $startY = $word['pos_y']; + $endY = $startY + mb_strlen($word['word'], 'UTF-8') - 1; + + foreach(range($startY, $endY) as $y) + { + if(array_key_exists($x, $answers) && array_key_exists($y, $answers[$x]) && !empty($answers[$x][$y])) { + $answer .= $answers[$x][$y]; + } + else { + $answer .= ' '; + } + } + } + else + { + $startX = $word['pos_x']; + $endX = $startX + mb_strlen($word['word'], 'UTF-8') - 1; + $y = $word['pos_y']; + + foreach(range($startX, $endX) as $x) + { + if(array_key_exists($x, $answers) && array_key_exists($y, $answers[$x]) && !empty($answers[$x][$y])) { + $answer .= $answers[$x][$y]; + } + else { + $answer .= ' '; + } + } + } + + // Check answer + if(mb_strtolower($word['word'], 'UTF-8') != mb_strtolower($answer, 'UTF-8')) { + return false; + } + } + + + // All answer right + return true; + } + + + /** + * Action: quest. + * + * Display a text with lists with predefined values. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param Exception $exception Character submission exception + */ + public function quest($seminary, $questgroup, $quest, $character, $exception) + { + // Get words + $words = $this->Crossword->getWordsForQuest($quest['id']); + + // Create 2D-matrix + $matrix = array(); + $maxX = 0; + $maxY = 0; + foreach($words as $index => &$word) + { + if($this->request->getGetParam('show-answer') == 'true' || !$this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id']) || $this->request->getGetParam('status') == 'solved') { + $word['answer'] = $this->Crossword->getCharacterSubmission($word['id'], $character['id']); + } + // Insert word + if($word['vertical']) + { + $x = $word['pos_x']; + $startY = $word['pos_y']; + $endY = $startY + mb_strlen($word['word'], 'UTF-8') - 1; + + $matrix = array_pad($matrix, $x+1, array()); + $matrix[$x] = array_pad($matrix[$x], $endY+1, null); + $maxX = max($maxX, $x); + $maxY = max($maxY, $endY); + + foreach(range($startY, $endY) as $y) + { + $matrix[$x][$y] = array( + 'char' => mb_substr($word['word'], $y-$startY, 1, 'UTF-8'), + 'indices' => (array_key_exists($x, $matrix) && array_key_exists($y, $matrix[$x]) && !is_null($matrix[$x][$y]) && array_key_exists('indices', $matrix[$x][$y])) ? $matrix[$x][$y]['indices'] : array(), + 'answer' => null + ); + if($y == $startY) { + $matrix[$x][$y]['indices'][] = $index; + } + if(array_key_exists('answer', $word)) + { + $answer = mb_substr($word['answer'], $y-$startY, 1, 'UTF-8'); + if($answer != ' ') { + $matrix[$x][$y]['answer'] = $answer; + } + } + } + } + else + { + $startX = $word['pos_x']; + $endX = $startX + mb_strlen($word['word'], 'UTF-8') - 1; + $y = $word['pos_y']; + + $matrix = array_pad($matrix, $endX+1, array()); + $maxX = max($maxX, $endX); + $maxY = max($maxY, $y); + + foreach(range($startX, $endX) as $x) + { + $matrix[$x] = array_pad($matrix[$x], $y+1, null); + + $matrix[$x][$y] = array( + 'char' => mb_substr($word['word'], $x-$startX, 1, 'UTF-8'), + 'indices' => (array_key_exists($x, $matrix) && array_key_exists($y, $matrix[$x]) && !is_null($matrix[$x][$y]) && array_key_exists('indices', $matrix[$x][$y])) ? $matrix[$x][$y]['indices'] : array(), + 'answer' => null + ); + if($x == $startX) { + $matrix[$x][$y]['indices'][] = $index; + } + if(array_key_exists('answer', $word)) + { + $answer = mb_substr($word['answer'], $x-$startX, 1, 'UTF-8'); + if($answer != ' ') { + $matrix[$x][$y]['answer'] = $answer; + } + } + } + } + } + + + // Pass data to view + $this->set('words', $words); + $this->set('maxX', $maxX); + $this->set('maxY', $maxY); + $this->set('matrix', $matrix); + } + + + /** + * Action: submission. + * + * Show the submission of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + */ + public function submission($seminary, $questgroup, $quest, $character) + { + // Get words + $words = $this->Crossword->getWordsForQuest($quest['id']); + + // Create 2D-matrix + $matrix = array(); + $maxX = 0; + $maxY = 0; + foreach($words as $index => &$word) + { + // Character answer + $word['answer'] = $this->Crossword->getCharacterSubmission($word['id'], $character['id']); + + // Insert word + if($word['vertical']) + { + $x = $word['pos_x']; + $startY = $word['pos_y']; + $endY = $startY + mb_strlen($word['word'], 'UTF-8') - 1; + + $matrix = array_pad($matrix, $x+1, array()); + $matrix[$x] = array_pad($matrix[$x], $endY+1, null); + $maxX = max($maxX, $x); + $maxY = max($maxY, $endY); + + foreach(range($startY, $endY) as $y) + { + $matrix[$x][$y] = array( + 'char' => mb_substr($word['word'], $y-$startY, 1, 'UTF-8'), + 'indices' => (array_key_exists($x, $matrix) && array_key_exists($y, $matrix[$x]) && !is_null($matrix[$x][$y]) && array_key_exists('indices', $matrix[$x][$y])) ? $matrix[$x][$y]['indices'] : array(), + 'answer' => null, + 'right' => false + ); + if($y == $startY) { + $matrix[$x][$y]['indices'][] = $index; + } + + if(!is_null($word['answer'])) + { + $answer = mb_substr($word['answer'], $y-$startY, 1, 'UTF-8'); + if($answer != ' ') + { + $matrix[$x][$y]['answer'] = $answer; + $matrix[$x][$y]['right'] = (mb_strtolower($matrix[$x][$y]['char'], 'UTF-8') == mb_strtolower($answer, 'UTF-8')); + } + } + } + } + else + { + $startX = $word['pos_x']; + $endX = $startX + mb_strlen($word['word'], 'UTF-8') - 1; + $y = $word['pos_y']; + + $matrix = array_pad($matrix, $endX+1, array()); + $maxX = max($maxX, $endX); + $maxY = max($maxY, $y); + + foreach(range($startX, $endX) as $x) + { + $matrix[$x] = array_pad($matrix[$x], $y+1, null); + + $matrix[$x][$y] = array( + 'char' => mb_substr($word['word'], $x-$startX, 1, 'UTF-8'), + 'indices' => (array_key_exists($x, $matrix) && array_key_exists($y, $matrix[$x]) && !is_null($matrix[$x][$y]) && array_key_exists('indices', $matrix[$x][$y])) ? $matrix[$x][$y]['indices'] : array(), + 'answer' => null, + 'right' => false + ); + if($x == $startX) { + $matrix[$x][$y]['indices'][] = $index; + } + if(!is_null($word['answer'])) + { + $answer = mb_substr($word['answer'], $x-$startX, 1, 'UTF-8'); + if($answer != ' ') + { + $matrix[$x][$y]['answer'] = $answer; + $matrix[$x][$y]['right'] = (mb_strtolower($matrix[$x][$y]['char'], 'UTF-8') == mb_strtolower($answer, 'UTF-8')); + } + } + } + } + } + + + // Pass data to view + $this->set('words', $words); + $this->set('maxX', $maxX); + $this->set('maxY', $maxY); + $this->set('matrix', $matrix); + } + + } + +?> diff --git a/questtypes/crossword/CrosswordQuesttypeModel.inc b/questtypes/crossword/CrosswordQuesttypeModel.inc new file mode 100644 index 00000000..78a42821 --- /dev/null +++ b/questtypes/crossword/CrosswordQuesttypeModel.inc @@ -0,0 +1,93 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Model of the CrosswordQuesttypeAgent for solving a crossword. + * + * @author Oliver Hanraths + */ + class CrosswordQuesttypeModel extends \hhu\z\QuesttypeModel + { + + + + + /** + * Get all words for a crossword-Quest. + * + * @param int $questId ID of Quest + * @return array Words + */ + public function getWordsForQuest($questId) + { + return $this->db->query( + 'SELECT id, question, word, vertical, pos_x, pos_y '. + 'FROM questtypes_crossword_words '. + 'WHERE quest_id = ? ', + 'i', + $questId + ); + } + + + /** + * Save Character’s submitted answer for one crossword-word. + * + * @param int $regexId ID of word + * @param int $characterId ID of Character + * @param string $answer Submitted answer for this word + */ + public function setCharacterSubmission($wordId, $characterId, $answer) + { + $this->db->query( + 'INSERT INTO questtypes_crossword_words_characters '. + '(questtypes_crossword_word_id, character_id, answer) '. + 'VALUES '. + '(?, ?, ?) '. + 'ON DUPLICATE KEY UPDATE '. + 'answer = ?', + 'iiss', + $wordId, $characterId, $answer, + $answer + ); + } + + + /** + * Get answer of one crossword-word submitted by Character. + * + * @param int $regexId ID of lisword + * @param int $characterId ID of Character + * @return int Submitted answer for this word or null + */ + public function getCharacterSubmission($wordId, $characterId) + { + $data = $this->db->query( + 'SELECT answer '. + 'FROM questtypes_crossword_words_characters '. + 'WHERE questtypes_crossword_word_id = ? AND character_id = ? ', + 'ii', + $wordId, $characterId + ); + if(!empty($data)) { + return $data[0]['answer']; + } + + + return null; + } + + } + +?> diff --git a/questtypes/crossword/html/quest.tpl b/questtypes/crossword/html/quest.tpl new file mode 100644 index 00000000..d202eb8f --- /dev/null +++ b/questtypes/crossword/html/quest.tpl @@ -0,0 +1,49 @@ +
+ + + + + + + + + + +
+ + 0) : ?> + + +
+
    + +
  1. + + : + + : + + +
  2. + +
+ +
+ diff --git a/questtypes/crossword/html/submission.tpl b/questtypes/crossword/html/submission.tpl new file mode 100644 index 00000000..c47e414d --- /dev/null +++ b/questtypes/crossword/html/submission.tpl @@ -0,0 +1,48 @@ +
+ + + + + + + + + + +
+ + 0) : ?> + + +
+
    + +
  1. + + : + + : + + +
  2. + +
+
+ diff --git a/questtypes/dragndrop/DragndropQuesttypeAgent.inc b/questtypes/dragndrop/DragndropQuesttypeAgent.inc new file mode 100644 index 00000000..c7d0abdf --- /dev/null +++ b/questtypes/dragndrop/DragndropQuesttypeAgent.inc @@ -0,0 +1,24 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * QuesttypeAgent for Drag&Drop. + * + * @author Oliver Hanraths + */ + class DragndropQuesttypeAgent extends \hhu\z\QuesttypeAgent + { + } + +?> diff --git a/questtypes/dragndrop/DragndropQuesttypeController.inc b/questtypes/dragndrop/DragndropQuesttypeController.inc new file mode 100644 index 00000000..4f359faa --- /dev/null +++ b/questtypes/dragndrop/DragndropQuesttypeController.inc @@ -0,0 +1,217 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Controller of the DragndropQuesttypeAgent for Drag&Drop. + * + * @author Oliver Hanraths + */ + class DragndropQuesttypeController extends \hhu\z\QuesttypeController + { + /** + * Required models + * + * @var array + */ + public $models = array('media'); + + + + + /** + * Save the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Get Drag&Drop field + $dndField = $this->Dragndrop->getDragndrop($quest['id']); + + // Get Drops + $drops = $this->Dragndrop->getDrops($dndField['quest_id']); + + // Save user answers + foreach($drops as &$drop) + { + // Determine user answer + $answer = null; + if(array_key_exists($drop['id'], $answers) && !empty($answers[$drop['id']])) + { + $a = intval(substr($answers[$drop['id']], 4)); + if($a !== false && $a > 0) { + $answer = $a; + } + } + + // Update database record + $this->Dragndrop->setCharacterSubmission($drop['id'], $character['id'], $answer); + } + } + + + /** + * Save additional data for the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $data Additional (POST-) data + */ + public function saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $data) + { + } + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @return boolean True/false for a right/wrong answer or null for moderator evaluation + */ + public function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Get Drag&Drop field + $dndField = $this->Dragndrop->getDragndrop($quest['id']); + + // Get Drags + $drags = $this->Dragndrop->getDrags($dndField['quest_id'], true); + + // Match drags with user answers + foreach($drags as &$drag) + { + $founds = array_keys($answers, 'drag'.$drag['id']); + if(count($founds) != 1) { + return false; + } + if(!$this->Dragndrop->dragMatchesDrop($drag['id'], $founds[0])) { + return false; + } + } + + + // Set status + return true; + } + + + /** + * Action: quest. + * + * Display a text with input fields and evaluate if user input + * matches with stored regular expressions. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param Exception $exception Character submission exception + */ + public function quest($seminary, $questgroup, $quest, $character, $exception) + { + // Get Drag&Drop field + $dndField = $this->Dragndrop->getDragndrop($quest['id']); + $dndField['media'] = $this->Media->getSeminaryMediaById($dndField['questmedia_id']); + + // Get Drags + $drags = array(); + $dragsByIndex = $this->Dragndrop->getDrags($dndField['quest_id']); + foreach($dragsByIndex as &$drag) { + $drag['media'] = $this->Media->getSeminaryMediaById($drag['questmedia_id']); + $drags[$drag['id']] = $drag; + } + + // Get Drops + $drops = $this->Dragndrop->getDrops($dndField['quest_id']); + + // Get Character answers + if($this->request->getGetParam('show-answer') == 'true' || !$this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id']) || $this->request->getGetParam('status') == 'solved') + { + foreach($drops as &$drop) + { + $drop['answer'] = $this->Dragndrop->getCharacterSubmission($drop['id'], $character['id']); + if(!is_null($drop['answer'])) + { + $drop['answer'] = $drags[$drop['answer']]; + unset($drags[$drop['answer']['id']]); + } + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('field', $dndField); + $this->set('drops', $drops); + $this->set('drags', $drags); + } + + + /** + * Action: submission. + * + * Show the submission of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + */ + public function submission($seminary, $questgroup, $quest, $character) + { + // Get Drag&Drop field + $dndField = $this->Dragndrop->getDragndrop($quest['id']); + $dndField['media'] = $this->Media->getSeminaryMediaById($dndField['questmedia_id']); + + // Get Drags + $drags = array(); + $dragsByIndex = $this->Dragndrop->getDrags($dndField['quest_id']); + foreach($dragsByIndex as &$drag) { + $drag['media'] = $this->Media->getSeminaryMediaById($drag['questmedia_id']); + $drags[$drag['id']] = $drag; + } + + // Get Drops + $drops = $this->Dragndrop->getDrops($dndField['quest_id']); + + // Get Character answers + foreach($drops as &$drop) + { + $drop['answer'] = $this->Dragndrop->getCharacterSubmission($drop['id'], $character['id']); + if(!is_null($drop['answer'])) + { + $drop['answer'] = $drags[$drop['answer']]; + unset($drags[$drop['answer']['id']]); + } + } + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('field', $dndField); + $this->set('drops', $drops); + $this->set('drags', $drags); + } + + } + +?> diff --git a/questtypes/dragndrop/DragndropQuesttypeModel.inc b/questtypes/dragndrop/DragndropQuesttypeModel.inc new file mode 100644 index 00000000..2aadb335 --- /dev/null +++ b/questtypes/dragndrop/DragndropQuesttypeModel.inc @@ -0,0 +1,180 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Model of the DragndropQuesttypeAgent for Drag&Drop. + * + * @author Oliver Hanraths + */ + class DragndropQuesttypeModel extends \hhu\z\QuesttypeModel + { + + + + + /** + * Get Drag&Drop-field. + * + * @param int $questId ID of Quest + * @return array Drag&Drop-field + */ + public function getDragndrop($questId) + { + $data = $this->db->query( + 'SELECT quest_id, questmedia_id, width, height '. + 'FROM questtypes_dragndrop '. + 'WHERE quest_id = ?', + 'i', + $questId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get Drop-items. + * + * @param int $dragndropId ID of Drag&Drop-field + * @return array Drop-items + */ + public function getDrops($dragndropId) + { + return $this->db->query( + 'SELECT id, top, `left`, width, height '. //, questtypes_dragndrop_drag_id '. + 'FROM questtypes_dragndrop_drops '. + 'WHERE questtypes_dragndrop_id = ?', + 'i', + $dragndropId + ); + } + + + /** + * Get Drag-items. + * + * @param int $dragndropId ID of Drag&Drop-field + * @param boolean $onlyUsed Only Drag-items that are used for a Drop-item + * @return array Drag-items + */ + public function getDrags($dragndropId, $onlyUsed=false) + { + return $this->db->query( + 'SELECT id, questmedia_id '. + 'FROM questtypes_dragndrop_drags '. + 'WHERE questtypes_dragndrop_id = ?'. + ($onlyUsed + ? ' AND EXISTS ('. + 'SELECT questtypes_dragndrop_drag_id '. + 'FROM questtypes_dragndrop_drops_drags '. + 'WHERE questtypes_dragndrop_drag_id = questtypes_dragndrop_drags.id'. + ')' + : null + ), + 'i', + $dragndropId + ); + } + + + /** + * Check if a Drag-item mathes a Drop-item. + * + * @param int $dragId ID of Drag-field + * @param int $dropId ID of Drop-field + * @return boolean Drag-item is valid for Drop-item + */ + public function dragMatchesDrop($dragId, $dropId) + { + $data = $this->db->query( + 'SELECT count(*) AS c '. + 'FROM questtypes_dragndrop_drops_drags '. + 'WHERE questtypes_dragndrop_drop_id = ? AND questtypes_dragndrop_drag_id = ?', + 'ii', + $dropId, $dragId + ); + if(!empty($data)) { + return ($data[0]['c'] > 0); + } + + + return false; + } + + + /** + * Save Character’s submitted answer for one Drop-field. + * + * @param int $dropId ID of Drop-field + * @param int $characterId ID of Character + * @param string $answer Submitted Drag-field-ID for this field + */ + public function setCharacterSubmission($dropId, $characterId, $answer) + { + if(is_null($answer)) + { + $this->db->query( + 'DELETE FROM questtypes_dragndrop_drops_characters '. + 'WHERE questtypes_dragndrop_drop_id = ? AND character_id = ?', + 'ii', + $dropId, $characterId + ); + } + else + { + $this->db->query( + 'INSERT INTO questtypes_dragndrop_drops_characters '. + '(questtypes_dragndrop_drop_id, character_id, questtypes_dragndrop_drag_id) '. + 'VALUES '. + '(?, ?, ?) '. + 'ON DUPLICATE KEY UPDATE '. + 'questtypes_dragndrop_drag_id = ?', + 'iiii', + $dropId, $characterId, $answer, $answer + ); + } + } + + + /** + * Get Character’s saved answer for one Drop-field. + * + * @param int $dropId ID of Drop-field + * @param int $characterId ID of Character + * @return int ID of Drag-field or null + */ + public function getCharacterSubmission($dropId, $characterId) + { + $data = $this->db->query( + 'SELECT questtypes_dragndrop_drag_id '. + 'FROM questtypes_dragndrop_drops_characters '. + 'WHERE questtypes_dragndrop_drop_id = ? AND character_id = ?', + 'ii', + $dropId, $characterId + ); + if(!empty($data)) { + return $data[0]['questtypes_dragndrop_drag_id']; + } + + + return null; + } + + } + +?> diff --git a/questtypes/dragndrop/html/quest.tpl b/questtypes/dragndrop/html/quest.tpl new file mode 100644 index 00000000..4cb10b60 --- /dev/null +++ b/questtypes/dragndrop/html/quest.tpl @@ -0,0 +1,17 @@ +
+
+ +
+ + +
+ +
+ + + +
+ +
+ +
diff --git a/questtypes/dragndrop/html/submission.tpl b/questtypes/dragndrop/html/submission.tpl new file mode 100644 index 00000000..c136fe1e --- /dev/null +++ b/questtypes/dragndrop/html/submission.tpl @@ -0,0 +1,15 @@ +
+ +
+ + + +
+ +
+ +
+ + + +
diff --git a/questtypes/multiplechoice/MultiplechoiceQuesttypeAgent.inc b/questtypes/multiplechoice/MultiplechoiceQuesttypeAgent.inc new file mode 100644 index 00000000..2789c5f6 --- /dev/null +++ b/questtypes/multiplechoice/MultiplechoiceQuesttypeAgent.inc @@ -0,0 +1,24 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * QuesttypeAgent for multiple choice. + * + * @author Oliver Hanraths + */ + class MultiplechoiceQuesttypeAgent extends \hhu\z\QuesttypeAgent + { + } + +?> diff --git a/questtypes/multiplechoice/MultiplechoiceQuesttypeController.inc b/questtypes/multiplechoice/MultiplechoiceQuesttypeController.inc new file mode 100644 index 00000000..09dbfe41 --- /dev/null +++ b/questtypes/multiplechoice/MultiplechoiceQuesttypeController.inc @@ -0,0 +1,276 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Controller of the MultiplechoiceQuesttypeAgent multiple choice. + * + * @author Oliver Hanraths + */ + class MultiplechoiceQuesttypeController extends \hhu\z\QuesttypeController + { + + + + + /** + * Save the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Save temporary user answer of last question + $answers = (!is_array($answers)) ? array() : $answers; + $pos = $this->Multiplechoice->getQuestionsCountOfQuest($quest['id']); + $question = $this->Multiplechoice->getQuestionOfQuest($quest['id'], $pos); + $this->saveUserAnswers($quest['id'], $question['id'], $answers); + + // Save answers + $questions = $this->Multiplechoice->getQuestionsOfQuest($quest['id']); + foreach($questions as &$question) + { + $userAnswers = $this->getUserAnswers($quest['id'], $question['id']); + $answers = $this->Multiplechoice->getAnswersOfQuestion($question['id']); + foreach($answers as &$answer) + { + $userAnswer = (array_key_exists($answer['pos']-1, $userAnswers)) ? true : false; + $this->Multiplechoice->setCharacterSubmission($answer['id'], $character['id'], $userAnswer); + } + } + } + + + /** + * Save additional data for the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $data Additional (POST-) data + */ + public function saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $data) + { + } + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @return boolean True/false for a right/wrong answer or null for moderator evaluation + */ + public function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Save temporary user answer of last question + $answers = (!is_array($answers)) ? array() : $answers; + $pos = $this->Multiplechoice->getQuestionsCountOfQuest($quest['id']); + $question = $this->Multiplechoice->getQuestionOfQuest($quest['id'], $pos); + $this->saveUserAnswers($quest['id'], $question['id'], $answers); + + // Get questions + $questions = $this->Multiplechoice->getQuestionsOfQuest($quest['id']); + + // Iterate questions + foreach($questions as &$question) + { + // Get answers + $userAnswers = $this->getUserAnswers($quest['id'], $question['id']); + $answers = $this->Multiplechoice->getAnswersOfQuestion($question['id']); + + // Match answers with user answers + foreach($answers as &$answer) + { + if(is_null($answer['tick'])) { + continue; + } + if($answer['tick']) { + if(!array_key_exists($answer['pos']-1, $userAnswers)) { + return false; + } + } + else { + if(array_key_exists($answer['pos']-1, $userAnswers)) { + return false; + } + } + } + } + + + // All questions correct answerd + return true; + } + + + /** + * Action: quest. + * + * Display questions with a checkbox to let the user choose the + * right ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param Exception $exception Character submission exception + */ + public function quest($seminary, $questgroup, $quest, $character, $exception) + { + // Get count of questions + $count = $this->Multiplechoice->getQuestionsCountOfQuest($quest['id']); + + // Get position + $pos = 1; + if($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('submit-answer'))) + { + if(!is_null($this->request->getPostParam('question'))) + { + // Get current position + $pos = intval($this->request->getPostParam('question')); + if($pos < 0 || $pos > $count) { + throw new \nre\exceptions\ParamsNotValidException($pos); + } + + // Save temporary answer of user + $question = $this->Multiplechoice->getQuestionOfQuest($quest['id'], $pos); + $answers = ($this->request->getRequestMethod() == 'POST' && !is_null($this->request->getPostParam('answers'))) ? $this->request->getPostParam('answers') : array(); + $this->saveUserAnswers($quest['id'], $question['id'], $answers); + + // Go to next position + $pos++; + } + else { + throw new \nre\exceptions\ParamsNotValidException('pos'); + } + } + + // Get current question + $question = $this->Multiplechoice->getQuestionOfQuest($quest['id'], $pos); + + // Get answers + $question['answers'] = $this->Multiplechoice->getAnswersOfQuestion($question['id']); + + + // Get previous user answers + if($this->request->getGetParam('show-answer') == 'true' || !$this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id']) || $this->request->getGetParam('status') == 'solved') + { + foreach($question['answers'] as &$answer) { + $answer['useranswer'] = $this->Multiplechoice->getCharacterSubmission($answer['id'], $character['id']); + } + } + + + // Pass data to view + $this->set('question', $question); + $this->set('pos', $pos); + $this->set('count', $count); + } + + + /** + * Action: submission. + * + * Show the submission of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + */ + public function submission($seminary, $questgroup, $quest, $character) + { + // Get questions + $questions = $this->Multiplechoice->getQuestionsOfQuest($quest['id']); + + // Get answers + foreach($questions as &$question) + { + $question['answers'] = $this->Multiplechoice->getAnswersOfQuestion($question['id']); + + // Get user answers + foreach($question['answers'] as &$answer) { + $answer['useranswer'] = $this->Multiplechoice->getCharacterSubmission($answer['id'], $character['id']); + } + } + + + // Pass data to view + $this->set('questions', $questions); + } + + + + + /** + * Save the answers of a user for a question temporary in the + * session. + * + * @param int $questId ID of Quest + * @param int $questionId ID of multiple choice question + * @param array $userAnswers Answers of user for the question + */ + private function saveUserAnswers($questId, $questionId, $userAnswers) + { + // Ensure session structure + if(!array_key_exists('answers', $_SESSION)) { + $_SESSION['answers'] = array(); + } + if(!array_key_exists($questId, $_SESSION['answers'])) { + $_SESSION['answers'][$questId] = array(); + } + $_SESSION['answers'][$questId][$questionId] = array(); + + // Save answres + foreach($userAnswers as $pos => &$answer) { + $_SESSION['answers'][$questId][$questionId][$pos] = $answer; + } + } + + + /** + * Get the temporary saved answers of a user for a question. + * + * @param int $questId ID of Quest + * @param int $questionId ID of multiple choice question + * @return array Answers of user for the question + */ + private function getUserAnswers($questId, $questionId) + { + // Ensure session structure + if(!array_key_exists('answers', $_SESSION)) { + $_SESSION['answers'] = array(); + } + if(!array_key_exists($questId, $_SESSION['answers'])) { + $_SESSION['answers'][$questId] = array(); + } + if(!array_key_exists($questionId, $_SESSION['answers'][$questId])) { + $_SESSION['answers'][$questId][$questionId] = array(); + } + + + // Return answers + return $_SESSION['answers'][$questId][$questionId]; + } + + } + +?> diff --git a/questtypes/multiplechoice/MultiplechoiceQuesttypeModel.inc b/questtypes/multiplechoice/MultiplechoiceQuesttypeModel.inc new file mode 100644 index 00000000..6c4931b7 --- /dev/null +++ b/questtypes/multiplechoice/MultiplechoiceQuesttypeModel.inc @@ -0,0 +1,159 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Model of the MultiplechoiceQuesttypeAgent for multiple choice. + * + * @author Oliver Hanraths + */ + class MultiplechoiceQuesttypeModel extends \hhu\z\QuesttypeModel + { + + + + + /** + * Get the count of multiple choice questions for a Quest. + * + * @param int $questId ID of Quest to get count for + * @return int Conut of questions + */ + public function getQuestionsCountOfQuest($questId) + { + $data = $this->db->query( + 'SELECT count(id) AS c '. + 'FROM questtypes_multiplechoice '. + 'WHERE quest_id = ?', + 'i', + $questId + ); + if(!empty($data)) { + return $data[0]['c']; + } + + + return 0; + } + + + /** + * Get all multiple choice questions of a Quest. + * + * @param int $questId ID of Quest + * @return array Multiple choice questions + */ + public function getQuestionsOfQuest($questId) + { + return $this->db->query( + 'SELECT id, pos, question '. + 'FROM questtypes_multiplechoice '. + 'WHERE quest_id = ?', + 'i', + $questId + ); + } + + + /** + * Get one multiple choice question of a Quest. + * + * @param int $questId ID of Quest + * @param int $pos Position of question + * @return array Question data + */ + public function getQuestionOfQuest($questId, $pos) + { + $data = $this->db->query( + 'SELECT id, pos, question '. + 'FROM questtypes_multiplechoice '. + 'WHERE quest_id = ? AND pos = ?', + 'ii', + $questId, $pos + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get all answers of a multiple choice question. + * + * @param int $questionId ID of multiple choice question + * @return array Answers of question + */ + public function getAnswersOfQuestion($questionId) + { + return $this->db->query( + 'SELECT id, pos, answer, tick '. + 'FROM questtypes_multiplechoice_answers '. + 'WHERE questtypes_multiplechoice_id = ?', + 'i', + $questionId + ); + } + + + /** + * Save Character’s submitted answer for one option. + * + * @param int $answerId ID of multiple choice answer + * @param int $characterId ID of Character + * @param boolean $answer Submitted answer for this option + */ + public function setCharacterSubmission($answerId, $characterId, $answer) + { + $this->db->query( + 'INSERT INTO questtypes_multiplechoice_characters '. + '(questtypes_multiplechoice_answer_id, character_id, ticked) '. + 'VALUES '. + '(?, ?, ?) '. + 'ON DUPLICATE KEY UPDATE '. + 'ticked = ?', + 'iiii', + $answerId, $characterId, $answer, $answer + ); + } + + + /** + * Get answer of one option submitted by Character. + * + * @param int $answerId ID of multiple choice answer + * @param int $characterId ID of Character + * @return boolean Submitted answer of Character or false + */ + public function getCharacterSubmission($answerId, $characterId) + { + $data = $this->db->query( + 'SELECT ticked '. + 'FROM questtypes_multiplechoice_characters '. + 'WHERE questtypes_multiplechoice_answer_id = ? AND character_id = ? ', + 'ii', + $answerId, $characterId + ); + if(!empty($data)) { + return $data[0]['ticked']; + } + + + return false; + } + + } + +?> diff --git a/questtypes/multiplechoice/html/quest.tpl b/questtypes/multiplechoice/html/quest.tpl new file mode 100644 index 00000000..eba80286 --- /dev/null +++ b/questtypes/multiplechoice/html/quest.tpl @@ -0,0 +1,21 @@ +
+
+ : +

+
    + &$answer) : ?> +
  1. + /> + +
  2. + +
+
+ + + + + + + +
diff --git a/questtypes/multiplechoice/html/submission.tpl b/questtypes/multiplechoice/html/submission.tpl new file mode 100644 index 00000000..ebbbd493 --- /dev/null +++ b/questtypes/multiplechoice/html/submission.tpl @@ -0,0 +1,16 @@ +
    + &$question) : ?> +
  1. +

    t($question['question'])?>

    +
      + +
    1. + + × + +
    2. + +
    +
  2. + +
diff --git a/questtypes/submit/SubmitQuesttypeAgent.inc b/questtypes/submit/SubmitQuesttypeAgent.inc new file mode 100644 index 00000000..68ca9ae5 --- /dev/null +++ b/questtypes/submit/SubmitQuesttypeAgent.inc @@ -0,0 +1,24 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * QuesttypeAgent for submitting. + * + * @author Oliver Hanraths + */ + class SubmitQuesttypeAgent extends \hhu\z\QuesttypeAgent + { + } + +?> diff --git a/questtypes/submit/SubmitQuesttypeController.inc b/questtypes/submit/SubmitQuesttypeController.inc new file mode 100644 index 00000000..d8d33186 --- /dev/null +++ b/questtypes/submit/SubmitQuesttypeController.inc @@ -0,0 +1,227 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Controller of the SubmitQuesttypeAgent for a submit task. + * + * @author Oliver Hanraths + */ + class SubmitQuesttypeController extends \hhu\z\QuesttypeController + { + /** + * Required models + * + * @var array + */ + public $models = array('quests', 'uploads', 'users'); + + + + + /** + * Save the answers of a Character for a Quest. + * + * @throws SubmissionNotValidException + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Save answer + if(array_key_exists('answers', $_FILES)) + { + $answer = $_FILES['answers']; + + // Check error + if($answer['error'] !== 0) { + throw new \hhu\z\exceptions\SubmissionNotValidException( + new \hhu\z\exceptions\FileUploadException($answer['error']) + ); + } + + // Check mimetype + $mimetypes = $this->Submit->getAllowedMimetypes($seminary['id']); + $answerMimetype = null; + $answer['mimetype'] = \hhu\z\Utils::getMimetype($answer['tmp_name'], $answer['type']); + foreach($mimetypes as &$mimetype) { + if($mimetype['mimetype'] == $answer['mimetype']) { + $answerMimetype = $mimetype; + break; + } + } + if(is_null($answerMimetype)) { + throw new \hhu\z\exceptions\SubmissionNotValidException( + new \hhu\z\exceptions\WrongFiletypeException($answer['mimetype']) + ); + } + + // Check file size + if($answer['size'] > $answerMimetype['size']) { + throw new \hhu\z\exceptions\SubmissionNotValidException( + new \hhu\z\exceptions\MaxFilesizeException() + ); + } + + // Create filename + $filename = sprintf( + '%s,%s,%s.%s', + $character['url'], + mb_substr($quest['url'], 0, 32), + date('Ymd-His'), + mb_substr(mb_substr($answer['name'], strrpos($answer['name'], '.')+1), 0, 4) + ); + + // Save file + if(!$this->Submit->setCharacterSubmission($seminary['id'], $quest['id'], $this->Auth->getUserId(), $character['id'], $answer, $filename)) { + throw new \hhu\z\exceptions\SubmissionNotValidException( + new \hhu\z\exceptions\FileUploadException(error_get_last()['message']) + ); + } + } + } + + + /** + * Save additional data for the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $data Additional (POST-) data + */ + public function saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $data) + { + $this->Submit->addCommentForCharacterAnswer($this->Auth->getUserId(), $data['submission_id'], $data['comment']); + } + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + * @return boolean True/false for a right/wrong answer or null for moderator evaluation + */ + public function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // A moderator has to evaluate the answer + return null; + } + + + /** + * Action: quest. + * + * Display a big textbox to let the user enter a text that has + * to be evaluated by a moderator. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param Exception $exception Character submission exception + */ + public function quest($seminary, $questgroup, $quest, $character, $exception) + { + // Answers (Submissions) + $characterSubmissions = $this->Submit->getCharacterSubmissions($quest['id'], $character['id']); + foreach($characterSubmissions as &$submission) + { + $submission['upload'] = $this->Uploads->getSeminaryuploadById($submission['upload_id']); + $submission['comments'] = $this->Submit->getCharacterSubmissionComments($submission['id']); + foreach($submission['comments'] as &$comment) + { + try { + $comment['user'] = $this->Users->getUserById($comment['created_user_id']); + $comment['user']['character'] = $this->Characters->getCharacterForUserAndSeminary($comment['user']['id'], $seminary['id']); + } + catch(\nre\exceptions\IdNotFoundException $e) { + } + } + } + + // Show answer of Character + if($this->request->getGetParam('show-answer') == 'true') { + $this->redirect($this->linker->link(array('uploads','seminary',$seminary['url'], $characterSubmissions[count($characterSubmissions)-1]['upload']['url']))); + } + + // Has Character already solved Quest? + $solved = $this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id']); + + // Last Quest status for Character + $lastStatus = $this->Quests->getLastQuestStatus($quest['id'], $character['id']); + + // Get allowed mimetypes + $mimetypes = $this->Submit->getAllowedMimetypes($seminary['id']); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('submissions', $characterSubmissions); + $this->set('solved', $solved); + $this->set('lastStatus', $lastStatus); + $this->set('mimetypes', $mimetypes); + $this->set('exception', $exception); + } + + + /** + * Action: submission. + * + * Show the submission of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + */ + public function submission($seminary, $questgroup, $quest, $character) + { + // Get Character submissions + $submissions = $this->Submit->getCharacterSubmissions($quest['id'], $character['id']); + foreach($submissions as &$submission) + { + $submission['upload'] = $this->Uploads->getSeminaryuploadById($submission['upload_id']); + $submission['comments'] = $this->Submit->getCharacterSubmissionComments($submission['id']); + foreach($submission['comments'] as &$comment) + { + try { + $comment['user'] = $this->Users->getUserById($comment['created_user_id']); + $comment['user']['character'] = $this->Characters->getCharacterForUserAndSeminary($comment['user']['id'], $seminary['id']); + } + catch(\nre\exceptions\IdNotFoundException $e) { + } + } + } + + // Status + $solved = $this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id']); + + + // Pass data to view + $this->set('seminary', $seminary); + $this->set('submissions', $submissions); + $this->set('solved', $solved); + } + + } + +?> diff --git a/questtypes/submit/SubmitQuesttypeModel.inc b/questtypes/submit/SubmitQuesttypeModel.inc new file mode 100644 index 00000000..5d821655 --- /dev/null +++ b/questtypes/submit/SubmitQuesttypeModel.inc @@ -0,0 +1,142 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Model of the SubmitQuesttypeAgent for a submit task. + * + * @author Oliver Hanraths + */ + class SubmitQuesttypeModel extends \hhu\z\QuesttypeModel + { + /** + * Required models + * + * @var array + */ + public $models = array('uploads'); + + + + + /** + * Save Character’s submitted upload. + * + * @param int $seminaryId ID of Seminary + * @param int $questId ID of Quest + * @param int $characterId ID of Character + * @param array $file Submitted upload + */ + public function setCharacterSubmission($seminaryId, $questId, $userId, $characterId, $file, $filename) + { + // Save file on harddrive + $uploadId = $this->Uploads->uploadSeminaryFile($userId, $seminaryId, $file['name'], $filename, $file['tmp_name'], $file['type']); + if($uploadId === false) { + return false; + } + + // Create database record + $this->db->query( + 'INSERT INTO questtypes_submit_characters '. + '(quest_id, character_id, upload_id) '. + 'VALUES '. + '(?, ?, ?) ', + 'iii', + $questId, $characterId, $uploadId + ); + + + return true; + } + + + /** + * Add a comment for the answer of a Character. + * + * @param int $userId ID of user that comments + * @param int $submissionId ID of Character answer to comment + * @param string $comment Comment text + */ + public function addCommentForCharacterAnswer($userId, $submissionId, $comment) + { + $this->db->query( + 'INSERT INTO questtypes_submit_characters_comments '. + '(created_user_id, questtypes_submit_character_id, comment) '. + 'VALUES '. + '(?, ?, ?)', + 'iis', + $userId, + $submissionId, + $comment + ); + } + + + /** + * Get all uploads submitted by Character. + * + * @param int $questId ID of Quest + * @param int $characterId ID of Character + * @return array Text submitted by Character or NULL + */ + public function getCharacterSubmissions($questId, $characterId) + { + return $this->db->query( + 'SELECT id, created, upload_id '. + 'FROM questtypes_submit_characters '. + 'WHERE quest_id = ? AND character_id = ? '. + 'ORDER BY created ASC', + 'ii', + $questId, $characterId + ); + } + + + /** + * Get allowed mimetypes for uploading a file. + * + * @param int $seminaryId ID of Seminary + * @return array Allowed mimetypes + */ + public function getAllowedMimetypes($seminaryId) + { + return $this->db->query( + 'SELECT id, mimetype, size '. + 'FROM questtypes_submit_mimetypes '. + 'WHERE seminary_id = ?', + 'i', + $seminaryId + ); + } + + + /** + * Get all comments for a Character submission. + * + * @param int $characterSubmissionId ID of Character submission + * @return array Comments for this submission + */ + public function getCharacterSubmissionComments($characterSubmissionId) + { + return $this->db->query( + 'SELECT id, created, created_user_id, comment '. + 'FROM questtypes_submit_characters_comments '. + 'WHERE questtypes_submit_character_id = ?', + 'i', + $characterSubmissionId + ); + } + + } + +?> diff --git a/questtypes/submit/html/quest.tpl b/questtypes/submit/html/quest.tpl new file mode 100644 index 00000000..bb0b5101 --- /dev/null +++ b/questtypes/submit/html/quest.tpl @@ -0,0 +1,53 @@ + +

+ getNestedException() instanceof \hhu\z\exceptions\WrongFiletypeException) : ?> + getNestedException()->getType())?> + getNestedException() instanceof \hhu\z\exceptions\WrongFiletypeException) : ?> + + getNestedException() instanceof \hhu\z\exceptions\FileUploadException) : ?> + getNestedException()->getNestedMessage())?> + + getNestedException()->getMessage()?> + +

+ + $submissions[count($submissions)-1]['created'])) : ?> +
+ +

:

+
    + +
  • 0) : ?>(  MiB)
  • + +
+ +
+ + + 0) : ?> +

+
    + &$submission) : ?> +
  1. + format(new \DateTime($submission['created'])), $timeFormatter->format(new \DateTime($submission['created'])))?> + 0) : ?> +

    + + 0) : ?> +
      + +
    1. + +

      format(new \DateTime($comment['created'])), $timeFormatter->format(new \DateTime($comment['created'])))?>

      + + +

      + +
    2. + +
    + +
  2. + +
+ diff --git a/questtypes/submit/html/submission.tpl b/questtypes/submit/html/submission.tpl new file mode 100644 index 00000000..e985f56e --- /dev/null +++ b/questtypes/submit/html/submission.tpl @@ -0,0 +1,33 @@ + 0) : ?> +
    + +
  1. +

    +

    format(new \DateTime($submission['created'])), $timeFormatter->format(new \DateTime($submission['created'])))?>

    + 0) : ?> +
      + +
    1. + +

      format(new \DateTime($comment['created'])), $timeFormatter->format(new \DateTime($comment['created'])))?>

      + +

      +
    2. + +
    + +
  2. + +
+ + +
+ + +
+
+ + + + +
diff --git a/questtypes/textinput/TextinputQuesttypeAgent.inc b/questtypes/textinput/TextinputQuesttypeAgent.inc new file mode 100644 index 00000000..8144c148 --- /dev/null +++ b/questtypes/textinput/TextinputQuesttypeAgent.inc @@ -0,0 +1,24 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * QuesttypeAgent for inserting text. + * + * @author Oliver Hanraths + */ + class TextinputQuesttypeAgent extends \hhu\z\QuesttypeAgent + { + } + +?> diff --git a/questtypes/textinput/TextinputQuesttypeController.inc b/questtypes/textinput/TextinputQuesttypeController.inc new file mode 100644 index 00000000..aa666790 --- /dev/null +++ b/questtypes/textinput/TextinputQuesttypeController.inc @@ -0,0 +1,185 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Controller of the TextinputQuesttypeAgent for for inserting text. + * + * @author Oliver Hanraths + */ + class TextinputQuesttypeController extends \hhu\z\QuesttypeController + { + + + + + /** + * Save the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $answers Character answers for the Quest + */ + public function saveAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Get regexs + $regexs = $this->Textinput->getTextinputRegexs($quest['id']); + + // Save answers + foreach($regexs as &$regex) + { + $pos = intval($regex['number']) - 1; + $answer = (array_key_exists($pos, $answers)) ? $answers[$pos] : ''; + $this->Textinput->setCharacterSubmission($regex['id'], $character['id'], $answer); + } + } + + + /** + * Save additional data for the answers of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param array $data Additional (POST-) data + */ + public function saveDataForCharacterAnswers($seminary, $questgroup, $quest, $character, $data) + { + } + + + /** + * Check if answers of a Character for a Quest match the correct ones. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @return boolean True/false for a right/wrong answer or null for moderator evaluation + */ + public function matchAnswersOfCharacter($seminary, $questgroup, $quest, $character, $answers) + { + // Get right answers + $regexs = $this->Textinput->getTextinputRegexs($quest['id']); + + // Match regexs with user answers + $allSolved = true; + foreach($regexs as $i => &$regex) + { + if(!array_key_exists($i, $answers)) + { + $allSolved = false; + break; + } + + if(!$this->isMatching($regex['regex'], $answers[$i])) + { + $allSolved = false; + break; + } + } + + + // Set status + return $allSolved; + } + + + /** + * Action: quest. + * + * Display a text with input fields and evaluate if user input + * matches with stored regular expressions. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + * @param Exception $exception Character submission exception + */ + public function quest($seminary, $questgroup, $quest, $character, $exception) + { + // Get Task + $task = $this->Textinput->getTextinputQuest($quest['id']); + + // Process text + $textParts = preg_split('/(\$\$)/', ' '.$task['text'].' ', -1, PREG_SPLIT_NO_EMPTY); + + // Has Character already solved Quest? + $solved = $this->Quests->hasCharacterSolvedQuest($quest['id'], $character['id']); + + // Get Character answers + $regexs = null; + if(!$solved || $this->request->getGetParam('show-answer') == 'true' || $this->request->getGetParam('status') == 'solved') + { + $regexs = $this->Textinput->getTextinputRegexs($quest['id']); + foreach($regexs as &$regex) { + $regex['answer'] = $this->Textinput->getCharacterSubmission($regex['id'], $character['id']); + } + } + + + // Pass data to view + $this->set('texts', $textParts); + $this->set('regexs', $regexs); + } + + + /** + * Action: submission. + * + * Show the submission of a Character for a Quest. + * + * @param array $seminary Current Seminary data + * @param array $questgroup Current Questgroup data + * @param array $quest Current Quest data + * @param array $character Current Character data + */ + public function submission($seminary, $questgroup, $quest, $character) + { + // Get Task + $task = $this->Textinput->getTextinputQuest($quest['id']); + + // Process text + $textParts = preg_split('/(\$\$)/', $task['text'], -1, PREG_SPLIT_NO_EMPTY); + + // Get Character answers + $regexs = $this->Textinput->getTextinputRegexs($quest['id']); + foreach($regexs as &$regex) { + $regex['answer'] = $this->Textinput->getCharacterSubmission($regex['id'], $character['id']); + $regex['right'] = $this->isMatching($regex['regex'], $regex['answer']); + } + + + // Pass data to view + $this->set('texts', $textParts); + $this->set('regexs', $regexs); + } + + + + + private function isMatching($regex, $answer) + { + $score = preg_match($regex, $answer); + + + return ($score !== false && $score > 0); + } + + } + +?> diff --git a/questtypes/textinput/TextinputQuesttypeModel.inc b/questtypes/textinput/TextinputQuesttypeModel.inc new file mode 100644 index 00000000..3d00d038 --- /dev/null +++ b/questtypes/textinput/TextinputQuesttypeModel.inc @@ -0,0 +1,117 @@ + + * @copyright 2014 Heinrich-Heine-Universität Düsseldorf + * @license http://www.gnu.org/licenses/gpl.html + * @link https://bitbucket.org/coderkun/the-legend-of-z + */ + + namespace hhu\z\questtypes; + + + /** + * Model of the TextinputQuesttypeAgent for inserting text. + * + * @author Oliver Hanraths + */ + class TextinputQuesttypeModel extends \hhu\z\QuesttypeModel + { + + + + + /** + * Get textinput-text for a Quest. + * + * @param int $questId ID of Quest + * @return array Textinput-text + */ + public function getTextinputQuest($questId) + { + $data = $this->db->query( + 'SELECT text '. + 'FROM questtypes_textinput '. + 'WHERE quest_id = ?', + 'i', + $questId + ); + if(!empty($data)) { + return $data[0]; + } + + + return null; + } + + + /** + * Get regular expressions for a textinput-text. + * + * @param int $questId ID of Quest + * @return array Regexs + */ + public function getTextinputRegexs($questId) + { + return $this->db->query( + 'SELECT id, number, regex '. + 'FROM questtypes_textinput_regexs '. + 'WHERE questtypes_textinput_quest_id = ? '. + 'ORDER BY number ASC', + 'i', + $questId + ); + } + + + /** + * Save Character’s submitted answer for one textinput field. + * + * @param int $regexId ID of regex + * @param int $characterId ID of Character + * @param string $answer Submitted answer for this field + */ + public function setCharacterSubmission($regexId, $characterId, $answer) + { + $this->db->query( + 'INSERT INTO questtypes_textinput_regexs_characters '. + '(questtypes_textinput_regex_id, character_id, value) '. + 'VALUES '. + '(?, ?, ?) '. + 'ON DUPLICATE KEY UPDATE '. + 'value = ?', + 'iiss', + $regexId, $characterId, $answer, $answer + ); + } + + + /** + * Get answer of one regex input field submitted by Character. + * + * @param int $regexId ID of regex + * @param int $characterId ID of Character + * @return string Submitted answer for this field or empty string + */ + public function getCharacterSubmission($regexId, $characterId) + { + $data = $this->db->query( + 'SELECT value '. + 'FROM questtypes_textinput_regexs_characters '. + 'WHERE questtypes_textinput_regex_id = ? AND character_id = ? ', + 'ii', + $regexId, $characterId + ); + if(!empty($data)) { + return $data[0]['value']; + } + + + return ''; + } + + } + +?> diff --git a/questtypes/textinput/html/quest.tpl b/questtypes/textinput/html/quest.tpl new file mode 100644 index 00000000..6616ba4d --- /dev/null +++ b/questtypes/textinput/html/quest.tpl @@ -0,0 +1,11 @@ +
+

+ &$text) : ?> + 0) : ?> + + + t($text)?> + +

+ +
diff --git a/questtypes/textinput/html/submission.tpl b/questtypes/textinput/html/submission.tpl new file mode 100644 index 00000000..b3d606f4 --- /dev/null +++ b/questtypes/textinput/html/submission.tpl @@ -0,0 +1,7 @@ + &$text) : ?> + 0) : ?> + + + +t($text)?> + diff --git a/requests/WebRequest.inc b/requests/WebRequest.inc new file mode 100644 index 00000000..2e0f19b9 --- /dev/null +++ b/requests/WebRequest.inc @@ -0,0 +1,401 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\requests; + + + /** + * Representation of a web-request. + * + * @author coderkun + */ + class WebRequest extends \nre\core\Request + { + /** + * Passed GET-parameters + * + * @var array + */ + private $getParams = array(); + /** + * Passed POST-parameters + * + * @var array + */ + private $postParams = array(); + /** + * Stored routes + * + * @var array + */ + private $routes = array(); + /** + * Stored reverse-routes + * + * @var array + */ + private $reverseRoutes = array(); + + + + + /** + * Construct a new web-request. + */ + public function __construct() + { + // Detect current request + $this->detectRequest(); + + // Load GET-parameters + $this->loadGetParams(); + + // Load POST-parameters + $this->loadPostParams(); + + // Detect AJAX + $this->detectAJAX(); + } + + + + + /** + * Get a parameter. + * + * @param int $index Index of parameter + * @param string $defaultIndex Index of default configuration value for this parameter + * @return string Value of parameter + */ + public function getParam($index, $defaultIndex=null) + { + if($index == 0) { + return $this->getGetParam('layout', $defaultIndex); + } + else { + return parent::getParam($index-1, $defaultIndex); + } + } + + + /** + * Get all parameters from index on. + * + * @param int $offset Offset-index + * @return array Parameters + */ + public function getParams($offset=0) + { + if($offset == 0) + { + return array_merge( + array( + $this->getGetParam('layout', 'toplevel') + ), + parent::getParams() + ); + } + + + return array_slice($this->params, $offset-1); + } + + + /** + * Get a GET-parameter. + * + * @param string $key Key of GET-parameter + * @param string $defaultIndex Index of default configuration value for this parameter + * @return string Value of GET-parameter + */ + public function getGetParam($key, $defaultIndex=null) + { + // Check key + if(array_key_exists($key, $this->getParams)) + { + // Return value + return $this->getParams[$key]; + } + + + // Return default value + return \nre\core\Config::getDefault($defaultIndex); + } + + + /** + * Get all GET-parameters. + * + * @return array GET-Parameters + */ + public function getGetParams() + { + return $this->getParams; + } + + + /** + * Get a POST-parameter. + * + * @param string $key Key of POST-parameter + * @param string $defaultValue Default value for this parameter + * @return string Value of POST-parameter + */ + public function getPostParam($key, $defaultValue=null) + { + // Check key + if(array_key_exists($key, $this->postParams)) + { + // Return value + return $this->postParams[$key]; + } + + + // Return default value + return $defaultValue; + } + + + /** + * Get all POST-parameters. + * + * @return array POST-parameters + */ + public function getPostParams() + { + return $this->postParams; + } + + + /** + * Get the method of the current request. + * + * @return string Current request method + */ + public function getRequestMethod() + { + return $_SERVER['REQUEST_METHOD']; + } + + + /** + * Add a URL-route. + * + * @param string $pattern Regex-Pattern that defines the routing + * @param string $replacement Regex-Pattern for replacement + * @param bool $isLast Stop after that rule + */ + public function addRoute($pattern, $replacement, $isLast=false) + { + // Store route + $this->routes[] = $this->newRoute($pattern, $replacement, $isLast); + } + + + /** + * Add a reverse URL-route. + * + * @param string $pattern Regex-Pattern that defines the reverse routing + * @param string $replacement Regex-Pattern for replacement + * @param bool $isLast Stop after that rule + */ + public function addReverseRoute($pattern, $replacement, $isLast=false) + { + // Store reverse route + $this->reverseRoutes[] = $this->newRoute($pattern, $replacement, $isLast); + } + + + /** + * Apply stored reverse-routes to an URL + * + * @param string $url URL to apply reverse-routes to + * @return string Reverse-routed URL + */ + public function applyReverseRoutes($url) + { + return $this->applyRoutes($url, $this->reverseRoutes); + } + + + /** + * Revalidate the current request + */ + public function revalidate() + { + $this->detectRequest(); + } + + + /** + * Get a SERVER-parameter. + * + * @param string $key Key of SERVER-parameter + * @return string Value of SERVER-parameter + */ + public function getServerParam($key) + { + if(array_key_exists($key, $_SERVER)) { + return $_SERVER[$key]; + } + + + return null; + } + + + + + /** + * Detect the current HTTP-request. + */ + private function detectRequest() + { + // URL ermitteln + $url = isset($_GET) && array_key_exists('url', $_GET) ? $_GET['url'] : ''; + $url = trim($url, '/'); + + // Routes anwenden + $url = $this->applyRoutes($url, $this->routes); + + // URL splitten + $params = explode('/', $url); + if(empty($params[0])) { + $params = array(); + } + + + // Parameter speichern + $this->params = $params; + } + + + /** + * Determine parameters passed by GET. + */ + private function loadGetParams() + { + if(isset($_GET)) { + $this->getParams = $_GET; + } + } + + + /** + * Determine parameters passed by POST. + */ + private function loadPostParams() + { + if(isset($_POST)) { + $this->postParams = $_POST; + } + } + + + /** + * Detect an AJAX-request by checking the X-Requested-With + * header and set the layout to 'ajax' in this case. + */ + private function detectAjax() + { + // Get request headers + $headers = apache_request_headers(); + + // Check X-Requested-With header and set layout + if(array_key_exists('X-Requested-With', $headers) && $headers['X-Requested-With'] == 'XMLHttpRequest') { + if(!array_key_exists('layout', $this->getParams)) { + $this->getParams['layout'] = 'ajax'; + } + } + } + + + /** + * Create a new URL-route. + * + * @param string $pattern Regex-Pattern that defines the reverse routing + * @param string $replacement Regex-Pattern for replacement + * @param bool $isLast Stop after that rule + * @return array New URL-route + */ + private function newRoute($pattern, $replacement, $isLast=false) + { + return array( + 'pattern' => $pattern, + 'replacement' => $replacement, + 'isLast' => $isLast + ); + } + + + /** + * Apply given routes to an URL + * + * @param string $url URL to apply routes to + * @param array $routes Routes to apply + * @return string Routed URL + */ + private function applyRoutes($url, $routes) + { + // Traverse given routes + foreach($routes as &$route) + { + // Create and apply Regex + $urlR = preg_replace( + '>'.$route['pattern'].'>i', + $route['replacement'], + $url + ); + + // Split URL + $get = ''; + if(($gpos = strrpos($urlR, '?')) !== false) { + $get = substr($urlR, $gpos+1); + $urlR = substr($urlR, 0, $gpos); + } + + // Has current route changed anything? + if($urlR != $url || !empty($get)) + { + // Extract GET-parameters + if(strlen($get) > 0) + { + $gets = explode('&', $get); + foreach($gets as $get) + { + $get = explode('=', $get); + if(!array_key_exists($get[0], $this->getParams)) { + $this->getParams[$get[0]] = $get[1]; + } + } + } + + + // Stop when route “isLast” + if($route['isLast']) { + $url = $urlR; + break; + } + } + + + // Set new URL + $url = $urlR; + } + + + // Return routed URL + return $url; + } + + } + +?> diff --git a/responses/WebResponse.inc b/responses/WebResponse.inc new file mode 100644 index 00000000..2ca25079 --- /dev/null +++ b/responses/WebResponse.inc @@ -0,0 +1,250 @@ + + * @copyright 2013 coderkun (http://www.coderkun.de) + * @license http://www.gnu.org/licenses/gpl.html + * @link http://www.coderkun.de/projects/nre + */ + + namespace nre\responses; + + + /** + * Representation of a web-response. + * + * @author coderkun + */ + class WebResponse extends \nre\core\Response + { + /** + * Applied GET-parameters + * + * @var array + */ + private $getParams = array(); + /** + * Changed header lines + * + * @var array + */ + private $headers = array(); + + + + + /** + * Add a parameter. + * + * @param mixed $value Value of parameter + */ + public function addParam($value) + { + if(array_key_exists('layout', $this->getParams)) { + parent::addParam($value); + } + else { + $this->addGetParam('layout', $value); + } + } + + + /** + * Add multiple parameters. + * + * @param mixed $value1 Value of first parameter + * @param mixed … Values of further parameters + */ + public function addParams($value1) + { + $this->addParam($value1); + + $this->params = array_merge( + $this->params, + array_slice( + func_get_args(), + 1 + ) + ); + } + + + /** + * Delete all stored parameters (from offset on). + * + * @param int $offset Offset-index + */ + public function clearParams($offset=0) + { + if($offset == 0) { + unset($this->getParams['layout']); + } + + parent::clearParams(max(0, $offset-1)); + } + + + /** + * Get a parameter. + * + * @param int $index Index of parameter + * @param string $defaultIndex Index of default configuration value for this parameter + * @return string Value of parameter + */ + public function getParam($index, $defaultIndex=null) + { + if($index == 0) { + return $this->getGetParam('layout', $defaultIndex); + } + else { + return parent::getParam($index-1, $defaultIndex); + } + } + + + /** + * Get all parameters from index on. + * + * @param int $offset Offset-index + * @return array Parameter values + */ + public function getParams($offset=0) + { + if($offset == 0) + { + if(!array_key_exists('layout', $this->getParams)) { + return array(); + } + + return array_merge( + array( + $this->getParams['layout'] + ), + $this->params + ); + } + + + return array_slice($this->params, $offset-1); + } + + + /** + * Add a GET-parameter. + * + * @param string $key Key of GET-parameter + * @param mixed $value Value of GET-parameter + */ + public function addGetParam($key, $value) + { + $this->getParams[$key] = $value; + } + + + /** + * Add multiple GET-parameters. + * + * @param array $params Associative arary with key-value GET-parameters + */ + public function addGetParams($params) + { + $this->getParams = array_merge( + $this->getParams, + $params + ); + } + + + /** + * Get a GET-parameter. + * + * @param int $index Index of GET-parameter + * @param string $defaultIndex Index of default configuration value for this parameter + * @return string Value of GET-parameter + */ + public function getGetParam($key, $defaultIndex=null) + { + // Check key + if(array_key_exists($key, $this->getParams)) + { + // Return value + return $this->getParams[$key]; + } + + // Return default value + return \nre\core\Config::getDefault($defaultIndex); + } + + + /** + * Get all GET-parameters. + * + * @return array All GET-parameters + */ + public function getGetParams() + { + return $this->getParams; + } + + + /** + * Add a line to the response header. + * + * @param string $headerLine Header line + * @param bool $replace Replace existing header line + * @param int $http_response_code HTTP-response code + */ + public function addHeader($headerLine, $replace=true, $http_response_code=null) + { + $this->headers[] = $this->newHeader($headerLine, $replace, $http_response_code); + } + + + /** + * Clear all stored headers. + */ + public function clearHeaders() + { + $this->headers = array(); + } + + + /** + * Send stored headers. + */ + public function header() + { + foreach($this->headers as $header) + { + header( + $header['string'], + $header['replace'], + $header['responseCode'] + ); + } + } + + + + + /** + * Create a new header line. + * + * @param string $headerLine Header line + * @param bool $replace Replace existing header line + * @param int $http_response_code HTTP-response code + */ + private function newHeader($headerLine, $replace=true, $http_response_code=null) + { + return array( + 'string' => $headerLine, + 'replace' => $replace, + 'responseCode' => $http_response_code + ); + } + + } + +?> diff --git a/seminarymedia/empty b/seminarymedia/empty new file mode 100644 index 00000000..e69de29b diff --git a/seminaryuploads/empty b/seminaryuploads/empty new file mode 100644 index 00000000..e69de29b diff --git a/tmp/empty b/tmp/empty new file mode 100644 index 00000000..e69de29b diff --git a/uploads/empty b/uploads/empty new file mode 100644 index 00000000..e69de29b diff --git a/views/binary/binary.tpl b/views/binary/binary.tpl new file mode 100644 index 00000000..43a06a51 --- /dev/null +++ b/views/binary/binary.tpl @@ -0,0 +1 @@ + diff --git a/views/binary/error/index.tpl b/views/binary/error/index.tpl new file mode 100644 index 00000000..2d8440b4 --- /dev/null +++ b/views/binary/error/index.tpl @@ -0,0 +1 @@ +: : diff --git a/views/binary/media/achievement.tpl b/views/binary/media/achievement.tpl new file mode 100644 index 00000000..0d6fb0df --- /dev/null +++ b/views/binary/media/achievement.tpl @@ -0,0 +1 @@ + diff --git a/views/binary/media/avatar.tpl b/views/binary/media/avatar.tpl new file mode 100644 index 00000000..0d6fb0df --- /dev/null +++ b/views/binary/media/avatar.tpl @@ -0,0 +1 @@ + diff --git a/views/binary/media/index.tpl b/views/binary/media/index.tpl new file mode 100644 index 00000000..0d6fb0df --- /dev/null +++ b/views/binary/media/index.tpl @@ -0,0 +1 @@ + diff --git a/views/binary/media/seminary.tpl b/views/binary/media/seminary.tpl new file mode 100644 index 00000000..0d6fb0df --- /dev/null +++ b/views/binary/media/seminary.tpl @@ -0,0 +1 @@ + diff --git a/views/binary/media/seminarymoodpic.tpl b/views/binary/media/seminarymoodpic.tpl new file mode 100644 index 00000000..0d6fb0df --- /dev/null +++ b/views/binary/media/seminarymoodpic.tpl @@ -0,0 +1 @@ + diff --git a/views/binary/uploads/charactergroup.tpl b/views/binary/uploads/charactergroup.tpl new file mode 100644 index 00000000..0d6fb0df --- /dev/null +++ b/views/binary/uploads/charactergroup.tpl @@ -0,0 +1 @@ + diff --git a/views/binary/uploads/seminary.tpl b/views/binary/uploads/seminary.tpl new file mode 100644 index 00000000..0d6fb0df --- /dev/null +++ b/views/binary/uploads/seminary.tpl @@ -0,0 +1 @@ + diff --git a/views/error.tpl b/views/error.tpl new file mode 100644 index 00000000..f45b01a0 --- /dev/null +++ b/views/error.tpl @@ -0,0 +1,13 @@ + + + + + + Service Unavailable + + + +

Die Anwendung steht zur Zeit leider nicht zur Verfügung.

+ + + diff --git a/views/fault/error/index.tpl b/views/fault/error/index.tpl new file mode 100644 index 00000000..f7e42500 --- /dev/null +++ b/views/fault/error/index.tpl @@ -0,0 +1,2 @@ +

Fehler

+

:

diff --git a/views/fault/fault.tpl b/views/fault/fault.tpl new file mode 100644 index 00000000..a6459f96 --- /dev/null +++ b/views/fault/fault.tpl @@ -0,0 +1,16 @@ + + + + + + Questlab + + + +

Questlab

+
+ +
+ + + diff --git a/views/html/achievements/index.tpl b/views/html/achievements/index.tpl new file mode 100644 index 00000000..346aa712 --- /dev/null +++ b/views/html/achievements/index.tpl @@ -0,0 +1,84 @@ + +
+ +
+ + +

+

+
+
+

+
    + +
  1. + + + + + + + + + +

    +

    +
  2. + +
+
+
+

+
    + +
  1. + +

    +

    +
  2. + +
+
+
+

+
+

+
+ +
+
+

. : .

+
    + +
  • + + + +

    format(new \DateTime($achievement['created'])))?>

    +

    +
  • + + +
  • + + + +

    + +

    + +

    + + +
    +
    + +
    +

    %

    +
    + +
  • + +
diff --git a/views/html/charactergroups/creategroup.tpl b/views/html/charactergroups/creategroup.tpl new file mode 100644 index 00000000..c0926aee --- /dev/null +++ b/views/html/charactergroups/creategroup.tpl @@ -0,0 +1,60 @@ + +
+ +
+ + + +

+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + getMessage(); + break; + } ?> +
    • + +
    +
  • + +
+ +
+
+ + />
+ + />
+
+ +
diff --git a/views/html/charactergroups/creategroupsgroup.tpl b/views/html/charactergroups/creategroupsgroup.tpl new file mode 100644 index 00000000..1d0e84aa --- /dev/null +++ b/views/html/charactergroups/creategroupsgroup.tpl @@ -0,0 +1,51 @@ + +
+ +
+ + + +

+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + getMessage(); + break; + } ?> +
    • + +
    +
  • + +
+ +
+
+ + />
+
checked="checked" />
+
+ +
diff --git a/views/html/charactergroups/deletegroup.tpl b/views/html/charactergroups/deletegroup.tpl new file mode 100644 index 00000000..fc2460c4 --- /dev/null +++ b/views/html/charactergroups/deletegroup.tpl @@ -0,0 +1,17 @@ + +
+ +
+ + +

+ + +
+ + +
diff --git a/views/html/charactergroups/deletegroupsgroup.tpl b/views/html/charactergroups/deletegroupsgroup.tpl new file mode 100644 index 00000000..5f788af8 --- /dev/null +++ b/views/html/charactergroups/deletegroupsgroup.tpl @@ -0,0 +1,16 @@ + +
+ +
+ + +

+ + +
+ + +
diff --git a/views/html/charactergroups/editgroup.tpl b/views/html/charactergroups/editgroup.tpl new file mode 100644 index 00000000..fd0bd6e5 --- /dev/null +++ b/views/html/charactergroups/editgroup.tpl @@ -0,0 +1,60 @@ + +
+ +
+ + + +

+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + getMessage(); + break; + } ?> +
    • + +
    +
  • + +
+ +
+
+ + />
+ + />
+
+ +
diff --git a/views/html/charactergroups/editgroupsgroup.tpl b/views/html/charactergroups/editgroupsgroup.tpl new file mode 100644 index 00000000..47e2ec91 --- /dev/null +++ b/views/html/charactergroups/editgroupsgroup.tpl @@ -0,0 +1,51 @@ + +
+ +
+ + + +

+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + getMessage(); + break; + } ?> +
    • + +
    +
  • + +
+ +
+
+ + />
+
checked="checked" />
+
+ +
diff --git a/views/html/charactergroups/group.tpl b/views/html/charactergroups/group.tpl new file mode 100644 index 00000000..4d59cc19 --- /dev/null +++ b/views/html/charactergroups/group.tpl @@ -0,0 +1,63 @@ + +
+ +
+ + + + 0) : ?> + + + +
+ + + + + +

+

""

+
+
    +
  • .
  • +
  • +
  • 1) ? _('Members') : _('Member')?>
  • +
+ +
+

+
    + +
  • + +

    + +

    +

    +
  • + +
+
+ +
+

+
    + +
  • +

    + format(new \DateTime($quest['created']))?> + + / +

    +
  • + +
+
diff --git a/views/html/charactergroups/groupsgroup.tpl b/views/html/charactergroups/groupsgroup.tpl new file mode 100644 index 00000000..3bbe6fa3 --- /dev/null +++ b/views/html/charactergroups/groupsgroup.tpl @@ -0,0 +1,41 @@ + +
+ +
+ + + 0) : ?> + + + +

+ 0) : ?> + + +
    + +
  1. XP
  2. + +
+ + +

+ 0) : ?> + + +
    + +
  • + +
diff --git a/views/html/charactergroups/index.tpl b/views/html/charactergroups/index.tpl new file mode 100644 index 00000000..9bc4b0b4 --- /dev/null +++ b/views/html/charactergroups/index.tpl @@ -0,0 +1,21 @@ + +
+ +
+ + +

+ + 0) : ?> + + + +
    + +
  • + +
diff --git a/views/html/charactergroups/managegroup.tpl b/views/html/charactergroups/managegroup.tpl new file mode 100644 index 00000000..37432cc9 --- /dev/null +++ b/views/html/charactergroups/managegroup.tpl @@ -0,0 +1,113 @@ + +
+ +
+ + + +
+ + + + + +

+

""

+
+
    +
  • .
  • +
  • XP
  • +
  • 1) ? _('Members') : _('Member')?>
  • +
+ +
+

+
+
+
    + +
  • + disabled="disabled"/> + +
  • + +
+ +
+
+

Charaktere der Gruppe hinzufügen:

+ + + +
+
+
+ +
+

+
    + +
  • +

    + format(new \DateTime($quest['created']))?> + + / XP +

    +
  • + +
+
+ + + + + diff --git a/views/html/charactergroupsquests/create.tpl b/views/html/charactergroupsquests/create.tpl new file mode 100644 index 00000000..48e2feb1 --- /dev/null +++ b/views/html/charactergroupsquests/create.tpl @@ -0,0 +1,76 @@ + +
+ +
+ + + +

+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + getMessage(); + break; + } ?> +
    • + +
    +
  • + +
+ +
+
+ + /> + + /> + + + + + + + + + + +
+ +
diff --git a/views/html/charactergroupsquests/delete.tpl b/views/html/charactergroupsquests/delete.tpl new file mode 100644 index 00000000..919dd29a --- /dev/null +++ b/views/html/charactergroupsquests/delete.tpl @@ -0,0 +1,17 @@ + +
+ +
+ + + +

+ +
+ + +
diff --git a/views/html/charactergroupsquests/edit.tpl b/views/html/charactergroupsquests/edit.tpl new file mode 100644 index 00000000..0e8b43a9 --- /dev/null +++ b/views/html/charactergroupsquests/edit.tpl @@ -0,0 +1,76 @@ + +
+ +
+ + + +

+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + getMessage(); + break; + } ?> +
    • + +
    +
  • + +
+ +
+
+ + /> + + /> + + + + + + + + + + +
+ +
diff --git a/views/html/charactergroupsquests/manage.tpl b/views/html/charactergroupsquests/manage.tpl new file mode 100644 index 00000000..ada60330 --- /dev/null +++ b/views/html/charactergroupsquests/manage.tpl @@ -0,0 +1,90 @@ + +
+ +
+ + + +
+ + + + + +

+
+
    +
  • +
  • +
+ +
+

+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + +
    • + +
    +
  • + +
+ + +

: 0) : ?>(  MiB)

+
+
+ +
+
+ +
+

+
+
    + +
  • + + + + +
  • + +
+ +
+
diff --git a/views/html/charactergroupsquests/quest.tpl b/views/html/charactergroupsquests/quest.tpl new file mode 100644 index 00000000..163d5ac6 --- /dev/null +++ b/views/html/charactergroupsquests/quest.tpl @@ -0,0 +1,81 @@ + +
+ +
+ + + + 0) : ?> + + + +
+ + + + + +

+
+
    +
  • +
  • +
+ + 0) : ?> +
+

+ +
+ + +
+

+

+ +

+

+ +
+ + +
+

+

+
+ + +
+

+

+
+ + +
+

+
    + +
  • + format(new \DateTime($group['created']))?> + + +
  • + +
+
diff --git a/views/html/characters/character.tpl b/views/html/characters/character.tpl new file mode 100644 index 00000000..7f2f37ac --- /dev/null +++ b/views/html/characters/character.tpl @@ -0,0 +1,134 @@ + +
+ +
+ + + +

+ + +
+
+
+
+ +
+

: %

+
+
+

+

+
+
+

+

XP

+
+
+

.

+

+
+

+
    + +
  • + +

    + +

    + +

    + +

    +
  • + +
+
+
+ +
+
+ +
+
+

+
    + &$rankCharacter) : ?> +
  • + +

    .

    +

    ()

    +
  • + +
  • + +

    .

    +

    ()

    +
  • + &$rankCharacter) : ?> +
  • + +

    .

    +

    ()

    +
  • + +
+
+
+

+ +
+
+ + +
+

+

+
+ + +
+

+
    + +
  • +

    (/)

    +
    + +
    +
  • + +
+
+ diff --git a/views/html/characters/delete.tpl b/views/html/characters/delete.tpl new file mode 100644 index 00000000..0b66be72 --- /dev/null +++ b/views/html/characters/delete.tpl @@ -0,0 +1,19 @@ + +
+ +
+ + + +

+ +
+ + +
diff --git a/views/html/characters/edit.tpl b/views/html/characters/edit.tpl new file mode 100644 index 00000000..e5815d77 --- /dev/null +++ b/views/html/characters/edit.tpl @@ -0,0 +1,109 @@ + +
+ +
+ + + +

+
+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + +
    • + +
    +
  • + +
+ +
+ + + 0) : ?> + /> + + + +
+
    + +
  • + + 0) : ?> + checked="checked" /> + + checked="checked" /> + + + + +
  • + +
+
+ + +
    + &$settings) : ?> +
  • + +
+ +
+ + + + + value="" required="required"/> + + + + + +
+ +
+ +
diff --git a/views/html/characters/index.tpl b/views/html/characters/index.tpl new file mode 100644 index 00000000..6e547654 --- /dev/null +++ b/views/html/characters/index.tpl @@ -0,0 +1,48 @@ + +
+ +
+ + +

+ + 0) : ?> + + + +
+
+

Sortierung:

+ + +
+
+ +
    + +
  • + +

    XP

    +

    + () + () + () +

    + +

    + +
  • + +
diff --git a/views/html/characters/manage.tpl b/views/html/characters/manage.tpl new file mode 100644 index 00000000..c86cd26b --- /dev/null +++ b/views/html/characters/manage.tpl @@ -0,0 +1,63 @@ + +
+ +
+ + +

+ +
+
+

Sortierung:

+ + +
+
+
    + +
  • + checked="checked" disabled="disabled"/> + +
  • + +
+
+
+ + 0 || !in_array('admin', \hhu\z\controllers\SeminaryController::$character['characterroles'])) : ?> + + + + +
+
+ + 0 || !in_array('admin', \hhu\z\controllers\SeminaryController::$character['characterroles'])) : ?> + + + + +
+
diff --git a/views/html/characters/register.tpl b/views/html/characters/register.tpl new file mode 100644 index 00000000..af8148a9 --- /dev/null +++ b/views/html/characters/register.tpl @@ -0,0 +1,95 @@ + +
+ +
+ + +

+
+ +
    + &$settings) : ?> +
  • +
      + $value) : ?> +
    • + +
    • + +
    +
  • + +
+ +
+ + +
+
    + +
  • + + checked="checked" /> +
  • + +
+
+ + +
    + &$settings) : ?> +
  • + +
+ +
+ + + + + value="" required="required"/> + + + + + +
+ +
+ +
diff --git a/views/html/error/index.tpl b/views/html/error/index.tpl new file mode 100644 index 00000000..a0b02462 --- /dev/null +++ b/views/html/error/index.tpl @@ -0,0 +1,17 @@ +

+

:

+ + +

+
+
+ +
+ +
+
+ + + +
+ diff --git a/views/html/html.tpl b/views/html/html.tpl new file mode 100644 index 00000000..844fecce --- /dev/null +++ b/views/html/html.tpl @@ -0,0 +1,119 @@ + + + + + + <?=\nre\configs\AppConfig::$app['name']?> + + + + + + + + + + + + + + + + + +
+ +
+
+ 0) : ?> +
    + + +
  • + + + +

    :

    + +

    + +

    + +
  • + +
  • + +

    :

    + +

    + +

    + +
  • + + +
+ + +
+ + + + + + diff --git a/views/html/introduction/index.tpl b/views/html/introduction/index.tpl new file mode 100644 index 00000000..5d51626c --- /dev/null +++ b/views/html/introduction/index.tpl @@ -0,0 +1,50 @@ +
+ +
+ +
+
+

+

+ +
+

+
+ +

+

+

Bereits im Sommersemester 2013 wurde das Projekt „Die Legende von Zyren“ unterstützt durch den Lehrförderungsfond der Heinrich-Heine-Universität Düsseldorf ins Leben gerufen, um die Inhalte der Vorlesung „Wissensrepräsentation“ den Studierenden des Faches Informationswissenschaft mit Hilfe von Spielelementen und -modellen zu vermitteln. Die innovative Lernumgebung besteht aus einem virtuellen Textadventure, dass über eine webbasierte Plattform zugänglich ist und realen Spielen in einer Präsenzveranstaltung, in denen die Studierenden unmittelbar in das Abenteuer eintauchen und in Teams spielerisch gegeneinander antreten.

+

Auf der Plattform spielt sich jeder der Studierenden mit seinem virtuellen Avatar durch das Reich von Zyren und erlernt und vertieft auf spielerische Weise die Inhalte der Vorlesung „Wissensrepräsentation“, die in Form von Herausforderungen oder Rätseln in das Abenteuer eingebunden wurden und über den Fortlauf und den Erfolg des Spiels entscheiden.

+

In der zusätzlichen Präsenzveranstaltung tauchen die Studierenden direkt in das Abenteuer ein und die vertiefen die Inhalte spielerisch. Hier schließen sich die Studierenden in Teams (Gilden) zusammen, müssen eigenverantwortlich Lerninhalte erarbeiten, Probleme lösen und in speziellen Gildenaufgaben gegen andere Teams antreten, um ihr kollaborativ erarbeitetes Wissen auf die Probe zu stellen.

+

Für jede erfolgreiche absolvierte Herausforderung auf der Plattform oder in der Übung erhalten die Studierenden Erfahrungspunkte und Belohnungen.

+

Um das Konzept auch anderen Fachbereichen und Lehrveranstaltungen zugänglich zu machen, wurde im Frühjahr 2014 das Projekt Questlab (Arbeitstitel „The Legend of Z“) gestartet um das Konzept zu generalisieren. Lehrende können die Plattform nun nutzen um eigene Aufgaben (Quests) zu kreieren und hochzuladen und sie optional in eine Geschichte einzubinden, die sie selbst gestalten können. Zudem wurde das Responsive Design überarbeitet und bietet nun optimalen Zugriff auf die Plattform über alle mobilen Endgeräte.

+ +

Die Legende von Zyren in der Presse

+ + +

Das Team

+

Projektleitung:

+
    +
  • Kathrin Knautz
  • +
+

Entwicklung und Evaluation des Prototypens:

+
    +
  • Lisa Orszullok
  • +
  • Simone Soubusta
  • +
  • Julia Göretz
  • +
  • Anja Wintermeyer
  • +
+

Entwicklung „Questlab“:

+
    +
  • Oliver Hanraths
  • +
  • Daniel Miskovic
  • +
diff --git a/views/html/library/index.tpl b/views/html/library/index.tpl new file mode 100644 index 00000000..8b42b8e9 --- /dev/null +++ b/views/html/library/index.tpl @@ -0,0 +1,28 @@ + +
+ +
+ + +

+

+
+

0) ? round($totalCharacterQuestcount/$totalQuestcount*100) : 0) ?>

+
+ +
+
+
    + +
  • + +

    +

    Fortschritt: /

    +
    + +
    +
  • + +
diff --git a/views/html/library/topic.tpl b/views/html/library/topic.tpl new file mode 100644 index 00000000..8d153215 --- /dev/null +++ b/views/html/library/topic.tpl @@ -0,0 +1,30 @@ + +
+ +
+ + +

+
+

Themenfortschritt: /

+
+ +
+
+ +

Quests zu diesem Thema:

+
    + +
  • +

    +
      + +
    • + +
    +
  • + +
diff --git a/views/html/menu/index.tpl b/views/html/menu/index.tpl new file mode 100644 index 00000000..9785edaf --- /dev/null +++ b/views/html/menu/index.tpl @@ -0,0 +1,9 @@ +
  • + 0) : ?>
  • +
  • + 0) : ?> + +
  • + +
  • + diff --git a/views/html/questgroups/create.tpl b/views/html/questgroups/create.tpl new file mode 100644 index 00000000..17692935 --- /dev/null +++ b/views/html/questgroups/create.tpl @@ -0,0 +1,18 @@ + +
    + +
    + + +

    + +
    +
    + +
    +
    + +
    diff --git a/views/html/questgroups/questgroup.tpl b/views/html/questgroups/questgroup.tpl new file mode 100644 index 00000000..aadc169e --- /dev/null +++ b/views/html/questgroups/questgroup.tpl @@ -0,0 +1,72 @@ + +
    + +
    + + + + +

    :

    + +

    + + 0): ?> +
    + +

    + +
    + + + + + 0) : ?> +

    +
      + +
    • + + + + + + + +
      +
      +

      Fortschritt:

      +
      + +
      +

      / XP

      +
      + +
      +

      :

      +

      +
      + +
    • + +
    + + + + + 0) : ?> +

    + + diff --git a/views/html/questgroupshierarchypath/index.tpl b/views/html/questgroupshierarchypath/index.tpl new file mode 100644 index 00000000..1f68e73c --- /dev/null +++ b/views/html/questgroupshierarchypath/index.tpl @@ -0,0 +1,16 @@ + diff --git a/views/html/quests/create.tpl b/views/html/quests/create.tpl new file mode 100644 index 00000000..68f8b4b8 --- /dev/null +++ b/views/html/quests/create.tpl @@ -0,0 +1,44 @@ + +
    + +
    + + + +

    + +
    +
    + +
    + +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    diff --git a/views/html/quests/createmedia.tpl b/views/html/quests/createmedia.tpl new file mode 100644 index 00000000..be46b2b8 --- /dev/null +++ b/views/html/quests/createmedia.tpl @@ -0,0 +1,21 @@ + +
    + +
    + + +

    Create Questsmedia

    + + +

    New mediaId:

    + + +

    Error:

    + +
    +
    +
    + +
    diff --git a/views/html/quests/index.tpl b/views/html/quests/index.tpl new file mode 100644 index 00000000..03724b57 --- /dev/null +++ b/views/html/quests/index.tpl @@ -0,0 +1,41 @@ + +
    + +
    + + +

    + +
    +
    + Filter + +
    + + +
    + + +
    + + diff --git a/views/html/quests/quest.tpl b/views/html/quests/quest.tpl new file mode 100644 index 00000000..3828a924 --- /dev/null +++ b/views/html/quests/quest.tpl @@ -0,0 +1,137 @@ + +
    + +
    + + +

    + + 0) : ?> +
    +

    +
    + + +

    + + + + + + + +

    + 0 || !empty($questtext['abort_text'])) : ?> +
      + +
    • + + +
    • + +
    + + +
    +
    + + + +
    +

    + + +
    +

    +

    0): ?>

    +
    + +
    +

    +

    +
    + + + + +

    t($quest['task'])?>

    + + + +
    +

    : +

    +
    + + +
    + + + 0) : ?> +
    +

    +
    + +

    + + + + +

    + 0 || !empty($questtext['abort_text'])) : ?> +
      + +
    • + + +
    • + +
    + + +
    +
    + + + +
    +

    + 0) : ?> +
      + + +
    • +

      + : + + + + + +

      +
    • + +
    • + + + +

      + + +

      :

      + +

      + + +
    • + + +
    + +

    :

    +

    + +
    + diff --git a/views/html/quests/submission.tpl b/views/html/quests/submission.tpl new file mode 100644 index 00000000..b8add296 --- /dev/null +++ b/views/html/quests/submission.tpl @@ -0,0 +1,12 @@ + +
    + +
    + + +

    + +

    +
    + +
    diff --git a/views/html/quests/submissions.tpl b/views/html/quests/submissions.tpl new file mode 100644 index 00000000..37699e48 --- /dev/null +++ b/views/html/quests/submissions.tpl @@ -0,0 +1,39 @@ + +
    + +
    + + +

    + +
    +

    +
      + +
    1. + + format(new \DateTime($character['submission_created'])), $timeFormatter->format(new \DateTime($character['submission_created'])))?> +
    2. + +
    + +

    +
      + +
    1. + + format(new \DateTime($character['submission_created'])), $timeFormatter->format(new \DateTime($character['submission_created'])))?> +
    2. + +
    + +

    +
      + +
    1. + + format(new \DateTime($character['submission_created'])), $timeFormatter->format(new \DateTime($character['submission_created'])))?> +
    2. + +
    +
    diff --git a/views/html/seminaries/create.tpl b/views/html/seminaries/create.tpl new file mode 100644 index 00000000..45637e02 --- /dev/null +++ b/views/html/seminaries/create.tpl @@ -0,0 +1,13 @@ +
    + +
    +

    +

    + +
    +
    + +
    +
    + +
    diff --git a/views/html/seminaries/delete.tpl b/views/html/seminaries/delete.tpl new file mode 100644 index 00000000..49387278 --- /dev/null +++ b/views/html/seminaries/delete.tpl @@ -0,0 +1,13 @@ + +
    + +
    + +

    +

    + + +
    + + +
    diff --git a/views/html/seminaries/edit.tpl b/views/html/seminaries/edit.tpl new file mode 100644 index 00000000..70cd86a9 --- /dev/null +++ b/views/html/seminaries/edit.tpl @@ -0,0 +1,15 @@ + +
    + +
    + +

    +

    + +
    +
    + +
    +
    + +
    diff --git a/views/html/seminaries/index.tpl b/views/html/seminaries/index.tpl new file mode 100644 index 00000000..7064834d --- /dev/null +++ b/views/html/seminaries/index.tpl @@ -0,0 +1,42 @@ +
    + +
    +

    + 0) : ?> + + +
      + +
    • + + + +
      +

      + 0) : ?> + + + + +

      + +
      +
      + +
      +

      /

      +
      + +

      +

      format(new \DateTime($seminary['created'])))?>

      + + + +

      + +
      +
    • + +
    diff --git a/views/html/seminaries/seminary.tpl b/views/html/seminaries/seminary.tpl new file mode 100644 index 00000000..f85af665 --- /dev/null +++ b/views/html/seminaries/seminary.tpl @@ -0,0 +1,40 @@ + +
    + +
    + +

    + 0) : ?> + + +

    + +

    +
      + +
    • + + + +
      +

      : +

      +
      +
      + +
      +

      / XP

      +
      + +

      + + +
      +
    • + +
    + diff --git a/views/html/seminarybar/index.tpl b/views/html/seminarybar/index.tpl new file mode 100644 index 00000000..ec094e70 --- /dev/null +++ b/views/html/seminarybar/index.tpl @@ -0,0 +1,48 @@ +
    +

    + + +
    + + +
    +

    +

    +
    + + + +
    +

    +
      +
    • + + + +

      +

      format(new \DateTime($lastAchievement['created'])))?>

      +
    • +
    +
    + + +
    + +

    +
      + +
    • + +

      +

      ()

      +
    • + +
    +

    + +
    diff --git a/views/html/seminarymenu/index.tpl b/views/html/seminarymenu/index.tpl new file mode 100644 index 00000000..b6f13f61 --- /dev/null +++ b/views/html/seminarymenu/index.tpl @@ -0,0 +1,7 @@ +
      +
    • + 0 || count(array_intersect(array('admin','moderator'),$loggedCharacter['characterroles']))) : ?>
    • +
    • +
    • +
    • +
    \ No newline at end of file diff --git a/views/html/userroles/user.tpl b/views/html/userroles/user.tpl new file mode 100644 index 00000000..df8b7a66 --- /dev/null +++ b/views/html/userroles/user.tpl @@ -0,0 +1,5 @@ +
      + +
    • + +
    diff --git a/views/html/users/create.tpl b/views/html/users/create.tpl new file mode 100644 index 00000000..590dff3b --- /dev/null +++ b/views/html/users/create.tpl @@ -0,0 +1,97 @@ +
    + +
    + + +

    + +
      + &$settings) : ?> +
    • +
        + $value) : ?> +
      • + getMessage(); + break; + } ?> +
      • + +
      +
    • + +
    + +
    +
    +
    + + />
    + + />
    + + />
    + + />
    + + />
    +
    +
    + +
    diff --git a/views/html/users/delete.tpl b/views/html/users/delete.tpl new file mode 100644 index 00000000..ca28f4fc --- /dev/null +++ b/views/html/users/delete.tpl @@ -0,0 +1,13 @@ +
    + +
    + + +

    + +
    + + +
    diff --git a/views/html/users/edit.tpl b/views/html/users/edit.tpl new file mode 100644 index 00000000..7a6bf041 --- /dev/null +++ b/views/html/users/edit.tpl @@ -0,0 +1,104 @@ +
    + +
    + + +

    + +
      + &$settings) : ?> +
    • +
        + $value) : ?> +
      • + getMessage(); + break; + } ?> +
      • + +
      +
    • + +
    + +
    +
    + + 0) : ?> + /> + + + +
    + + />
    + + />
    + + />
    + + />
    +
    + +
    diff --git a/views/html/users/index.tpl b/views/html/users/index.tpl new file mode 100644 index 00000000..8a5cb847 --- /dev/null +++ b/views/html/users/index.tpl @@ -0,0 +1,13 @@ +
    + +
    +

    + +
      + +
    1. format(new \DateTime($user['created'])))?>

    2. + +
    diff --git a/views/html/users/login.tpl b/views/html/users/login.tpl new file mode 100644 index 00000000..71b5fa4c --- /dev/null +++ b/views/html/users/login.tpl @@ -0,0 +1,21 @@ +
    + +
    +

    + +

    + +

    .

    + +
    +
    + +
    + +
    +
    + + + + +
    diff --git a/views/html/users/logout.tpl b/views/html/users/logout.tpl new file mode 100644 index 00000000..e69de29b diff --git a/views/html/users/manage.tpl b/views/html/users/manage.tpl new file mode 100644 index 00000000..8d4a1495 --- /dev/null +++ b/views/html/users/manage.tpl @@ -0,0 +1,54 @@ +
    + +
    + + +

    + +
    +
    +

    Sortierung:

    + + +
    +
    +
      + +
    • + checked="checked" disabled="disabled"/> + +
    • + +
    +
    +
    + + + + + + +
    +
    + + + + + + +
    +
    diff --git a/views/html/users/register.tpl b/views/html/users/register.tpl new file mode 100644 index 00000000..48fe6f47 --- /dev/null +++ b/views/html/users/register.tpl @@ -0,0 +1,95 @@ +
    + +
    + + +

    + +
      + &$settings) : ?> +
    • +
        + $value) : ?> +
      • + getMessage(); + break; + } ?> +
      • + +
      +
    • + +
    + +
    +
    + + />
    + + />
    + + />
    + + />
    + + />
    +
    + +
    diff --git a/views/html/users/user.tpl b/views/html/users/user.tpl new file mode 100644 index 00000000..31c4907b --- /dev/null +++ b/views/html/users/user.tpl @@ -0,0 +1,47 @@ +
    + +
    + + +

    + +

    + format(new \DateTime($user['created'])))?>
    + :
    + : +

    + +

    +
      + +
    • + +

      + +

      + 0) : ?> + + + + +

      +

      +
    • + +
    + +

    + diff --git a/views/inlineerror.tpl b/views/inlineerror.tpl new file mode 100644 index 00000000..87a2b222 --- /dev/null +++ b/views/inlineerror.tpl @@ -0,0 +1 @@ +

    Dieser Teil der Anwendung steht zur Zeit leider nicht zur Verfügung.

    diff --git a/www/.htaccess b/www/.htaccess new file mode 100644 index 00000000..63163a80 --- /dev/null +++ b/www/.htaccess @@ -0,0 +1,8 @@ + + RewriteEngine On + + RewriteBase / + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^(.*)$ index.php?url=$1 [QSA,L,NE] + diff --git a/www/analytics/LEGALNOTICE b/www/analytics/LEGALNOTICE new file mode 100644 index 00000000..e95df846 --- /dev/null +++ b/www/analytics/LEGALNOTICE @@ -0,0 +1,265 @@ +COPYRIGHT + + Piwik - Open Source Web Analytics + + The software package is: + + Copyright (C) 2013 Matthieu Aubry + + Individual contributions, components, and libraries are copyright + of their respective authors. + + +SOFTWARE LICENSE + + The free software license of Piwik is GNU General Public License v3 + or later. A copy of GNU GPL v3 should have been included in this + software package in misc/gpl-3.0.txt. + + +TRADEMARK + + Piwik (TM) is an internationally registered trademark. + + The software license does not grant any rights under trademark + law for use of the trademark. Refer to http://piwik.org/trademark/ + for up-to-date trademark licensing information. + +* +* The software license applies to both the aggregate software and +* application-specific portions of the software. +* +* You may not remove this legal notice or modify the software +* in such a way that misrepresents the origin of the software. +* + +CREDITS + + The software consists of contributions made by many individuals. + Major contributors are listed in http://piwik.org/the-piwik-team/. + + For detailed contribution history, refer to the source, tickets, + patches, and Git revision history, available at + http://dev.piwik.org/trac/ + https://github.com/piwik/piwik + + +SEPARATELY LICENSED COMPONENTS AND LIBRARIES + + The following components/libraries are distributed in this package, + and subject to their respective licenses. + + Name: javascriptCode.tpl - tracking tag to embed in your web pages + Link: https://github.com/piwik/piwik/blob/master/core/Tracker/javascriptTag.tpl + License: Public Domain + + Name: jquery.truncate + Link: https://github.com/piwik/piwik/blob/master/libs/jquery/truncate/ + License: New BSD + + Name: piwik.js - JavaScript tracker + Link: https://github.com/piwik/piwik/blob/master/js/piwik.js + License: New BSD + + Name: PiwikTracker - server-side tracker (PHP) + Link: https://github.com/piwik/piwik/blob/master/libs/PiwikTracker/ + License: New BSD + + Name: UserAgentParser + Link: https://github.com/piwik/piwik/blob/master/libs/UserAgentParser/ + License: New BSD + + +THIRD-PARTY COMPONENTS AND LIBRARIES + + The following components/libraries are redistributed in this package, + and subject to their respective licenses. + + Name: jqPlot + Link: http://www.jqplot.com/ + License: Dual-licensed: MIT or GPL v2 + + Name: jQuery + Link: http://jquery.com/ + License: Dual-licensed: MIT or GPL + Notes: + - GPL version not explicitly stated in source but GPL v2 is in git + - includes Sizzle.js - multi-licensed: MIT, New BSD, or GPL [v2] + + Name: jQuery UI + Link: http://jqueryui.com/ + License: Dual-licensed: MIT or GPL + Notes: + - GPL version not explicitly stated in source but GPL v2 is in git + + Name: jquery.history + Link: http://tkyk.github.com/jquery-history-plugin/ + License: MIT + + Name: jquery.scrollTo + Link: http://plugins.jquery.com/project/ScrollTo + License: Dual licensed: MIT or GPL + + Name: jquery Tooltip + Link: http://bassistance.de/jquery-plugins/jquery-plugin-tooltip/ + License: Dual licensed: MIT or GPL + + Name: jquery placeholder + Link: http://mths.be/placeholder + License: Dual licensed: MIT or GPL + + Name: jquery smartbanner + Link: https://github.com/jasny/jquery.smartbanner + License: Dual licensed: MIT + + Name: json2.js + Link: http://json.org/ + License: Public domain + Notes: + - reference implementation + + Name: jshrink + Link: https://github.com/tedivm/jshrink + License: BSD-3-Clause + + Name: sparkline + Link: https//sourceforge.net/projects/sparkline/ + License: Dual-licensed: New BSD or GPL v2 + + Name: sprintf + Link: http://www.diveintojavascript.com/projects/javascript-sprintf + License: New BSD + + Name: upgrade.php + Link: http://upgradephp.berlios.de/ + License: Public domain + + Name: Archive Tar + Link: http://pear.php.net/package/Archive_Tar + License: New BSD + + Name: Event Dispatcher (and Notification) + Link: http://pear.php.net/package/Event_Dispatcher/ + License: New BSD + + Name: HTML Common2 + Link: http://pear.php.net/package/HTML_Common2/ + License: New BSD + + Name: HTML QuickForm2 + Link: http://pear.php.net/package/HTML_QuickForm2/ + License: New BSD + + Name: HTML QuickForm2_Renderer_Smarty + Link: http://www.phcomp.co.uk/tmp/Smarty.phps + License: New BSD + + Name: MaxMindGeoIP + Link: http://dev.maxmind.com/geoip/downloadable#PHP-7 + License: LGPL + + Name: PclZip + Link: http://www.phpconcept.net/pclzip/ + License: LGPL + Notes: + - GPL version not explicitly stated but tarball contains LGPL v2.1 + + Name: PEAR (base system) + Link: http://pear.php.net/package/PEAR + License: New BSD + + Name: PhpSecInfo + Link: http://phpsec.org/projects/phpsecinfo/ + License: New BSD + + Name: RankChecker + Link: http://www.getrank.org/free-pagerank-script + License: GPL + + Name: Twig + Link: http://twig.sensiolabs.org/ + License: BSD + + Name: TCPDF + Link: http://sourceforge.net/projects/tcpdf + License: LGPL v3 or later + + Name: Zend Framework + Link: http://www.zendframework.com/ + License: New BSD + + Name: pChart 2.1.3 + Link: http://www.pchart.net + License: GPL v3 + + Name: Chroma.js + Link: https://github.com/gka/chroma.js + License: GPL v3 + + Name: qTip2 - Pretty powerful tooltips + Link: http://craigsworks.com/projects/qtip2/ + License: GPL + + Name: Kartograph.js + Link: http://kartograph.org/ + License: LGPL v3 or later + + Name: Raphaël - JavaScript Vector Library + Link: http://raphaeljs.com/ + License: MIT + + Name: lessphp + Link: http://leafo.net/lessphp + License: GPL3/MIT + + Name: Symfony Console Component + Link: https://github.com/symfony/Console + License: MIT + + +THIRD-PARTY CONTENT + + Name: FamFamFam icons - Mark James + Link: http://www.famfamfam.com/lab/icons/ + License: CC BY 3.0 + + Name: Solar System icons - Dan Wiersema + Link: http://www.iconspedia.com/icon/neptune-4672.html + License: Free for non-commercial use + Notes: + - used in Piwik's ExampleUI plugin + + Name: Wine project - tahoma.ttf font + Link: http://source.winehq.org/git/wine.git/blob_plain/HEAD:/fonts/tahoma.ttf + License: LGPL v2.1 + Notes: + - used in ImageGraph plugin + + Name: plugins/CorePluginsAdmin/images/themes.png + Link: https://www.iconfinder.com/icons/17022/colors_draw_paint_icon + License: Free for commercial use + + Name: plugins/Feedback/angularjs/ratefeature/thumbs-down.png + Link: https://www.iconfinder.com/icons/216428/down_thumbs_icon + License: Creative Commons (Attribution-Share Alike 3.0 Unported) + + Name: plugins/Feedback/angularjs/ratefeature/thumbs-up.png + Link: https://www.iconfinder.com/icons/216429/thumbs_up_icon + License: Creative Commons (Attribution-Share Alike 3.0 Unported) + + Name: plugins/CorePluginsAdmin/images/plugins.png + Link: http://findicons.com/icon/94051/tools_wizard?id=396912 + License: GNU/GPL + + Name: plugins/Insights/images/idea.png + Link: https://www.iconfinder.com/icons/6074/brainstorm_bulb_idea_jabber_light_icon + License: GPL + By: Alessandro Rei - http://www.kde-look.org/usermanager/search.php?username=mentalrey + +Notes: +- the "New BSD" license refers to either the "Modified BSD" and "Simplified BSD" + licenses (2- or 3-clause), which are GPL compatible. +- the "MIT" license is also referred to as the "X11" license +- icons for browsers, operating systems, browser plugins, search engines, and + and flags of countries are nominative use of third-party trademarks when + referring to the corresponding product or entity diff --git a/www/analytics/README.md b/www/analytics/README.md new file mode 100644 index 00000000..7894effd --- /dev/null +++ b/www/analytics/README.md @@ -0,0 +1,92 @@ +# Piwik - piwik.org + +## Description + +Piwik is the leading Free/Libre open source Web Analytics platform. + +Piwik is a full featured PHP MySQL software program that you download and install on your own webserver. +At the end of the five minute installation process you will be given a JavaScript code. +Simply copy and paste this tag on websites you wish to track and access your analytics reports in real time. + +Piwik aims to be a Free software alternative to Google Analytics, and is already used on more than 1,000,000 websites. + +## Mission Statement + +> « To create, as a community, the leading international Free/Libre web analytics platform, providing access to all functionality through open components and open APIs. » + +Or in short: +> « Liberate Web Analytics » + +## License + +Piwik is released under the GPL v3 (or later) license, see [misc/gpl-3.0.txt](misc/gpl-3.0.txt) + +## Requirements + + * PHP 5.3.2 or greater + * MySQL 4.1 or greater, and either MySQLi or PDO library must be enabled + * Piwik is OS / server independent + +See http://piwik.org/docs/requirements/ + +## Install + + * Upload piwik to your webserver + * Point your browser to the directory + * Follow the steps + * Add the given javascript code to your pages + * (You may also generate fake data to experiment, by enabling the plugin VisitorGenerator) + +See http://piwik.org/docs/installation/ + +If you do not have a server, consider our Piwik Hosting partner: http://piwik.org/hosting/ + +## Changelog + +For the list of all tickets closed in the current and past releases, see http://piwik.org/changelog/ + +## Participate! + +We believe in liberating Web Analytics, providing a free platform for simple and advanced analytics. Piwik was built by dozens of people like you, +and we need your help to make Piwik better… Why not participate in a useful project today? + +You will find pointers on how you can participate in Piwik at http://piwik.org/get-involved/ + +## Contact + +http://piwik.org + +hello@piwik.org + +About us: http://piwik.org/the-piwik-team/ + +## More information + +What makes Piwik unique from the competition: + + * Real time web analytics reports: in Piwik, reports are by default generated in real time. + For high traffic websites, you can choose the frequency for reports to be processed. + + * You own your web analytics data: since Piwik is installed on your server, the data is stored in your own database and you can get all the statistics + using the powerful Piwik Analytics API. + + * Piwik is a Free Software which can easily be configured to respect your visitors privacy. + + * Modern, easy to use User Interface: you can fully customize your dashboard, drag and drop widgets and more. + + * Piwik features are built inside plugins: you can add new features and remove the ones you don’t need. + You can build your own web analytics plugins or hire a consultant to have your custom feature built in Piwik + + * Vibrant international Open community of more than 200,000 active users (tracking even more websites!) + + * Advanced Web Analytics capabilities such as Ecommerce Tracking, Goal tracking, Campaign tracking, + Custom Variables, Email Reports, Custom Segment Editor, Geo Location, Real time maps, and more! + +Documentation and more info on http://piwik.org + +## Code Status +The Piwik project uses an ever-expanding comprehensive set of thousands of unit tests and dozens of integration [tests](https://github.com/piwik/piwik/tree/master/tests), + running on the hosted distributed continuous integration platform Travis-CI. + +Build status (master branch) [![Build Status](https://travis-ci.org/piwik/piwik.png?branch=master)](https://travis-ci.org/piwik/piwik) - Screenshot tests Build [![Build Status](https://travis-ci.org/piwik/piwik-ui-tests.png?branch=master)](https://travis-ci.org/piwik/piwik-ui-tests) + diff --git a/www/analytics/composer.json b/www/analytics/composer.json new file mode 100644 index 00000000..8c3fc620 --- /dev/null +++ b/www/analytics/composer.json @@ -0,0 +1,30 @@ +{ + "name": "piwik/piwik", + "type": "application", + "description": "Open Source Real Time Web Analytics Platform", + "keywords": ["piwik","web","analytics"], + "homepage": "http://piwik.org", + "license": "GPL-3.0+", + "authors": [ + { + "name": "The Piwik Team", + "email": "hello@piwik.org", + "homepage": "http://piwik.org/the-piwik-team/" + } + ], + "support": { + "forum": "http://forum.piwik.org/", + "issues": "http://dev.piwik.org/trac/roadmap", + "wiki": "http://dev.piwik.org/", + "source": "https://github.com/piwik/piwik" + }, + "require": { + "php": ">=5.3.2", + "twig/twig": "1.*", + "leafo/lessphp": "~0.3", + "symfony/console": ">=v2.3.5", + "tedivm/jshrink": "v0.5.1", + "mustangostang/spyc": "0.5.*", + "piwik/device-detector": "*" + } +} diff --git a/www/analytics/composer.lock b/www/analytics/composer.lock new file mode 100644 index 00000000..c31eafe5 --- /dev/null +++ b/www/analytics/composer.lock @@ -0,0 +1,310 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file" + ], + "hash": "69246584e6b57bfbc8d39799cd3b9213", + "packages": [ + { + "name": "leafo/lessphp", + "version": "v0.4.0", + "source": { + "type": "git", + "url": "https://github.com/leafo/lessphp.git", + "reference": "51f3f06f0fe78a722dabfd14578444bdd078d9de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/leafo/lessphp/zipball/51f3f06f0fe78a722dabfd14578444bdd078d9de", + "reference": "51f3f06f0fe78a722dabfd14578444bdd078d9de", + "shasum": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.3-dev" + } + }, + "autoload": { + "classmap": [ + "lessc.inc.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT", + "GPL-3.0" + ], + "authors": [ + { + "name": "Leaf Corcoran", + "email": "leafot@gmail.com", + "homepage": "http://leafo.net" + } + ], + "description": "lessphp is a compiler for LESS written in PHP.", + "homepage": "http://leafo.net/lessphp/", + "time": "2013-08-09 17:09:19" + }, + { + "name": "mustangostang/spyc", + "version": "0.5.1", + "source": { + "type": "git", + "url": "https://github.com/mustangostang/spyc.git", + "reference": "dc4785b4d7227fd9905e086d499fb8abfadf9977" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mustangostang/spyc/zipball/dc4785b4d7227fd9905e086d499fb8abfadf9977", + "reference": "dc4785b4d7227fd9905e086d499fb8abfadf9977", + "shasum": "" + }, + "require": { + "php": ">=5.3.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.5.x-dev" + } + }, + "autoload": { + "files": [ + "Spyc.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT License" + ], + "authors": [ + { + "name": "mustangostang", + "email": "vlad.andersen@gmail.com" + } + ], + "description": "A simple YAML loader/dumper class for PHP", + "homepage": "https://github.com/mustangostang/spyc/", + "keywords": [ + "spyc", + "yaml", + "yml" + ], + "time": "2013-02-21 10:52:01" + }, + { + "name": "piwik/device-detector", + "version": "1.0", + "source": { + "type": "git", + "url": "https://github.com/piwik/device-detector.git", + "reference": "ea7c5d8b76def0d8345a4eba59c5f98ec0109de6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/piwik/device-detector/zipball/ea7c5d8b76def0d8345a4eba59c5f98ec0109de6", + "reference": "ea7c5d8b76def0d8345a4eba59c5f98ec0109de6", + "shasum": "" + }, + "require": { + "mustangostang/spyc": "*", + "php": ">=5.3.1" + }, + "type": "library", + "autoload": { + "files": [ + "DeviceDetector.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0+" + ], + "authors": [ + { + "name": "The Piwik Team", + "email": "hello@piwik.org", + "homepage": "http://piwik.org/the-piwik-team/" + } + ], + "description": "The Universal Device Detection library, that parses User Agents and detects devices (desktop, tablet, mobile, tv, cars, console, etc.), and detects browsers, operating systems, devices, brands and models.", + "homepage": "http://piwik.org", + "keywords": [ + "devicedetection", + "parser", + "useragent" + ], + "time": "2014-04-03 08:59:48" + }, + { + "name": "symfony/console", + "version": "v2.4.3", + "target-dir": "Symfony/Component/Console", + "source": { + "type": "git", + "url": "https://github.com/symfony/Console.git", + "reference": "ef20f1f58d7f693ee888353962bd2db336e3bbcb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/Console/zipball/ef20f1f58d7f693ee888353962bd2db336e3bbcb", + "reference": "ef20f1f58d7f693ee888353962bd2db336e3bbcb", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "symfony/event-dispatcher": "~2.1" + }, + "suggest": { + "symfony/event-dispatcher": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.4-dev" + } + }, + "autoload": { + "psr-0": { + "Symfony\\Component\\Console\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "http://symfony.com", + "time": "2014-03-01 17:35:04" + }, + { + "name": "tedivm/jshrink", + "version": "v0.5.1", + "source": { + "type": "git", + "url": "https://github.com/tedivm/JShrink.git", + "reference": "2d3f1a7d336ad54bdf2180732b806c768a791cbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tedivm/JShrink/zipball/2d3f1a7d336ad54bdf2180732b806c768a791cbf", + "reference": "2d3f1a7d336ad54bdf2180732b806c768a791cbf", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "JShrink": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Robert Hafner", + "email": "tedivm@tedivm.com" + } + ], + "description": "Javascript Minifier built in PHP", + "homepage": "http://github.com/tedivm/JShrink", + "keywords": [ + "javascript", + "minifier" + ], + "time": "2012-11-26 04:48:55" + }, + { + "name": "twig/twig", + "version": "v1.15.1", + "source": { + "type": "git", + "url": "https://github.com/fabpot/Twig.git", + "reference": "1fb5784662f438d7d96a541e305e28b812e2eeed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fabpot/Twig/zipball/1fb5784662f438d7d96a541e305e28b812e2eeed", + "reference": "1fb5784662f438d7d96a541e305e28b812e2eeed", + "shasum": "" + }, + "require": { + "php": ">=5.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.15-dev" + } + }, + "autoload": { + "psr-0": { + "Twig_": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + }, + { + "name": "Twig Team", + "homepage": "https://github.com/fabpot/Twig/graphs/contributors", + "role": "Contributors" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "http://twig.sensiolabs.org", + "keywords": [ + "templating" + ], + "time": "2014-02-13 10:19:29" + } + ], + "packages-dev": [ + + ], + "aliases": [ + + ], + "minimum-stability": "stable", + "stability-flags": [ + + ], + "platform": { + "php": ">=5.3.2" + }, + "platform-dev": [ + + ] +} diff --git a/www/analytics/config/.htaccess b/www/analytics/config/.htaccess new file mode 100644 index 00000000..6cd2e134 --- /dev/null +++ b/www/analytics/config/.htaccess @@ -0,0 +1,13 @@ + + +Deny from all + + + +Deny from all + + + +Deny from all + + diff --git a/www/analytics/config/global.ini.php b/www/analytics/config/global.ini.php new file mode 100644 index 00000000..9d76c4be --- /dev/null +++ b/www/analytics/config/global.ini.php @@ -0,0 +1,627 @@ +; DO NOT REMOVE THIS LINE +; If you want to change some of these default values, the best practise is to override +; them in your configuration file in config/config.ini.php. If you directly edit this file, +; you will lose your changes when you upgrade Piwik. +; For example if you want to override action_title_category_delimiter, +; edit config/config.ini.php and add the following: +; [General] +; action_title_category_delimiter = "-" + +;-------- +; WARNING - YOU SHOULD NOT EDIT THIS FILE DIRECTLY - Edit config.ini.php instead. +;-------- + +[database] +host = +username = +password = +dbname = +tables_prefix = +port = 3306 +adapter = PDO_MYSQL +type = InnoDB +schema = Mysql + +; if charset is set to utf8, Piwik will ensure that it is storing its data using UTF8 charset. +; it will add a sql query SET at each page view. +; Piwik should work correctly without this setting. +;charset = utf8 + +[database_tests] +host = localhost +username = root +password = +dbname = piwik_tests +tables_prefix = piwiktests_ +port = 3306 +adapter = PDO_MYSQL +type = InnoDB +schema = Mysql + +[log] +; possible values for log: screen, database, file +log_writers[] = screen + +; log level, everything logged w/ this level or one of greater severity +; will be logged. everything else will be ignored. possible values are: +; NONE, ERROR, WARN, INFO, DEBUG, VERBOSE +log_level = WARN + +; if set to 1, only requests done in CLI mode (eg. the archive.php cron run) will be logged +; NOTE: log_only_when_debug_parameter will also be checked for +log_only_when_cli = 0 + +; if set to 1, only requests with "&debug" parameter will be logged +; NOTE: log_only_when_cli will also be checked for +log_only_when_debug_parameter = 0 + +; if configured to log in a file, log entries will be made to this file +logger_file_path = tmp/logs/piwik.log + + +[Debug] +; if set to 1, the archiving process will always be triggered, even if the archive has already been computed +; this is useful when making changes to the archiving code so we can force the archiving process +always_archive_data_period = 0; +always_archive_data_day = 0; +; Force archiving Custom date range (without re-archiving sub-periods used to process this date range) +always_archive_data_range = 0; + +; if set to 1, all the SQL queries will be recorded by the profiler +; and a profiling summary will be printed at the end of the request +; NOTE: you must also set [log] log_writers[] = "screen" to enable the profiler to print on screen +enable_sql_profiler = 0 + +; if set to 1, a Piwik tracking code will be included in the Piwik UI footer and will track visits, pages, etc. to idsite = 1 +; this is useful for Piwik developers as an easy way to create data in their local Piwik +track_visits_inside_piwik_ui = 0 + +; if set to 1, javascript files will be included individually and neither merged nor minified. +; this option must be set to 1 when adding, removing or modifying javascript files +disable_merged_assets = 0 + +; If set to 1, all requests to piwik.php will be forced to be 'new visitors' +tracker_always_new_visitor = 0 + +; Allow automatic upgrades to Beta or RC releases +allow_upgrades_to_beta = 0 + +[DebugTests] +; Set to 1 by default. If you set to 0, the standalone plugins (with their own git repositories) +; will not be loaded when executing tests. +enable_load_standalone_plugins_during_tests = 1 + +[General] +; the following settings control whether Unique Visitors will be processed for different period types. +; year and range periods are disabled by default, to ensure optimal performance for high traffic Piwik instances +; if you set it to 1 and want the Unique Visitors to be re-processed for reports in the past, drop all piwik_archive_* tables +; it is recommended to always enable Unique Visitors processing for 'day' periods +enable_processing_unique_visitors_day = 1 +enable_processing_unique_visitors_week = 1 +enable_processing_unique_visitors_month = 1 +enable_processing_unique_visitors_year = 0 +enable_processing_unique_visitors_range = 0 + +; when set to 1, all requests to Piwik will return a maintenance message without connecting to the DB +; this is useful when upgrading using the shell command, to prevent other users from accessing the UI while Upgrade is in progress +maintenance_mode = 0 + +; character used to automatically create categories in the Actions > Pages, Outlinks and Downloads reports +; for example a URL like "example.com/blog/development/first-post" will create +; the page first-post in the subcategory development which belongs to the blog category +action_url_category_delimiter = / + +; similar to above, but this delimiter is only used for page titles in the Actions > Page titles report +action_title_category_delimiter = / + +; the maximum url category depth to track. if this is set to 2, then a url such as +; "example.com/blog/development/first-post" would be treated as "example.com/blog/development". +; this setting is used mainly to limit the amount of data that is stored by Piwik. +action_category_level_limit = 10 + +; minimum number of websites to run autocompleter +autocomplete_min_sites = 5 + +; maximum number of websites showed in search results in autocompleter +site_selector_max_sites = 15 + +; if set to 1, shows sparklines (evolution graph) in 'All Websites' report (MultiSites plugin) +show_multisites_sparklines = 1 + +; number of websites to display per page in the All Websites dashboard +all_websites_website_per_page = 50 + +; if set to 0, the anonymous user will not be able to use the 'segments' parameter in the API request +; this is useful to prevent full DB access to the anonymous user, or to limit performance usage +anonymous_user_enable_use_segments_API = 1 + +; if browser trigger archiving is disabled, API requests with a &segment= parameter will still trigger archiving. +; You can force the browser archiving to be disabled in most cases by setting this setting to 1 +; The only time that the browser will still trigger archiving is when requesting a custom date range that is not pre-processed yet +browser_archiving_disabled_enforce = 0 + +; By default, users can create Segments which are to be processed in Real-time. +; Setting this to 0 will force all newly created Custom Segments to be "Pre-processed (faster, requires archive.php cron)" +; This can be useful if you want to prevent users from adding much load on the server. +; Note: any existing Segment set to "processed in Real time", will still be set to Real-time. +; this will only affect custom segments added or modified after this setting is changed. +enable_create_realtime_segments = 1 + +; this action name is used when the URL ends with a slash / +; it is useful to have an actual string to write in the UI +action_default_name = index + +; if you want all your users to use Piwik in only one language, disable the LanguagesManager +; plugin, and set this default_language (users won't see the language drop down) +default_language = en + +; default number of elements in the datatable +datatable_default_limit = 10 + +; default number of rows returned in API responses +; this value is overwritten by the '# Rows to display' selector. +; if set to -1, a click on 'Export as' will export all rows independently of the current '# Rows to display'. +API_datatable_default_limit = 100 + +; When period=range, below the datatables, when user clicks on "export", the data will be aggregate of the range. +; Here you can specify the comma separated list of formats for which the data will be exported aggregated by day +; (ie. there will be a new "date" column). For example set to: "rss,tsv,csv" +datatable_export_range_as_day = "rss" + +; This setting is overriden in the UI, under "User Settings". +; The date and period loaded by Piwik uses the defaults below. Possible values: yesterday, today. +default_day = yesterday +; Possible values: day, week, month, year. +default_period = day + +; Time in seconds after which an archive will be computed again. This setting is used only for today's statistics. +; Defaults to 10 seconds so that by default, Piwik provides real time reporting. +; This setting is overriden in the UI, under "General Settings". +; This setting is only used if it hasn't been overriden via the UI yet, or if enable_general_settings_admin=0 +time_before_today_archive_considered_outdated = 10 + +; This setting is overriden in the UI, under "General Settings". +; The default value is to allow browsers to trigger the Piwik archiving process. +; This setting is only used if it hasn't been overriden via the UI yet, or if enable_general_settings_admin=0 +enable_browser_archiving_triggering = 1 + +; By default Piwik runs OPTIMIZE TABLE SQL queries to free spaces after deleting some data. +; If your Piwik tracks millions of pages, the OPTIMIZE TABLE queries might run for hours (seen in "SHOW FULL PROCESSLIST \g") +; so you can disable these special queries here: +enable_sql_optimize_queries = 1 + +; MySQL minimum required version +; note: timezone support added in 4.1.3 +minimum_mysql_version = 4.1 + +; PostgreSQL minimum required version +minimum_pgsql_version = 8.3 + +; Minimum adviced memory limit in php.ini file (see memory_limit value) +minimum_memory_limit = 128 + +; Minimum memory limit enforced when archived via misc/cron/archive.php +minimum_memory_limit_when_archiving = 768 + +; Piwik will check that usernames and password have a minimum length, and will check that characters are "allowed" +; This can be disabled, if for example you wish to import an existing User database in Piwik and your rules are less restrictive +disable_checks_usernames_attributes = 0 + +; Piwik will use the configured hash algorithm where possible. +; For legacy data, fallback or non-security scenarios, we use md5. +hash_algorithm = whirlpool + +; by default, Piwik uses PHP's built-in file-based session save handler with lock files. +; For clusters, use dbtable. +session_save_handler = files + +; If set to 1, Piwik will automatically redirect all http:// requests to https:// +; If SSL / https is not correctly configured on the server, this will break Piwik +; If you set this to 1, and your SSL configuration breaks later on, you can always edit this back to 0 +; it is recommended for security reasons to always use Piwik over https +force_ssl = 0 + +; login cookie name +login_cookie_name = piwik_auth + +; login cookie expiration (14 days) +login_cookie_expire = 1209600 + +; The path on the server in which the cookie will be available on. +; Defaults to empty. See spec in http://curl.haxx.se/rfc/cookie_spec.html +login_cookie_path = + +; email address that appears as a Sender in the password recovery email +; if specified, {DOMAIN} will be replaced by the current Piwik domain +login_password_recovery_email_address = "password-recovery@{DOMAIN}" +; name that appears as a Sender in the password recovery email +login_password_recovery_email_name = Piwik + +; By default when user logs out he is redirected to Piwik "homepage" usually the Login form. +; Uncomment the next line to set a URL to redirect the user to after he logs out of Piwik. +; login_logout_url = http://... + +; Set to 1 to disable the framebuster on standard Non-widgets pages (a click-jacking countermeasure). +; Default is 0 (i.e., bust frames on all non Widget pages such as Login, API, Widgets, Email reports, etc.). +enable_framed_pages = 0 + +; Set to 1 to disable the framebuster on Admin pages (a click-jacking countermeasure). +; Default is 0 (i.e., bust frames on the Settings forms). +enable_framed_settings = 0 + +; language cookie name for session +language_cookie_name = piwik_lang + +; standard email address displayed when sending emails +noreply_email_address = "noreply@{DOMAIN}" + +; feedback email address; +; when testing, use your own email address or "nobody" +feedback_email_address = "feedback@piwik.org" + +; during archiving, Piwik will limit the number of results recorded, for performance reasons +; maximum number of rows for any of the Referrers tables (keywords, search engines, campaigns, etc.) +datatable_archiving_maximum_rows_referrers = 1000 +; maximum number of rows for any of the Referrers subtable (search engines by keyword, keyword by campaign, etc.) +datatable_archiving_maximum_rows_subtable_referrers = 50 + +; maximum number of rows for the Custom Variables names report +; Note: if the website is Ecommerce enabled, the two values below will be automatically set to 50000 +datatable_archiving_maximum_rows_custom_variables = 1000 +; maximum number of rows for the Custom Variables values reports +datatable_archiving_maximum_rows_subtable_custom_variables = 1000 + +; maximum number of rows for any of the Actions tables (pages, downloads, outlinks) +datatable_archiving_maximum_rows_actions = 500 +; maximum number of rows for pages in categories (sub pages, when clicking on the + for a page category) +; note: should not exceed the display limit in Piwik\Actions\Controller::ACTIONS_REPORT_ROWS_DISPLAY +; because each subdirectory doesn't have paging at the bottom, so all data should be displayed if possible. +datatable_archiving_maximum_rows_subtable_actions = 100 + +; maximum number of rows for any of the Events tables (Categories, Actions, Names) +datatable_archiving_maximum_rows_events = 500 +; maximum number of rows for sub-tables of the Events tables (eg. for the subtables Categories>Actions or Categories>Names). +datatable_archiving_maximum_rows_subtable_events = 100 + +; maximum number of rows for other tables (Providers, User settings configurations) +datatable_archiving_maximum_rows_standard = 500 + +; maximum number of rows to fetch from the database when archiving. if set to 0, no limit is used. +; this can be used to speed up the archiving process, but is only useful if you're site has a large +; amount of actions, referrers or custom variable name/value pairs. +archiving_ranking_query_row_limit = 50000 + +; maximum number of actions that is shown in the visitor log for each visitor +visitor_log_maximum_actions_per_visit = 500 + +; by default, the real time Live! widget will update every 5 seconds and refresh with new visits/actions/etc. +; you can change the timeout so the widget refreshes more often, or not as frequently +live_widget_refresh_after_seconds = 5 + +; by default, the Live! real time visitor count widget will check to see how many visitors your +; website received in the last 3 minutes. changing this value will change the number of minutes +; the widget looks in. +live_widget_visitor_count_last_minutes = 3 + +; In "All Websites" dashboard, when looking at today's reports (or a date range including today), +; the page will automatically refresh every 5 minutes. Set to 0 to disable automatic refresh +multisites_refresh_after_seconds = 300 + +; Set to 1 if you're using https on your Piwik server and Piwik can't detect it, +; e.g., a reverse proxy using https-to-http, or a web server that doesn't +; set the HTTPS environment variable. +assume_secure_protocol = 0 + +; List of proxy headers for client IP addresses +; +; CloudFlare (CF-Connecting-IP) +;proxy_client_headers[] = HTTP_CF_CONNECTING_IP +; +; ISP proxy (Client-IP) +;proxy_client_headers[] = HTTP_CLIENT_IP +; +; de facto standard (X-Forwarded-For) +;proxy_client_headers[] = HTTP_X_FORWARDED_FOR + +; List of proxy headers for host IP addresses +; +; de facto standard (X-Forwarded-Host) +;proxy_host_headers[] = HTTP_X_FORWARDED_HOST + +; List of proxy IP addresses (or IP address ranges) to skip (if present in the above headers). +; Generally, only required if there's more than one proxy between the visitor and the backend web server. +; +; Examples: +;proxy_ips[] = 204.93.240.* +;proxy_ips[] = 204.93.177.0/24 +;proxy_ips[] = 199.27.128.0/21 +;proxy_ips[] = 173.245.48.0/20 + +; Whether to enable trusted host checking. This can be disabled if you're running Piwik +; on several URLs and do not wish to constantly edit the trusted host list. +enable_trusted_host_check = 1 + +; List of trusted hosts (eg domain or subdomain names) when generating absolute URLs. +; +; Examples: +;trusted_hosts[] = example.com +;trusted_hosts[] = stats.example.com + +; The release server is an essential part of the Piwik infrastructure/ecosystem +; to provide the latest software version. +latest_version_url = http://builds.piwik.org/latest.zip + +; The API server is an essential part of the Piwik infrastructure/ecosystem to +; provide services to Piwik installations, e.g., getLatestVersion and +; subscribeNewsletter. +api_service_url = http://api.piwik.org + +; When the ImageGraph plugin is activated, report metadata have an additional entry : 'imageGraphUrl'. +; This entry can be used to request a static graph for the requested report. +; When requesting report metadata with $period=range, Piwik needs to translate it to multiple periods for evolution graphs. +; eg. $period=range&date=previous10 becomes $period=day&date=previous10. Use this setting to override the $period value. +graphs_default_period_to_plot_when_period_range = day + +; The Overlay plugin shows the Top X following pages, Top X downloads and Top X outlinks which followed +; a view of the current page. The value X can be set here. +overlay_following_pages_limit = 300 + +; With this option, you can disable the framed mode of the Overlay plugin. Use it if your website contains a framebuster. +overlay_disable_framed_mode = 0 + +; By default we check whether the Custom logo is writable or not, before we display the Custom logo file uploader +enable_custom_logo_check = 1 + +; If php is running in a chroot environment, when trying to import CSV files with createTableFromCSVFile(), +; Mysql will try to load the chrooted path (which is imcomplete). To prevent an error, here you can specify the +; absolute path to the chroot environment. eg. '/path/to/piwik/chrooted/' +absolute_chroot_path = + +; In some rare cases it may be useful to explicitely tell Piwik not to use LOAD DATA INFILE +; This may for example be useful when doing Mysql AWS replication +enable_load_data_infile = 1 + +; By setting this option to 0, you can disable the Piwik marketplace. This is useful to prevent giving the Super user +; the access to disk and install custom PHP code (Piwik plugins). +enable_marketplace = 1 + +; By setting this option to 0: +; - links to Enable/Disable/Uninstall plugins will be hidden and disabled +; - links to Uninstall themes will be disabled (but user can still enable/disable themes) +; - as well as disabling plugins admin actions (such as "Upload new plugin"), setting this to 1 will have same effect as setting enable_marketplace=1 +enable_plugins_admin = 1 + +; By setting this option to 0, you can prevent Super User from editing the Geolocation settings. +enable_geolocation_admin = 1 + +; By setting this option to 0, the old log data and old report data features will be hidden from the UI +; Note: log purging and old data purging still occurs, just the Super User cannot change the settings. +enable_delete_old_data_settings_admin = 1 + +; By setting this option to 0, the following settings will be hidden and disabled from being set in the UI: +; - "Archiving Settings" +; - "Update settings" +; - "Email server settings" +enable_general_settings_admin = 1 + +; By setting this option to 0, it will disable the "Auto update" feature +enable_auto_update = 1 + +; By setting this option to 0, no emails will be sent in case of an available core. +; If set to 0 it also disables the "sent plugin update emails" feature in general and the related setting in the UI. +enable_update_communication = 1 + +[Tracker] +; Piwik uses first party cookies by default. If set to 1, +; the visit ID cookie will be set on the Piwik server domain as well +; this is useful when you want to do cross websites analysis +use_third_party_id_cookie = 0 + +; If tracking does not work for you or you are stuck finding an issue, you might want to enable the tracker debug mode. +; Once enabled (set to 1) messages will be logged to all loggers defined in "[log] log_writers" config. +debug = 0 + +; There is a feature in the Tracking API that lets you create new visit at any given time, for example if you know that a different user/customer is using +; the app then you would want to tell Piwik to create a new visit (even though both users are using the same browser/computer). +; To prevent abuse and easy creation of fake visits, this feature requires admin token_auth by default +; If you wish to use this feature using the Javascript tracker, you can set the setting new_visit_api_requires_admin=0, and in Javascript write: +; _paq.push(['appendToTrackingUrl', 'new_visit=1']); +new_visit_api_requires_admin = 1 + +; This setting should only be set to 1 in an intranet setting, where most users have the same configuration (browsers, OS) +; and the same IP. If left to 0 in this setting, all visitors will be counted as one single visitor. +trust_visitors_cookies = 0 + +; name of the cookie used to store the visitor information +; This is used only if use_third_party_id_cookie = 1 +cookie_name = _pk_uid + +; by default, the Piwik tracking cookie expires in 2 years +; This is used only if use_third_party_id_cookie = 1 +cookie_expire = 63072000 + +; The path on the server in which the cookie will be available on. +; Defaults to empty. See spec in http://curl.haxx.se/rfc/cookie_spec.html +; This is used for the Ignore cookie, and the third party cookie if use_third_party_id_cookie = 1 +cookie_path = + +; set to 0 if you want to stop tracking the visitors. Useful if you need to stop all the connections on the DB. +record_statistics = 1 + +; length of a visit in seconds. If a visitor comes back on the website visit_standard_length seconds +; after his last page view, it will be recorded as a new visit +visit_standard_length = 1800 + +; The window to look back for a previous visit by this current visitor. Defaults to visit_standard_length. +; If you are looking for higher accuracy of "returning visitors" metrics, you may set this value to 86400 or more. +; This is especially useful when you use the Tracking API where tracking Returning Visitors often depends on this setting. +; The value window_look_back_for_visitor is used only if it is set to greater than visit_standard_length +window_look_back_for_visitor = 0 + +; visitors that stay on the website and view only one page will be considered as time on site of 0 second +default_time_one_page_visit = 0 + +; if set to 1, Piwik attempts a "best guess" at the visitor's country of +; origin when the preferred language tag omits region information. +; The mapping is defined in core/DataFiles/LanguageToCountry.php, +enable_language_to_country_guess = 1 + +; When the misc/cron/archive.php cron hasn't been setup, we still need to regularly run some maintenance tasks. +; Visits to the Tracker will try to trigger Scheduled Tasks (eg. scheduled PDF/HTML reports by email). +; Scheduled tasks will only run if 'Enable Piwik Archiving from Browser' is enabled in the General Settings. +; Tasks run once every hour maximum, they might not run every hour if traffic is low. +; Set to 0 to disable Scheduled tasks completely. +scheduled_tasks_min_interval = 3600 + +; name of the cookie to ignore visits +ignore_visits_cookie_name = piwik_ignore + +; Comma separated list of variable names that will be read to define a Campaign name, for example CPC campaign +; Example: If a visitor first visits 'index.php?piwik_campaign=Adwords-CPC' then it will be counted as a campaign referrer named 'Adwords-CPC' +; Includes by default the GA style campaign parameters +campaign_var_name = "pk_campaign,piwik_campaign,utm_campaign,utm_source,utm_medium" + +; Comma separated list of variable names that will be read to track a Campaign Keyword +; Example: If a visitor first visits 'index.php?piwik_campaign=Adwords-CPC&piwik_kwd=My killer keyword' ; +; then it will be counted as a campaign referrer named 'Adwords-CPC' with the keyword 'My killer keyword' +; Includes by default the GA style campaign keyword parameter utm_term +campaign_keyword_var_name = "pk_kwd,piwik_kwd,pk_keyword,utm_term" + +; maximum length of a Page Title or a Page URL recorded in the log_action.name table +page_maximum_length = 1024; + +; Tracker cache files are the simple caching layer for Tracking. +; TTL: Time to live for cache files, in seconds. Default to 5 minutes. +tracker_cache_file_ttl = 300 + +; DO NOT USE THIS SETTING ON PUBLICLY AVAILABLE PIWIK SERVER +; !!! Security risk: if set to 0, it would allow anyone to push data to Piwik with custom dates in the past/future and even with fake IPs! +; When using the Tracking API, to override either the datetime and/or the visitor IP, +; token_auth with an "admin" access is required. If you set this setting to 0, the token_auth will not be required anymore. +; DO NOT USE THIS SETTING ON PUBLIC PIWIK SERVERS +tracking_requests_require_authentication = 1 + +[Segments] +; Reports with segmentation in API requests are processed in real time. +; On high traffic websites it is recommended to pre-process the data +; so that the analytics reports are always fast to load. +; You can define below the list of Segments strings +; for which all reports should be Archived during the cron execution +; All segment values MUST be URL encoded. +;Segments[]="visitorType==new" +;Segments[]="visitorType==returning" + +; If you define Custom Variables for your visitor, for example set the visit type +;Segments[]="customVariableName1==VisitType;customVariableValue1==Customer" + +[Deletelogs] +; delete_logs_enable - enable (1) or disable (0) delete log feature. Make sure that all archives for the given period have been processed (setup a cronjob!), +; otherwise you may lose tracking data. +; delete_logs_schedule_lowest_interval - lowest possible interval between two table deletes (in days, 1|7|30). Default: 7. +; delete_logs_older_than - delete data older than XX (days). Default: 180 +delete_logs_enable = 0 +delete_logs_schedule_lowest_interval = 7 +delete_logs_older_than = 180 +delete_logs_max_rows_per_query = 100000 +enable_auto_database_size_estimate = 1 + +[Deletereports] +delete_reports_enable = 0 +delete_reports_older_than = 12 +delete_reports_keep_basic_metrics = 1 +delete_reports_keep_day_reports = 0 +delete_reports_keep_week_reports = 0 +delete_reports_keep_month_reports = 1 +delete_reports_keep_year_reports = 1 +delete_reports_keep_range_reports = 0 +delete_reports_keep_segment_reports = 0 + +[mail] +defaultHostnameIfEmpty = defaultHostnameIfEmpty.example.org ; default Email @hostname, if current host can't be read from system variables +transport = ; smtp (using the configuration below) or empty (using built-in mail() function) +port = ; optional; defaults to 25 when security is none or tls; 465 for ssl +host = ; SMTP server address +type = ; SMTP Auth type. By default: NONE. For example: LOGIN +username = ; SMTP username +password = ; SMTP password +encryption = ; SMTP transport-layer encryption, either 'ssl', 'tls', or empty (i.e., none). + +[proxy] +type = BASIC ; proxy type for outbound/outgoing connections; currently, only BASIC is supported +host = ; Proxy host: the host name of your proxy server (mandatory) +port = ; Proxy port: the port that the proxy server listens to. There is no standard default, but 80, 1080, 3128, and 8080 are popular +username = ; Proxy username: optional; if specified, password is mandatory +password = ; Proxy password: optional; if specified, username is mandatory + +[Plugins] +Plugins[] = CorePluginsAdmin +Plugins[] = CoreAdminHome +Plugins[] = CoreHome +Plugins[] = CoreVisualizations +Plugins[] = Proxy +Plugins[] = API +Plugins[] = ExamplePlugin +Plugins[] = Widgetize +Plugins[] = Transitions +Plugins[] = LanguagesManager +Plugins[] = Actions +Plugins[] = Dashboard +Plugins[] = MultiSites +Plugins[] = Referrers +Plugins[] = UserSettings +Plugins[] = Goals +Plugins[] = SEO +Plugins[] = Events +Plugins[] = UserCountry +Plugins[] = VisitsSummary +Plugins[] = VisitFrequency +Plugins[] = VisitTime +Plugins[] = VisitorInterest +Plugins[] = ExampleAPI +Plugins[] = ExampleRssWidget +Plugins[] = Provider +Plugins[] = Feedback + +Plugins[] = Login +Plugins[] = UsersManager +Plugins[] = SitesManager +Plugins[] = Installation +Plugins[] = CoreUpdater +Plugins[] = CoreConsole +Plugins[] = ScheduledReports +Plugins[] = UserCountryMap +Plugins[] = Live +Plugins[] = CustomVariables +Plugins[] = PrivacyManager +Plugins[] = ImageGraph +Plugins[] = Annotations +Plugins[] = MobileMessaging +Plugins[] = Overlay +Plugins[] = SegmentEditor +Plugins[] = Insights + +Plugins[] = Morpheus + +[PluginsInstalled] +PluginsInstalled[] = Login +PluginsInstalled[] = CoreAdminHome +PluginsInstalled[] = UsersManager +PluginsInstalled[] = SitesManager +PluginsInstalled[] = Installation + +[Plugins_Tracker] +Plugins_Tracker[] = Provider +Plugins_Tracker[] = Goals +Plugins_Tracker[] = PrivacyManager +Plugins_Tracker[] = UserCountry +Plugins_Tracker[] = Login + +[APISettings] +; Any key/value pair can be added in this section, they will be available via the REST call +; index.php?module=API&method=API.getSettings +; This can be used to expose values from Piwik, to control for example a Mobile app tracking +SDK_batch_size = 10 +SDK_interval_value = 30 + +; NOTE: do not directly edit this file! See notice at the top + diff --git a/www/analytics/config/manifest.inc.php b/www/analytics/config/manifest.inc.php new file mode 100644 index 00000000..2a4eada2 --- /dev/null +++ b/www/analytics/config/manifest.inc.php @@ -0,0 +1,3200 @@ + array("890", "69246584e6b57bfbc8d39799cd3b9213"), + "composer.lock" => array("10488", "8a98b2fd7cda2b841fe15aa1e72402a5"), + "config/global.ini.php" => array("28859", "5d3e559652bf2ebd390c2a4c0054b02b"), + "console" => array("924", "a4877c66060ee26f1edc476a5ee72776"), + "core/Access.php" => array("12704", "c9839d3aa44e8edc82acfbf1c2e9a4aa"), + "core/API/DataTableGenericFilter.php" => array("5060", "ab1da3c6e3e965a56f5d1274b4703a69"), + "core/API/DataTableManipulator/Flattener.php" => array("4096", "1ee3893fee29909ea33f6e4b767c99d5"), + "core/API/DataTableManipulator/LabelFilter.php" => array("5127", "92fffb40df331cd852a222cc482ea273"), + "core/API/DataTableManipulator.php" => array("6316", "45d5aabe63f1d50766fb036dfd9e5aa1"), + "core/API/DataTableManipulator/ReportTotalsCalculator.php" => array("7459", "dc3674946b59f691b4da26775ab575a9"), + "core/API/DocumentationGenerator.php" => array("10290", "2a0b1f027ca702048e7af16e238f89fa"), + "core/API/Proxy.php" => array("20309", "b447ae17730f347efd4c8407e2172c85"), + "core/API/Request.php" => array("14637", "3373eb0d7cf984a910a18e0335c1576a"), + "core/API/ResponseBuilder.php" => array("17712", "5b666de76bebe3f4163d106da1ef1610"), + "core/Archive/DataCollection.php" => array("11407", "64bcbd03c02f25cc0548ec9960459830"), + "core/Archive/DataTableFactory.php" => array("13364", "3fc26be6b543d69fcf5a5e24b071c89c"), + "core/Archive/Parameters.php" => array("1333", "4d8b5cee96ed8db3b9accb37e3be80b8"), + "core/Archive.php" => array("33032", "9102dc388920e1ce6962103d3afc5e01"), + "core/ArchiveProcessor/Loader.php" => array("6875", "d6dea12a41b609b1fb1a2cc52dc8ae8a"), + "core/ArchiveProcessor/Parameters.php" => array("4312", "fe51534b7e53509e371481a13295cbcd"), + "core/ArchiveProcessor.php" => array("18191", "e1266497acaac1c9f772965a123d832d"), + "core/ArchiveProcessor/PluginsArchiver.php" => array("5983", "aa8b4c422def253126cec71a6a7f4e26"), + "core/ArchiveProcessor/Rules.php" => array("11465", "6133c6cccad15b54b92e22b46ede06c9"), + "core/AssetManager.php" => array("11423", "97e469ef84fdd26bd050169ab869e8b3"), + "core/AssetManager/UIAssetCacheBuster.php" => array("1508", "4d376f8e099aaea9a2341528bf1db023"), + "core/AssetManager/UIAssetCatalog.php" => array("1340", "a225abb9e87ad2ee661f81f855d44872"), + "core/AssetManager/UIAssetCatalogSorter.php" => array("1512", "c6989c08fb3124904ced569774468488"), + "core/AssetManager/UIAssetFetcher/JScriptUIAssetFetcher.php" => array("2772", "8aa332089d60c0362bfabe121020448c"), + "core/AssetManager/UIAssetFetcher.php" => array("2247", "fb0cd6454f394c71adcbb3c19d897dfd"), + "core/AssetManager/UIAssetFetcher/StaticUIAssetFetcher.php" => array("729", "736a364eb1c3f2bef8d420364a17c6f4"), + "core/AssetManager/UIAssetFetcher/StylesheetUIAssetFetcher.php" => array("1915", "5476bd9d96ec0effeed0bd4696dadae6"), + "core/AssetManager/UIAsset/InMemoryUIAsset.php" => array("1090", "f26f85ec2046ec9d767f7d69434247a8"), + "core/AssetManager/UIAssetMerger/JScriptUIAssetMerger.php" => array("2466", "9098ce95776b28e6d48fab8dab09ea74"), + "core/AssetManager/UIAssetMerger.php" => array("4549", "9ac6a5d8b409076548b0dcc277985e29"), + "core/AssetManager/UIAssetMerger/StylesheetUIAssetMerger.php" => array("4254", "d85777f9b6a2fffefbe3d329e450cc79"), + "core/AssetManager/UIAssetMinifier.php" => array("1727", "28d43428fd6ae8b1460c3e444e269b29"), + "core/AssetManager/UIAsset/OnDiskUIAsset.php" => array("2540", "f70f89fd35d15983aae8681e1facea6b"), + "core/AssetManager/UIAsset.php" => array("1198", "073b3a89bd5737ca6176b597ef16eea5"), + "core/Auth.php" => array("2915", "aeb757345dec1077cd0ba75774356962"), + "core/CacheFile.php" => array("5665", "985342d2fdae584019a7f4c1e955976a"), + "core/CliMulti/Output.php" => array("1063", "d00891b635aaf4d1dc2120ee39b5c5a6"), + "core/CliMulti.php" => array("8349", "265403a19c082fc4b0b4c74959a9208b"), + "core/CliMulti/Process.php" => array("4681", "3a6faa96f191b8d405f6a4972abd13b3"), + "core/CliMulti/RequestCommand.php" => array("2249", "c224d55ef5d6a3a55295cf35259feb96"), + "core/Common.php" => array("35753", "a8c976da84bc9ff1c54918992725eadc"), + "core/Config.php" => array("22997", "147ab7aa770b1fa5423e46871bc0278a"), + "core/Console.php" => array("4872", "c594ab40478f30fa294ea96484389439"), + "core/Cookie.php" => array("11300", "9f160dcf724d3f0f30ac6c50556ca88d"), + "core/CronArchive/FixedSiteIds.php" => array("1305", "7b0307e871cc50d74bbd00e8e26cccbd"), + "core/CronArchive.php" => array("47570", "18a2222278c77c7f4fba542005fa3f17"), + "core/CronArchive/SharedSiteIds.php" => array("4596", "0bc0bd027d24d9cb11f8cc99f73023bc"), + "core/DataAccess/ArchiveSelector.php" => array("14351", "98eaf72ed205b6261ca663d93a6693fe"), + "core/DataAccess/ArchiveTableCreator.php" => array("3320", "455700705f6f6c784f97dc2c84be1f7e"), + "core/DataAccess/ArchiveWriter.php" => array("9384", "3e3235f3a37fdd1e6a78e5624048dbf3"), + "core/DataAccess/LogAggregator.php" => array("41234", "7f733c60c036d166c31cea37567791e9"), + "core/DataArray.php" => array("16587", "ca482fd7e31e0f3479387c80ae746e88"), + "core/DataFiles/Countries.php" => array("7867", "32e4468ba434ffd83bc07d70ad7f4969"), + "core/DataFiles/Currencies.php" => array("8648", "4edf0cb07ef189624b4d14d682183d7a"), + "core/DataFiles/Languages.php" => array("7317", "7cbf38c6feae3f1dea58bc3d74a6e6a4"), + "core/DataFiles/LanguageToCountry.php" => array("2520", "d2de6f0a9b23560bc52c7b1726f1e5c7"), + "core/DataFiles/Providers.php" => array("1792", "bbb4dec4a616cd73ae6566feadc24154"), + "core/DataFiles/SearchEngines.php" => array("45747", "c4f37ecde5286617e51a445300502034"), + "core/DataFiles/Socials.php" => array("5467", "ff231864e2c740a1cac4c70311c09756"), + "core/DataTable/BaseFilter.php" => array("2042", "fce60bb7fa6dc0fe4f9ac0cc5cb0de69"), + "core/DataTable/Bridges.php" => array("562", "d0d252681214102d0f10a277bbddad57"), + "core/DataTable/DataTableInterface.php" => array("857", "19babd1eb37f5541bdd436b2eadbca5e"), + "core/DataTable/Filter/AddColumnsProcessedMetricsGoal.php" => array("10060", "19245f7364829705ed645129187a26be"), + "core/DataTable/Filter/AddColumnsProcessedMetrics.php" => array("5444", "6cab57109eb9db0400a5d747bf88539e"), + "core/DataTable/Filter/AddSummaryRow.php" => array("1380", "072952c5aebbc41e28a032f3766b3e71"), + "core/DataTable/Filter/BeautifyRangeLabels.php" => array("6074", "ea93fc021a0a3f19c9a83111e63279d3"), + "core/DataTable/Filter/BeautifyTimeRangeLabels.php" => array("4548", "bcc614caf8c17085b9ac4b12e15701cd"), + "core/DataTable/Filter/CalculateEvolutionFilter.php" => array("5891", "fe1b274e742a054dbc701d905a5087f1"), + "core/DataTable/Filter/ColumnCallbackAddColumnPercentage.php" => array("1012", "2132297b4a7073faf44f38eec883eb58"), + "core/DataTable/Filter/ColumnCallbackAddColumn.php" => array("2845", "ceb1d9be8b76d9869585dfe107c4a45a"), + "core/DataTable/Filter/ColumnCallbackAddColumnQuotient.php" => array("5056", "6e5056fd84b5745733e65c628609e184"), + "core/DataTable/Filter/ColumnCallbackAddMetadata.php" => array("3021", "29eea90b9dbb6b5c3ce730bd5eb8ef29"), + "core/DataTable/Filter/ColumnCallbackDeleteRow.php" => array("2460", "8bf70a091da4f4e014ae393fbc999bc2"), + "core/DataTable/Filter/ColumnCallbackReplace.php" => array("4249", "fec351b71143e9298255c6e86a12ca6c"), + "core/DataTable/Filter/ColumnDelete.php" => array("5062", "dc7f5cd71a488bbc996fd38f1bf0bb30"), + "core/DataTable/Filter/ExcludeLowPopulation.php" => array("3379", "9847cc4a48fc0549546b51427f223457"), + "core/DataTable/Filter/GroupBy.php" => array("3472", "551e742f8d0c5e64e89b0660969fa6f5"), + "core/DataTable/Filter/Limit.php" => array("1892", "276f4352a6918be4c549d008d406c7c0"), + "core/DataTable/Filter/MetadataCallbackAddMetadata.php" => array("2651", "e0576cdfa84a99765f6e88960bb8e422"), + "core/DataTable/Filter/MetadataCallbackReplace.php" => array("2346", "be71bc6d2a6aca70a04404b42a1085f0"), + "core/DataTable/Filter/Pattern.php" => array("2933", "08e09a9da40a39a12d262fbc51d2753d"), + "core/DataTable/Filter/PatternRecursive.php" => array("2743", "12abb36c7bde6d4cf981f9d9a0684c9c"), + "core/DataTable/Filter/RangeCheck.php" => array("1607", "091a7675c8217cb03448b129b8daca5e"), + "core/DataTable/Filter/ReplaceColumnNames.php" => array("5749", "ff15fca13dcd7b5ed2f669f583233dc9"), + "core/DataTable/Filter/ReplaceSummaryRowLabel.php" => array("2089", "ada7b250bd2ea1079d3b10387763627f"), + "core/DataTable/Filter/SafeDecodeLabel.php" => array("1832", "4e46025ff3ae6b1fb1b8fae2db421785"), + "core/DataTable/Filter/Sort.php" => array("6432", "424750b4a5d7f94f4896c4705837cc53"), + "core/DataTable/Filter/Truncate.php" => array("4175", "7ea93e0bd0abd847c0e919207d1a1320"), + "core/DataTable/Manager.php" => array("4400", "c95164db72086a7c6479257aeef0d173"), + "core/DataTable/Map.php" => array("13011", "8dfdbc7dfe0f7992a83977005f0e2324"), + "core/DataTable.php" => array("57572", "d509d1932005f8dfb00c3a9f03ea78c9"), + "core/DataTable/Renderer/Console.php" => array("5028", "4b1b777fec6b5ae5ccd9c9ec2d523a0a"), + "core/DataTable/Renderer/Csv.php" => array("12195", "d127b1ac63e24895389b17cb43072752"), + "core/DataTable/Renderer/Html.php" => array("5839", "559f0fc629844984a9adf5edeb0b1507"), + "core/DataTable/Renderer/Json.php" => array("3671", "e4b3138a405fc1e129185150cee5b8ce"), + "core/DataTable/Renderer.php" => array("13396", "cfad13e54038358b5776d4cda01e98ee"), + "core/DataTable/Renderer/Php.php" => array("7732", "c603669a6b9945e711ba68b59c8b737d"), + "core/DataTable/Renderer/Rss.php" => array("6068", "fbb296b297c6d3ad4705196c587f76db"), + "core/DataTable/Renderer/Tsv.php" => array("701", "772cf9af832f601154357535a62a9d32"), + "core/DataTable/Renderer/Xml.php" => array("15715", "37c3a6d8f2b5965c720e2b6abca0c0b0"), + "core/DataTable/Row/DataTableSummaryRow.php" => array("1903", "8a25ba9516a43929261f157023402b49"), + "core/DataTable/Row.php" => array("21822", "91d9896e5123c829035a1011c524dd3a"), + "core/DataTable/Simple.php" => array("967", "5e662c2478307b1a9ca6b69294b99c0d"), + "core/DataTable/TableNotFoundException.php" => array("234", "74c2ecb43569238eb30d3f91a21941d8"), + "core/Date.php" => array("22031", "9a8a5696c6595e2ea04d5e5b2f17a61d"), + "core/Db/AdapterInterface.php" => array("1372", "e61f5273b376e6698f3a0a83e15896a6"), + "core/Db/Adapter/Mysqli.php" => array("4455", "8aef57991aae0aef6210a958569c2415"), + "core/Db/Adapter/Pdo/Mssql.php" => array("7582", "f5bbeeb60469cd98048933056286bcec"), + "core/Db/Adapter/Pdo/Mysql.php" => array("6172", "a1c2f38bf52784583abd6dbc7af639fe"), + "core/Db/Adapter/Pdo/Pgsql.php" => array("4769", "decde138f1566ea834a159b8694433f1"), + "core/Db/Adapter.php" => array("2807", "87a49f2522ddbec917fb1d7ec1456708"), + "core/Db/BatchInsert.php" => array("9296", "33039a060eaff7d7abaff65eaaa1c93b"), + "core/DbHelper.php" => array("3822", "a2b18b7df87431bcc7d9740557150412"), + "core/Db.php" => array("24578", "9f2e812e7a76db407a59a9f7495d1e14"), + "core/Db/SchemaInterface.php" => array("2112", "f55d75b05af2cb7de8133942faef36ca"), + "core/Db/Schema/Mysql.php" => array("19642", "9fe1d3d20a5c3a824fa13d7ae7e8da7c"), + "core/Db/Schema.php" => array("5616", "622f40c2f6a7a49e333f7309bb7e7111"), + "core/dispatch.php" => array("955", "6a9a4c5f99af08ed0048f8b2de1886ec"), + "core/Error.php" => array("7210", "5153b7ae689b464cce8b721ddb361d28"), + "core/EventDispatcher.php" => array("6188", "787ae06fb148af48dacf6fe2ab768586"), + "core/ExceptionHandler.php" => array("2053", "86c9c27bd7868af403d53c8a0b143fff"), + "core/Filechecks.php" => array("8786", "576db7c700047fbe3de33abd3a3e6dd3"), + "core/Filesystem.php" => array("10596", "fefaf74a3736cb39ee144e22a77a30ac"), + "core/FrontController.php" => array("20372", "3acd41cc80fdf61723cf0ec8f94c60b0"), + "core/Http.php" => array("29559", "4319b7bb5789c60dfb67b91f2c09520e"), + "core/IP.php" => array("14032", "c418038633d69c9c6acb2f512b08ae14"), + "core/Loader.php" => array("3251", "8a3541845131b8a209b60057b1a97568"), + "core/Log.php" => array("22487", "ae607b359689ee4539d1acade5dfe0bd"), + "core/Mail.php" => array("3051", "dd77f65726ccb1fb35125bc446fc7e9e"), + "core/Menu/MenuAbstract.php" => array("8516", "2380174f7029dad633a52319be1af913"), + "core/Menu/MenuAdmin.php" => array("3752", "d52a4c7296cb935b74122b42a802d8ab"), + "core/Menu/MenuMain.php" => array("2644", "d74adf1afca5dc8fc72e80b93b99a5c4"), + "core/Menu/MenuTop.php" => array("4237", "62983c9f66e4caf4812af80c9407b348"), + "core/MetricsFormatter.php" => array("8530", "aa60c76868df55960a3235d7e2f4b37f"), + "core/Metrics.php" => array("14842", "b1dbee19d075b83a3080a99909b0b380"), + "core/Nonce.php" => array("5260", "6908ea4c50150171fd6eed821a916a14"), + "core/Notification/Manager.php" => array("3903", "a23c790d484dc3ac76e19947efb6fd54"), + "core/Notification.php" => array("5732", "1cbe56fe6665c7206ca88739aad8832f"), + "core/Option.php" => array("6684", "3b110408402a646d6c7861dfd3eaed4e"), + "core/Period/Day.php" => array("2058", "dd8cc0376ce9235221bbc25c8730bc41"), + "core/Period/Month.php" => array("1630", "974b6b2604e1f9ef2a928ab4eaab58b7"), + "core/Period.php" => array("9748", "def7194a46591d12a1b66eb93a97a9cb"), + "core/Period/Range.php" => array("15936", "30c69c5eacb62cc6c173e58b95ca96dc"), + "core/Period/Week.php" => array("2544", "663151eb94b86e7a30404e49146088c2"), + "core/Period/Year.php" => array("1869", "da04fd59cdd94516ecb842c8ea00e5f2"), + "core/Piwik.php" => array("26583", "e8e87eb48bd0df5b155c9649e55cd457"), + "core/Plugin/API.php" => array("1108", "ee9a5d269cf33cde142c8c438322ace9"), + "core/Plugin/Archiver.php" => array("4319", "bd0c156de5ef58c75c3bb68cc79bc6f5"), + "core/Plugin/ConsoleCommand.php" => array("1805", "f1850b6f0d989d47b369afe18fb261f2"), + "core/Plugin/ControllerAdmin.php" => array("7991", "fe4081f2d6d58c5587b1b462afe20b0a"), + "core/Plugin/Controller.php" => array("38680", "7fcbba7a289fb5b1908046e7ef1a35ff"), + "core/Plugin/Dependency.php" => array("2890", "aaa67fe7929ebd4ca30a19a9635a94b8"), + "core/Plugin/Manager.php" => array("37639", "21ba8ef6db04c3800ea9074dafd4c7fe"), + "core/Plugin/MetadataLoader.php" => array("2553", "22ce1fad1069035d6692129b65cec244"), + "core/Plugin.php" => array("10373", "715f226ecb91745815cac730585df8d5"), + "core/Plugin/Settings.php" => array("12013", "ddb5052a732359804337c4eadee99bb4"), + "core/Plugin/ViewDataTable.php" => array("15746", "d6e134e0ae998383ea20d981106400a8"), + "core/Plugin/Visualization.php" => array("22404", "e0313135a50bbf1cd236a4de20606f81"), + "core/Profiler.php" => array("9578", "fb3a2b8d4abe4e848976ea842560f064"), + "core/ProxyHeaders.php" => array("2329", "2c2040497ebd44a257a7813b873ce8ae"), + "core/ProxyHttp.php" => array("9293", "a66d7477c1715daa43bfaf03848bc2d1"), + "core/QuickForm2.php" => array("3991", "38181ddffae0ddb03dded14f44ae8233"), + "core/RankingQuery.php" => array("12775", "86664cae23f6c9669606d1189d33d4af"), + "core/Registry.php" => array("1104", "5f93fbe06640eef9980ada45677c6ebe"), + "core/ReportRenderer/Csv.php" => array("3977", "5ec7fe33c5a71d6a0044446d2dc823bf"), + "core/ReportRenderer/Html.php" => array("5797", "b46530e59c537058a1cd5bebed7d5d9c"), + "core/ReportRenderer/Pdf.php" => array("20769", "1ace48a18c949c67516c3124db35cad7"), + "core/ReportRenderer.php" => array("7959", "bf5443a70c2f0c83ee4649139b3d25a2"), + "core/ScheduledTask.php" => array("5696", "83c2633ccf1c6d970827bb43bbe59578"), + "core/ScheduledTaskTimetable.php" => array("3398", "166f58a5c50c282c7c29d6f644ccc58a"), + "core/ScheduledTime/Daily.php" => array("1233", "d022648a5088a797a26348dc450fd774"), + "core/ScheduledTime/Hourly.php" => array("1300", "05f17149dbc4a4fbe3763097abc2526a"), + "core/ScheduledTime/Monthly.php" => array("4052", "8badd753f6c06f07ecac69f5e91bf16c"), + "core/ScheduledTime.php" => array("7625", "ab30399dcfca1ba1f3aa2d89c67e3cc8"), + "core/ScheduledTime/Weekly.php" => array("2032", "8de6f993989ee66ea786ad1ddc76baab"), + "core/SegmentExpression.php" => array("12147", "1c989bf36472614b2be51beef8f59eea"), + "core/Segment.php" => array("16496", "4a4795937a2c7726150e5d5d797a1f4c"), + "core/Session.php" => array("5408", "9c0b50a50516a2078ee3e043e5c5cdf8"), + "core/Session/SaveHandler/DbTable.php" => array("3400", "231cf14b800bbfbe688ced3fba0c743c"), + "core/Session/SessionNamespace.php" => array("656", "43fd5d53fdb1a60b41e93be8c8f80b44"), + "core/Settings/Manager.php" => array("4028", "42535abca90e8eb964a46c7289dde797"), + "core/SettingsPiwik.php" => array("12885", "a07fe55e36ea565d9bdb38c384345ce5"), + "core/SettingsServer.php" => array("6313", "2785b33d06035d586e892f2868ff57d0"), + "core/Settings/Setting.php" => array("6833", "82707639f441064423be496ecb229ede"), + "core/Settings/StorageInterface.php" => array("1615", "eacd97cea047d06e137d569e15af1179"), + "core/Settings/SystemSetting.php" => array("984", "821339fb538bc6fe35f6cc2ede502e8b"), + "core/Settings/UserSetting.php" => array("3449", "79cc353b6019a245fef3f0bacc6ec7e6"), + "core/Singleton.php" => array("1353", "ee0398b6ffb82242e498de630f73c091"), + "core/Site.php" => array("15122", "9ce12ed8d49fb31fbabf3380052cd199"), + "core/TaskScheduler.php" => array("6626", "4850a933550e4570430b73512cceab7a"), + "core/TCPDF.php" => array("1971", "a94bb257665eefecceadf7f753d7fc77"), + "core/testMinimumPhpVersion.php" => array("7398", "deada667cb4971e1296074cdc780d07f"), + "core/Theme.php" => array("4541", "d4de1d79c1cd622603ca70e5f0af91ae"), + "core/Timer.php" => array("1808", "39acf463b724b25bcce2583d7292d87f"), + "core/Tracker/ActionClickUrl.php" => array("1742", "61c02b31e21b6f9a401eb657fee4364e"), + "core/Tracker/ActionEvent.php" => array("2265", "336607e4d024a5f08c3be764f33c2460"), + "core/Tracker/ActionPageview.php" => array("2013", "366739709f90aa8d232511834f11414b"), + "core/Tracker/Action.php" => array("9811", "2f4aa70d96316e21d62aa56ce1cac5fb"), + "core/Tracker/ActionSiteSearch.php" => array("9254", "1b0f81d05b783b2bca998cd896e1ab8f"), + "core/Tracker/Cache.php" => array("6289", "94a28a7608f12b3e8acffe67c7acc211"), + "core/Tracker/Db/DbException.php" => array("271", "e705dd83b4a7498aa4f37ba24f7a82ca"), + "core/Tracker/Db/Mysqli.php" => array("7571", "63642f63c2364d44188c65e543d44a34"), + "core/Tracker/Db/Pdo/Mysql.php" => array("6810", "32af933dd0219d7f17234d61b6222f75"), + "core/Tracker/Db/Pdo/Pgsql.php" => array("3204", "d76f5782916317fa5d560baddf554877"), + "core/Tracker/Db.php" => array("6120", "f2fda7c7dfc83c28c5ab585ed47e26f0"), + "core/Tracker/GoalManager.php" => array("35981", "be9605a31afa46221ad36eb0a1dcb249"), + "core/Tracker/IgnoreCookie.php" => array("1762", "9c438161c21ee28ca5fceeb67d0884f8"), + "core/Tracker/PageUrl.php" => array("11076", "3c36bc8fdc8fcaf564f25a97ad1c766f"), + "core/Tracker.php" => array("28846", "9edc0aec5accc5d1f6c3a1bdea7fced1"), + "core/Tracker/Referrer.php" => array("11012", "0697bfd2c20a646fc6de922a7ec6e153"), + "core/Tracker/Request.php" => array("18612", "8796d52a20094467b8f3cb46b24ee4c6"), + "core/Tracker/TableLogAction.php" => array("9173", "0688a6b89a8eef0943bbb051dd5464b8"), + "core/Tracker/VisitExcluded.php" => array("8453", "d9179d7244a794b2a9fc471e53e0a96b"), + "core/Tracker/VisitInterface.php" => array("591", "44e61d7caeb0701b0a0cbd4351b8a4d5"), + "core/Tracker/VisitorNotFoundInDb.php" => array("237", "bb64791a33dfee4d64aff1f8495302ec"), + "core/Tracker/Visit.php" => array("42168", "193887df85ce090bcfb9f50bcf326422"), + "core/Translate/Filter/ByBaseTranslations.php" => array("1711", "0592c1a63f43e15c49b6f9579e80b0e2"), + "core/Translate/Filter/ByParameterCount.php" => array("2399", "1b054c3111be6abbbb393efcd80cdecf"), + "core/Translate/Filter/EmptyTranslations.php" => array("1140", "21a8ee941ba93b652f22043424ef0ca6"), + "core/Translate/Filter/EncodedEntities.php" => array("1028", "0fcf7b3c769742621beef97887b6f968"), + "core/Translate/Filter/FilterAbstract.php" => array("651", "385a5f448f1f38e14570a3e462c477b9"), + "core/Translate/Filter/UnnecassaryWhitespaces.php" => array("2408", "502ac2955cf7be7f655ddd5bc0c867b1"), + "core/Translate.php" => array("6857", "b18b68afe4900f9a9b42df364e4370ae"), + "core/Translate/Validate/CoreTranslations.php" => array("3269", "42f30ed563dbac7855e1558194bb28f6"), + "core/Translate/Validate/NoScripts.php" => array("1023", "d1db00f141ac004f0cb195a44d327468"), + "core/Translate/Validate/ValidateAbstract.php" => array("685", "5af8be0c67032bb6405c3dc6dd36a88f"), + "core/Translate/Writer.php" => array("9383", "2ffc837feb2e9e3c3b27f03a0d0cb931"), + "core/Twig.php" => array("10465", "f2cdbdb455ea6537b221905d9823d7bb"), + "core/Unzip/Gzip.php" => array("1588", "c37fe684918ef45cf5fceba0d90f2db8"), + "core/Unzip/PclZip.php" => array("2203", "a9b66ff795a00b9f9489231c40f00f38"), + "core/Unzip.php" => array("1246", "962c3818d46540dba5d0bdb1ff99d747"), + "core/Unzip/Tar.php" => array("1991", "aca63f78845e3a94b2c7c4afc65e0fa1"), + "core/Unzip/UncompressInterface.php" => array("789", "914626f0c5ff3750f2db9b618146a9fa"), + "core/Unzip/ZipArchive.php" => array("4446", "aa086fbaa27dc9653c49019022e912dc"), + "core/UpdateCheck.php" => array("3554", "78229bc91a7ab4d029e2c1a8f1285f11"), + "core/Updater.php" => array("11899", "1f59e2ee92b7cec575344d2c51fa4eae"), + "core/Updates/0.2.10.php" => array("2651", "876e9fe07b71a4fe9d76fd8fd174f0a6"), + "core/Updates/0.2.12.php" => array("932", "59f8221c661ccf08e94b5bf923d67c51"), + "core/Updates/0.2.13.php" => array("786", "349c538337653cb7c1b61603001ead79"), + "core/Updates/0.2.24.php" => array("978", "db0f1757f204103520d52a369d305406"), + "core/Updates/0.2.27.php" => array("2791", "4947fca28656acd2e1343514c7203ec3"), + "core/Updates/0.2.32.php" => array("1092", "0962b3c0781f2621e15825facc5f5ed8"), + "core/Updates/0.2.33.php" => array("1185", "a9d96ddf9036a5dd819da808ed875d68"), + "core/Updates/0.2.34.php" => array("592", "98ae3b028633eba1791d9b92c794d760"), + "core/Updates/0.2.35.php" => array("587", "a5c358cd52da528bcf9becb54172df8c"), + "core/Updates/0.2.37.php" => array("642", "ec8400f457c7a945713fe252947a9436"), + "core/Updates/0.4.1.php" => array("806", "e223d3c70ea7e3c1d804489a91853c7b"), + "core/Updates/0.4.2.php" => array("1156", "d37d6ddf7068db07377601fa6595ffb7"), + "core/Updates/0.4.4.php" => array("660", "c72894136443d613abb674baa7c11338"), + "core/Updates/0.4.php" => array("1167", "98af006ec25c0c0140e7f5ad9135794d"), + "core/Updates/0.5.4.php" => array("1958", "779d5f3a0d4d326f9e2f74d1c8cd128e"), + "core/Updates/0.5.5.php" => array("1293", "7f0033047ccc97f6d66d30390402070a"), + "core/Updates/0.5.php" => array("2096", "959cd8b36d92f8ef20ebc1ad78b1b28f"), + "core/Updates/0.6.2.php" => array("1126", "ac49a76d42ed5be509d3c7a40276ea6d"), + "core/Updates/0.6.3.php" => array("1279", "d2fb0180705d57144e3652f69ec3b166"), + "core/Updates/0.6-rc1.php" => array("4507", "9634cef986b658716b06e3a62c066adb"), + "core/Updates/0.7.php" => array("594", "6210a8eeb517c83d6d839e6b1864c13f"), + "core/Updates/0.9.1.php" => array("1454", "1c5185e07ca0bb2f08c93a86c65ed4b4"), + "core/Updates/1.10.1.php" => array("515", "9c1f18c6bed150d9088e4ffbd6ae85e0"), + "core/Updates/1.10.2-b1.php" => array("660", "6b8e21cc1f8082b01858940dee50fb92"), + "core/Updates/1.10.2-b2.php" => array("674", "64b57788df79cbc9ea1180b2ca2f4ced"), + "core/Updates/1.10-b4.php" => array("524", "da5f285f2e7de1a17ef8d39df8778fbf"), + "core/Updates/1.11-b1.php" => array("523", "e230d3911722fd98b6cbf349d34c664d"), + "core/Updates/1.12-b15.php" => array("452", "31134ebb6971b1459ca2f876d4892f22"), + "core/Updates/1.12-b16.php" => array("650", "7e4186377a518ddb166b1d55b8d25bac"), + "core/Updates/1.12-b1.php" => array("668", "bb6566428c9bf4382ff58cba3bc90091"), + "core/Updates/1.1.php" => array("971", "270bf13926c63efc04e6157d1e351ef8"), + "core/Updates/1.2.3.php" => array("1077", "f667e11902edc92fd98b335c236e2afe"), + "core/Updates/1.2.5-rc1.php" => array("864", "a36b86d356ce093a63177efcc56a8d3c"), + "core/Updates/1.2.5-rc7.php" => array("604", "8db19752e89143fe991cd9529d8be11d"), + "core/Updates/1.2-rc1.php" => array("6822", "346d520a049c2426fb2442e7f29347c3"), + "core/Updates/1.2-rc2.php" => array("434", "c92ff71c25dfb7eccec537abf745e1e1"), + "core/Updates/1.4-rc1.php" => array("805", "0691f7982446f93ddcc1047291c86001"), + "core/Updates/1.4-rc2.php" => array("1639", "c9f5619ad1c8a92545c73d548e44b58a"), + "core/Updates/1.5-b1.php" => array("2262", "ba785cbad196b247571781cadada94b6"), + "core/Updates/1.5-b2.php" => array("1087", "5f5e77f985c277d714b7281d7f965069"), + "core/Updates/1.5-b3.php" => array("2805", "3b4e6761ecdf28343cb3184223525f75"), + "core/Updates/1.5-b4.php" => array("571", "ad302b8cb925c0c10a417456fb502b07"), + "core/Updates/1.5-b5.php" => array("700", "96c669e08b45e60525fe1bb1fb943c9d"), + "core/Updates/1.5-rc6.php" => array("433", "db7f6f03adbb92928cb439c53d9d9ef9"), + "core/Updates/1.6-b1.php" => array("3151", "e5544d116d44ab8538b92eeeca4f3c58"), + "core/Updates/1.6-rc1.php" => array("429", "abc0632476540e4e8ef7e8a0c5d311f9"), + "core/Updates/1.7.2-rc5.php" => array("675", "c204aae2d7f7689d37f4091c37524268"), + "core/Updates/1.7.2-rc7.php" => array("1310", "111c0d0cb4f19766ff8e7635c387eb48"), + "core/Updates/1.7-b1.php" => array("801", "7d0bf59a66243ae5adb41e01b584a30a"), + "core/Updates/1.8.3-b1.php" => array("4214", "d2dab6e537e059c3f42be3b3fd00c1d2"), + "core/Updates/1.8.4-b1.php" => array("5602", "f6e09c6abb389ebb423530214fcb3732"), + "core/Updates/1.9.1-b2.php" => array("756", "926f80df9c4e1874aefe77d59a6ec56e"), + "core/Updates/1.9.3-b10.php" => array("522", "4b239e1966e6183b52fe8a56887e0480"), + "core/Updates/1.9.3-b3.php" => array("687", "effe8f40e5733e5a0244421735944f63"), + "core/Updates/1.9.3-b8.php" => array("730", "34ccb3d0357dbcd9a0d9df4030fdbf36"), + "core/Updates/1.9-b16.php" => array("1493", "07e70dd30d645aada68bfd71e65e9e49"), + "core/Updates/1.9-b19.php" => array("976", "a1f50808b7f7339bec7bb31c7423b120"), + "core/Updates/1.9-b9.php" => array("1440", "67363fcdc79574bda86993f3cea59405"), + "core/Updates/2.0.3-b7.php" => array("1816", "80902356a3532433940567151aaa5a82"), + "core/Updates/2.0.4-b5.php" => array("2589", "95f1d30f619a50ad99ad21d52713c949"), + "core/Updates/2.0.4-b7.php" => array("1773", "1bb19c5e4277994b1f414647def74cd6"), + "core/Updates/2.0.4-b8.php" => array("2124", "91e5b1108076279e5a3c8b0764887573"), + "core/Updates/2.0-a12.php" => array("1221", "c96c6665f2f05c26527968bb9391b01e"), + "core/Updates/2.0-a13.php" => array("2361", "bcc1c2d24b5e72bace075ac8075918fb"), + "core/Updates/2.0-a17.php" => array("1015", "c5d6321d2bbb8fd436c733a4213e3513"), + "core/Updates/2.0-a7.php" => array("872", "72f65ce2241de0e53e02e8f9a6911709"), + "core/Updates/2.0-b10.php" => array("404", "c10a63eb97efeb774f78b5efdbf8a866"), + "core/Updates/2.0-b13.php" => array("986", "897baf57c6104b512bb81ec186ed173a"), + "core/Updates/2.0-b3.php" => array("1122", "5f4d8995e425682b8e2feb9dc162e35a"), + "core/Updates/2.0-b9.php" => array("653", "8d3bd1b10367fac6d9ac351addd2e4da"), + "core/Updates/2.0-rc1.php" => array("431", "172c8341bd784da35b3b83b2c2f5b1f6"), + "core/Updates/2.1.1-b11.php" => array("5745", "acce7f7e6ff35875809478861353dbcb"), + "core/Updates/2.2.0-b15.php" => array("639", "c89858a73296cda3e66b2ded05ba6f4d"), + "core/Updates.php" => array("2803", "2deac460daaceeee0fa1fe9e479185bb"), + "core/UrlHelper.php" => array("18349", "dbd9d9cd951cfafa14e29bcc382502c5"), + "core/Url.php" => array("17731", "554508c27948cb1b29da65844b73b6aa"), + "core/Version.php" => array("345", "636e0ebdf450757751fa98b6ccca24c4"), + "core/ViewDataTable/Config.php" => array("20852", "41d12a2246ca76006b223105edcf5e4d"), + "core/ViewDataTable/Factory.php" => array("7098", "36920d013e8245b49cbf92ba2c0b500d"), + "core/ViewDataTable/Manager.php" => array("8846", "51e33664ee6ae517ed42648eddd31964"), + "core/ViewDataTable/RequestConfig.php" => array("8907", "343b0baae408f730c167129bf1ba2571"), + "core/ViewDataTable/Request.php" => array("4031", "d7b2c817853f519a8b2fda06e6211038"), + "core/View/OneClickDone.php" => array("2077", "bf64751dc4c8fa73f7bbc49b319308c6"), + "core/View.php" => array("12640", "47020ad4b4284ce91a83b1149a1190e5"), + "core/View/RenderTokenParser.php" => array("2051", "a8b5b4566507c29e1fdd09bfaeedd99e"), + "core/View/ReportsByDimension.php" => array("4192", "d72f400326ec14706f7f08f5386828c3"), + "core/View/UIControl.php" => array("4410", "d16a751a2063cac0b834ab63b448fdef"), + "core/View/ViewInterface.php" => array("403", "4a9732dd334255ba0ead94222d74f87f"), + "core/Visualization/Sparkline.php" => array("4576", "3533412a25076c86b0b2a2474a024e74"), + "core/WidgetsList.php" => array("7060", "dc525a4a4e618d56ae0563d22549bb08"), + "index.php" => array("1546", "15d01f358d90f66a19dcab6946679095"), + "js/index.php" => array("749", "a80b88022a2748952a62401871d025bd"), + "js/LICENSE.txt" => array("1549", "c1d16895887980c4494a95a7f6ee3671"), + "js/piwik.js" => array("125559", "9f58141514a1bb0001636373cbb8e068"), + "js/README.md" => array("2142", "a59319062bafe9657baac94e9fdb7995"), + "lang/am.json" => array("43298", "2e05b5fda32bdb397888910de8165463"), + "lang/ar.json" => array("116774", "108b69246990df2762bfddbe22696501"), + "lang/be.json" => array("133845", "d4791872ccafc77296fdc7f01c18935a"), + "lang/bg.json" => array("236400", "c618daca1fc95e32e57eefdcefcd6642"), + "lang/bn.json" => array("30389", "de30a94afc6c0cfd5e39805821dbcb8a"), + "lang/bs.json" => array("47846", "5f02521870cfe3146efee207e59bca4b"), + "lang/ca.json" => array("148886", "6219033d88bf58c96e5c09029708e38b"), + "lang/cs.json" => array("96631", "95e50796f835a36f0dd4a2f5b45ba362"), + "lang/cy.json" => array("33669", "3ae7125bad892632b4dbadbcde73e0eb"), + "lang/da.json" => array("175497", "3770805fc026e5100fd9ae140849514e"), + "lang/de.json" => array("192559", "021bc6cbdbce3eaae44017ef834d2f09"), + "lang/el.json" => array("293119", "f3a72418fd95b71ca8ec2ba7f833e0f2"), + "lang/en.json" => array("176161", "9c820d429d7a4cc0db23376a3166d9e2"), + "lang/es.json" => array("187985", "003cf7629a086625ba4277b9d1bdbe50"), + "lang/et.json" => array("89740", "7c339aa2dca5c02fcddd31ea37566a98"), + "lang/eu.json" => array("46504", "220e4c85c784d98f04b7915a07070dc0"), + "lang/fa.json" => array("185290", "3b5a3430bd600b2980f13418a2457884"), + "lang/fi.json" => array("171923", "92fabf05e4bc46c407dba69407077b53"), + "lang/fr.json" => array("191935", "169d8019b419009aaeee37c7f5ab2682"), + "lang/gl.json" => array("31239", "6e8e2ee3b00d9edb52d909ddf3765d0f"), + "lang/he.json" => array("66841", "5b3bded179f179a250499886a4dcb141"), + "lang/hi.json" => array("267566", "1243af856ce0f2de652a47777c01bdeb"), + "lang/hr.json" => array("44181", "d4d1f8a8210b743dfc74b2909c93e29d"), + "lang/hu.json" => array("88631", "5a393be54f20621ec373aae7952119b0"), + "lang/id.json" => array("160275", "8fe3288e6ab07886257964d2755af0d0"), + "lang/is.json" => array("44565", "d3e6d051c1bd28ec5677c50582e2249b"), + "lang/it.json" => array("188242", "f7547dcef63cce6d91627d6c001d22c1"), + "lang/ja.json" => array("146362", "f47bd751197cfc3d072f806d8deaaef2"), + "lang/ka.json" => array("133972", "d919209c916cd66daad1e233b97c24a6"), + "lang/ko.json" => array("159126", "7e91ce1f784680466763eb7b5a664c87"), + "lang/lt.json" => array("72812", "bacac0086478ec8d07f63229cd109c04"), + "lang/lv.json" => array("68729", "4073b4637d26b430110508ff59a800e8"), + "lang/nb.json" => array("79362", "b0da604e507b5c621b7e697ea1c6bf84"), + "lang/nl.json" => array("159749", "25663b03d177a31f8a8986dd8edcbde6"), + "lang/nn.json" => array("72987", "61ece0f6d31afbd53f42e840643613a8"), + "lang/pl.json" => array("105283", "96ea726f5a9c5ebf8b85f531069760eb"), + "lang/pt-br.json" => array("178705", "955707f471cbde34c6597fc06199469b"), + "lang/pt.json" => array("103399", "c8db5c35133612b7badeca45967a8cf8"), + "lang/README.md" => array("439", "7f5249109f73ae292bcd01eb23853738"), + "lang/ro.json" => array("107388", "4e16d637081c5f7d3bf887debbadee3d"), + "lang/ru.json" => array("236266", "3601f2fbf822f4e3eade4a99f25f0abb"), + "lang/sk.json" => array("72678", "82b11fbdab21b8bcb55665b48794ff42"), + "lang/sl.json" => array("71473", "8e2836b6c48a39847f029a08cc864768"), + "lang/sq.json" => array("108350", "abde20a3257f155aa6b6e88dd65830c9"), + "lang/sr.json" => array("171239", "b348d6ce2bb4f75a190b0e468e0b1584"), + "lang/sv.json" => array("179173", "ce59c0b2c6dab1f8180c6f87daffc90d"), + "lang/ta.json" => array("89885", "f861e7329aac8c31dc1f634d7bab8625"), + "lang/te.json" => array("45655", "41136b9370c8df0f8264d098e576a984"), + "lang/th.json" => array("161630", "ee834a50d83b3a356b0ee7069b80d6f2"), + "lang/tr.json" => array("67775", "d3059314807802e6e9c308c02c7a2b1f"), + "lang/uk.json" => array("101355", "305b6faaf47f8f52ff96070300759047"), + "lang/vi.json" => array("201534", "7a21f65bd54d7b500a4fbf1e6d4de09c"), + "lang/zh-cn.json" => array("149382", "3a6056925b838c9c17ec72ce97705958"), + "lang/zh-tw.json" => array("69883", "1b3dc84c6da058b41f3faf6f33052302"), + "LEGALNOTICE" => array("7322", "cec47f121512f46d75f04edc3821e0e9"), + "libs/angularjs/angular-animate.js" => array("73299", "f57eec6cfc24a9d7de89e7815c54b9cb"), + "libs/angularjs/angular-animate.min.js" => array("10506", "5927bc7044bf9f88c7fcef982cfc84e0"), + "libs/angularjs/angular-cookies.js" => array("5712", "8f359b0b2ccce92d6d0f27a275178646"), + "libs/angularjs/angular-cookies.min.js" => array("850", "f70eb186d69b8c34b9fc083fd95ad46e"), + "libs/angularjs/angular-csp.css" => array("346", "34f147527516ef16935f3499d13fdadf"), + "libs/angularjs/angular.js" => array("737843", "823656f04f0d28049733ea5472eaf885"), + "libs/angularjs/angular-loader.js" => array("14464", "dbd936b897ff5995f868309b1682973e"), + "libs/angularjs/angular-loader.min.js" => array("1505", "540937ba021f2ba2d65679de81cfc225"), + "libs/angularjs/angular.min.js" => array("101076", "f13ec1ca50778530d05f5e7d55964873"), + "libs/angularjs/angular-mocks.js" => array("67709", "b64cf0b49047bda8058ffebcb4b969ba"), + "libs/angularjs/angular-resource.js" => array("23494", "e567b1825d04f7d3c8c4932bafe45c92"), + "libs/angularjs/angular-resource.min.js" => array("3297", "52f14aa58396ba6f06c873048ac757ad"), + "libs/angularjs/angular-route.js" => array("32505", "8eaf3177d26ff41eadaa0bb325cc84cc"), + "libs/angularjs/angular-route.min.js" => array("3885", "39e7751707e2fb3108f6148c957bb726"), + "libs/angularjs/angular-sanitize.js" => array("20736", "bccd4bf0adf6318da2e84e87eea451ef"), + "libs/angularjs/angular-sanitize.min.js" => array("4246", "57046863351fc02e0058321e20691254"), + "libs/angularjs/angular-scenario.js" => array("1080031", "b4202d55bb0ef488e710d0ec0b718f7b"), + "libs/angularjs/angular-touch.js" => array("20706", "f1c23f96e05f1cf0991be9f0ac5f44d8"), + "libs/angularjs/angular-touch.min.js" => array("3205", "27edc5cdcdb0cc9da0a8e1bc6510cb9b"), + "libs/angularjs/errors.json" => array("5385", "4de0bf0d0e45b08c96275179d1e45445"), + "libs/angularjs/LICENSE" => array("1098", "0547d38c79782d84bb4df3c036a0bcc0"), + "libs/angularjs/version.json" => array("102", "2ca74bf29fbf307a7f626d922bb20696"), + "libs/angularjs/version.txt" => array("6", "88979175ec60695fcd6e3c97243cdecd"), + "libs/Archive_Tar/Tar.php" => array("66956", "501bf628481dce9ded472e4ae24d1c7a"), + "libs/html5shiv/html5shiv.js" => array("2427", "11af8654413ddf8b0fe00c3395787e77"), + "libs/HTML/Common2.php" => array("15005", "a9c9b708cd3f2691192171bb78177db3"), + "libs/HTML/QuickForm2/Container/Fieldset.php" => array("3009", "c01d23505028494f202da1e7c092c285"), + "libs/HTML/QuickForm2/Container/Group.php" => array("10690", "af6598a96ab2505936156cf3c82eb231"), + "libs/HTML/QuickForm2/Container.php" => array("15568", "907e655970384bc7761709412e756b25"), + "libs/HTML/QuickForm2/Controller/Action/Back.php" => array("3041", "23865bbfba7359c376c51c8cfc947310"), + "libs/HTML/QuickForm2/Controller/Action/Direct.php" => array("2906", "94748939d6be141ebeb7f44c92c52717"), + "libs/HTML/QuickForm2/Controller/Action/Display.php" => array("4938", "b934c86efb099ae30adaaf2c3157f41b"), + "libs/HTML/QuickForm2/Controller/Action/Jump.php" => array("7694", "79e07bc733b2a9548b6e43470d970f79"), + "libs/HTML/QuickForm2/Controller/Action/Next.php" => array("3492", "6d503bf9c4a33c6c2fa4e6cdd653c10b"), + "libs/HTML/QuickForm2/Controller/Action.php" => array("2820", "6feaf02ad3852182db2be80bcdd39245"), + "libs/HTML/QuickForm2/Controller/Action/Submit.php" => array("3070", "3408028e3f5cc33f837a89bbd48759dd"), + "libs/HTML/QuickForm2/Controller/DefaultAction.php" => array("4312", "25469caf28ac5e6b1a7a38acd83a3e2f"), + "libs/HTML/QuickForm2/Controller/Page.php" => array("8568", "2bc1a11f7ab068f70841630c8313aaa3"), + "libs/HTML/QuickForm2/Controller.php" => array("16291", "9a233f661695b278e42656e088c455a2"), + "libs/HTML/QuickForm2/Controller/SessionContainer.php" => array("6377", "f2fe7d2b18792c9177192ac151b2696a"), + "libs/HTML/QuickForm2/DataSource/Array.php" => array("3541", "03534063e0146c04b3cd3f9f2c0c654c"), + "libs/HTML/QuickForm2/DataSource.php" => array("2683", "65331d40fb6a97680442255b29f9d81f"), + "libs/HTML/QuickForm2/DataSource/Session.php" => array("3179", "ff60e85f7c3cee6f7b5ee1af86d61198"), + "libs/HTML/QuickForm2/DataSource/Submit.php" => array("3075", "7a4ec6f260af2b63eb2957e2ff0b74ba"), + "libs/HTML/QuickForm2/DataSource/SuperGlobal.php" => array("6087", "135315d3a791d4666404750cc627bacf"), + "libs/HTML/QuickForm2/Element/Button.php" => array("5179", "59e46ad40d28c606928d77d0c135adef"), + "libs/HTML/QuickForm2/Element/Date.php" => array("35658", "93d5fd008c523739663e61d6d5f6acaf"), + "libs/HTML/QuickForm2/Element/InputButton.php" => array("3363", "9c1cf6f4a218fe171640b0e11b44ee2c"), + "libs/HTML/QuickForm2/Element/InputCheckable.php" => array("5638", "2ac2c2b81fdcd2fce8cd4c58b178a474"), + "libs/HTML/QuickForm2/Element/InputCheckbox.php" => array("3740", "f9351dba835f3e06a09cc5a5994ddfed"), + "libs/HTML/QuickForm2/Element/InputFile.php" => array("12294", "4f0ae38065fefb2267394efe7322db27"), + "libs/HTML/QuickForm2/Element/InputHidden.php" => array("3049", "d15383783799c661b04c47b831af7b1a"), + "libs/HTML/QuickForm2/Element/InputImage.php" => array("5721", "d3f9f6d831482752dcabed9631c5db4a"), + "libs/HTML/QuickForm2/Element/InputPassword.php" => array("2771", "248ee12b9cf08708e56a6e95d08374b1"), + "libs/HTML/QuickForm2/Element/Input.php" => array("3846", "78f8d93e73d3526a42177ca0c79b1b92"), + "libs/HTML/QuickForm2/Element/InputRadio.php" => array("2714", "d0d7bb6033e27a63bf24059f7981388b"), + "libs/HTML/QuickForm2/Element/InputReset.php" => array("3368", "5a024664c05f7100d64c4f3da8948c44"), + "libs/HTML/QuickForm2/Element/InputSubmit.php" => array("4018", "8c65288419b635f579e5cfe3f80897ad"), + "libs/HTML/QuickForm2/Element/InputText.php" => array("2561", "a4422cb34668d6201db9f86297f84e01"), + "libs/HTML/QuickForm2/Element.php" => array("4547", "3b7e83d416635f885cde255031c23061"), + "libs/HTML/QuickForm2/Element/Select.php" => array("19653", "a301198923f2a48397c028d7e0d6b016"), + "libs/HTML/QuickForm2/Element/Static.php" => array("4653", "3507961ac37bfcc4b50f9fba986da73b"), + "libs/HTML/QuickForm2/Element/Textarea.php" => array("3817", "a494dd182508598a7034de948f7a9b8a"), + "libs/HTML/QuickForm2/Exception.php" => array("3832", "97bd3d2daab0f1adee753a8b60da3224"), + "libs/HTML/QuickForm2/Factory.php" => array("10234", "e6570f82222a308a5e3292555dac07db"), + "libs/HTML/QuickForm2/JavascriptBuilder.php" => array("4132", "6efa38420b43753762d2ed5e202d94a1"), + "libs/HTML/QuickForm2/Loader.php" => array("5033", "50ac48c8f880a82c5cbaf4de45a01818"), + "libs/HTML/QuickForm2/Node.php" => array("20904", "21ea89366d3f68617bf77cc407bf22b1"), + "libs/HTML/QuickForm2.php" => array("7407", "aa4307cba861837825a3ded27a0b6b0b"), + "libs/HTML/QuickForm2/Renderer/Array.php" => array("12034", "add33fbd588592ec5946b8201686ccc3"), + "libs/HTML/QuickForm2/Renderer/Default.php" => array("22480", "7dd6af9bcecf2a883a951605db183fbd"), + "libs/HTML/QuickForm2/Renderer.php" => array("13052", "b63f79f5185291c1f1ccbe290593ca2d"), + "libs/HTML/QuickForm2/Renderer/Plugin.php" => array("2698", "868b936844b79bbb4e9a0db084830ebb"), + "libs/HTML/QuickForm2/Renderer/Proxy.php" => array("9190", "59eb89991cabcda6509d3445a2d63eb1"), + "libs/HTML/QuickForm2/Renderer/Smarty.php" => array("11535", "a9587fa6fe27be5c97538bc8acaa35a6"), + "libs/HTML/QuickForm2/Rule/Callback.php" => array("6779", "536ccc9641568a038ef354350f63541c"), + "libs/HTML/QuickForm2/Rule/Compare.php" => array("8708", "7d1a7c78eb33648a5a89acf5eb6adee3"), + "libs/HTML/QuickForm2/Rule/Each.php" => array("5371", "ae1d806fbcc145a71901c3b1253c8347"), + "libs/HTML/QuickForm2/Rule/Empty.php" => array("3393", "79be971ced97cecdd2e1d1378cbcddb7"), + "libs/HTML/QuickForm2/Rule/Length.php" => array("9237", "977498e22d978636d6b703a83a64da44"), + "libs/HTML/QuickForm2/Rule/MaxFileSize.php" => array("4880", "3517b0cec4010c556aba1656f8db8cf8"), + "libs/HTML/QuickForm2/Rule/MimeType.php" => array("4841", "45b5784084abb9f7d338b8eb303650dc"), + "libs/HTML/QuickForm2/Rule/Nonempty.php" => array("5821", "04addd2b94427be3954d807e289f05d0"), + "libs/HTML/QuickForm2/Rule/NotCallback.php" => array("3176", "e63a66af2231d91bca713458a4731825"), + "libs/HTML/QuickForm2/Rule/NotRegex.php" => array("4139", "7bcfef75d06d6e772566b8a5806f4ae4"), + "libs/HTML/QuickForm2/Rule.php" => array("10991", "3dec84bf1d9ab983608bce52553cd822"), + "libs/HTML/QuickForm2/Rule/Regex.php" => array("5158", "842b30f0687b7d65c2c940bbfc44008e"), + "libs/HTML/QuickForm2/Rule/Required.php" => array("3555", "4c440ff4e187faf10e307b04e774011b"), + "libs/javascript/json2.js" => array("3377", "ba3293970e13b03a2ea92f5b6b5bf544"), + "libs/javascript/sprintf.js" => array("3795", "f8659e7549fb9e482d4f8145399f421c"), + "libs/jqplot/build_minified_script.sh" => array("1434", "b5fe6cddbb5e8bf8f440a380f3e7e5a9"), + "libs/jqplot/excanvas.min.js" => array("19351", "5e2fefd5c782233c12383cca3b19e935"), + "libs/jqplot/gpl-2.0.txt" => array("15112", "8ef64b86db8e0e63606284cf36b643be"), + "libs/jqplot/jqplot.axisLabelRenderer.js" => array("3275", "61248e4baf0752ee2e89cf303c52a95e"), + "libs/jqplot/jqplot.axisTickRenderer.js" => array("6570", "f7bdc4053619d39dd6ad9564de13b779"), + "libs/jqplot/jqplot.canvasGridRenderer.js" => array("19588", "5d6c6588a60d6a298747d095fa11c662"), + "libs/jqplot/jqplot.core.js" => array("175127", "57d2753e6e1a1aedb78e4c1ed7e2f48b"), + "libs/jqplot/jqplot-custom.min.js" => array("191785", "ad2a8daa551040c7c6061ade311f91b0"), + "libs/jqplot/jqplot.divTitleRenderer.js" => array("4174", "1ae09b12907cc772b2137ce60364b8a0"), + "libs/jqplot/jqplot.linearAxisRenderer.js" => array("45487", "187b69d695443da0b2135a5e46d7b292"), + "libs/jqplot/jqplot.linePattern.js" => array("4772", "1091be3885aaa2a2d9ed8171b85aed80"), + "libs/jqplot/jqplot.lineRenderer.js" => array("52298", "e29243f2d8d9fd062487f9ee61419a94"), + "libs/jqplot/jqplot.markerRenderer.js" => array("9045", "3c2a6c18390499ca28663acda6c787cb"), + "libs/jqplot/jqplot.shadowRenderer.js" => array("5769", "0557c0911bd30b61fdf6ae3e12b5943e"), + "libs/jqplot/jqplot.shapeRenderer.js" => array("6369", "335e972fd8f402b55350f2f4877f78de"), + "libs/jqplot/jqplot.sprintf.js" => array("14391", "c677c8120d186d393c67a439ffab99db"), + "libs/jqplot/jqplot.tableLegendRenderer.js" => array("13478", "9e82bd2c17228d0cf3fe922eb2827395"), + "libs/jqplot/jqplot.themeEngine.js" => array("31016", "3ca6a4eece29fd93244a57f17ec2cd4f"), + "libs/jqplot/MIT-LICENSE.txt" => array("1082", "fc0fc2aa2423fd4397d40a7d7ffafbe4"), + "libs/jqplot/plugins/jqplot.barRenderer.js" => array("34737", "cabdfba746026a2f4eac7bbfbb81ec0f"), + "libs/jqplot/plugins/jqplot.canvasAxisTickRenderer.js" => array("9843", "3f8a0bbc13e1793f6fc9a123c9bf3b98"), + "libs/jqplot/plugins/jqplot.canvasTextRenderer.js" => array("24372", "58a963d919b36061685c46a75f6a4a50"), + "libs/jqplot/plugins/jqplot.categoryAxisRenderer.js" => array("28571", "77d0a561f20e0b889e88758449ba7180"), + "libs/jqplot/plugins/jqplot.pieRenderer.js" => array("35551", "f78c034a0bae5eb0310392b5632907ec"), + "libs/jquery/gpl-2.0.txt" => array("15099", "2c1778696d3ba68569a0352e709ae6b7"), + "libs/jquery/gpl-3.0.txt" => array("35147", "d32239bcb673463ab874e80d47fae504"), + "libs/jquery/images/down_arrow.png" => array("2898", "6a5d9f1bd953608817ebf4904d4591bc"), + "libs/jquery/images/scroller.png" => array("3329", "899d80589a386f767cb07b33cc28c216"), + "libs/jquery/images/slide.png" => array("2831", "ef737b4f1d6b594656bca9e99b7f5968"), + "libs/jquery/images/up_arrow.png" => array("2881", "bf4bbd999b2027d836e00fde27a46b39"), + "libs/jquery/jquery.browser.js" => array("619", "236a3b854b2eeae01c8640ad82bb4178"), + "libs/jquery/jquery.history.js" => array("5592", "acd0667f090e0bf6e759bb86db1d674f"), + "libs/jquery/jquery.js" => array("96380", "52d16e147b5346147d0f3269cd4d0f80"), + "libs/jquery/jquery.jscrollpane.js" => array("63508", "08450c17ce2a83ab43176f80253d6f99"), + "libs/jquery/jquery.mousewheel.js" => array("3846", "f77bd9ca0396c7a8672f536884b1e1aa"), + "libs/jquery/jquery.placeholder.js" => array("4515", "6e5b889042b348bdee267ceecbe4159d"), + "libs/jquery/jquery.scrollTo.js" => array("2434", "c4dff68594e0fdb05b48aac9a90c0a19"), + "libs/jquery/jquery.smartbanner.js" => array("9921", "4e24cb04b88e2e0a2c013ab589ef5e2f"), + "libs/jquery/jquery.truncate.js" => array("2452", "cc3fcfc30d8a4496e891469ee20fbd77"), + "libs/jquery/jquery-ui.js" => array("228546", "ea464a34403c8772f3bc6ff2f5355f4b"), + "libs/jquery/LICENSE-sizzle.txt" => array("17635", "418d0239a1435dae6b5c2e919a75f8c9"), + "libs/jquery/MIT-LICENSE-history.txt" => array("1109", "242685841d67aa0bbc837735b177709f"), + "libs/jquery/MIT-LICENSE-jquery.txt" => array("1074", "13a84fe33922678518c49596de032d92"), + "libs/jquery/MIT-LICENSE-jqueryui.txt" => array("1311", "bac9338b4387621f0cea7720ace0450c"), + "libs/jquery/MIT-LICENSE-placeholder.txt" => array("1075", "0146cb31436f780624be47ce08eec616"), + "libs/jquery/MIT-LICENSE-scrollto.txt" => array("1120", "4a5d0d8578e331f5172fe5d0d1470fc4"), + "libs/jquery/MIT-LICENSE-smartbanner.txt" => array("1071", "a23932b5f367ad0272456270829b52a7"), + "libs/jquery/mwheelIntent.js" => array("2249", "71fe6a97b5e149ae08829a586fe7e7ad"), + "libs/jquery/stylesheets/jquery.jscrollpane.css" => array("1400", "ead9005a6e67449768db45b42e3f9b89"), + "libs/jquery/stylesheets/jquery.smartbanner.css" => array("3901", "94fb09ced6636405d47b5ec6c1ed23c1"), + "libs/jquery/stylesheets/scroll.less" => array("2066", "ab33e88a6d4360052d87685173dbf07a"), + "libs/jquery/themes/base/images/ui-anim_basic_16x16.gif" => array("1553", "03ce3dcc84af110e9da8699a841e5200"), + "libs/jquery/themes/base/images/ui-bg_flat_0_aaaaaa_40x100.png" => array("180", "2a44fbdb7360c60122bcf6dcef0387d8"), + "libs/jquery/themes/base/images/ui-bg_flat_75_ffffff_40x100.png" => array("178", "8692e6efddf882acbff144c38ea7dfdf"), + "libs/jquery/themes/base/images/ui-bg_glass_55_fbf9ee_1x400.png" => array("120", "f8f4558e0b92ff2cd6136781533902ec"), + "libs/jquery/themes/base/images/ui-bg_glass_65_ffffff_1x400.png" => array("105", "e5a8f32e28fd5c27bf0fed33c8a8b9b5"), + "libs/jquery/themes/base/images/ui-bg_glass_75_dadada_1x400.png" => array("111", "c12c6510dad3ebfa64c8a30e959a2469"), + "libs/jquery/themes/base/images/ui-bg_glass_75_e6e6e6_1x400.png" => array("110", "f4254356c2a8c9a383205ef2c4de22c4"), + "libs/jquery/themes/base/images/ui-bg_glass_95_fef1ec_1x400.png" => array("119", "5a3be2d8fff8324d59aec3df7b0a0c83"), + "libs/jquery/themes/base/images/ui-bg_highlight-soft_75_cccccc_1x100.png" => array("101", "72c593d16e998952cd8d798fee33c6f3"), + "libs/jquery/themes/base/images/ui-icons_222222_256x240.png" => array("4369", "9129e086dc488d8bcaf808510bc646ba"), + "libs/jquery/themes/base/images/ui-icons_2e83ff_256x240.png" => array("4369", "25162bf857a8eb83ea932a58436e1049"), + "libs/jquery/themes/base/images/ui-icons_454545_256x240.png" => array("4369", "771099482bdc1571ece41073b1752596"), + "libs/jquery/themes/base/images/ui-icons_888888_256x240.png" => array("4369", "faf6f5dc44e713178784c1fb053990aa"), + "libs/jquery/themes/base/images/ui-icons_cd0a0a_256x240.png" => array("4369", "5d8808d43cefca6f6781a5316d176632"), + "libs/jquery/themes/base/jquery-ui.css" => array("25699", "76b945b2f5246c4cbe31857afd481ceb"), + "libs/MaxMindGeoIP/geoipcity.inc" => array("6973", "d191ea9f911a9cf3bc98a07faadf511d"), + "libs/MaxMindGeoIP/geoip.inc" => array("31447", "26f4d94b6c394dcbb6abf57df5acbccf"), + "libs/MaxMindGeoIP/geoipregionvars.php" => array("95763", "1635d7024c6a8b2502ed6f60659c9a1f"), + "libs/pChart2.1.3/change.log" => array("11838", "47d20227c6b22d5425e0bc60475084ad"), + "libs/pChart2.1.3/class/pData.class.php" => array("30575", "3e35fc351f143b70e8959fb65f6bb522"), + "libs/pChart2.1.3/class/pDraw.class.php" => array("319989", "f27d42bc9fa34966f4483546254c312c"), + "libs/pChart2.1.3/class/pImage.class.php" => array("19960", "abbaf39d5fb456ccec682c0f0814d8a9"), + "libs/pChart2.1.3/class/pPie.class.php" => array("65564", "4f2ed8e8c89bd3646c4289e5f2547b7b"), + "libs/pChart2.1.3/GPLv3.txt" => array("35148", "8f0e2cd40e05189ec81232da84bd6e1a"), + "libs/pChart2.1.3/readme.txt" => array("5971", "8e04cbe332d419f3177b2d9300b15e1a"), + "libs/PclZip/lgpl-2.1.txt" => array("26530", "4fbd65380cdd255951079008b364516c"), + "libs/PclZip/pclzip.lib.php" => array("196363", "968cb96854866df0370e6fd5523fa05a"), + "libs/PEAR5.php" => array("1087", "1a8f67d58009372a6cbcddd638b128cf"), + "libs/PEAR/Exception.php" => array("14006", "424a61a67dbd5f9f3ed5fc3be2b9ac54"), + "libs/PEAR/FixPHP5PEARWarnings.php" => array("152", "ff5f4e5d365b916ea63225840bc0b71a"), + "libs/PEAR/LICENSE" => array("1477", "45b44486d8090de17b2a8b4211fab247"), + "libs/PEAR.php" => array("33932", "f9f83fb6efef354ec16765ffe17d2ae4"), + "libs/PiwikTracker/LICENSE.txt" => array("1505", "7bbcab51f5db7fee7e6864a639ff56ab"), + "libs/PiwikTracker/PiwikTracker.php" => array("56782", "2a84b2de3492460089a52baad3b1fcd6"), + "libs/README.md" => array("1713", "fad15836001bfea82cdc4f362febe4fb"), + "libs/sparkline/CHANGES" => array("648", "b6d213a7ad5d1f2c6e3eac38d48c1f8a"), + "libs/sparkline/DESIGN" => array("648", "a9e2a29ce386fb408ab2eaa02a86c2ed"), + "libs/sparkline/gpl-2.0.txt" => array("18092", "b234ee4d69f5fce4486a80fdaf4a4263"), + "libs/sparkline/lib/Object.php" => array("3951", "095eae57154bed92a78e3d951e0135ef"), + "libs/sparkline/lib/Sparkline_Bar.php" => array("6834", "c5aa452cf5698ca7baca128aa02ab2f8"), + "libs/sparkline/lib/Sparkline_Line.php" => array("11026", "9cc756ac498e10ae7291d0a252ef3bf6"), + "libs/sparkline/lib/Sparkline.php" => array("16623", "31777f846d96077e1b32cba2f1874138"), + "libs/sparkline/LICENSE-BSD.txt" => array("1505", "51639a73ddb4999a16fc9249eb445acc"), + "libs/sparkline/README" => array("1043", "aa954952640a7c645151f63905e9692a"), + "libs/tcpdf/2dbarcodes.php" => array("7785", "ab7d01f44bbea0b839c6b2c2f362f4cb"), + "libs/tcpdf/barcodes.php" => array("59791", "9f0f79a92e025acfd5aef0c09b0ea736"), + "libs/tcpdf/composer.json" => array("933", "8681cdc1c3eea06625bf0c827f4a353e"), + "libs/tcpdf/config/lang/eng.php" => array("1200", "cc64ed556dacc69a43777204d08c952b"), + "libs/tcpdf/config/tcpdf_config_alt.php" => array("5320", "43c5e852ab49252b52871512ef6bd3db"), + "libs/tcpdf/config/tcpdf_config.php" => array("5202", "f2ca50b70b473fa4f0fd002e34970761"), + "libs/tcpdf/fonts/almohanad.ctg.z" => array("2780", "f2a06979cd7c5262b773f1c5257f93f8"), + "libs/tcpdf/fonts/almohanad.php" => array("14074", "3cf9d431468593f9d4eba1795631943b"), + "libs/tcpdf/fonts/almohanad.z" => array("121292", "80a4fbacf654e77c487bbcefad390dfb"), + "libs/tcpdf/fonts/dejavusans.ctg.z" => array("10120", "b693c24b2880b5d2e5f7c7ecd31213dc"), + "libs/tcpdf/fonts/dejavusans.php" => array("52352", "f38c6f5c629707b6b1197c60a68a77ed"), + "libs/tcpdf/fonts/dejavusans.z" => array("361229", "df214f9763ae5fcab34b85aab01d49d9"), + "libs/tcpdf/fonts/helveticabi.php" => array("2589", "c22fdc8941f2956e0930b20105870468"), + "libs/tcpdf/fonts/helveticab.php" => array("2580", "3daad3713df02c15beebd09ceecacacd"), + "libs/tcpdf/fonts/helveticai.php" => array("2584", "e0a7f23376f50de631db93814aff2e35"), + "libs/tcpdf/fonts/helvetica.php" => array("2575", "2a315fa2593161154c319788f0ef2127"), + "libs/tcpdf/fonts/hysmyeongjostdmedium.php" => array("1829", "51f6fe162641de3714866950d5eff4e8"), + "libs/tcpdf/fonts/kozgopromedium.php" => array("3577", "2c5e8a67d1a805aae9842bbad59a873f"), + "libs/tcpdf/fonts/kozminproregular.php" => array("3454", "78fdf805f1cea6cd01912192821ec734"), + "libs/tcpdf/fonts/msungstdlight.php" => array("1550", "c940b153fb6c5b3498efa181881b5b6c"), + "libs/tcpdf/fonts/stsongstdlight.php" => array("1627", "eb85dc872664c0769e9fab1b7540b4d5"), + "libs/tcpdf/fonts/symbol.php" => array("2555", "20e28c8b386ddbb38ead777f717d7c44"), + "libs/tcpdf/fonts/timesbi.php" => array("2580", "a5f3fbbef1831fe0bcd060edb6e5010b"), + "libs/tcpdf/fonts/timesb.php" => array("2577", "ad485022027867116de0bf6c25b1854a"), + "libs/tcpdf/fonts/timesi.php" => array("2575", "8fd8e9a11cca513a4da0f25ff1a24149"), + "libs/tcpdf/fonts/times.php" => array("2572", "a75033315ee90464410b47cc27ce9ff0"), + "libs/tcpdf/gpl.txt" => array("35147", "d32239bcb673463ab874e80d47fae504"), + "libs/tcpdf/htmlcolors.php" => array("5499", "fe132ea6a41cd787b4c2ff18716625b5"), + "libs/tcpdf/include/sRGB.icc" => array("3048", "060e79448f1454582be37b3de490da2f"), + "libs/tcpdf/include/tcpdf_colors.php" => array("14661", "f782f4cb468e8a5e956ca0759530e132"), + "libs/tcpdf/include/tcpdf_filters.php" => array("14677", "e175abe0e5c8661e08834014abead451"), + "libs/tcpdf/include/tcpdf_font_data.php" => array("313432", "8f83bbc144d70505672f82679546c72d"), + "libs/tcpdf/include/tcpdf_fonts.php" => array("95129", "70425e7872fbabb524b934ec474b0880"), + "libs/tcpdf/include/tcpdf_images.php" => array("11170", "b1c6baba7c286f48d813df899350c8db"), + "libs/tcpdf/include/tcpdf_static.php" => array("106691", "fab9356ece2e9d51e831dd00da64324b"), + "libs/tcpdf/lgpl-3.0.txt" => array("7651", "e6a600fd5e1d9cbde2d983680233ad02"), + "libs/tcpdf/LICENSE.TXT" => array("43636", "5c87b66a5358ebcc495b03e0afcd342c"), + "libs/tcpdf/pdf417.php" => array("53738", "130560d35265c9666440dbf5086f7846"), + "libs/tcpdf/qrcode.php" => array("80058", "59adfba524c4445f850a92380eb2a17f"), + "libs/tcpdf/README.TXT" => array("5396", "46bcf50a79dc0d5f82b4c5c3df95f504"), + "libs/tcpdf/spotcolors.php" => array("2153", "4b3c09efafad1e9c28021fca16f52147"), + "libs/tcpdf/tcpdf_autoconfig.php" => array("6918", "1df4c5b1b14c31160da910ed8e390e91"), + "libs/tcpdf/tcpdf.crt" => array("2290", "c137aab97f7e06a6038589968e10d976"), + "libs/tcpdf/tcpdf.fdf" => array("1286", "96f873c30a6f6a0f884d713d185d5bd8"), + "libs/tcpdf/tcpdf_import.php" => array("3327", "6bb88a8a3d69511d1bf9e7af12ab5f47"), + "libs/tcpdf/tcpdf.p12" => array("1749", "7e078148a1ab66ca05041915f7013204"), + "libs/tcpdf/tcpdf_parser.php" => array("26986", "d7fcc07fbea917f9ae4e07aad9b86fd3"), + "libs/tcpdf/tcpdf.php" => array("883754", "5c02ba6d7a3dba39c140ca7aa25d22f0"), + "libs/tcpdf/unicode_data.php" => array("227828", "2ec28c23ec6ad9f71f8b822a0ce0a1bf"), + "libs/upgradephp/README" => array("373", "f0a0f26fd38b1e01be19766c11654564"), + "libs/upgradephp/upgrade.php" => array("16164", "4cc4006f9ae5e23b73e69c51b64cd99c"), + "libs/UserAgentParser/README.md" => array("972", "34ff27b5748bcd32dd255595cb386ece"), + "libs/UserAgentParser/UserAgentParser.php" => array("28445", "974d5ff712e0331175d543d797e821fd"), + "libs/UserAgentParser/UserAgentParser.test.php" => array("4690", "7ec960da6d502b46706f6fa3602a72f4"), + "libs/Zend/Cache/Backend/Apc.php" => array("11108", "db51f9156bd866fa2ffcb7ee3f8bfd77"), + "libs/Zend/Cache/Backend/BlackHole.php" => array("7461", "0058fa2c88b65f6c3aa022534aa1f86e"), + "libs/Zend/Cache/Backend/ExtendedInterface.php" => array("4035", "ac5c1f66c2eb9a542dd144a9c1ea8b53"), + "libs/Zend/Cache/Backend/File.php" => array("34776", "e1e689dc52f044dd4193509124576261"), + "libs/Zend/Cache/Backend/Interface.php" => array("3904", "99e8ce8e5cb9d4a2f5ef9a5497ee6bb8"), + "libs/Zend/Cache/Backend/Libmemcached.php" => array("16282", "00af5472742bd210a2a50324434ae91e"), + "libs/Zend/Cache/Backend/Memcached.php" => array("17894", "1ca58d3fafd0b6cd543e83d5f0ba3b85"), + "libs/Zend/Cache/Backend.php" => array("7904", "5e2f9dfa4b59950217a2f3fdc4bd2c60"), + "libs/Zend/Cache/Backend/Sqlite.php" => array("22998", "507c9052187140bd66041d718534e40c"), + "libs/Zend/Cache/Backend/Static.php" => array("19549", "c440ac44e75c3787f9b1ea19be1fd34d"), + "libs/Zend/Cache/Backend/Test.php" => array("11830", "c9857254695014074cc6d120cbb08ac1"), + "libs/Zend/Cache/Backend/TwoLevels.php" => array("19721", "a0f3d47a82ca81aed51b7a29d86e7353"), + "libs/Zend/Cache/Backend/WinCache.php" => array("10912", "bd2f8a26e9063a25c498aae14cccf613"), + "libs/Zend/Cache/Backend/Xcache.php" => array("7349", "cf198559e45316e0d9f62b206ef3312e"), + "libs/Zend/Cache/Backend/ZendPlatform.php" => array("11985", "1e397d0389a1e704657271a58c498dbf"), + "libs/Zend/Cache/Backend/ZendServer/Disk.php" => array("2962", "10d8e804fc1e8d268a702437e43aef62"), + "libs/Zend/Cache/Backend/ZendServer.php" => array("6504", "7ca2debde7c4913b281606a4504a3800"), + "libs/Zend/Cache/Backend/ZendServer/ShMem.php" => array("2925", "d4710ceb0fef64004047ba2a13f1bdb0"), + "libs/Zend/Cache/Core.php" => array("25933", "58a9232671767047d6bd40a26353d2e9"), + "libs/Zend/Cache/Exception.php" => array("1070", "959583afad956ae0539248ff79eb904b"), + "libs/Zend/Cache/Frontend/Capture.php" => array("2448", "a0a0fea8115ea64b6725b8e514b963c4"), + "libs/Zend/Cache/Frontend/Class.php" => array("7681", "ec321e41fa5dfb0ba53220d75c9f52f4"), + "libs/Zend/Cache/Frontend/File.php" => array("6943", "410eaa222a5a89ae11d79e0d1c4d8493"), + "libs/Zend/Cache/Frontend/Function.php" => array("6068", "d6c647fc22012bad41ba19f53c16414f"), + "libs/Zend/Cache/Frontend/Output.php" => array("3462", "57b0fefac338ebea55c859d965eb8e8f"), + "libs/Zend/Cache/Frontend/Page.php" => array("14366", "16200bf6fe055e0722f2b2478820a7e0"), + "libs/Zend/Cache/Manager.php" => array("9603", "653dac865fe4e38a23c364b383ff6fd1"), + "libs/Zend/Cache.php" => array("9726", "e750f91ff55b5dd0dee7421a185445c8"), + "libs/Zend/Config/Exception.php" => array("1093", "76092808cc3b56cd09cd9643d2fce0d7"), + "libs/Zend/Config/Ini.php" => array("10885", "54bca1c1151a1f8202c9ad0973bc9a3a"), + "libs/Zend/Config/Json.php" => array("8250", "3876a5476bf7c40b4821cd00fc082902"), + "libs/Zend/Config.php" => array("12969", "a031082ad853bb15c8dff89bfb0268a4"), + "libs/Zend/Config/Writer/Array.php" => array("1635", "db20bc7ffd5eda5394b82a9542f1f4a8"), + "libs/Zend/Config/Writer/FileAbstract.php" => array("3487", "8a29ec44e8144727021c7ec399db180e"), + "libs/Zend/Config/Writer/Ini.php" => array("5749", "d8ddcc8c365acf30150106d05226761c"), + "libs/Zend/Config/Writer/Json.php" => array("3030", "2f68be8aeaed2e9cbffce4fe8bc54249"), + "libs/Zend/Config/Writer.php" => array("2477", "821b961c378d009c50cbe5f9a275d3fc"), + "libs/Zend/Config/Writer/Xml.php" => array("4149", "3498b638e9a2d694aff8b40723310cbb"), + "libs/Zend/Config/Writer/Yaml.php" => array("4151", "b600f000e238dffa088d965c95bc2f70"), + "libs/Zend/Config/Xml.php" => array("11406", "4ac395cd38fe1e623d61f677cef9169e"), + "libs/Zend/Config/Yaml.php" => array("12535", "e6cdff175bfac2f33e1c6e605ae848bb"), + "libs/Zend/Db/Adapter/Abstract.php" => array("41521", "8815cdbfe075893a3551713e262816dc"), + "libs/Zend/Db/Adapter/Db2/Exception.php" => array("1413", "0e8ed8be745de00e92bfb3525b351cb5"), + "libs/Zend/Db/Adapter/Db2.php" => array("27603", "0e3132abd56e493b76df721eb37ad1ce"), + "libs/Zend/Db/Adapter/Exception.php" => array("1595", "51665355378ff8414795d85ccfef5c2e"), + "libs/Zend/Db/Adapter/Mysqli/Exception.php" => array("1192", "9e9e5636095fc47fd3e5c2a75cabd90f"), + "libs/Zend/Db/Adapter/Mysqli.php" => array("17419", "f9813544420dcd4d92a78e3f8cd3a49d"), + "libs/Zend/Db/Adapter/Oracle/Exception.php" => array("1994", "6c5110c3729078357759f489dba8ed0c"), + "libs/Zend/Db/Adapter/Oracle.php" => array("21700", "2b190daba528c06706d7a44651e85b32"), + "libs/Zend/Db/Adapter/Pdo/Abstract.php" => array("11914", "9d9ff4dc2c4968da967e580487c5c3c8"), + "libs/Zend/Db/Adapter/Pdo/Ibm/Db2.php" => array("7569", "60d45091ab06357533c30c0dd68fc854"), + "libs/Zend/Db/Adapter/Pdo/Ibm/Ids.php" => array("9392", "316050a3025e2b8a7b7a198a087861b3"), + "libs/Zend/Db/Adapter/Pdo/Ibm.php" => array("11884", "466784fa6d0821fcda67e872c10ecc3c"), + "libs/Zend/Db/Adapter/Pdo/Mssql.php" => array("14262", "bda19408a8162fc3abf1bfa68d22cabf"), + "libs/Zend/Db/Adapter/Pdo/Mysql.php" => array("9330", "5f412003fd6977f9749879adbd17da3b"), + "libs/Zend/Db/Adapter/Pdo/Oci.php" => array("13957", "550360b2439f751626e554b3d9e66ec2"), + "libs/Zend/Db/Adapter/Pdo/Pgsql.php" => array("12271", "8487c4baaee4df2190cabb28bf8b3b67"), + "libs/Zend/Db/Adapter/Pdo/Sqlite.php" => array("10122", "59609edf7a71e971aaf3b27ceb33e2e9"), + "libs/Zend/Db/Adapter/Sqlsrv/Exception.php" => array("2035", "f325fe5cf94848bb6323f9a3bce94730"), + "libs/Zend/Db/Adapter/Sqlsrv.php" => array("22053", "dc0db212f96ab188071b60797e6e52d9"), + "libs/Zend/Db/Exception.php" => array("1077", "d54398825f7c3fad909a84ae58715e41"), + "libs/Zend/Db/Expr.php" => array("2608", "dd587f06121cb5e88ad392f7f1b3d2b3"), + "libs/Zend/Db.php" => array("10143", "d4a194aaa867c6a819425c7c1d686ab0"), + "libs/Zend/Db/Profiler/Exception.php" => array("1151", "f256021ad53646cb54a2e92aa8603c01"), + "libs/Zend/Db/Profiler/Firebug.php" => array("4890", "d8abc94dc862905d569bfe04f5a8960a"), + "libs/Zend/Db/Profiler.php" => array("14204", "f1661ce5c33c88c0cedb625ab83fbe0d"), + "libs/Zend/Db/Profiler/Query.php" => array("4985", "57794c09e0ba954c16472616c927e7a7"), + "libs/Zend/Db/Select/Exception.php" => array("1139", "983119e8c9188c1233a7317c249f577c"), + "libs/Zend/Db/Select.php" => array("44421", "8818e5ffd4070e93d1e14b84fcfb903f"), + "libs/Zend/Db/Statement/Db2/Exception.php" => array("1526", "33acfe812ee187965341d5cfd62fae09"), + "libs/Zend/Db/Statement/Db2.php" => array("10236", "b6a6033a5ca3afbe64f5917cb743b618"), + "libs/Zend/Db/Statement/Exception.php" => array("1572", "d0d988d59ac560916273d2918e279091"), + "libs/Zend/Db/Statement/Interface.php" => array("6625", "d0689b067d31691e6b3ade50be1a5835"), + "libs/Zend/Db/Statement/Mysqli/Exception.php" => array("1165", "998d0c773e745cb6a6dcf6d7c7edcc37"), + "libs/Zend/Db/Statement/Mysqli.php" => array("10859", "3281b0909307260f8485576aa786fbf6"), + "libs/Zend/Db/Statement/Oracle/Exception.php" => array("1899", "847f3a25ac2890812075350510b2cf31"), + "libs/Zend/Db/Statement/Oracle.php" => array("17263", "1bf918c7b7c276847d1afe2e45d8c67e"), + "libs/Zend/Db/Statement/Pdo/Ibm.php" => array("3397", "88515f1af79d21f220e020df57460974"), + "libs/Zend/Db/Statement/Pdo/Oci.php" => array("3031", "31e7984762ddc13679cb9bec8a56247b"), + "libs/Zend/Db/Statement/Pdo.php" => array("14364", "fa99e7d7f3d1d28929e78d6c76a8b5f4"), + "libs/Zend/Db/Statement.php" => array("14085", "e746df61336a9296964e6839fabd54b2"), + "libs/Zend/Db/Statement/Sqlsrv/Exception.php" => array("1957", "9a19266373fa09bdf7859194169a35c8"), + "libs/Zend/Db/Statement/Sqlsrv.php" => array("12348", "71d0197118360b6aeec7d45580864c53"), + "libs/Zend/Db/Table/Abstract.php" => array("50573", "a1dd4a222db071be104566cd06ad1f1b"), + "libs/Zend/Db/Table/Definition.php" => array("3241", "3995bddf9cb81b4567337b175cd7c13c"), + "libs/Zend/Db/Table/Exception.php" => array("1135", "6a0d57ab372184670c8e79710784c533"), + "libs/Zend/Db/Table.php" => array("2754", "504c6665d867bb043273aeee466381fb"), + "libs/Zend/Db/Table/Row/Abstract.php" => array("39920", "bc3e1b05140c1ffd0185c987c61b433e"), + "libs/Zend/Db/Table/Row/Exception.php" => array("1157", "f0d6c6ff51ecc3cd091109f78c3bf12a"), + "libs/Zend/Db/Table/Row.php" => array("1308", "1be229fa2c6fa297b0a2d56051a0a9b6"), + "libs/Zend/Db/Table/Rowset/Abstract.php" => array("11117", "010b900fae45fb59301be979c5338e3f"), + "libs/Zend/Db/Table/Rowset/Exception.php" => array("1159", "23e8752ebbfa48e036f63b1bf1a92918"), + "libs/Zend/Db/Table/Rowset.php" => array("1327", "80f842e48117b43e2ab0eb810a479fde"), + "libs/Zend/Db/Table/Select/Exception.php" => array("1158", "30805dc268e86338b44700afa0a6c537"), + "libs/Zend/Db/Table/Select.php" => array("6673", "522b3005d9a684590778faf24b017bf0"), + "libs/Zend/Exception.php" => array("2555", "496025ea0e253c9d0b75a0fbf8f355b2"), + "libs/Zend/LICENSE.txt" => array("1548", "81fc08e70d11a8ecb317b16a97bfbcc3"), + "libs/Zend/Mail/Exception.php" => array("1090", "b9f0d25d50c78939d660fc0f06793f98"), + "libs/Zend/Mail/Message/File.php" => array("2638", "1318de5056a1121ba8820dd3d31c5aa3"), + "libs/Zend/Mail/Message/Interface.php" => array("1572", "1290f359b6cd4b186048a673a447a8f1"), + "libs/Zend/Mail/Message.php" => array("3308", "7d12b766dd42dadd47649f9d8f101297"), + "libs/Zend/Mail/Part/File.php" => array("6163", "d6ad1139df9e878af0f62de25f486b54"), + "libs/Zend/Mail/Part/Interface.php" => array("4221", "97f42b9ccbc229328cb7fcc85f5a9d9d"), + "libs/Zend/Mail/Part.php" => array("14365", "1a030ff9c9bcab54142b88c39d019197"), + "libs/Zend/Mail.php" => array("32415", "1539511cc9d93db522bffdd11d9cbe99"), + "libs/Zend/Mail/Protocol/Abstract.php" => array("11300", "c4f17b922a216b7647093e72a46ebcd7"), + "libs/Zend/Mail/Protocol/Exception.php" => array("1157", "906550551820e61592e7d3612d0c8d65"), + "libs/Zend/Mail/Protocol/Imap.php" => array("27668", "a26f41950c29af2fb9ef999101fa79e9"), + "libs/Zend/Mail/Protocol/Pop3.php" => array("13024", "56bce77ea137ac0a7f93cfbb8b6f71f4"), + "libs/Zend/Mail/Protocol/Smtp/Auth/Crammd5.php" => array("3163", "7aeeca7c701a27751646950b822de583"), + "libs/Zend/Mail/Protocol/Smtp/Auth/Login.php" => array("2522", "9b779d5cca265a5bd637e4649c7012d2"), + "libs/Zend/Mail/Protocol/Smtp/Auth/Plain.php" => array("2471", "b7fda475bebbebb35d5cae2cb7f15e84"), + "libs/Zend/Mail/Protocol/Smtp.php" => array("11894", "65482fffb922640d19b2a490e2c6d2be"), + "libs/Zend/Mail/Storage/Abstract.php" => array("9320", "5c5f202001e8fe4bece29aaf85d47006"), + "libs/Zend/Mail/Storage/Exception.php" => array("1159", "36c0e03007ce490970cc2b9eceed34b1"), + "libs/Zend/Mail/Storage/Folder/Interface.php" => array("1866", "c42d375159c8c7e2f52336ae21898815"), + "libs/Zend/Mail/Storage/Folder/Maildir.php" => array("8942", "a8aa29833af22a57b32547d14be91490"), + "libs/Zend/Mail/Storage/Folder/Mbox.php" => array("8744", "fc5db27edf62c75a54ed49842c7172cb"), + "libs/Zend/Mail/Storage/Folder.php" => array("5739", "9f67663c26984532c6bb36cd1c03bc1e"), + "libs/Zend/Mail/Storage/Imap.php" => array("21921", "cd05cb2e160632fc939bc8bb6c307496"), + "libs/Zend/Mail/Storage/Maildir.php" => array("14507", "2bcbe4a98a75574f9216a1201b82ce38"), + "libs/Zend/Mail/Storage/Mbox.php" => array("13272", "c773cb8f8b42ad3cdc687bcdacfd8ab5"), + "libs/Zend/Mail/Storage.php" => array("1389", "f6dfb86357dd6c45e9ff8a4c71ed9a3e"), + "libs/Zend/Mail/Storage/Pop3.php" => array("9807", "3f4973116e37a78ef33a152cfa4aafa9"), + "libs/Zend/Mail/Storage/Writable/Interface.php" => array("3802", "0a3c13e8799e46a27cb9d7dddfef8702"), + "libs/Zend/Mail/Storage/Writable/Maildir.php" => array("39879", "04ef151a97b29640ce1fdac54f0d4d85"), + "libs/Zend/Mail/Transport/Abstract.php" => array("10410", "0087be943456216a8cb418472df971b6"), + "libs/Zend/Mail/Transport/Exception.php" => array("1165", "3bea49e543c5158eccc7879b4a9d70ff"), + "libs/Zend/Mail/Transport/File.php" => array("3797", "aa5d88139cb4462fbc3a71323e1e5154"), + "libs/Zend/Mail/Transport/Sendmail.php" => array("6540", "e4b0c2b97a61a0877e0f5dd848b7be93"), + "libs/Zend/Mail/Transport/Smtp.php" => array("6229", "5ceaaa08b1f9d9d7f2e2103834026009"), + "libs/Zend/Mime/Decode.php" => array("8805", "b9d2cbd7e7e6d9318579b5fac81ff74d"), + "libs/Zend/Mime/Exception.php" => array("1085", "b8189c831996b86dfd924aa5413caf24"), + "libs/Zend/Mime/Message.php" => array("8518", "7612d04ebd0dd380502366ed2cd8a20d"), + "libs/Zend/Mime/Part.php" => array("6671", "a2ca88579585efada8b1bdbcb9339094"), + "libs/Zend/Mime.php" => array("12927", "0059c7eef064f1225af33d1ddc0e8cef"), + "libs/Zend/Registry.php" => array("6155", "cebfa4d6d7658af3167d521ffdc09153"), + "libs/Zend/Session/Abstract.php" => array("6051", "f46f1047a6f5b37b2180cfe18f9f37a2"), + "libs/Zend/Session/Exception.php" => array("2313", "5c87f32e2d6c61f0937481ff6da38d05"), + "libs/Zend/Session/Namespace.php" => array("16860", "1351d7294b06b262d8fa63f88fed5503"), + "libs/Zend/Session.php" => array("27322", "02d23cef6fbabae08268d017defdb0bc"), + "libs/Zend/Session/SaveHandler/DbTable.php" => array("17926", "97ed5c81d24270470450211991de3c12"), + "libs/Zend/Session/SaveHandler/Exception.php" => array("1174", "b49e92bc8cd9c7e29afb7a5182da8325"), + "libs/Zend/Session/SaveHandler/Interface.php" => array("2056", "f27f2b6f050869a7f7aef5a5385a8e05"), + "libs/Zend/Session/Validator/Abstract.php" => array("2111", "38cda6376c7519170579ea0a012364c1"), + "libs/Zend/Session/Validator/HttpUserAgent.php" => array("1991", "52ae6c4e4afcb62b1a935c4b807beb22"), + "libs/Zend/Session/Validator/Interface.php" => array("1594", "bbbb5dfd24df96e4d8d115bc62aa1b7c"), + "libs/Zend/Validate/Abstract.php" => array("12334", "1feb4fc3aa9245d7c7d9eb40a98cf1f3"), + "libs/Zend/Validate/Alnum.php" => array("4121", "9a4536c9051c7c80319594e4dc872bb6"), + "libs/Zend/Validate/Alpha.php" => array("4027", "d34f2e3a78c21b707e37af8d9819e53d"), + "libs/Zend/Validate/Between.php" => array("5698", "bc105fb1a1c1522b85a48ff5612205f6"), + "libs/Zend/Validate/Callback.php" => array("4479", "89b01c7bdd1aabfa80700314ddc47e2d"), + "libs/Zend/Validate/Ccnum.php" => array("3143", "b55b29546253067178aa79cecb347dab"), + "libs/Zend/Validate/CreditCard.php" => array("10109", "3358f5b5d31aabf5ce6ba35a53b0bba9"), + "libs/Zend/Validate/Date.php" => array("7713", "c3b1ea92bb219a5651307e9d9c2907e2"), + "libs/Zend/Validate/Digits.php" => array("2637", "a7a1ed17245d8e42493d6ed1c4801b94"), + "libs/Zend/Validate/EmailAddress.php" => array("17338", "8e481bb7208b0eaf6176a74ef7342a45"), + "libs/Zend/Validate/Exception.php" => array("1099", "c4a0c36f38e3345b65edba863b16635a"), + "libs/Zend/Validate/File/Count.php" => array("8002", "e243f53362e28bdec644aa9a98344f59"), + "libs/Zend/Validate/File/Crc32.php" => array("4927", "9c63207b87e8bb54f62bd927dbbcf1e1"), + "libs/Zend/Validate/File/ExcludeExtension.php" => array("2957", "f959a30f6813b0212b5593b07223bc06"), + "libs/Zend/Validate/File/ExcludeMimeType.php" => array("3592", "3749ff0f8954c5a553660663dbf1399d"), + "libs/Zend/Validate/File/Exists.php" => array("5785", "eb5fb5df3d52fee96eaf45a6c3fc5d3c"), + "libs/Zend/Validate/File/Extension.php" => array("6012", "3d680a7541dbd03d9dba46f8d8c76322"), + "libs/Zend/Validate/File/FilesSize.php" => array("5330", "494fa7561432a0eb8f0bf1b8adf89045"), + "libs/Zend/Validate/File/Hash.php" => array("5472", "ce5218d5a498c422f2cb55c37dd12616"), + "libs/Zend/Validate/File/ImageSize.php" => array("11171", "743921b7ac4ce28a4a0d54e1bd4d2de0"), + "libs/Zend/Validate/File/IsCompressed.php" => array("4663", "a1c9c4a90166ff846733dd44060a1026"), + "libs/Zend/Validate/File/IsImage.php" => array("5287", "17212ae2c053b562d262186dbdbde8af"), + "libs/Zend/Validate/File/Md5.php" => array("5104", "484d03c5852a9fc51710da516939c2b8"), + "libs/Zend/Validate/File/MimeType.php" => array("11534", "1ced67edf9732895c766b9edbd95e718"), + "libs/Zend/Validate/File/NotExists.php" => array("2521", "95cd7265a2e92d853a0f3f832a430408"), + "libs/Zend/Validate/File/Sha1.php" => array("4959", "a73df076e93830182509e351111c8632"), + "libs/Zend/Validate/File/Size.php" => array("11213", "8fae681d72b8aeb28745050b188ac274"), + "libs/Zend/Validate/File/Upload.php" => array("7857", "596ce8ec37c74472f326f317ab59f886"), + "libs/Zend/Validate/File/WordCount.php" => array("3115", "d1de816d4952a5141ed78e1b278f5156"), + "libs/Zend/Validate/Float.php" => array("3521", "296ea1a209fce97d748e914522404688"), + "libs/Zend/Validate/GreaterThan.php" => array("2899", "02b85da332cf6f1a815e5ee24aa6b6bd"), + "libs/Zend/Validate/Hex.php" => array("2037", "8bfb961b57b882a0719d54ccc885add7"), + "libs/Zend/Validate/Hostname/Biz.php" => array("223195", "ffa0c236e122b45995cff4ee4964e595"), + "libs/Zend/Validate/Hostname/Cn.php" => array("168071", "55fa16bdeecbebf10df472ede8681606"), + "libs/Zend/Validate/Hostname/Com.php" => array("13661", "1b25658d4741fdcb5702e0fdc64f7e0e"), + "libs/Zend/Validate/Hostname/Jp.php" => array("55551", "0fe384871528b21e2a981bbf1d0de767"), + "libs/Zend/Validate/Hostname.php" => array("35735", "cb6fff7c0769efb452f7e04ba185e54a"), + "libs/Zend/Validate/Iban.php" => array("7303", "2725d29f25df052fe2a0b5b28464960b"), + "libs/Zend/Validate/Identical.php" => array("4094", "3c325081db3380872533a48ffb4e54f3"), + "libs/Zend/Validate/InArray.php" => array("5291", "2a1f8aab4b1d7e39da319972c0fcbba5"), + "libs/Zend/Validate/Interface.php" => array("1926", "f406d5497efbd054be505a4fdce3fab3"), + "libs/Zend/Validate/Int.php" => array("4020", "c3c5ea3796107150a78d1c92eff0c899"), + "libs/Zend/Validate/Ip.php" => array("5511", "06007f3e8595f35b51403ab2bc2f825e"), + "libs/Zend/Validate/Isbn.php" => array("7716", "51f8056b95dfb18359628b0715b9d166"), + "libs/Zend/Validate/LessThan.php" => array("2869", "0840ec582e239eb647a7435d5a7eca2d"), + "libs/Zend/Validate/NotEmpty.php" => array("8138", "9190fda962b420724260002bc466e7d8"), + "libs/Zend/Validate.php" => array("8509", "88ef476e71d54440da1123a10f59ceea"), + "libs/Zend/Validate/PostCode.php" => array("6018", "1cffbba8fc10e1b3780bb4c6e5550956"), + "libs/Zend/Validate/Regex.php" => array("4021", "eb2c6b2377b924a3ebcd1c708802caf7"), + "libs/Zend/Validate/StringLength.php" => array("6633", "9c0f8e241452e13eb5a19c3994e74d11"), + "libs/Zend/Version.php" => array("2558", "5d840e09ef8ece6d15e8bfb786af0faf"), + "misc/cron/archive.php" => array("1427", "17d3d028e0063eddbd2171a971937e96"), + "misc/cron/archive.sh" => array("3411", "310b24292e5968a2b27fc81cc2d0133b"), + "misc/cron/updatetoken.php" => array("1227", "40be39dd7be96bf3cf07a25321646a53"), + "misc/gpl-3.0.txt" => array("35147", "d32239bcb673463ab874e80d47fae504"), + "misc/How to install Piwik.html" => array("281", "f4a48dcdad08996699fa52876fe2e59f"), + "misc/log-analytics/import_logs.py" => array("63576", "d27a2bb0e1aeb6c8ef28ee2140eac2f6"), + "misc/log-analytics/README.md" => array("12850", "81e24593a103725475033d73a7911308"), + "misc/others/api_internal_call.php" => array("925", "f0cadfd1d505c9190c5b799116329c27"), + "misc/others/api_rest_call.php" => array("926", "ee4a436279d3588350998d23a4457331"), + "misc/others/cli-script-bootstrap.php" => array("1197", "c9f9cc36be2fa58324405d675f6cdb21"), + "misc/others/download-count.txt" => array("563", "fa8ababaa1c23873c85b576807012997"), + "misc/others/ExamplePiwikTracker.php" => array("650", "fef8fb6167d19edfe9050da691d3e80d"), + "misc/others/geoipUpdateRows.php" => array("8375", "f35ef4770cebf00f9cf6b733aef3b14a"), + "misc/others/iframeWidget.htm" => array("613", "3c0f965d8cbc05e01e431f2a060fa3e5"), + "misc/others/iframeWidget_localhost.php" => array("2156", "d0ab5ceb17e0010599d67442a6e113f7"), + "misc/others/phpstorm-codestyles/Piwik_codestyle.xml" => array("3585", "18b7d31e7283b1db8e778fdf7ab18721"), + "misc/others/phpstorm-codestyles/README.md" => array("984", "948c9dfd9918312ffe9dab44eb454054"), + "misc/others/stress.sh" => array("293", "d1964246f8080b3e2101610fad483c8c"), + "misc/others/test_cookies_GenerateHundredsWebsitesAndVisits.php" => array("1055", "0dee3fddccfe0ab8aa440f1a9f4d7827"), + "misc/others/test_generateLotsVisitsWebsites.php" => array("10290", "2e61928d6e310ddf6ab7a61964850fdb"), + "misc/others/tracker_simpleImageTracker.php" => array("1060", "a9436b017e7ad30d59cff5b87e715308"), + "misc/others/uninstall-delete-piwik-directory.php" => array("1140", "8bdcdad35ced4e49b82cb9b809eebabe"), + "misc/others/widget_example_lastvisits.html" => array("456", "faacc1f323fede8966eabd56cbacc362"), + "misc/proxy-hide-piwik-url/piwik.php" => array("2763", "742ba5f11208ea5e08dc90d8055bc6c8"), + "misc/proxy-hide-piwik-url/README.md" => array("3234", "dd49db51608aa49d3b490053a98f0d68"), + "misc/user/.gitkeep" => array("0", "d41d8cd98f00b204e9800998ecf8427e"), + "misc/user/index.html" => array("167", "bcef3676ee6eefa6968695ef954db78f"), + "piwik.js" => array("23183", "6e7bb550f0b87029593c66404f477088"), + "piwik.php" => array("5150", "30817ddf6963d6ef026ec587b8c262c7"), + "plugins/Actions/Actions.php" => array("47067", "8fd3c026d5bda12e6e35d4582fece05e"), + "plugins/Actions/API.php" => array("25131", "eb9695dccffefdb8b0be655caf4c9c7e"), + "plugins/Actions/Archiver.php" => array("22986", "f340a0c2888b7ad69baaa017ae85978f"), + "plugins/Actions/ArchivingHelper.php" => array("23888", "e2a415e828d32a1ba8f07137df8cf60d"), + "plugins/Actions/Controller.php" => array("3508", "d2b0b84b804e880aee1d80a68e8bdd03"), + "plugins/Actions/javascripts/actionsDataTable.js" => array("13340", "00a944a54e01638152a462776094fecf"), + "plugins/Actions/stylesheets/dataTableActions.less" => array("80", "6ea9a28abb3fdf5d59b75af48dbde129"), + "plugins/Actions/templates/indexSiteSearch.twig" => array("598", "f8495db5767bbb21576a5690e9b92435"), + "plugins/Annotations/AnnotationList.php" => array("15708", "0507b807d118f763ff4012e34ffca9ae"), + "plugins/Annotations/Annotations.php" => array("1134", "fa376e6beeda62967ac35bf96f42486f"), + "plugins/Annotations/API.php" => array("14544", "83c95cab8ef9e4862d31cab0dfa21c55"), + "plugins/Annotations/Controller.php" => array("8319", "42bb469975f6bc567f0bc78f3362b08d"), + "plugins/Annotations/javascripts/annotations.js" => array("22584", "b14eadbeac0cfacb80e9858140da762f"), + "plugins/Annotations/stylesheets/annotations.less" => array("3505", "f311fd592023253c5a386753b70eec9f"), + "plugins/Annotations/templates/_annotationList.twig" => array("1281", "6305cb8e31ab433b40e2fde4ae22ecf2"), + "plugins/Annotations/templates/_annotation.twig" => array("2543", "2e4cd965a46c6c3ed241be7ef6253006"), + "plugins/Annotations/templates/getAnnotationManager.twig" => array("1133", "b330fa07ac6c11095ed0c165f3a51446"), + "plugins/Annotations/templates/getEvolutionIcons.twig" => array("859", "fb82d9e0fe92f4e31701a902173f17a1"), + "plugins/Annotations/templates/saveAnnotation.twig" => array("45", "5ad07d0a082d1cb3b199874b80285ea0"), + "plugins/API/API.php" => array("29238", "2b460cf97b6ab918db29b21d2effae1b"), + "plugins/API/Controller.php" => array("4818", "4d7a6fc1ec88da6888efa79a784387af"), + "plugins/API/ProcessedReport.php" => array("30939", "247cb79b399231721079044292d3686e"), + "plugins/API/RowEvolution.php" => array("19547", "2f282c72293c1b915683ba7e88033bec"), + "plugins/API/stylesheets/listAllAPI.less" => array("724", "c18396c6a93e74893483fbdcd7ff3fb6"), + "plugins/API/templates/listAllAPI.twig" => array("1049", "4adf1392c2bd87a6aac4b578dbcd65b6"), + "plugins/CoreAdminHome/API.php" => array("8164", "96c8e23e9390b352a77e4242f79665df"), + "plugins/CoreAdminHome/Controller.php" => array("12138", "5ff36d4263102d2c0e2e576b56354dfe"), + "plugins/CoreAdminHome/CoreAdminHome.php" => array("4902", "36802b98ee443d60935a58497c19306d"), + "plugins/CoreAdminHome/CustomLogo.php" => array("6329", "c8d6e7f8a505489bb4eb6a2e31c2ca05"), + "plugins/CoreAdminHome/javascripts/generalSettings.js" => array("4863", "b219e00ad3f5544e7b5e37b0effb3463"), + "plugins/CoreAdminHome/javascripts/jsTrackingGenerator.js" => array("12282", "291cc9b609d1b3f40ff1b4084043b3a8"), + "plugins/CoreAdminHome/javascripts/pluginSettings.js" => array("2358", "4a5e5984b547f409e8bd3a73542e961c"), + "plugins/CoreAdminHome/stylesheets/generalSettings.less" => array("2793", "fc401910d4ff9fa8b7be809f0468f717"), + "plugins/CoreAdminHome/stylesheets/jsTrackingGenerator.css" => array("1405", "9265daa10ff275ccf75dcfcd2926b9a9"), + "plugins/CoreAdminHome/stylesheets/menu.less" => array("2149", "4f32e2ba236cf2ea0798676b670050d3"), + "plugins/CoreAdminHome/stylesheets/pluginSettings.less" => array("520", "4e1b32c3e7108e45055051cfbdede938"), + "plugins/CoreAdminHome/templates/generalSettings.twig" => array("17731", "16a31e5ccac7ff372d952e492d037d90"), + "plugins/CoreAdminHome/templates/_menu.twig" => array("840", "5ee52c14bfe1e41fa07db26bd0f49e90"), + "plugins/CoreAdminHome/templates/optOut.twig" => array("1122", "4b2985cac29da2d123a4c780d368fa50"), + "plugins/CoreAdminHome/templates/pluginSettings.twig" => array("7608", "52ae5d20cd1ad28f616fe08ecf3047ad"), + "plugins/CoreAdminHome/templates/trackingCodeGenerator.twig" => array("13443", "34381ec263e148ee5991913c51a1bd01"), + "plugins/CoreConsole/Commands/CodeCoverage.php" => array("3188", "675e82de2141b1caadedbf2f91a59529"), + "plugins/CoreConsole/Commands/CoreArchiver.php" => array("4245", "37f3acf3ee8df8d4973f192a56227cb5"), + "plugins/CoreConsole/Commands/GenerateApi.php" => array("1863", "0998c1fcfd9fe56c82bc22812a45bb60"), + "plugins/CoreConsole/Commands/GenerateCommand.php" => array("2898", "d4180aa10d123f70c33ea2385d760cb0"), + "plugins/CoreConsole/Commands/GenerateController.php" => array("1958", "93b4d27330e51e13c497052ca3f5854a"), + "plugins/CoreConsole/Commands/GeneratePluginBase.php" => array("4542", "6f359b866d9df97303139fbf955bd30d"), + "plugins/CoreConsole/Commands/GeneratePlugin.php" => array("7264", "786dcfdc680750902ddea34ee0325aa8"), + "plugins/CoreConsole/Commands/GenerateSettings.php" => array("1935", "d760e64d6b9f41d48431d7cfff2247da"), + "plugins/CoreConsole/Commands/GenerateTest.php" => array("6086", "dab2b156fe2548658562090e686c9b88"), + "plugins/CoreConsole/Commands/GenerateVisualizationPlugin.php" => array("3556", "653ebdfc17dccde2d643134d7c708f1c"), + "plugins/CoreConsole/Commands/GitCommit.php" => array("4456", "dc6ef207850ca10950f694d8cd9b4cc0"), + "plugins/CoreConsole/Commands/GitPull.php" => array("1592", "69f2c84488535f2944a8b3c61619da35"), + "plugins/CoreConsole/Commands/GitPush.php" => array("1186", "774591997e01ba6d6a898e447048b6a7"), + "plugins/CoreConsole/Commands/ManagePlugin.php" => array("2372", "d2926cda27fee604b517b113b2ad83ad"), + "plugins/CoreConsole/Commands/ManageTestFiles.php" => array("1812", "a86ae0b8137c618dcf1af27fa18ba68b"), + "plugins/CoreConsole/Commands/RunTests.php" => array("3057", "b3a65e6e74b23b67d682edbc9e6679b8"), + "plugins/CoreConsole/Commands/RunUITests.php" => array("2647", "538924f987e82a8c4ae48851404ad03f"), + "plugins/CoreConsole/Commands/SetupFixture.php" => array("6080", "2b1aff33dd7ffda5737cb83f9998ac8d"), + "plugins/CoreConsole/Commands/SyncUITestScreenshots.php" => array("3016", "595086d2646fe3e3b15d2e7e904cb948"), + "plugins/CoreConsole/Commands/WatchLog.php" => array("855", "092903712bd6a244a1199eeab7458b50"), + "plugins/CoreHome/angularjs/anchorLinkFix.js" => array("2634", "96f55c02ee23e9e4b2c8f88e9c05e2b8"), + "plugins/CoreHome/angularjs/common/directives/autocomplete-matched.js" => array("1355", "9e42e502d560b4a4d140db85b9ac485e"), + "plugins/CoreHome/angularjs/common/directives/autocomplete-matched_spec.js" => array("1515", "ac1cb173a0b49e64d18f8f7bd2e49c34"), + "plugins/CoreHome/angularjs/common/directives/dialog.js" => array("1197", "343ca648f67762baada217cb848d0564"), + "plugins/CoreHome/angularjs/common/directives/directive.js" => array("174", "35c24d7f8602201a1a86fccd1c2ced63"), + "plugins/CoreHome/angularjs/common/directives/focus-anywhere-but-here.js" => array("1257", "a54b382326b1724ecfe08b75d9bda271"), + "plugins/CoreHome/angularjs/common/directives/focusif.js" => array("727", "3e050dd7229a73fc4c850acba33f215d"), + "plugins/CoreHome/angularjs/common/directives/ignore-click.js" => array("606", "7c44f9411918577878d2786d29149709"), + "plugins/CoreHome/angularjs/common/directives/onenter.js" => array("737", "c4da43d5d72695e6accaa72635e0b619"), + "plugins/CoreHome/angularjs/common/filters/evolution.js" => array("1067", "f03049c52419158e37b3b7485ad9ec97"), + "plugins/CoreHome/angularjs/common/filters/filter.js" => array("170", "e3cb8ba6c47023ea0eb11b00da89d752"), + "plugins/CoreHome/angularjs/common/filters/startfrom.js" => array("319", "b0d892fbbba5a5c59c0ebfe9cccc2ff4"), + "plugins/CoreHome/angularjs/common/filters/startfrom_spec.js" => array("997", "28e65c932ef636402a5436ce9036747b"), + "plugins/CoreHome/angularjs/common/filters/translate.js" => array("518", "4b3608c3d3925c2de1c812ca4c7095b7"), + "plugins/CoreHome/angularjs/common/services/piwik-api.js" => array("4931", "1f26832cd0e4667a9550f140d9b63f2d"), + "plugins/CoreHome/angularjs/common/services/piwik.js" => array("289", "7f8e1209a339cbb3df5c8b1c67608ff5"), + "plugins/CoreHome/angularjs/common/services/piwik_spec.js" => array("958", "918d81d86edb8ac04ea9fd4b2b3233d2"), + "plugins/CoreHome/angularjs/common/services/service.js" => array("172", "7df34c228b662caedc891189287d26bc"), + "plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline-directive.js" => array("2117", "f53eb6919e53ee6085b20af35e75a071"), + "plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.html" => array("968", "0a3bb46fea31257f0c3c254e5686a2d4"), + "plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.less" => array("763", "74046d60c1f671857b7583fad14cb2b5"), + "plugins/CoreHome/angularjs/enrichedheadline/help.png" => array("350", "a2442fd403f530897728f540aa374c70"), + "plugins/CoreHome/angularjs/piwikAppConfig.js" => array("335", "286c144553a0f1389f804a4f2931c27a"), + "plugins/CoreHome/angularjs/piwikApp.js" => array("321", "8b891e5568091f5bef5566fe2a8440f3"), + "plugins/CoreHome/angularjs/siteselector/siteselector-controller.js" => array("1826", "a297e0adb8c651e9c49b975f72290e17"), + "plugins/CoreHome/angularjs/siteselector/siteselector-directive.js" => array("2676", "3c6cb7efaefd524922328842205f15ed"), + "plugins/CoreHome/angularjs/siteselector/siteselector.html" => array("3030", "2a1df7bc671183fb140a04132a0a6a6e"), + "plugins/CoreHome/angularjs/siteselector/siteselector.less" => array("3527", "7741715b7b9ccbdfd9c44f92e9080eff"), + "plugins/CoreHome/angularjs/siteselector/siteselector-model.js" => array("1909", "9ef295ef4cec80096d9c24705411618b"), + "plugins/CoreHome/Controller.php" => array("8189", "5cc9b5304f98cd5dd4fe96c28665a7eb"), + "plugins/CoreHome/CoreHome.php" => array("10068", "d32fe2afae1943efe49db6276a33c164"), + "plugins/CoreHome/DataTableRowAction/MultiRowEvolution.php" => array("2134", "09919d9abed888e1a1ad0149cf241ea9"), + "plugins/CoreHome/DataTableRowAction/RowEvolution.php" => array("11730", "6dbc8fcb21eb457148022097a445d778"), + "plugins/CoreHome/images/bg_header.jpg" => array("9097", "0af504cfba0e9a1df7024e5e8d37ace9"), + "plugins/CoreHome/images/bullet1.gif" => array("52", "4ea97a49fbc122f369c0509a1e312d36"), + "plugins/CoreHome/images/bullet2.gif" => array("52", "e105364ac53547afc6453aac69c4e43a"), + "plugins/CoreHome/images/favicon.ico" => array("17947", "9f21326e0c543b50bbe7022b44d97615"), + "plugins/CoreHome/images/googleplay.png" => array("16550", "94203701766674b6ea54cce20b5e4a7e"), + "plugins/CoreHome/images/more_date.gif" => array("56", "0f97ae90441e7bf074981d7854f7bdb7"), + "plugins/CoreHome/images/more_period.gif" => array("53", "0741dc6f4a2e767ac60c619e85142555"), + "plugins/CoreHome/images/more.png" => array("1045", "91d6a597dc70d86071f6aa333cce20aa"), + "plugins/CoreHome/images/promo_splash.png" => array("12070", "98b50af9fcfe08214b630aa5c598848e"), + "plugins/CoreHome/images/reset_search.png" => array("1021", "7e761f3444bf4edd4cd1779801c963bd"), + "plugins/CoreHome/images/search.png" => array("136", "2fae5ccecd05ad37bd228e9490875ad4"), + "plugins/CoreHome/javascripts/broadcast.js" => array("22763", "4c3cd06055cafa714fd2e2efd4ff82ba"), + "plugins/CoreHome/javascripts/calendar.js" => array("22538", "b87e51fbf1cad8c856337656bca22a8b"), + "plugins/CoreHome/javascripts/color_manager.js" => array("10985", "c22a21e71febe410b3e62de68ce31359"), + "plugins/CoreHome/javascripts/corehome.js" => array("5684", "30d487fa1f07c5b3907886359d4b168a"), + "plugins/CoreHome/javascripts/dataTable.js" => array("67710", "03047b604c67c04845a34b7a1ff3074c"), + "plugins/CoreHome/javascripts/dataTable_rowactions.js" => array("12810", "c98799009416f594610fb887bd7414f9"), + "plugins/CoreHome/javascripts/donate.js" => array("5832", "b1519aaa05ab20ff55e6515f73f22840"), + "plugins/CoreHome/javascripts/menu_init.js" => array("445", "d11ca5e76d64fcb588095037bb3a9e4f"), + "plugins/CoreHome/javascripts/menu.js" => array("3808", "0c2b7400db1cd4144b9820d8a71d6dbf"), + "plugins/CoreHome/javascripts/notification.js" => array("6438", "409154616a282cc31d394f15b1207ee9"), + "plugins/CoreHome/javascripts/notification_parser.js" => array("824", "4860da380ee3224ae5628967055c7285"), + "plugins/CoreHome/javascripts/popover.js" => array("8640", "805e980248f8043a1df32b011d677c13"), + "plugins/CoreHome/javascripts/promo.js" => array("541", "d55cfc743448fc782c3a084ca5205619"), + "plugins/CoreHome/javascripts/require.js" => array("1297", "7f170c5276e38599d334b6e4c167ceb7"), + "plugins/CoreHome/javascripts/sparkline.js" => array("3304", "47ab94274d11f6bdbf840aad41129f0e"), + "plugins/CoreHome/javascripts/top_controls.js" => array("704", "5a27c5eeaa6e8e0c0682c170128be44d"), + "plugins/CoreHome/javascripts/uiControl.js" => array("3663", "b0d3706d547d7f102d39c17b000ea894"), + "plugins/CoreHome/stylesheets/cloud.less" => array("874", "7f28097a511022a86beb4735cb3eb877"), + "plugins/CoreHome/stylesheets/color_manager.css" => array("42", "40709b59fb63c5e7fcdcab9230c1dbaa"), + "plugins/CoreHome/stylesheets/coreHome.less" => array("4274", "966a251617979d9103c999cb5a3c56e8"), + "plugins/CoreHome/stylesheets/dataTable/_dataTable.less" => array("10157", "7fe43c52bc99e776a9e2b7de3db419f4"), + "plugins/CoreHome/stylesheets/dataTable.less" => array("366", "b5a4df46b9d7f8e1193f54d3aad665c8"), + "plugins/CoreHome/stylesheets/dataTable/_limitSelection.less" => array("1306", "68d63775a50badf3b9ac1992997d1c9b"), + "plugins/CoreHome/stylesheets/dataTable/_reportDocumentation.less" => array("1182", "2423f8ebb34d4a3fd818d580b176ca45"), + "plugins/CoreHome/stylesheets/dataTable/_rowActions.less" => array("656", "afe520a206b30502fa9e4c3812efa605"), + "plugins/CoreHome/stylesheets/dataTable/_subDataTable.less" => array("860", "e5b7fb01c2a7682310d5e9121960d6fa"), + "plugins/CoreHome/stylesheets/dataTable/_tableConfiguration.less" => array("1499", "5c63c6d3ff0fc046514ee80f572158a4"), + "plugins/CoreHome/stylesheets/_donate.less" => array("2251", "8a37ac490210fd01f9439e821d2f5aeb"), + "plugins/CoreHome/stylesheets/jqplotColors.less" => array("2449", "cee067a8b15cf5a393fdde65c03e0d03"), + "plugins/CoreHome/stylesheets/jquery.ui.autocomplete.css" => array("1200", "518cf68de85e435653447e9102316661"), + "plugins/CoreHome/stylesheets/menu.less" => array("2935", "de31a81958b769f798adaea6673f6fe8"), + "plugins/CoreHome/stylesheets/notification.less" => array("1612", "a5400eef0a05d0159e5675726ef8e2f3"), + "plugins/CoreHome/stylesheets/promo.less" => array("1222", "10e9dd09f69cd77f2d2b35ae86a99b7b"), + "plugins/CoreHome/stylesheets/sparklineColors.less" => array("522", "a8fe19271fefc2f1829b9e271838467a"), + "plugins/CoreHome/templates/checkForUpdates.twig" => array("45", "15faa60f49f1c4c281c76bc2a7e00ce7"), + "plugins/CoreHome/templates/_dataTableCell.twig" => array("2957", "d6b282e985fee6e9f1b9524c3d5b8346"), + "plugins/CoreHome/templates/_dataTableFooter.twig" => array("8068", "cbe13cdda27009f397de576cbab1e37d"), + "plugins/CoreHome/templates/_dataTableHead.twig" => array("821", "f39052d9ca22525d016796aa27662900"), + "plugins/CoreHome/templates/_dataTableJS.twig" => array("159", "7233ccf9c682cdb1744b43ccb9ca84d7"), + "plugins/CoreHome/templates/_dataTable.twig" => array("2016", "0e4018ea821c7e713d8f62c7e1bd31f1"), + "plugins/CoreHome/templates/_donate.twig" => array("2986", "2dc12be6edbf01dcb96bca7caf65a9d2"), + "plugins/CoreHome/templates/getDefaultIndexView.twig" => array("291", "a63dbf0283c9695118f5403b9cea9c9e"), + "plugins/CoreHome/templates/getDonateForm.twig" => array("38", "ceda9c8c6ba889dbac8b55ca7e61e030"), + "plugins/CoreHome/templates/getMultiRowEvolutionPopover.twig" => array("1907", "2777f9e84b4d06496a779b8581566038"), + "plugins/CoreHome/templates/getPromoVideo.twig" => array("1379", "f40ed16a053efaa6df2c682c3e620b41"), + "plugins/CoreHome/templates/getRowEvolutionPopover.twig" => array("1685", "d9be8e51169aef72e1e6acf3d93b1d82"), + "plugins/CoreHome/templates/_headerMessage.twig" => array("2915", "83a77be74cbcedac5eff74e80e33be49"), + "plugins/CoreHome/templates/_indexContent.twig" => array("567", "936cb35de43d98985a05715734b634d6"), + "plugins/CoreHome/templates/_javaScriptDisabled.twig" => array("134", "9f7e7b75b3dd7928b0d7db27802cec9e"), + "plugins/CoreHome/templates/_logo.twig" => array("607", "d14a3ee156c537331868b5cfe52034b9"), + "plugins/CoreHome/templates/_menu.twig" => array("952", "499786b48fe52cdc248d4ecb45a7d7dd"), + "plugins/CoreHome/templates/_notifications.twig" => array("333", "974e20fdb4837324b61cbfb052d3b911"), + "plugins/CoreHome/templates/_periodSelect.twig" => array("1794", "71ebf4a07f218ccd3a6b8e71bd9ad9a4"), + "plugins/CoreHome/templates/ReportRenderer/_htmlReportBody.twig" => array("3773", "5e44bacceb55616bd659c7c6b7883fe6"), + "plugins/CoreHome/templates/ReportRenderer/_htmlReportFooter.twig" => array("15", "4d214dd44eaca17a5b8c100a0cee7ebc"), + "plugins/CoreHome/templates/ReportRenderer/_htmlReportHeader.twig" => array("1165", "f16ab5bc3e57cb8df3a4d5c101f506b2"), + "plugins/CoreHome/templates/ReportsByDimension/_reportsByDimension.twig" => array("1112", "7c22d58e39b426bd649d88d1199aa749"), + "plugins/CoreHome/templates/_singleReport.twig" => array("61", "3ed4c965676469386ba1e6142f55f049"), + "plugins/CoreHome/templates/_siteSelectHeader.twig" => array("234", "9b4fa86af9fec5ea3755b4dde733efa6"), + "plugins/CoreHome/templates/_topBarHelloMenu.twig" => array("1027", "f7dfa6faaa6a6ccc28f5f00e16874a0b"), + "plugins/CoreHome/templates/_topBarTopMenu.twig" => array("763", "ab004040fabe91dff6bc8752ddbc8ef0"), + "plugins/CoreHome/templates/_topBar.twig" => array("200", "9987986288e7ce9072205fb329f9fc87"), + "plugins/CoreHome/templates/_topScreen.twig" => array("109", "6fae551319f6efff81bf460ebad78b66"), + "plugins/CoreHome/templates/_uiControl.twig" => array("327", "35b5eef8d6bb93196947469ab721d0d6"), + "plugins/CoreHome/templates/_warningInvalidHost.twig" => array("915", "ef59e3a2d2828b269f2e9dd41238602d"), + "plugins/CorePluginsAdmin/Controller.php" => array("17319", "a0cecaa6e6c468d9a598d2e5ded186b8"), + "plugins/CorePluginsAdmin/CorePluginsAdmin.php" => array("4817", "4b53a6ef27725c8d61723351162f3156"), + "plugins/CorePluginsAdmin/images/plugins.png" => array("14076", "44b89bd30206dd317c032a8595e1ddeb"), + "plugins/CorePluginsAdmin/images/rating_important.png" => array("673", "0e63393e13ce89a4920684b4b06f68ee"), + "plugins/CorePluginsAdmin/images/themes.png" => array("79517", "67eb6beb264c9181c79246fc6f255c04"), + "plugins/CorePluginsAdmin/javascripts/pluginDetail.js" => array("2270", "5122b5212d9bd47b215b8899fcc448a2"), + "plugins/CorePluginsAdmin/javascripts/pluginExtend.js" => array("756", "4177e6ed911cce4e0c2fdf257f0b538f"), + "plugins/CorePluginsAdmin/javascripts/pluginOverview.js" => array("900", "a7221dc1b624beb54936fce19faf8fcf"), + "plugins/CorePluginsAdmin/javascripts/plugins.js" => array("2974", "e9b57f6e7a1fe31bf46672c7af2001a7"), + "plugins/CorePluginsAdmin/MarketplaceApiClient.php" => array("5380", "32b4ee5cc03f93a5cb0b437023025628"), + "plugins/CorePluginsAdmin/MarketplaceApiException.php" => array("258", "504b17a7c04736eac83d9d532c4666d9"), + "plugins/CorePluginsAdmin/Marketplace.php" => array("5717", "9ee5d691a3e9b28404b7caddfafe6fb3"), + "plugins/CorePluginsAdmin/PluginInstallerException.php" => array("286", "6f252dd00be63fc7c60035c042bfa17e"), + "plugins/CorePluginsAdmin/PluginInstaller.php" => array("9509", "6b462a3b03902bcfeeebab6491e6cfca"), + "plugins/CorePluginsAdmin/stylesheets/marketplace.less" => array("5941", "0d9e782e0476bed3714d79572289e3fb"), + "plugins/CorePluginsAdmin/stylesheets/plugins_admin.less" => array("910", "c3f90c42b657a52f81e4de1f774acfad"), + "plugins/CorePluginsAdmin/templates/browsePluginsActions.twig" => array("787", "ec50c06b24b52ac8a77e0473f35ecdf8"), + "plugins/CorePluginsAdmin/templates/browsePlugins.twig" => array("1569", "807f2a4521e8c2fa60ecb096c7733aae"), + "plugins/CorePluginsAdmin/templates/browseThemes.twig" => array("1355", "ace812045eef7acf7f999493badb3154"), + "plugins/CorePluginsAdmin/templates/extend.twig" => array("3852", "d29aebabcbd868d40e6e13703436d65e"), + "plugins/CorePluginsAdmin/templates/installPlugin.twig" => array("1593", "678e6e40b503dfce996bd1638140dae2"), + "plugins/CorePluginsAdmin/templates/macros.twig" => array("13807", "c53678062e9729c889b4f0c964e3eed6"), + "plugins/CorePluginsAdmin/templates/pluginDetails.twig" => array("10614", "425ca997711e3a742a8c05b9a7547a40"), + "plugins/CorePluginsAdmin/templates/pluginMetadata.twig" => array("579", "573e9a85cc47e6581d4ea2c27e21f271"), + "plugins/CorePluginsAdmin/templates/pluginOverview.twig" => array("1373", "f63e81873d0894f1fe510860e0bb72ce"), + "plugins/CorePluginsAdmin/templates/plugins.twig" => array("985", "614227399bb790846563585476ab7293"), + "plugins/CorePluginsAdmin/templates/safemode.twig" => array("4851", "b7bc45d170dd3d6906251f8f66c24fd4"), + "plugins/CorePluginsAdmin/templates/themeOverview.twig" => array("1432", "7a7fe7a864cacf63b83afcb9ad1ae51a"), + "plugins/CorePluginsAdmin/templates/themes.twig" => array("1165", "ab2cb33c411d1b72aae3b2810982e603"), + "plugins/CorePluginsAdmin/templates/updatePlugin.twig" => array("1431", "509baa9e82b722e0518e31f45c85678c"), + "plugins/CorePluginsAdmin/templates/uploadPlugin.twig" => array("1640", "f61cd131dda39221cf364b82f03d727e"), + "plugins/CorePluginsAdmin/UpdateCommunication.php" => array("6203", "cd40906be1bc9082e7ae01ebe55b4434"), + "plugins/CoreUpdater/Commands/Update.php" => array("2038", "f713523751a5850ea31df6068d813a4f"), + "plugins/CoreUpdater/Controller.php" => array("15315", "baa77e239df7fe6679394684ef0db52f"), + "plugins/CoreUpdater/CoreUpdater.php" => array("5380", "7e2b3bc913e03712d30aef6eecf7af00"), + "plugins/CoreUpdater/javascripts/updateLayout.js" => array("246", "97c81a1a8c66bb5ddb6567591c623387"), + "plugins/CoreUpdater/NoUpdatesFoundException.php" => array("243", "4c4002fd3bdb636949915467b95d9607"), + "plugins/CoreUpdater/stylesheets/updateLayout.css" => array("564", "9fd4b49281584e2af9d964fd9e8a754a"), + "plugins/CoreUpdater/templates/layout.twig" => array("1774", "4764b278f8e7d90fdab9523feef448a1"), + "plugins/CoreUpdater/templates/newVersionAvailable.twig" => array("1667", "dc764e7c76232bca2fac9626e7127b6c"), + "plugins/CoreUpdater/templates/oneClickResults.twig" => array("768", "fd13410beecb2907de3e0121379fc0a2"), + "plugins/CoreUpdater/templates/runUpdaterAndExit_done_cli.twig" => array("1632", "34eaa2c5de8a88c4b320e5f8d4c0df54"), + "plugins/CoreUpdater/templates/runUpdaterAndExit_done.twig" => array("3017", "e28b48da5e46a69f714e03d6e0ad845b"), + "plugins/CoreUpdater/templates/runUpdaterAndExit_welcome_cli.twig" => array("1329", "b84d36f1cac2070b4f82efc39955e8da"), + "plugins/CoreUpdater/templates/runUpdaterAndExit_welcome.twig" => array("4394", "ec2fafcabb5b9ff34f63873f710dc259"), + "plugins/CoreUpdater/UpdateCommunication.php" => array("4260", "44ea4f46e1a34360f2b8d2b1779d1bf0"), + "plugins/CoreVisualizations/CoreVisualizations.php" => array("3098", "59fc60592977b80fecb9a22cb789a339"), + "plugins/CoreVisualizations/javascripts/jqplotBarGraph.js" => array("2624", "8663e49a42d16fcd7faaf628b88c2cbc"), + "plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js" => array("4990", "0555513c693cdcc529d45b82044646c0"), + "plugins/CoreVisualizations/javascripts/jqplot.js" => array("37901", "e06b89f2c320761abc576e24f0d73057"), + "plugins/CoreVisualizations/javascripts/jqplotPieGraph.js" => array("2675", "f51532d8a5c7ff32da50b032c31c5584"), + "plugins/CoreVisualizations/javascripts/seriesPicker.js" => array("13503", "834304040b3260bb6f708cc5f43d7547"), + "plugins/CoreVisualizations/JqplotDataGenerator/Chart.php" => array("3191", "b7b441685a7b019bb9c29b8478be0538"), + "plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php" => array("7403", "be2979d456835753937f5c10890270cc"), + "plugins/CoreVisualizations/JqplotDataGenerator.php" => array("4865", "b53f8d32bfe0d79da637d502863170ce"), + "plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less" => array("537", "d54bd4990eb168a209dea17adfccc1df"), + "plugins/CoreVisualizations/stylesheets/jqplot.css" => array("4847", "03cf5f3e336655a1b5e43b7d7d35bc3a"), + "plugins/CoreVisualizations/templates/_dataTableViz_htmlTable.twig" => array("2641", "e28eee7fdc78defe23ba01c368f153b4"), + "plugins/CoreVisualizations/templates/_dataTableViz_jqplotGraph.twig" => array("149", "ee20fc44c0c435c5dc4b876a42b5632d"), + "plugins/CoreVisualizations/templates/_dataTableViz_tagCloud.twig" => array("839", "b01f096bf415c6b90b405e5be27938dd"), + "plugins/CoreVisualizations/Visualizations/Cloud/Config.php" => array("848", "096f5191662d35f16818ec8262976315"), + "plugins/CoreVisualizations/Visualizations/Cloud.php" => array("5071", "c7e1b761725380954a056fd1b523a0aa"), + "plugins/CoreVisualizations/Visualizations/Graph/Config.php" => array("3359", "44e5bfb03fdc0a72f58f8a50e34f6e15"), + "plugins/CoreVisualizations/Visualizations/Graph.php" => array("5311", "f72e91bb0e8c6a7dd7870a330f51b028"), + "plugins/CoreVisualizations/Visualizations/HtmlTable/AllColumns.php" => array("2206", "891d8f79c7fa4509dbe51fd21d87759d"), + "plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php" => array("3363", "22c395e75a04d580abd824cba212d43d"), + "plugins/CoreVisualizations/Visualizations/HtmlTable.php" => array("2170", "4281101774e7592f03d22abb332e760d"), + "plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php" => array("1559", "1fa011dffcbb16625833555774fec70e"), + "plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php" => array("1054", "d94682d59ff312d8cb3632b5f1bb34a0"), + "plugins/CoreVisualizations/Visualizations/JqplotGraph/Config.php" => array("1808", "c070eb1e52624df57e477ef395a1d8b8"), + "plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php" => array("1146", "6d32a05c97c3ed61fcf07a4934772099"), + "plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php" => array("6285", "273e819c224b072f98acbd38db00dcd2"), + "plugins/CoreVisualizations/Visualizations/JqplotGraph.php" => array("1415", "802e838dd17eb03d047bf80cdf75507a"), + "plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php" => array("1526", "a63ef246318b7af4a3f930b8ba9e1f95"), + "plugins/CoreVisualizations/Visualizations/Sparkline.php" => array("3542", "4901c3281e7997d351f27bcd868aabc9"), + "plugins/CustomVariables/API.php" => array("3729", "a14f0b41fa2c9d429f4bcb1b79604a89"), + "plugins/CustomVariables/Archiver.php" => array("8023", "67584cad7e95fcea09249f9a98d79dae"), + "plugins/CustomVariables/Commands/Info.php" => array("2337", "7ad2373927c02fc97c9dd0b718918b43"), + "plugins/CustomVariables/Commands/SetNumberOfCustomVariables.php" => array("7048", "7988044609e3aec8acb12b5adb9add94"), + "plugins/CustomVariables/Controller.php" => array("734", "4660b5e7cda48db92afab520d689c7bc"), + "plugins/CustomVariables/CustomVariables.php" => array("8766", "c0dfd6ad3cef8186ca6512524c71a604"), + "plugins/CustomVariables/Model.php" => array("4890", "ab27c1ca66ad3015d05f5fcda3ef660f"), + "plugins/Dashboard/API.php" => array("3932", "d87fdaf7673503e6014957c961d16189"), + "plugins/Dashboard/Controller.php" => array("10134", "f867dfa6d4e7c74293bfb9e44abf247b"), + "plugins/Dashboard/DashboardManagerControl.php" => array("1610", "fb1b28942d7990b8bbe90406cf800830"), + "plugins/Dashboard/Dashboard.php" => array("9928", "ffdb68de39c57e26b77d7ac9ecaf08c8"), + "plugins/Dashboard/DashboardSettingsControlBase.php" => array("671", "4979cb16799468c2e48306fc69b51a9d"), + "plugins/Dashboard/javascripts/dashboard.js" => array("10758", "b790e5f955248dec1129ef302cd268e3"), + "plugins/Dashboard/javascripts/dashboardObject.js" => array("18451", "dd99d1afae535b93176693343d7496b0"), + "plugins/Dashboard/javascripts/dashboardWidget.js" => array("11715", "60dc1080ad458f829e2ecca912fd2a34"), + "plugins/Dashboard/javascripts/widgetMenu.js" => array("16065", "45348093e269dbbfaa759ad98965ecc9"), + "plugins/Dashboard/stylesheets/dashboard.less" => array("8508", "65b1b29e4898fb49936a9c02bbcfc1fc"), + "plugins/Dashboard/stylesheets/standalone.css" => array("1163", "c05b2508df30267e2272e8998fd441d8"), + "plugins/Dashboard/templates/_dashboardSettings.twig" => array("800", "2c5218bdfc8b5500d438590af1babacd"), + "plugins/Dashboard/templates/embeddedIndex.twig" => array("4843", "6ee72f07ff096808072bd4667298bf90"), + "plugins/Dashboard/templates/_header.twig" => array("581", "5d695bcc4f1f82c41acb586eeac3d191"), + "plugins/Dashboard/templates/index.twig" => array("775", "f9cfac7175ab189a36954a9a6021796c"), + "plugins/Dashboard/templates/_widgetFactoryTemplate.twig" => array("963", "a2a1af34eef792aaa743d97e6d0f15e4"), + "plugins/DBStats/API.php" => array("9746", "7fc743a372e25e41bf222d6aaab68162"), + "plugins/DBStats/Controller.php" => array("5044", "d4efb7c5a12a4c87209c848e36766d75"), + "plugins/DBStats/DBStats.php" => array("15822", "98f174296aea7b597ff7b508944e3377"), + "plugins/DBStats/.gitignore" => array("33", "478d4f2ee44c0d87d241ed2565482e1c"), + "plugins/DBStats/lang/am.json" => array("544", "7baa1ff89068b48d9d30244ee87c00cd"), + "plugins/DBStats/lang/ar.json" => array("1131", "56ba42e94f87e55fe5350d50eb1be05f"), + "plugins/DBStats/lang/be.json" => array("1093", "d46871abcfc087252143e630b0658a21"), + "plugins/DBStats/lang/bg.json" => array("1470", "4529c23a8802c448433bee85ca847bbe"), + "plugins/DBStats/lang/bn.json" => array("61", "82e91890b454800e8d058c5f53f2e5d0"), + "plugins/DBStats/lang/bs.json" => array("178", "f35b90a6e1ec759920d7b1e1eb536308"), + "plugins/DBStats/lang/ca.json" => array("1129", "25c425a101f8093240cbc5499c9343e5"), + "plugins/DBStats/lang/cs.json" => array("1008", "cffa24240285345e52e5ef6e9d1204bc"), + "plugins/DBStats/lang/da.json" => array("1025", "12d7024ec084a9be18e42dfbeade0690"), + "plugins/DBStats/lang/de.json" => array("1063", "db7842b901098a4f140678bdedebf630"), + "plugins/DBStats/lang/el.json" => array("1672", "dc03df11ce42ed0f466fccd2cf512cbe"), + "plugins/DBStats/lang/en.json" => array("971", "19ece5a2cac5a31c426fd8bb21435e89"), + "plugins/DBStats/lang/es.json" => array("1120", "68b351e3419418a1da6084016a5a05ac"), + "plugins/DBStats/lang/et.json" => array("1043", "f0cfc6c767c2659465ec0c0d909c2502"), + "plugins/DBStats/lang/eu.json" => array("607", "ba6f32b3c0e57fb6ccaedc4ba30b64e2"), + "plugins/DBStats/lang/fa.json" => array("1512", "c2dae8fe3dfb7b109afa25ccdfedd69e"), + "plugins/DBStats/lang/fi.json" => array("971", "9fdf9bb616f21541583c6d7cbe2b98c6"), + "plugins/DBStats/lang/fr.json" => array("1183", "9cbacf1e08fdc1f5addf27e3ed1b723f"), + "plugins/DBStats/lang/he.json" => array("867", "dfa4fc8df95dd71cc99bc14ad034d229"), + "plugins/DBStats/lang/hi.json" => array("1844", "46f80ba8f44e7333f082f2f35e595b40"), + "plugins/DBStats/lang/hu.json" => array("780", "fcbd2ce72e8155f459fe5cb090f09a31"), + "plugins/DBStats/lang/id.json" => array("1034", "b9e8adfb0c460cd383755fb9f5a81987"), + "plugins/DBStats/lang/is.json" => array("480", "9610b7003f73148183d44ac3f6cc7ded"), + "plugins/DBStats/lang/it.json" => array("1080", "3b7b0b7c2eb09c2c57d748f11c2a78bc"), + "plugins/DBStats/lang/ja.json" => array("1206", "6a36a174f6cca7cc6d16e95f99f88814"), + "plugins/DBStats/lang/ka.json" => array("1486", "17d027bb3b07c7b4f6e84b138f32a668"), + "plugins/DBStats/lang/ko.json" => array("1082", "259dbf55c70e0f2dac918896e2a104fc"), + "plugins/DBStats/lang/lt.json" => array("683", "f2dd194a13234292af5c78d4f918e948"), + "plugins/DBStats/lang/lv.json" => array("667", "e22bc702ad6799f314090447d3890c4d"), + "plugins/DBStats/lang/nb.json" => array("770", "6e9614e188669135143894149cf6afb8"), + "plugins/DBStats/lang/nl.json" => array("1034", "b75930ea35d558b9b31239dabb98615b"), + "plugins/DBStats/lang/nn.json" => array("1010", "bc65370bea4018a4c4a89f471170316c"), + "plugins/DBStats/lang/pl.json" => array("741", "351e6de4ab5a458b65790e7f61374607"), + "plugins/DBStats/lang/pt-br.json" => array("1098", "35a2292924438fdabc003199c9ce0927"), + "plugins/DBStats/lang/pt.json" => array("707", "b9f87b78d97a52b9815fa071ec0f1fdd"), + "plugins/DBStats/lang/ro.json" => array("1071", "58ff2ea72c28ee743b598ff434ca1135"), + "plugins/DBStats/lang/ru.json" => array("1521", "56c5e96f586510b1c3d29fcd89f80cfb"), + "plugins/DBStats/lang/sk.json" => array("486", "1df64facbdd26e49116c87e6629c71e6"), + "plugins/DBStats/lang/sl.json" => array("576", "e1e0d54c1bf14659683595826509e709"), + "plugins/DBStats/lang/sq.json" => array("796", "3d358248703abc04f0609902883fdbcb"), + "plugins/DBStats/lang/sr.json" => array("1013", "abb86e964d3e0fa91e301d6d2ea6d9fc"), + "plugins/DBStats/lang/sv.json" => array("1037", "b8bda69de93baab3d956a23d3e8a03bc"), + "plugins/DBStats/lang/ta.json" => array("993", "e1d3c2a837fa6be11f1d5668dc4d812a"), + "plugins/DBStats/lang/te.json" => array("125", "10cb7b33f9cab70f0cd8f12b752063df"), + "plugins/DBStats/lang/th.json" => array("1383", "efaa040a75b0b20ef860d27218d7a784"), + "plugins/DBStats/lang/tr.json" => array("651", "848ac6471a4e74f96d213e3488dae3dc"), + "plugins/DBStats/lang/uk.json" => array("978", "56d1978d76916aea7480b4414003ea41"), + "plugins/DBStats/lang/vi.json" => array("1245", "336115958d203a6e6b72beebc7278b4a"), + "plugins/DBStats/lang/zh-cn.json" => array("943", "ccfe32f44e5d3cdd8febf164e3a239f5"), + "plugins/DBStats/lang/zh-tw.json" => array("673", "760492ac770163bc1cf019e96ae1d464"), + "plugins/DBStats/MySQLMetadataDataAccess.php" => array("2279", "d6b533086e17fb9fe1eb988c4e030744"), + "plugins/DBStats/MySQLMetadataProvider.php" => array("12336", "7d8c570b43a07269a54627220ba473fe"), + "plugins/DBStats/stylesheets/dbStatsTable.less" => array("233", "4324953bbb513a4e0aa2d932d2447e5a"), + "plugins/DBStats/templates/index.twig" => array("4282", "3790e00ea5964ec59aa5b014b7a9d957"), + "plugins/DevicesDetection/API.php" => array("5930", "1d311bf37c78c7ffd3d6a55523f53e57"), + "plugins/DevicesDetection/Archiver.php" => array("2780", "9f07edb22c5f4ce1240d8d742193895a"), + "plugins/DevicesDetection/Controller.php" => array("5541", "ded6665d42e88f384cd5e45574600a52"), + "plugins/DevicesDetection/DevicesDetection.php" => array("14416", "3065fdd010d298d6ad65ef38625335ae"), + "plugins/DevicesDetection/functions.php" => array("6788", "f239b1d1cb96dfc6e4ff2ed2ef676d25"), + "plugins/DevicesDetection/images/brand/Acer.ico" => array("673", "5453e4cc0e9fddd4aac446d0b10d4e36"), + "plugins/DevicesDetection/images/brand/Alcatel.ico" => array("577", "df5ccd4326721199d02870aab0e225c3"), + "plugins/DevicesDetection/images/brand/Apple.ico" => array("1179", "3b58ada0634f0a1687a44c15e4e1936f"), + "plugins/DevicesDetection/images/brand/Archos.ico" => array("205", "c3fc2d2e6b64fa163a7d17603f247ea7"), + "plugins/DevicesDetection/images/brand/Asus.ico" => array("1016", "1107fad067ea7b3551081c56b9f525cf"), + "plugins/DevicesDetection/images/brand/Audiovox.ico" => array("807", "5e684babc5c21bf7a8255a0ad0f9545a"), + "plugins/DevicesDetection/images/brand/Avvio.ico" => array("964", "c4a27544c3af4093265a0cbb45cd5b31"), + "plugins/DevicesDetection/images/brand/BangOlufsen.ico" => array("3692", "e062a24b4ed0af1fe513053eb2e70750"), + "plugins/DevicesDetection/images/brand/Becker.ico" => array("519", "6c5ed512b9de01a0d60d4efcc78b1ea0"), + "plugins/DevicesDetection/images/brand/Beetel.ico" => array("1645", "034453672fc6b71827ea0126248843b8"), + "plugins/DevicesDetection/images/brand/BenQ.ico" => array("846", "70f0361f99a39dcdde7e1731364e19c9"), + "plugins/DevicesDetection/images/brand/Cat.ico" => array("809", "2b5844049936b7e33220ba80accfabfb"), + "plugins/DevicesDetection/images/brand/CnM.ico" => array("421", "6a3a2122bcab660df2d010087914b7b7"), + "plugins/DevicesDetection/images/brand/Compal.ico" => array("432", "a7190dfd9e3b833a17bd9576b2fc47a0"), + "plugins/DevicesDetection/images/brand/CreNova.ico" => array("3142", "b34cd9a39800b0e0109ab291368e3177"), + "plugins/DevicesDetection/images/brand/Cricket.ico" => array("1483", "6b3f14fc16f8b9e61cbf4eaf4af9efbf"), + "plugins/DevicesDetection/images/brand/Dell.ico" => array("886", "10a5c1a530dc6a0758dec93fea8a08ff"), + "plugins/DevicesDetection/images/brand/Denver.ico" => array("552", "6efa83cf0f661e769272515970bcbc97"), + "plugins/DevicesDetection/images/brand/DMM.ico" => array("3623", "3a154f04b274d2e67ac9298ca2df08fd"), + "plugins/DevicesDetection/images/brand/DoCoMo.ico" => array("636", "28df0a65fbcb99b0e2ca93c6cdcc8e38"), + "plugins/DevicesDetection/images/brand/Ericsson.ico" => array("684", "de92f235c5d09fa7b1140538058c8bcf"), + "plugins/DevicesDetection/images/brand/eTouch.ico" => array("889", "779c7c6654749cffea8363ed1c6ee971"), + "plugins/DevicesDetection/images/brand/Fly.ico" => array("572", "d608aa4b0b9a861c77501c744aa4c275"), + "plugins/DevicesDetection/images/brand/Gemini.ico" => array("323", "07def536ff813416b91b196660b1f4a2"), + "plugins/DevicesDetection/images/brand/Google.ico" => array("863", "ad67a48c2dc917325f9cb38a88f8a8a3"), + "plugins/DevicesDetection/images/brand/Gradiente.ico" => array("1012", "25ed337d67bf8d6fdb5012b331aadd05"), + "plugins/DevicesDetection/images/brand/Grundig.ico" => array("3029", "afae28b8f05bae1e8c476fc3cdb6b5ab"), + "plugins/DevicesDetection/images/brand/Haier.ico" => array("957", "23b40417f5adfe2e0e0a263f9d70456d"), + "plugins/DevicesDetection/images/brand/HP.ico" => array("936", "ae90fea570472f65946c4f2b71a2aac7"), + "plugins/DevicesDetection/images/brand/HTC.ico" => array("1161", "b0b6419c96392cbaa646129a9f609f51"), + "plugins/DevicesDetection/images/brand/Huawei.ico" => array("1022", "cf2cca917f1b7655f69ebf439d464823"), + "plugins/DevicesDetection/images/brand/Humax.ico" => array("3010", "cb8aee651c6434c0ff6630e609808db9"), + "plugins/DevicesDetection/images/brand/Ikea.ico" => array("3291", "56fc079b565f603be4bea0faa90dfb71"), + "plugins/DevicesDetection/images/brand/i-mobile.ico" => array("615", "15a5cf09f8e2ad6d60956ae07086b50c"), + "plugins/DevicesDetection/images/brand/INQ.ico" => array("1059", "7ece6e5474c9ab6fdcda278c7e341b81"), + "plugins/DevicesDetection/images/brand/Intek.ico" => array("3121", "fa198786b40dc3cae6c36d9bbe481fa8"), + "plugins/DevicesDetection/images/brand/Inverto.ico" => array("3292", "f80dab0f0c81f444c2ea6cbf2fd833ad"), + "plugins/DevicesDetection/images/brand/Jolla.ico" => array("494", "940ce2510cdc18ca0e5a87a0d34e8b90"), + "plugins/DevicesDetection/images/brand/Karbonn.ico" => array("1042", "59d3efb46e18658f4ee0c780ce175dd7"), + "plugins/DevicesDetection/images/brand/KDDI.ico" => array("473", "d0f2c629657b13fc0fe671eb9221e9b2"), + "plugins/DevicesDetection/images/brand/Kindle.ico" => array("720", "bf3c48f92a4ca2beedb7485ff7eae609"), + "plugins/DevicesDetection/images/brand/Kyocera.ico" => array("639", "ca13b59af47f69c5ac6917cef9512add"), + "plugins/DevicesDetection/images/brand/Lanix.ico" => array("437", "fb1e54c478ce9d908a08e4a0b1005c5c"), + "plugins/DevicesDetection/images/brand/Lenovo.ico" => array("237", "42abac25970f9750ec395cefffa30b9d"), + "plugins/DevicesDetection/images/brand/LG.ico" => array("1510", "79a2c6d36a1ba70423162b4c1fe46ec2"), + "plugins/DevicesDetection/images/brand/LGUPlus.ico" => array("1081", "337b3f1e7368a44b7c40dd8e53940445"), + "plugins/DevicesDetection/images/brand/Loewe.ico" => array("2938", "e5ec4483d155267a2944dadad9d9235e"), + "plugins/DevicesDetection/images/brand/Manta_Multimedia.ico" => array("800", "cf2f49d840f9492ebcaf53e9b8ed8944"), + "plugins/DevicesDetection/images/brand/MediaTek.ico" => array("2976", "19e087b87519151688299d3f9366a3e3"), + "plugins/DevicesDetection/images/brand/Medion.ico" => array("3161", "be609b90e6df4e4121bb656575f256b1"), + "plugins/DevicesDetection/images/brand/Metz.ico" => array("3244", "4cb9c9047e80672b5cc5f48b6e0696de"), + "plugins/DevicesDetection/images/brand/MicroMax.ico" => array("1531", "db3505d0efd7517bf2820de499a08c88"), + "plugins/DevicesDetection/images/brand/Microsoft.ico" => array("285", "8a7346d4ef508c9e55ac4eb0933a58ff"), + "plugins/DevicesDetection/images/brand/Mio.ico" => array("753", "9f53b557757e285f1de1fe7b67434406"), + "plugins/DevicesDetection/images/brand/Mitsubishi.ico" => array("342", "a0d050b3c27c242d11a1a58f30e58ff9"), + "plugins/DevicesDetection/images/brand/Motorola.ico" => array("465", "0561bdea0b5842d6c9bc0301bde19b0f"), + "plugins/DevicesDetection/images/brand/MyPhone.ico" => array("933", "84cd21bc7363dda6be3a1181be22de6b"), + "plugins/DevicesDetection/images/brand/NEC.ico" => array("450", "a3ae0709656decaaf958595aed950f4d"), + "plugins/DevicesDetection/images/brand/Nexian.ico" => array("2041", "4001957b806ce17dcaca59b9b852be8a"), + "plugins/DevicesDetection/images/brand/NGM.ico" => array("1298", "3cbe7ef2372c4074ee2734aa3a58b328"), + "plugins/DevicesDetection/images/brand/Nintendo.ico" => array("740", "7bdf9ff565d9cc45a08824a6bade6f47"), + "plugins/DevicesDetection/images/brand/Nokia.ico" => array("1283", "ae41cc8ec06c6e81d9f5afadf6097f5b"), + "plugins/DevicesDetection/images/brand/O2.ico" => array("768", "36b1b15cc750a45b5c2b6edc80594bbd"), + "plugins/DevicesDetection/images/brand/Onda.ico" => array("732", "2a8e1c31d12c2cf27c1931a13fd178df"), + "plugins/DevicesDetection/images/brand/OPPO.ico" => array("870", "4128f2c75414aa765adc5866bb627635"), + "plugins/DevicesDetection/images/brand/Orange.ico" => array("461", "21a6df281c10a240cbc134f79ea0d6eb"), + "plugins/DevicesDetection/images/brand/Panasonic.ico" => array("3649", "71da2a84a1fdf04abd130562699a9ab4"), + "plugins/DevicesDetection/images/brand/Pantech.ico" => array("605", "c9f16f7630211e6db026ea36919e46d0"), + "plugins/DevicesDetection/images/brand/PEAQ.ico" => array("3060", "3f0244e520fccb81e4acfffa330d7aab"), + "plugins/DevicesDetection/images/brand/Philips.ico" => array("3749", "2213ce1a4b99c4af0a53dff146aca457"), + "plugins/DevicesDetection/images/brand/Polaroid.ico" => array("737", "c6703ba6a2d008aa2197df33370e124c"), + "plugins/DevicesDetection/images/brand/PolyPad.ico" => array("1381", "35f641a4463636392646a476a4ffbfbd"), + "plugins/DevicesDetection/images/brand/RIM.ico" => array("705", "e5660baaa9484cddcd779e16672d48ce"), + "plugins/DevicesDetection/images/brand/Sagem.ico" => array("694", "6d0664111d655a222cf7ec86cebd7750"), + "plugins/DevicesDetection/images/brand/Samsung.ico" => array("3095", "988bb1039ac4b49d767dcfbdff86172a"), + "plugins/DevicesDetection/images/brand/Sanyo.ico" => array("639", "ca13b59af47f69c5ac6917cef9512add"), + "plugins/DevicesDetection/images/brand/Sega.ico" => array("706", "d73c2098167d02eb81c7b83cfe5787a2"), + "plugins/DevicesDetection/images/brand/Selevision.ico" => array("3497", "c920706882f4d553619e275f0d336423"), + "plugins/DevicesDetection/images/brand/Sharp.ico" => array("403", "ce78aee244f948bba92bfbf91397448b"), + "plugins/DevicesDetection/images/brand/Siemens.ico" => array("395", "a82f9c7c51665f04cad1a2a4a69bc590"), + "plugins/DevicesDetection/images/brand/Smart.ico" => array("3419", "04bc938b9b436ac140922f399767fd9f"), + "plugins/DevicesDetection/images/brand/Softbank.ico" => array("381", "8c42b583b4e1b05900b06698a65678c5"), + "plugins/DevicesDetection/images/brand/Sony_Ericsson.ico" => array("628", "7c8230f2d8dc34c6c5761aa9db204588"), + "plugins/DevicesDetection/images/brand/Sony.ico" => array("3525", "181229784d53d42d3699d3fc0f562ddd"), + "plugins/DevicesDetection/images/brand/Spice.ico" => array("556", "1d61255e7c137b6214eee3f3a7059027"), + "plugins/DevicesDetection/images/brand/TCL.ico" => array("2927", "207121f24b1b59c9e3e2ae43ad420f97"), + "plugins/DevicesDetection/images/brand/TechniSat.ico" => array("3347", "08762844360b5220aeb01e23dd6ea410"), + "plugins/DevicesDetection/images/brand/TechnoTrend.ico" => array("3500", "65748765610b09c759b739f1a97dc8ca"), + "plugins/DevicesDetection/images/brand/Telefunken.ico" => array("3651", "677bdb0ce07503bf11c437896148332d"), + "plugins/DevicesDetection/images/brand/Telit.ico" => array("527", "419e9ae7ac193023e1b0082fd96bc5df"), + "plugins/DevicesDetection/images/brand/Thomson.ico" => array("2974", "bff62a739264a6a81f8447a1aaf1d955"), + "plugins/DevicesDetection/images/brand/TiPhone.ico" => array("1179", "d5e6eeafbbde1409d878c6b17a6bba9e"), + "plugins/DevicesDetection/images/brand/T-Mobile.ico" => array("499", "d7869b7ce833ed1d9db2f85dfb0140b5"), + "plugins/DevicesDetection/images/brand/Toshiba.ico" => array("248", "f96539816d5b1d433bd5e8c4f1222970"), + "plugins/DevicesDetection/images/brand/unknown.ico" => array("1077", "32104fb21cf3f82b68d30bee64972ea7"), + "plugins/DevicesDetection/images/brand/Vertu.ico" => array("387", "f87d83b0a636bb325aefddcf436d50c4"), + "plugins/DevicesDetection/images/brand/Vestel.ico" => array("3096", "33dcd35a24a21eb0f660b26fcc41bca1"), + "plugins/DevicesDetection/images/brand/Videocon.ico" => array("617", "c9948a26325d7e73f4eb287e1475cb82"), + "plugins/DevicesDetection/images/brand/Videoweb.ico" => array("3129", "ca8e2df12c63f4799f69f74e141319e2"), + "plugins/DevicesDetection/images/brand/ViewSonic.ico" => array("605", "537cb66bf7d13d8be07efefc178af164"), + "plugins/DevicesDetection/images/brand/Voxtel.ico" => array("222", "93267b0d0b38e7b47f2dccb7bd791171"), + "plugins/DevicesDetection/images/brand/Xiaomi.ico" => array("492", "fe0ef8c5a55aeb5e33067b051ca87fd7"), + "plugins/DevicesDetection/images/brand/Yuandao.ico" => array("639", "866dddeae2afafd1a86a28b3621962f3"), + "plugins/DevicesDetection/images/brand/Zonda.ico" => array("371", "43bfb6a3cfca4edd0bf27bbf21e5baed"), + "plugins/DevicesDetection/images/brand/ZTE.ico" => array("555", "1f124412f72d709497dd3f4ad50b0761"), + "plugins/DevicesDetection/images/screens/camera.png" => array("644", "f948997fff235b4bbdece324d9054969"), + "plugins/DevicesDetection/images/screens/carbrowser.png" => array("3218", "fcbb6fa6a2c617418df8b17299d3d7fb"), + "plugins/DevicesDetection/images/screens/computer.png" => array("550", "305f4f50137fe4e909ad4aa4f61d52a8"), + "plugins/DevicesDetection/images/screens/console.gif" => array("617", "d8f2cae9e8e7723241c6c862f12c7511"), + "plugins/DevicesDetection/images/screens/dual.gif" => array("1082", "7903e070f4c4aa7933030e547e78073e"), + "plugins/DevicesDetection/images/screens/mobile.gif" => array("324", "04942ef60dd75380cfd682314e87e857"), + "plugins/DevicesDetection/images/screens/normal.gif" => array("1088", "b0c3de8704745e58c67844e54282ee42"), + "plugins/DevicesDetection/images/screens/smartphone.png" => array("547", "ca9c84628d2fb0bd58362d4689ab38f2"), + "plugins/DevicesDetection/images/screens/tablet.png" => array("602", "13529400c1e0d81762f08efac9f865d8"), + "plugins/DevicesDetection/images/screens/tv.png" => array("644", "415f9d8ae19f77c0203fd82de985ab2a"), + "plugins/DevicesDetection/images/screens/unknown.gif" => array("80", "c4a7f0e333a6079fdb0b6595e11bca74"), + "plugins/DevicesDetection/images/screens/wide.gif" => array("1025", "c0104958e6fb23668d0406fd4d89095e"), + "plugins/DevicesDetection/lang/bg.json" => array("1918", "4efec5ddc313eff04a301805586e13f2"), + "plugins/DevicesDetection/lang/cs.json" => array("340", "9cbf609028387879af0c6a54c9fd67f1"), + "plugins/DevicesDetection/lang/da.json" => array("1309", "09ba8d9edf2c0bf40a4b78c2df0d9174"), + "plugins/DevicesDetection/lang/de.json" => array("1334", "14ab1307ce18bb680bc954764bdd8e2f"), + "plugins/DevicesDetection/lang/el.json" => array("2121", "71ba9e2ac13f5592daae2e6f8085361d"), + "plugins/DevicesDetection/lang/en.json" => array("1305", "e1cc97069255c3ed10eb9c96ab1faab9"), + "plugins/DevicesDetection/lang/es.json" => array("1487", "38413c85a6b8d00fb099c450bc215493"), + "plugins/DevicesDetection/lang/et.json" => array("1326", "925cd20b8c5560958c1c5af28fdabf03"), + "plugins/DevicesDetection/lang/fa.json" => array("1191", "f9021aacbeef48009222e6157bed9fb2"), + "plugins/DevicesDetection/lang/fi.json" => array("1360", "d35fa82abe7de2b9d6ff3b6ecb40aa90"), + "plugins/DevicesDetection/lang/fr.json" => array("1616", "304b6674bbda463695f47ef601130345"), + "plugins/DevicesDetection/lang/it.json" => array("1405", "ea2a06c896ed564b2d8fb3b3b128abf3"), + "plugins/DevicesDetection/lang/ja.json" => array("1757", "b8c584f9b316aa5be5694ea2a9452baf"), + "plugins/DevicesDetection/lang/nb.json" => array("359", "bd7d02fa1b1c53285f948b31d7aae4a6"), + "plugins/DevicesDetection/lang/nl.json" => array("1251", "b0ae612fa5e5c16ae19201e610934f68"), + "plugins/DevicesDetection/lang/pt-br.json" => array("937", "a3eb60325ac25d48584eecfb771b89e6"), + "plugins/DevicesDetection/lang/ro.json" => array("1003", "676591f9dc497e4117264d56690a5647"), + "plugins/DevicesDetection/lang/ru.json" => array("1510", "ea3b2ebb6e5c0cf0ea1944c56116a539"), + "plugins/DevicesDetection/lang/sr.json" => array("550", "b31ae00fd98c9b050823db27790b2628"), + "plugins/DevicesDetection/lang/sv.json" => array("1325", "9c96fbcb18791021e36d2ca4868830ba"), + "plugins/DevicesDetection/templates/detection.twig" => array("3599", "12494f5f5bff7b5f031e040c0f44f3df"), + "plugins/DevicesDetection/templates/index.twig" => array("632", "31c98fab8308315ac38c8c0d7800e105"), + "plugins/DevicesDetection/templates/list.twig" => array("174", "bfda35ec5629ff523fea8502339daa58"), + "plugins/DevicesDetection/Updates/1.14.php" => array("780", "e405cf2a34f8299ab31ff7f27e7cda9f"), + "plugins/Events/API.php" => array("7610", "1f1edc6d625743c1301eb05f28b94534"), + "plugins/Events/Archiver.php" => array("9467", "1058da454ca46bdcbf2751605eb434ef"), + "plugins/Events/Controller.php" => array("2819", "2744d5a2eaa6532b02fdae411518d6cc"), + "plugins/Events/Events.php" => array("11572", "eb5998643543849b6a86625296dc6f37"), + "plugins/Events/lang/bg.json" => array("342", "b6ca024b4dc8d44227d9b09687522b20"), + "plugins/Events/lang/da.json" => array("257", "53898474762bee31b2bce9efa4218033"), + "plugins/Events/lang/de.json" => array("846", "e7ffc157518edd6f563a036a33ceea6b"), + "plugins/Events/lang/el.json" => array("332", "f8fa308217f7fea4bad1b5cd1fda8dae"), + "plugins/Events/lang/en.json" => array("1388", "ccfc90445d2f2d1b2e9a03276c96e076"), + "plugins/Events/lang/es.json" => array("852", "02df421dd69675296c0f084dae5c3d0a"), + "plugins/Events/lang/et.json" => array("262", "b6f500811defac532922653d562467c6"), + "plugins/Events/lang/fa.json" => array("291", "b11cba57d7f4654e63e864509c018bcf"), + "plugins/Events/lang/fi.json" => array("262", "d00802eb689dac69d2609cc6289017f2"), + "plugins/Events/lang/fr.json" => array("279", "39724ce65da5324898709bd365ceddee"), + "plugins/Events/lang/it.json" => array("818", "6a9e72466790e1e84a20281cf188a543"), + "plugins/Events/lang/ja.json" => array("300", "8b531ac5ac179f64c711cbfa3ca7b692"), + "plugins/Events/lang/nl.json" => array("271", "6a01d8a33c0234eee5d06c2da015e23e"), + "plugins/Events/lang/pt-br.json" => array("208", "292c740643d6491e0fe066ac5198345d"), + "plugins/Events/lang/ro.json" => array("921", "59e6c3b7b2527ec51a61fdc08b8d4754"), + "plugins/Events/lang/sr.json" => array("529", "993200724d25f114e3e500eab2459df8"), + "plugins/Events/lang/sv.json" => array("258", "c3d44c14aa30eebcad1133d6ae1a7b2c"), + "plugins/Events/lang/ta.json" => array("355", "b0e9b20f277a78aee6dc90005ec0c3f3"), + "plugins/Events/lang/vi.json" => array("254", "63d3e8849dd421c075b182f3db5b3d3e"), + "plugins/Events/lang/zh-cn.json" => array("204", "f15db4c7236481d992551e7964b1cc79"), + "plugins/Events/plugin.json" => array("139", "b5998447ccf2ee34f37ebe94be0aa845"), + "plugins/Events/templates/index.twig" => array("27", "a1080e4dc13f29008f29ac2d2e5dc8a8"), + "plugins/ExampleAPI/API.php" => array("4049", "2bda550f0e37806d7d8910789eee8199"), + "plugins/ExampleAPI/ExampleAPI.php" => array("325", "cef3615976cd2876afa2d2fc77acc5c1"), + "plugins/ExampleAPI/plugin.json" => array("448", "1a13a70c7ea7281490ed4af6a7221064"), + "plugins/ExampleCommand/Commands/HelloWorld.php" => array("1074", "70069208b271812bbf7e8faca036e7af"), + "plugins/ExampleCommand/plugin.json" => array("115", "cb68e3b4adc3511c16067f2cf02c5922"), + "plugins/ExamplePlugin/API.php" => array("815", "8bfc9244633eabb0c4f7f32e9b4a3fcf"), + "plugins/ExamplePlugin/Controller.php" => array("484", "f998699c283e1b20006c9afa8092f4ad"), + "plugins/ExamplePlugin/ExamplePlugin.php" => array("587", "7fedf67b0a08fb7fe1f8d71372ce6e91"), + "plugins/ExamplePlugin/.gitignore" => array("20", "4a9a2f0c455651c6b04479aafba04568"), + "plugins/ExamplePlugin/javascripts/plugin.js" => array("480", "eb32c805acb3e4af9370afd515a6dd1d"), + "plugins/ExamplePlugin/plugin.json" => array("208", "89a9672ed7f49b867ed88133a69120f4"), + "plugins/ExamplePlugin/README.md" => array("207", "48e2fb45e071d5d991d2b6243d9e8ff4"), + "plugins/ExamplePlugin/screenshots/.gitkeep" => array("0", "d41d8cd98f00b204e9800998ecf8427e"), + "plugins/ExamplePlugin/templates/index.twig" => array("77", "16efbdb92b36269be3801782f230f7d6"), + "plugins/ExamplePlugin/.travis.yml" => array("1056", "3a37a0ec586442058d923e1d7d640f43"), + "plugins/ExampleRssWidget/Controller.php" => array("1192", "090efd2ade281d8b58713d703b2beeda"), + "plugins/ExampleRssWidget/ExampleRssWidget.php" => array("949", "75f2273eb309a058b35e8c04cf3bfc45"), + "plugins/ExampleRssWidget/plugin.json" => array("449", "5ff4fed5b604abbae9e887aa0b45cb6a"), + "plugins/ExampleRssWidget/RssRenderer.php" => array("2078", "e2101bb2155cf6cc36ec064b2ca93b9e"), + "plugins/ExampleRssWidget/stylesheets/rss.less" => array("468", "9890a5a97e8e0be796c1b993b6991218"), + "plugins/ExampleSettingsPlugin/plugin.json" => array("163", "2672083085a6a297afd769beef9107a2"), + "plugins/ExampleSettingsPlugin/Settings.php" => array("5641", "a65a623faf6b325eb9109bb480eff723"), + "plugins/ExampleTheme/plugin.json" => array("158", "7bd2577897bda6846b7c457e059766f4"), + "plugins/ExampleTheme/README.md" => array("208", "1e90083ee059ed260d3cc73e3bbff133"), + "plugins/ExampleTheme/stylesheets/theme.less" => array("0", "d41d8cd98f00b204e9800998ecf8427e"), + "plugins/ExampleUI/API.php" => array("3032", "c4622a9b0fa70d56d9eef72dbf22b546"), + "plugins/ExampleUI/Controller.php" => array("7325", "4255650a64a9cf4f3b41f98fd101dff5"), + "plugins/ExampleUI/ExampleUI.php" => array("1707", "f5ce7ad0cc8af387cc373ce590105a00"), + "plugins/ExampleUI/images/icons-planet/earth.png" => array("11823", "82a4f58618b5194ba7d50600115d36da"), + "plugins/ExampleUI/images/icons-planet/jupiter.png" => array("10686", "08c6c466b8068c18e0c1859115515a5d"), + "plugins/ExampleUI/images/icons-planet/LICENSE" => array("112", "5275944112cc13e4e74b2de749497988"), + "plugins/ExampleUI/images/icons-planet/mars.png" => array("9837", "5714bd49c465b187d60e64f64ca4a566"), + "plugins/ExampleUI/images/icons-planet/mercury.png" => array("10322", "a83fe0221f91cd7581947323ebfdff44"), + "plugins/ExampleUI/images/icons-planet/neptune.png" => array("9770", "207ad3426696544e7fc301f82f81ce40"), + "plugins/ExampleUI/images/icons-planet/saturn.png" => array("10561", "9514535a2fde794c6dac3e3382fcf517"), + "plugins/ExampleUI/images/icons-planet/uranus.png" => array("10358", "9ff6281138f4ebb8ca5b8b3b54b9d9b0"), + "plugins/ExampleUI/images/icons-planet/venus.png" => array("10557", "1aa0425158220a53f36537f83d266231"), + "plugins/ExampleUI/plugin.json" => array("502", "fdd7feb05198028a198c8cedae0ce6b8"), + "plugins/ExampleUI/templates/evolutiongraph.twig" => array("90", "541ef7126cfe01ac071d053d720d2ccd"), + "plugins/ExampleUI/templates/notifications.twig" => array("447", "9525341678a5441f789cd446cbbdba56"), + "plugins/ExampleUI/templates/sparklines.twig" => array("237", "31e796d35929d2839fe62b5d2f3460f6"), + "plugins/ExampleVisualization/ExampleVisualization.php" => array("631", "44f36c70c57d48b86a5342b7ee2e3010"), + "plugins/ExampleVisualization/images/table.png" => array("151", "327ee0e75605ab865796053f2c0aebf1"), + "plugins/ExampleVisualization/plugin.json" => array("410", "6879c05e6a2b969cfac1eb24b53e5e79"), + "plugins/ExampleVisualization/README.md" => array("238", "1353685fc485ed3691c8ce507d530411"), + "plugins/ExampleVisualization/SimpleTable.php" => array("2515", "c5dcc6edbdb74ed20ffe9d36f6846c22"), + "plugins/ExampleVisualization/templates/simpleTable.twig" => array("829", "abccf18a3c8bfb921a53f65cb339282a"), + "plugins/Feedback/angularjs/ratefeature/icon_license" => array("268", "0a278d7bd9ac7a0022c8adfdf07418cd"), + "plugins/Feedback/angularjs/ratefeature/ratefeature-controller.js" => array("633", "4dd44a1936c30a3cfc157ed7c3fd2653"), + "plugins/Feedback/angularjs/ratefeature/ratefeature-directive.js" => array("542", "4376ff3f0cf1d5c78a92e8900fa14161"), + "plugins/Feedback/angularjs/ratefeature/ratefeature.html" => array("1554", "f61eee9f73fada95471dec5c5980b6b4"), + "plugins/Feedback/angularjs/ratefeature/ratefeature.less" => array("358", "51dbbd1fab358ef455a06d53697bffc7"), + "plugins/Feedback/angularjs/ratefeature/ratefeature-model.js" => array("543", "3807890aedaff7895eb3f3c9f1f00015"), + "plugins/Feedback/angularjs/ratefeature/thumbs-down.png" => array("2188", "38c37611f821aa2671d60dc843345d12"), + "plugins/Feedback/angularjs/ratefeature/thumbs-up.png" => array("2202", "9befe01114b4a5801808e8899b5010a7"), + "plugins/Feedback/API.php" => array("3265", "09a9d79dc7bbc8010239f81887d0de38"), + "plugins/Feedback/Controller.php" => array("623", "89de65e12271361e2e2aee5c59641288"), + "plugins/Feedback/Feedback.php" => array("2185", "73bd60a41e1b6221493799866a47291b"), + "plugins/Feedback/images/facebook.png" => array("302", "9ae558673b40a7f3eeaad17b281ba4f8"), + "plugins/Feedback/images/github.png" => array("361", "9417fe2b0664c8d8f23b1765728c46c1"), + "plugins/Feedback/images/linkedin.png" => array("336", "26bebcbbef85af866892f6aa7c49e079"), + "plugins/Feedback/images/newsletter.png" => array("444", "740cc523cc992a4e9c089d7dc589b3eb"), + "plugins/Feedback/images/twitter.png" => array("376", "fcac98bcbf056e136c7b06557d7bcb98"), + "plugins/Feedback/stylesheets/feedback.less" => array("1539", "c0c45446610709fef33f18bcf4f6ef14"), + "plugins/Feedback/templates/index.twig" => array("5746", "e963b3fcd9eb4f753c2d944c174def7b"), + "plugins/Goals/API.php" => array("24370", "539367dfc9fbf1b43bf84839ee86eb58"), + "plugins/Goals/Archiver.php" => array("15665", "d6178a9fdfbaf1c96ae1fed57ba6a683"), + "plugins/Goals/Controller.php" => array("21788", "377dae6591eab934b51afaa0fab43aa9"), + "plugins/Goals/Goals.php" => array("31816", "0ea5f9311ee7c1d5ef051dd14f81e357"), + "plugins/Goals/javascripts/goalsForm.js" => array("6201", "acbd9a7ee2de6fd6e5a2f2b6698ece5a"), + "plugins/Goals/stylesheets/goals.css" => array("460", "6b076df7c73aa99431820279517ea9cd"), + "plugins/Goals/templates/_addEditGoal.twig" => array("4456", "980da0a6237f27c853fd9c57a3db1380"), + "plugins/Goals/templates/addNewGoal.twig" => array("432", "530107fc4f4a5fb8ea5744592e3c3646"), + "plugins/Goals/templates/_formAddGoal.twig" => array("5011", "af7bdec1ae036d7dc363cbb0977fc850"), + "plugins/Goals/templates/getGoalReportView.twig" => array("3030", "395e8c171b9268be5e4e7ccf24e7c6be"), + "plugins/Goals/templates/getOverviewView.twig" => array("1863", "afb8bced90b1de94b24546bd5ae21604"), + "plugins/Goals/templates/_listGoalEdit.twig" => array("2736", "6c0d563edf0c1971097e0c41d5516a9c"), + "plugins/Goals/templates/_listTopDimension.twig" => array("615", "86df6f4ecfa56b02c72075f7beace1d5"), + "plugins/Goals/templates/_titleAndEvolutionGraph.twig" => array("3785", "ae6e728ff64176a617f69d426834e6ba"), + "plugins/Goals/Visualizations/Goals.php" => array("11535", "13ea4bfab83f1f683db27a8f62d453f8"), + "plugins/ImageGraph/API.php" => array("22505", "b5f4b78e2b6b1e4eb667cd5601124edf"), + "plugins/ImageGraph/Controller.php" => array("2596", "028bc0f95cddb8fd42a72007ee8677d4"), + "plugins/ImageGraph/fonts/tahoma.ttf" => array("94740", "fabde5f388432a4a2bd40bc07ac53971"), + "plugins/ImageGraph/ImageGraph.php" => array("6041", "e604bd6a45f1e5c165319f0108826fbf"), + "plugins/ImageGraph/StaticGraph/Evolution.php" => array("576", "75d6bc92ee26a9153a19479fc26e81d6"), + "plugins/ImageGraph/StaticGraph/Exception.php" => array("1513", "b2e05ae76bb47496c259bbbb23632b1c"), + "plugins/ImageGraph/StaticGraph/GridGraph.php" => array("19969", "3beb0b57cb2837fac11c5f53be6605d3"), + "plugins/ImageGraph/StaticGraph/HorizontalBar.php" => array("7097", "a0b09ce105f15b874f7f867a71a80888"), + "plugins/ImageGraph/StaticGraph.php" => array("9417", "9d2e8b5b979bed66c7db0bbd64c87152"), + "plugins/ImageGraph/StaticGraph/Pie3D.php" => array("464", "f432e2a17d9c16fb86909bc596bacf44"), + "plugins/ImageGraph/StaticGraph/PieGraph.php" => array("4227", "d61366790d5b83979b81633631479e50"), + "plugins/ImageGraph/StaticGraph/Pie.php" => array("463", "4ffc2f6fbc909875f01df6159472d6c5"), + "plugins/ImageGraph/StaticGraph/VerticalBar.php" => array("700", "fa2f2c41aa965694711d69b94269f0c2"), + "plugins/ImageGraph/templates/index.twig" => array("148", "29658e5544cd076021ea898993b8506d"), + "plugins/ImageGraph/templates/testAllSizes.twig" => array("2557", "2eca39df92b75a1c67933d928d8815e9"), + "plugins/Insights/API.php" => array("13847", "788dd0b6a9e6d3ead74f319a706b94db"), + "plugins/Insights/Controller.php" => array("2184", "2f3d6d7f84ecddfb5b75a1ad780ace40"), + "plugins/Insights/DataTable/Filter/ExcludeLowValue.php" => array("1607", "bde83b71854b84f55894e754cc36b43f"), + "plugins/Insights/DataTable/Filter/Insight.php" => array("3876", "d5b9908e494619123f6c61d1e2a9aeb0"), + "plugins/Insights/DataTable/Filter/Limit.php" => array("1427", "896f5aaa0f1203918f09bcd96152c713"), + "plugins/Insights/DataTable/Filter/MinGrowth.php" => array("1489", "f385e31013c7e27a8b178ce5cf193aed"), + "plugins/Insights/DataTable/Filter/OrderBy.php" => array("1984", "fa2aa2e46620f2d92fa2aac30960efa1"), + "plugins/Insights/images/idea.png" => array("364", "bc677415eb4fb941a85c568b6aa656ba"), + "plugins/Insights/InsightReport.php" => array("11233", "e74af746ab91d381bb04cb1cf5c9506a"), + "plugins/Insights/Insights.php" => array("1412", "00a6ff865dc2ed2728dd8dd58438784b"), + "plugins/Insights/javascripts/insightsDataTable.js" => array("3882", "559f938d2e4a3e48ceb0e2827c2a33c3"), + "plugins/Insights/Model.php" => array("3366", "ca38127d95af08cb9f7e7aa1744f396c"), + "plugins/Insights/plugin.json" => array("284", "15013f4f566ee639b3d4566468b46fe8"), + "plugins/Insights/stylesheets/insightVisualization.less" => array("619", "5cb33ec3db0c5d33d3d7714ec80be638"), + "plugins/Insights/templates/cannotDisplayReport.twig" => array("103", "02d257f7747a1f7734235a56faa900ba"), + "plugins/Insights/templates/insightControls.twig" => array("3190", "a1b43257232d5e18e746f3839b8e17be"), + "plugins/Insights/templates/insightsOverviewWidget.twig" => array("314", "19c200a37664de6dda1a858e2e133df4"), + "plugins/Insights/templates/insightVisualization.twig" => array("1360", "7036f0548b2901935552b9929bf4bd6d"), + "plugins/Insights/templates/moversAndShakersOverviewWidget.twig" => array("672", "26093e2b2575a0b1b058d8b15e30f02e"), + "plugins/Insights/templates/overviewWidget.twig" => array("1270", "b420d52e333ea4ca167b897de920a029"), + "plugins/Insights/templates/table_header.twig" => array("440", "6f5ad76f20c6fa8536612ffe632a8d99"), + "plugins/Insights/templates/table_row.twig" => array("1497", "955a553498046e53131a3320fab79a6f"), + "plugins/Insights/Visualizations/Insight.php" => array("3714", "028a760f1f2c2fd291f0609881aac743"), + "plugins/Insights/Visualizations/Insight/RequestConfig.php" => array("1254", "e62e3a81b32acf3e8f834ff688d9e94f"), + "plugins/Installation/Controller.php" => array("37029", "f9b9cc3440e4b8ebf9a4ba4f5eb5ebe0"), + "plugins/Installation/FormDatabaseSetup.php" => array("11591", "fb9e70d02e5a8201d1455ea6b9cedd13"), + "plugins/Installation/FormFirstWebsiteSetup.php" => array("3369", "e3e7a92773c26cedb6d7b3f9801cf7a3"), + "plugins/Installation/FormGeneralSetup.php" => array("4342", "74f162e30c4601c9bc2d06fa0a26590f"), + "plugins/Installation/Installation.php" => array("3249", "3ad87571d8d4abdc72779452f3b2bb9e"), + "plugins/Installation/javascripts/installation.js" => array("307", "4c37587c9e1d490726acb45f723ca7b9"), + "plugins/Installation/ServerFilesGenerator.php" => array("4673", "64c8e3186155de098d2b7f47cf2f6fee"), + "plugins/Installation/stylesheets/installation.css" => array("3791", "821b94028ac1602a34fce93061d3a7ca"), + "plugins/Installation/stylesheets/systemCheckPage.less" => array("778", "cae7f2f24da86ea2b691ebfed87b8e4c"), + "plugins/Installation/templates/_allSteps.twig" => array("392", "82b5478f80593011dbcf3ecf0af61302"), + "plugins/Installation/templates/databaseCheck.twig" => array("1462", "0ec6aa932fdbbb9fc9a21c498f9d38c4"), + "plugins/Installation/templates/databaseSetup.twig" => array("453", "126314587ffced4a2251edfdf9805169"), + "plugins/Installation/templates/finished.twig" => array("828", "98ccc1a91a49856f891a514a434df74c"), + "plugins/Installation/templates/firstWebsiteSetup.twig" => array("803", "c069ebc5ad0dcd2f032de51e12718437"), + "plugins/Installation/templates/generalSetup.twig" => array("450", "bfbb3e56dfd595a6085034ece031ce5c"), + "plugins/Installation/templates/_integrityDetails.twig" => array("1157", "26f9f8e6a60611f972182b9188cb40ce"), + "plugins/Installation/templates/layout.twig" => array("2369", "171a8bd7aa74aa4e0993c68c6ce69937"), + "plugins/Installation/templates/reuseTables.twig" => array("2884", "11a9acbfc09adb2bdeb2b37c9c810fe0"), + "plugins/Installation/templates/_systemCheckLegend.twig" => array("783", "868726c336470173b390efd117db3eb8"), + "plugins/Installation/templates/systemCheckPage.twig" => array("959", "2e4074ddeff70cf07b68a34500647a42"), + "plugins/Installation/templates/_systemCheckSection.twig" => array("13651", "3ae35c6efa7d38f24dc0fc0206988e91"), + "plugins/Installation/templates/systemCheck.twig" => array("654", "4ca83235ac1786803d690125beb943d5"), + "plugins/Installation/templates/tablesCreation.twig" => array("2445", "0713ca27f416427d8dde18759284e5fa"), + "plugins/Installation/templates/trackingCode.twig" => array("678", "e13755a135a80b77df5960ca92b59174"), + "plugins/Installation/templates/welcome.twig" => array("1341", "c04fe82942a7b099e7d35c32f2752b33"), + "plugins/Installation/View.php" => array("1591", "7433b9692f499c2c3f8804cd8095c788"), + "plugins/LanguagesManager/API.php" => array("9715", "bf712c41d7a1171976490570ab3dac04"), + "plugins/LanguagesManager/Commands/CreatePull.php" => array("8019", "da7be9f183aed2f38461d989a9841ac8"), + "plugins/LanguagesManager/Commands/FetchFromOTrance.php" => array("6166", "19f36b874c56ad9767ce0a3378d9351d"), + "plugins/LanguagesManager/Commands/LanguageCodes.php" => array("1063", "283814548ee804e448d6f1e0e5b90739"), + "plugins/LanguagesManager/Commands/LanguageNames.php" => array("1071", "c7df597dd84037238cba1369489ad396"), + "plugins/LanguagesManager/Commands/PluginsWithTranslations.php" => array("1192", "27e732d20ad5343ad54c2671c7f469bc"), + "plugins/LanguagesManager/Commands/SetTranslations.php" => array("3990", "0197fbf7ed6a56ec8e20edd236487cd0"), + "plugins/LanguagesManager/Commands/Update.php" => array("5748", "e71235b082bad8b9c1f67b941d4ece99"), + "plugins/LanguagesManager/Controller.php" => array("813", "ccfc729a22e4398f1caffd2b4ee32d4b"), + "plugins/LanguagesManager/javascripts/languageSelector.js" => array("2545", "8cad615cf92b73f4c3401419957c2abf"), + "plugins/LanguagesManager/LanguagesManager.php" => array("6527", "522f063e16bec7dd87d380fca5095f18"), + "plugins/LanguagesManager/templates/getLanguagesSelector.twig" => array("1015", "61337194fa9393f34a99726587c6939e"), + "plugins/LeftMenu/plugin.json" => array("128", "af274a9b50394d8054b01dd4508cfcf1"), + "plugins/LeftMenu/stylesheets/theme.less" => array("3139", "3e1b68f368095012788e07bce8e111bd"), + "plugins/Live/API.php" => array("29944", "4aa60c7abc780bb6dfa2ecb0385e6e66"), + "plugins/Live/Controller.php" => array("10331", "d0abb0c4882fd59af19da0968dc063b5"), + "plugins/Live/images/avatar_frame.png" => array("5375", "b9a974286bc6de507e88ffd8ad40a8dd"), + "plugins/Live/images/file0.png" => array("593", "d3bd9b53340629a9ba00ff9d3c295995"), + "plugins/Live/images/file1.png" => array("637", "c1844ec7c1a8acf7573a54f78bf3565a"), + "plugins/Live/images/file2.png" => array("642", "c441ff9c29af20f36c4a2939184b1e7e"), + "plugins/Live/images/file3.png" => array("674", "d76acf5c11f14b310877bae8bb22a57c"), + "plugins/Live/images/file4.png" => array("611", "ac9684501547cf526bbe19f69ef7e1bf"), + "plugins/Live/images/file5.png" => array("668", "fe6c43a812e1f10025e3413562fcaac3"), + "plugins/Live/images/file6.png" => array("659", "6ec8688f0470d55ffcc6077241e0fbf8"), + "plugins/Live/images/file7.png" => array("681", "f9678a677fb759b43d9dc66a573bff89"), + "plugins/Live/images/file8.png" => array("642", "c164fa32f73b6f67012b0fde60d0e756"), + "plugins/Live/images/file9.png" => array("569", "0551f3605e761068b1b1169c188cb45c"), + "plugins/Live/images/paperclip.png" => array("10924", "aa65189568a2cb4c9d08af6f46926ae0"), + "plugins/Live/images/pause_disabled.gif" => array("619", "5c68e96a0ce8eac7b75fff5735dc7a3e"), + "plugins/Live/images/pause.gif" => array("669", "534b3892f5bda663651f86356df69a8d"), + "plugins/Live/images/play_disabled.gif" => array("407", "3c1374c5bfc73f12d50a97e5017a120f"), + "plugins/Live/images/play.gif" => array("666", "10ab3e64171780613434062a50378714"), + "plugins/Live/images/returningVisitor.gif" => array("995", "dbdd14d7d5528f2c74ec8508c99aacfc"), + "plugins/Live/images/unknown_avatar.jpg" => array("13984", "8151db2b0f9b45ba92f9b81c2791df94"), + "plugins/Live/images/visitor_profile_background.jpg" => array("11060", "b7eaca3d0097fe8f27d6625214092fd5"), + "plugins/Live/images/visitor_profile_close.png" => array("4734", "e12231490573aa1f37d7eed6eb6867f0"), + "plugins/Live/images/visitor_profile_gradient.png" => array("2840", "377a58f8be3b30690845aa71b0db0a5b"), + "plugins/Live/images/visitorProfileLaunch.png" => array("661", "60ac00d9db615450c8d6090521f30330"), + "plugins/Live/javascripts/live.js" => array("8926", "7208e2076c000a03a5f9ada452d8c428"), + "plugins/Live/javascripts/visitorLog.js" => array("3150", "5d8488563053c8dd0248bdbba49aa59d"), + "plugins/Live/javascripts/visitorProfile.js" => array("10635", "6d6476652f681ef87d8eda4eecffb2e1"), + "plugins/Live/Live.php" => array("2559", "3b4cbcdd287bc9bdc96c5927a15a2a4f"), + "plugins/Live/stylesheets/live.less" => array("3761", "78a7b21ca37b37518c66eda4d07c18d7"), + "plugins/Live/stylesheets/visitor_profile.less" => array("9904", "86b092ab364a0e298e8edbfa0c6418c1"), + "plugins/Live/templates/_actionsList.twig" => array("7713", "d8c003ff4ef3b36a2f9ce6426faa09ed"), + "plugins/Live/templates/ajaxTotalVisitors.twig" => array("41", "5c57d90d62eab1c2c11969f14ff6faaa"), + "plugins/Live/templates/_dataTableViz_visitorLog.twig" => array("11165", "f10f764079fc95b18bee9964e505bbd5"), + "plugins/Live/templates/getLastVisitsStart.twig" => array("9637", "66723cfc37872398f74bcebc0170fa3a"), + "plugins/Live/templates/getSimpleLastVisitCount.twig" => array("1501", "ff589f5ab71d3364bbce2b50ac635304"), + "plugins/Live/templates/getSingleVisitSummary.twig" => array("3644", "a6d27ef3106ba799ee77ab6c7e52901b"), + "plugins/Live/templates/getVisitList.twig" => array("1401", "21f5605f7c91c89b27ec184d71e89a34"), + "plugins/Live/templates/getVisitorProfilePopup.twig" => array("10894", "ae422171a4dc85bae4c491b8530a04c7"), + "plugins/Live/templates/index.twig" => array("1728", "6de08f5ddb5d6802ab97258cafbf7d13"), + "plugins/Live/templates/indexVisitorLog.twig" => array("170", "8e65a17376e8d870da1fdb791bdb019b"), + "plugins/Live/templates/_totalVisitors.twig" => array("1193", "cb5f29dc2c34e531f7e8469feb201a32"), + "plugins/Live/VisitorLog.php" => array("4032", "95e464cd76d6842f656e0b4f7bb5e9d8"), + "plugins/Live/Visitor.php" => array("35950", "df9ec109f4a08c6bc5b7c33a1b544f73"), + "plugins/Login/Auth.php" => array("3935", "449818805ed2d144bfcd22d4e881318f"), + "plugins/Login/Controller.php" => array("14593", "3ad0b3aefe6b7aec78622079aa965691"), + "plugins/Login/FormLogin.php" => array("1283", "97995c1ab8eb36c1c4a899f5f87a4e23"), + "plugins/Login/FormResetPassword.php" => array("1247", "13d7b72379ff19943051061fa0e17f4b"), + "plugins/Login/javascripts/login.js" => array("3457", "ddf60c9aab3769010b4e022369337dfb"), + "plugins/Login/Login.php" => array("4566", "48ff16be33aab8f87ad9fa99c615ab71"), + "plugins/Login/stylesheets/login.css" => array("3564", "df89b474360b0e1c59f306df7c76255c"), + "plugins/Login/templates/login.twig" => array("7493", "7c6eebc0b8825c41214dba7647238660"), + "plugins/Login/templates/resetPassword.twig" => array("389", "751937bee878c24f3b72890273548069"), + "plugins/MobileMessaging/APIException.php" => array("261", "9f8b71ac2a11ea82638ff785a1368ecc"), + "plugins/MobileMessaging/API.php" => array("12198", "36de01e312125b59016d8f2f2ad3aa58"), + "plugins/MobileMessaging/Controller.php" => array("2675", "9b26ad0184726bc42e630ebe95b2a05d"), + "plugins/MobileMessaging/CountryCallingCodes.php" => array("7207", "0f3e6aa1f434d4c93ed4dc899ec751bf"), + "plugins/MobileMessaging/GSMCharset.php" => array("2978", "1a7692e6ddd18ff57f146e9c064db4c9"), + "plugins/MobileMessaging/images/Clockwork.png" => array("3585", "c0ae966302ddca87a6cc55abf0617efe"), + "plugins/MobileMessaging/images/phone.png" => array("568", "c294efbf17ecc6c3384e1cbff1a69521"), + "plugins/MobileMessaging/javascripts/MobileMessagingSettings.js" => array("10525", "eedfe8a5ae54d77c74590698e108b258"), + "plugins/MobileMessaging/MobileMessaging.php" => array("8963", "08ac0b55c51fb2e617173ce07c0ed673"), + "plugins/MobileMessaging/ReportRenderer/ReportRendererException.php" => array("1433", "864c4f04d4bc18daa94d59927fc87ca8"), + "plugins/MobileMessaging/ReportRenderer/Sms.php" => array("4337", "8c5cd9fb9e9e88ac7490db381ac414c3"), + "plugins/MobileMessaging/SMSProvider/Clockwork.php" => array("2850", "5de85a92828a6fb7ab107fd1821efb22"), + "plugins/MobileMessaging/SMSProvider.php" => array("6529", "4265464da0ce3dd71daccd3365f8b5d3"), + "plugins/MobileMessaging/SMSProvider/StubbedProvider.php" => array("591", "ec1c6ab90be8c4ee259ee51012a116af"), + "plugins/MobileMessaging/stylesheets/MobileMessagingSettings.less" => array("248", "b8f6c5c9dc4d2376def8d30a436f2f2d"), + "plugins/MobileMessaging/templates/index.twig" => array("8828", "ff291f3400b4d3e6548d7d72254fcaeb"), + "plugins/MobileMessaging/templates/reportParametersScheduledReports.twig" => array("2220", "facbb81972351c7bf9552bb6d23ad6a6"), + "plugins/MobileMessaging/templates/SMSReport.twig" => array("2081", "8757ecc10c18b0db36031f0ef659756a"), + "plugins/Morpheus/images/add.png" => array("1261", "56daa2c3de8f7091e4a796940a395399"), + "plugins/Morpheus/images/annotations.png" => array("1069", "d7778841e7e56de10f4d594e56f315c8"), + "plugins/Morpheus/images/annotations_starred.png" => array("248", "c55f6bd966fee694d42996d73de74e0e"), + "plugins/Morpheus/images/bullet.png" => array("963", "efd0e064fa9fc92187d976b08d707855"), + "plugins/Morpheus/images/calendar.gif" => array("1260", "92079affe9d57713492ba8fc8991ed15"), + "plugins/Morpheus/images/chart_bar.png" => array("968", "ff2414d707510b4188795cd7ba73b092"), + "plugins/Morpheus/images/chart_line_edit.png" => array("421", "d7f696960ffe8f17fedefcc42415adf4"), + "plugins/Morpheus/images/chart_pie.png" => array("1223", "cf703b3878e59ffde83f5cbe6d7571b3"), + "plugins/Morpheus/images/cities.png" => array("1038", "7eaa39f8e0021507b8685e3e0b2c87e9"), + "plugins/Morpheus/images/close.png" => array("1122", "9276313cd6bbff312cf65222b970c1d3"), + "plugins/Morpheus/images/configure-highlight.png" => array("356", "b7e641dd5732e92000dfedcacf0fb42d"), + "plugins/Morpheus/images/configure.png" => array("1210", "2241fa7107cc901a362a54b372d9e5d4"), + "plugins/Morpheus/images/datepicker_arr_l.png" => array("963", "e770866fb6c0b6dc9318bb635cb8b25c"), + "plugins/Morpheus/images/datepicker_arr_r.png" => array("968", "324f7f5e7b2d60ac543d574fec0638fe"), + "plugins/Morpheus/images/export.png" => array("1182", "4ab0af6ccaf4cbe06da8e2f19d611893"), + "plugins/Morpheus/images/forms-sprite.png" => array("1758", "c87c3f0ae1eeb5b9f928d76a019afbea"), + "plugins/Morpheus/images/goal.png" => array("1042", "13fcf0fc4f2f5c7cbeb3b954be2302cb"), + "plugins/Morpheus/images/help.png" => array("1297", "2030eb18bab29a957d84f6ab578f04a1"), + "plugins/Morpheus/images/ico_delete.png" => array("1306", "fd41409c74d29bab6be2a1f1def2f1e3"), + "plugins/Morpheus/images/ico_edit.png" => array("1202", "67cfadf5265b4d2c506f3db2e1ca367a"), + "plugins/Morpheus/images/icon-calendar.gif" => array("1260", "7acac555dc283dfb65de9f7f524e662b"), + "plugins/Morpheus/images/image.png" => array("1139", "70283dccd506faa0a0a4580d822a4609"), + "plugins/Morpheus/images/info.png" => array("1278", "6df252a916c3df5b269732e76ecfa50e"), + "plugins/Morpheus/images/link.gif" => array("1147", "6098367a5cd6a3dd93c56aced55925bf"), + "plugins/Morpheus/images/loading-blue.gif" => array("723", "23f0762fea3d694b579522524bd5628f"), + "plugins/Morpheus/images/logo-header.png" => array("2215", "9695149e76d3080dcffd78eb45cd3735"), + "plugins/Morpheus/images/logo.png" => array("3902", "d11828697d06b1117c49413d77a6e9ff"), + "plugins/Morpheus/images/logo.svg" => array("2290", "d19186837572bb5994e4d6e2b463348b"), + "plugins/Morpheus/images/maximise.png" => array("1053", "b98f8497b9b242cac626128ffd4d5ef7"), + "plugins/Morpheus/images/minimise.png" => array("968", "2ad0c819d2416043d07be478ac9a9156"), + "plugins/Morpheus/images/pause_disabled.gif" => array("1141", "5dcb5e11d359f2a4e180fd7b5a3a5dfd"), + "plugins/Morpheus/images/pause.gif" => array("1142", "defa62045c5e21609cc6b36de1d299ef"), + "plugins/Morpheus/images/play_disabled.gif" => array("1155", "dd7c8730e4be8128138ee68aebd0a5b2"), + "plugins/Morpheus/images/play.gif" => array("1184", "8db25ba7dc1701ed2c93a483fa25ef88"), + "plugins/Morpheus/images/refresh.png" => array("1312", "1672c3502fac047c53f2e74797fe124b"), + "plugins/Morpheus/images/regions.png" => array("1265", "1fdee0b6664804d6525dfbe12931b922"), + "plugins/Morpheus/images/search_ico.png" => array("1227", "e2da6bc860b189996bdafef30bb2e3cc"), + "plugins/Morpheus/images/segment-users.png" => array("1270", "95b3e6b1ed4c79c826f59da6100a27e1"), + "plugins/Morpheus/images/sortasc_dark.png" => array("92", "96b06c701e6ef3645c39206045821495"), + "plugins/Morpheus/images/sortasc.png" => array("964", "736682039b3ce45e6565e3f871f71057"), + "plugins/Morpheus/images/sortdesc_dark.png" => array("98", "dbc223ec613d5bc863e897cf3b525335"), + "plugins/Morpheus/images/sortdesc.png" => array("980", "968ded7fbfb0c38d1883788c07f090a4"), + "plugins/Morpheus/images/sort_subtable_desc.png" => array("980", "bc2a98535cbcd85c256c534627f4fffc"), + "plugins/Morpheus/images/table_more.png" => array("1157", "720c588117a9dc781d40a42e90c40466"), + "plugins/Morpheus/images/table.png" => array("1056", "375eae11704a2d2e749cdefcb26f2a39"), + "plugins/Morpheus/images/tagcloud.png" => array("1098", "1c9b9a43cef5e807087cf3f69544c9ef"), + "plugins/Morpheus/images/zoom-out-disabled.png" => array("1297", "81d56e2c732e3ed4bc1a1c7d94632e7b"), + "plugins/Morpheus/images/zoom-out.png" => array("1300", "b598e49632becfb91970083f8a9ff62e"), + "plugins/Morpheus/javascripts/jquery.icheck.min.js" => array("4005", "a31ce1654416358e8d933cbf79b5ffbd"), + "plugins/Morpheus/javascripts/morpheus.js" => array("617", "ad7fbd24380028485a5e0758e4b66e81"), + "plugins/Morpheus/plugin.json" => array("468", "e7be1bd0537250d805421d36c880232a"), + "plugins/Morpheus/stylesheets/admin.less" => array("2273", "103712f53f7978b39f5898f4581815b5"), + "plugins/Morpheus/stylesheets/charts.less" => array("3046", "c72c436df07f2c5282afa95e13722d0e"), + "plugins/Morpheus/stylesheets/colors.less" => array("1454", "6fa45b1e4c87373f790dec9d4822645d"), + "plugins/Morpheus/stylesheets/components.less" => array("8430", "66b71131d92624e3a3d5cd654e29bb3b"), + "plugins/Morpheus/stylesheets/forms.less" => array("6410", "df34cc9255b55f23c7eb82d90abe06f5"), + "plugins/Morpheus/stylesheets/map.less" => array("1824", "fd90d3d1127a9ad8b8c71b611f202904"), + "plugins/Morpheus/stylesheets/mixins.less" => array("2884", "b62ca2d8a1c8c1867a60520243a417c2"), + "plugins/Morpheus/stylesheets/popups.less" => array("1122", "24fd3106538b5a84d77c516bb647651c"), + "plugins/Morpheus/stylesheets/theme.less" => array("19045", "b9c3f6250cd4d85602a319464731c47d"), + "plugins/Morpheus/stylesheets/tooltip.less" => array("725", "8801596d6ea42a576a486f82ffe41e29"), + "plugins/Morpheus/stylesheets/typography.less" => array("2196", "582c812de3aa430bb86b54b7d82d548f"), + "plugins/MultiSites/angularjs/dashboard/dashboard-controller.js" => array("955", "7cdb911b5a86c789d1545d7c16d90084"), + "plugins/MultiSites/angularjs/dashboard/dashboard-directive.js" => array("1097", "231dadf20d40d8e78622e9b97621c026"), + "plugins/MultiSites/angularjs/dashboard/dashboard-filter.js" => array("1962", "aa3286a874ef6b3aa0f982a815feb533"), + "plugins/MultiSites/angularjs/dashboard/dashboard.html" => array("6925", "6b97a82b739ea3c1309caf7ce274982e"), + "plugins/MultiSites/angularjs/dashboard/dashboard.less" => array("2810", "f9d750db11d5a06dc3cc60f9fcd0a5e8"), + "plugins/MultiSites/angularjs/dashboard/dashboard-model.js" => array("8474", "8ae117224a8c797c54f7fe8383503368"), + "plugins/MultiSites/angularjs/site/site-directive.js" => array("1984", "4944d752571c5e40cf6717bc74ef83cf"), + "plugins/MultiSites/angularjs/site/site.html" => array("2225", "e2ce56b83fd0599b73ffb2c64c8f6d55"), + "plugins/MultiSites/API.php" => array("20412", "11973574d15341554d0bec191dfea6e4"), + "plugins/MultiSites/Controller.php" => array("2577", "b68eb4db8925d0a5f3a6a445c76c7c4d"), + "plugins/MultiSites/images/arrow_asc.gif" => array("120", "ff5921e3047f33fa2ddc2b85c960133c"), + "plugins/MultiSites/images/arrow_desc.gif" => array("130", "38ab9e8c27b8ce2d1a6b6f9753206f4b"), + "plugins/MultiSites/images/arrow_down_green.png" => array("221", "9b81c5a2fb3f3b979e5d941a7b51d0e5"), + "plugins/MultiSites/images/arrow_down.png" => array("234", "e28552b85bf45e547a6fa5a387c8a062"), + "plugins/MultiSites/images/arrow_up.png" => array("222", "d5642aff98a988d93f317894b665eeb2"), + "plugins/MultiSites/images/arrow_up_red.png" => array("248", "906746c2060a32f4aa0ee204b60fe027"), + "plugins/MultiSites/images/door_in.png" => array("693", "e20ba15525185c16acfbf043e7b4a9cd"), + "plugins/MultiSites/images/link.gif" => array("75", "b8de0b2b517e1999b32353209be4e976"), + "plugins/MultiSites/images/loading-blue.gif" => array("1849", "483d45d0beb0b5547988926b795f8190"), + "plugins/MultiSites/images/stop.png" => array("307", "031e5dba74ad3b375096806ff1662402"), + "plugins/MultiSites/MultiSites.php" => array("4567", "09b8e3130ea25bbb6eaa46058c854f2d"), + "plugins/MultiSites/templates/getSitesInfo.twig" => array("791", "93ca3b820a17e74883bd777766da5660"), + "plugins/Overlay/API.php" => array("4545", "5bdb88b44970b0286c3c6476115f7422"), + "plugins/Overlay/client/client.css" => array("2403", "b89a7f8790c46666d3f1f4bd324b0842"), + "plugins/Overlay/client/client.js" => array("7986", "c91fc2d4c5af7f909a3189cb4e6bd10b"), + "plugins/Overlay/client/close.png" => array("655", "42492684e24356a4081134894eabeb9e"), + "plugins/Overlay/client/followingpages.js" => array("20057", "017a346250b30fe408b154388439dac4"), + "plugins/Overlay/client/linktags.eps" => array("460096", "3b89babacdf68b23c14e37619af9c6a7"), + "plugins/Overlay/client/linktags_lessshadow.png" => array("6353", "80e04f62a5efa4ec128efb40b0670d37"), + "plugins/Overlay/client/linktags_noshadow.png" => array("5355", "1471510499dfc32c978ace341095f0aa"), + "plugins/Overlay/client/linktags.png" => array("6489", "1e7ee586288b9aa1d1ecb3b6cc402cae"), + "plugins/Overlay/client/linktags.psd" => array("38518", "0ad1ccd72db63437365e1d2225213afe"), + "plugins/Overlay/client/loading.gif" => array("723", "6ce8f9a2c650cf90261acfc98b2edf90"), + "plugins/Overlay/client/translations.js" => array("756", "b25f65e35f2091e51f3846f58f0b0843"), + "plugins/Overlay/client/urlnormalizer.js" => array("5688", "4c43dc9ca32de3fcf5f6c9fb3a93082f"), + "plugins/Overlay/Controller.php" => array("8120", "0145004a547bfc7983686f765040b2c9"), + "plugins/Overlay/images/info.png" => array("778", "3750c701d2ec35a45d289b9b9c1a0667"), + "plugins/Overlay/images/overlay_icon_hover.png" => array("360", "7021a0169999242feade5c21859185b1"), + "plugins/Overlay/images/overlay_icon.png" => array("359", "dfabdc7dd24cad1b55101fa5dc77a2ad"), + "plugins/Overlay/javascripts/Overlay_Helper.js" => array("952", "76159386fdc4544d42d58af61051f655"), + "plugins/Overlay/javascripts/Piwik_Overlay.js" => array("8715", "0757e757ec98579e800c54557633f78b"), + "plugins/Overlay/javascripts/rowaction.js" => array("1904", "47d98c45ed01ec168770ea77fa4d200b"), + "plugins/Overlay/Overlay.php" => array("1239", "5f268e56246335ca2ab65fb295d537a4"), + "plugins/Overlay/stylesheets/overlay.css" => array("2736", "c45eed3e933565c3737d3500d642fa53"), + "plugins/Overlay/stylesheets/showErrorWrongDomain.css" => array("225", "c816e5c78ca1e4b4cffd6a94defcd42d"), + "plugins/Overlay/templates/index_noframe.twig" => array("651", "c52979a6cf4b4271976930fee8d75c9c"), + "plugins/Overlay/templates/index.twig" => array("2884", "39ccaab561b54a8b08d2928a111df52d"), + "plugins/Overlay/templates/notifyParentIframe.twig" => array("408", "511ff3cd9499c53ca4c83bf12ca4a2d5"), + "plugins/Overlay/templates/renderSidebar.twig" => array("872", "2692f857cb9ed8c4e57e42e32f9e9d79"), + "plugins/Overlay/templates/showErrorWrongDomain.twig" => array("434", "5ea0fec6f13ac36e82316bd98faffa17"), + "plugins/PrivacyManager/Config.php" => array("3733", "cb83704c0bef57bfd13926749ff933c0"), + "plugins/PrivacyManager/Controller.php" => array("12791", "f42e2f56e22820fc57379e2ca5445728"), + "plugins/PrivacyManager/DoNotTrackHeaderChecker.php" => array("2463", "f8225b87bffb42e0f2e37677b3887991"), + "plugins/PrivacyManager/IPAnonymizer.php" => array("2588", "b1d6687046c4138308f8906e29f5aa27"), + "plugins/PrivacyManager/javascripts/privacySettings.js" => array("7303", "6bc9a333ab44580364f515dabaff87d5"), + "plugins/PrivacyManager/LogDataPurger.php" => array("11350", "1615d14d06032ab180b20ec90d07475e"), + "plugins/PrivacyManager/PrivacyManager.php" => array("17475", "20d15ebced957a28e59abfda6bb85c9b"), + "plugins/PrivacyManager/ReportsPurger.php" => array("14516", "f5927d9196cb3efb7804c537b243a0f4"), + "plugins/PrivacyManager/templates/getDatabaseSize.twig" => array("394", "b20b279686f6084c9b6d1e054711991c"), + "plugins/PrivacyManager/templates/privacySettings.twig" => array("20783", "b213cd581d7f612668eded5b4daf9875"), + "plugins/Provider/API.php" => array("1282", "02ed45d84f2acf2d31d1ec3dfa657715"), + "plugins/Provider/Archiver.php" => array("885", "12721c47c4a162253ef42338f86d6407"), + "plugins/Provider/Controller.php" => array("442", "074ac889d8bbd8521336f33cafc5980b"), + "plugins/Provider/functions.php" => array("1874", "fcce854c96b6a99d906cb348833c6f79"), + "plugins/Provider/Provider.php" => array("7890", "bcf48128ceabc04b1295ba16cffbaf20"), + "plugins/Proxy/Controller.php" => array("3809", "92af78c5373bb29f60fa3dbbdc6e96de"), + "plugins/Proxy/Proxy.php" => array("748", "7fa5c2c560d621ea057cb3258f6ed0e4"), + "plugins/Referrers/API.php" => array("21979", "18add5717656db81281bf2aab318f4bd"), + "plugins/Referrers/Archiver.php" => array("11276", "312f7099cd0f95a633a20a20d9e0e7a3"), + "plugins/Referrers/Controller.php" => array("21890", "3d8ee0bbab081d220d5501ba2535e9a6"), + "plugins/Referrers/functions.php" => array("6981", "5bb99c15c9912d20d94aa399831c3cc3"), + "plugins/Referrers/images/searchEngines/1.cz.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/abcsok.no.png" => array("734", "46c308c614869c6a7b6dea023df9fb7a"), + "plugins/Referrers/images/searchEngines/alexa.com.png" => array("878", "dea186e4049c87f56d90aa473cc58e65"), + "plugins/Referrers/images/searchEngines/all.by.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/apollo7.de.png" => array("477", "abcd4567b53f0fe2bdcca311c2a187bd"), + "plugins/Referrers/images/searchEngines/apollo.lv.png" => array("664", "bfa565028e74747d1e23c08c552cf66d"), + "plugins/Referrers/images/searchEngines/arama.com.png" => array("736", "cba21479a2a41f47c032f9b5b64f78f8"), + "plugins/Referrers/images/searchEngines/ariadna.elmundo.es.png" => array("272", "3914156463a1c2b30ab7cdb016daa05d"), + "plugins/Referrers/images/searchEngines/arianna.libero.it.png" => array("763", "c1a56d1d62a9688628a7535c7a1fc43e"), + "plugins/Referrers/images/searchEngines/ask.com.png" => array("587", "abe650b5fec9261b153ca4ec220f7957"), + "plugins/Referrers/images/searchEngines/bg.setooz.com.png" => array("493", "edaf9d5fc1de89a88a7962e0d9b2bb16"), + "plugins/Referrers/images/searchEngines/bing.com.png" => array("590", "b0e1af67d03e24178a6d36188ba5fca5"), + "plugins/Referrers/images/searchEngines/blekko.com.png" => array("174", "9d2a93c74a1a5e12be0b3e86738d9ca5"), + "plugins/Referrers/images/searchEngines/blogsearch.google.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/blogs.icerocket.com.png" => array("461", "79157675a65ad90c9a142b49f23e39aa"), + "plugins/Referrers/images/searchEngines/buscador.terra.es.png" => array("869", "63d72ea6bbcc6d19955873b50b5426d2"), + "plugins/Referrers/images/searchEngines/busca.orange.es.png" => array("430", "da209f011cf118792af6682db448e0cc"), + "plugins/Referrers/images/searchEngines/busca.uol.com.br.png" => array("701", "b7108a19b9c249b774f6a5777580cf18"), + "plugins/Referrers/images/searchEngines/cgi.search.biglobe.ne.jp.png" => array("716", "806a4ed72d78c7b84472567d28589894"), + "plugins/Referrers/images/searchEngines/claro-search.com.png" => array("564", "302c71d5eb2d7216713dc6bec51d51d4"), + "plugins/Referrers/images/searchEngines/daemon-search.com.png" => array("718", "c14a1c8b35e78a1d6661f7e25e370f9f"), + "plugins/Referrers/images/searchEngines/digg.com.png" => array("465", "1755793491662ad23c71eb1623706dc2"), + "plugins/Referrers/images/searchEngines/dir.gigablast.com.png" => array("846", "e81d15ca1d53cf6a929fefa476ed144d"), + "plugins/Referrers/images/searchEngines/dmoz.org.png" => array("732", "ede1566f596031434b7dc6f7444bd371"), + "plugins/Referrers/images/searchEngines/duckduckgo.com.png" => array("900", "52012c79bfab9cc5001892030f0a9fc3"), + "plugins/Referrers/images/searchEngines/ecosia.org.png" => array("667", "2a93403cf58fb4ad1f7393abca387180"), + "plugins/Referrers/images/searchEngines/encrypted.google.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/eo.st.png" => array("445", "aa76f4c0c414201782d015ee304643d0"), + "plugins/Referrers/images/searchEngines/forestle.org.png" => array("814", "3849505c44dd9af3564964b2c7f72c24"), + "plugins/Referrers/images/searchEngines/fr.dir.com.png" => array("735", "a08719a48edcf34246e545276a9537b6"), + "plugins/Referrers/images/searchEngines/friendfeed.com.png" => array("524", "91638ac895b524e32425e742cfd98dd0"), + "plugins/Referrers/images/searchEngines/fr.wedoo.com.png" => array("490", "b6a644b5c63af673421a7ee33f63e154"), + "plugins/Referrers/images/searchEngines/gais.cs.ccu.edu.tw.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/geona.net.png" => array("3072", "4396514318dc0485426e50686a887997"), + "plugins/Referrers/images/searchEngines/go.mail.ru.png" => array("409", "a4e16117bb905b547a4607836503f858"), + "plugins/Referrers/images/searchEngines/google.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/googlesyndicatedsearch.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/holmes.ge.png" => array("774", "89027cbd84500007fb09c4ed1a2c8273"), + "plugins/Referrers/images/searchEngines/images.google.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/images.search.yahoo.com.png" => array("538", "9c23aa1c6d5ad4fdea3738632364cbb2"), + "plugins/Referrers/images/searchEngines/images.yandex.ru.png" => array("497", "0ff0e2ee9e0b08fbfba2eaa9f4612848"), + "plugins/Referrers/images/searchEngines/infospace.com.png" => array("940", "c269cb5b0658fe1679db465613a7c406"), + "plugins/Referrers/images/searchEngines/iwon.ask.com.png" => array("587", "abe650b5fec9261b153ca4ec220f7957"), + "plugins/Referrers/images/searchEngines/ixquick.com.png" => array("613", "203d372a14add8806719d1fe499210e4"), + "plugins/Referrers/images/searchEngines/junglekey.com.png" => array("580", "498a589e8b6b01a45be9b5b090897c03"), + "plugins/Referrers/images/searchEngines/jyxo.1188.cz.png" => array("401", "f1845d795944ceb17f0d2490e771331d"), + "plugins/Referrers/images/searchEngines/ko.search.need2find.com.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/lo.st.png" => array("828", "cebd310f1c9e95a2afab3699e8877e4a"), + "plugins/Referrers/images/searchEngines/maps.google.com.png" => array("1132", "b95321cca7e7e4745c9478060113984e"), + "plugins/Referrers/images/searchEngines/metager2.de.png" => array("556", "147f78f79ab041ce6131f310a94ad772"), + "plugins/Referrers/images/searchEngines/meta.rrzn.uni-hannover.de.png" => array("281", "c851ac18187f08ac7f5860f31f5a8231"), + "plugins/Referrers/images/searchEngines/meta.ua.png" => array("873", "190caab492691e94df900c74f9445f85"), + "plugins/Referrers/images/searchEngines/news.google.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/nigma.ru.png" => array("673", "41743688398e2ee8a5e30bb32f74ad34"), + "plugins/Referrers/images/searchEngines/nova.rambler.ru.png" => array("765", "f74b4c361dfae2e7a1b4116ad7b28925"), + "plugins/Referrers/images/searchEngines/online.no.png" => array("591", "58e1432fce37400564a576212cadf667"), + "plugins/Referrers/images/searchEngines/otsing.delfi.ee.png" => array("543", "46e62fb3ebddb902b5b426dda74bdc07"), + "plugins/Referrers/images/searchEngines/pesquisa.clix.pt.png" => array("838", "97772b088367714116f98eb907facf1e"), + "plugins/Referrers/images/searchEngines/pesquisa.sapo.pt.png" => array("718", "34578b89393fc073f9dc6b8020834624"), + "plugins/Referrers/images/searchEngines/plusnetwork.com.png" => array("719", "e8b97277b41273e1d5c356b4d43b722f"), + "plugins/Referrers/images/searchEngines/poisk.ru.png" => array("275", "9ceb066fcbdf350f7c62afda4de91779"), + "plugins/Referrers/images/searchEngines/p.zhongsou.com.png" => array("869", "c26a7cf8255c86ff68507a9e4dff651a"), + "plugins/Referrers/images/searchEngines/recherche.francite.com.png" => array("706", "ea7d6f697a21a5ebad227bca27228ad5"), + "plugins/Referrers/images/searchEngines/rechercher.aliceadsl.fr.png" => array("755", "c2adde9c48b4f209ec0b5517d8ebb986"), + "plugins/Referrers/images/searchEngines/req.hit-parade.com.png" => array("275", "fcba5ae6bd7ad81283454b6307e65f10"), + "plugins/Referrers/images/searchEngines/ricerca.virgilio.it.png" => array("333", "f2e51cb3e90152cd7acd6db08912d404"), + "plugins/Referrers/images/searchEngines/rpmfind.net.png" => array("867", "9a85c9538342f8612b52c7932a7e1484"), + "plugins/Referrers/images/searchEngines/s1.metacrawler.de.png" => array("368", "7be2b165fae10c54b6b28b2b69be07d6"), + "plugins/Referrers/images/searchEngines/scholar.google.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/scour.com.png" => array("706", "21e8a06160647a9d9406c745c65327a6"), + "plugins/Referrers/images/searchEngines/searchalot.com.png" => array("369", "6dad79f26644dfc7640af20743994c82"), + "plugins/Referrers/images/searchEngines/search.aol.com.png" => array("713", "52bf4d61fe18c1e8564c75709cfee36a"), + "plugins/Referrers/images/searchEngines/searchatlas.centrum.cz.png" => array("716", "fac3ddbc5e34c229aac77d4297d09e8b"), + "plugins/Referrers/images/searchEngines/search.babylon.com.png" => array("930", "5db9449cdc8ab7964896f62c26bd5799"), + "plugins/Referrers/images/searchEngines/search.bluewin.ch.png" => array("349", "4ce3ac28b1d3446d71c8db2335f79506"), + "plugins/Referrers/images/searchEngines/search.centrum.cz.png" => array("599", "4a90a6fd8cd5a587ee3f887fb2e91a87"), + "plugins/Referrers/images/searchEngines/search.comcast.net.png" => array("839", "642a06a86184d81af3a95a149f3b3dbd"), + "plugins/Referrers/images/searchEngines/search.conduit.com.png" => array("617", "359aa81dbcc48d70537a37d661bf8dbd"), + "plugins/Referrers/images/searchEngines/search.daum.net.png" => array("800", "fc91668b8deb23b4b7290dceb75f2428"), + "plugins/Referrers/images/searchEngines/search.earthlink.net.png" => array("564", "889aec3d7357d1c7cbb6d52225644907"), + "plugins/Referrers/images/searchEngines/search.excite.it.png" => array("323", "5c915ccd6526168e81de15c7fefb050a"), + "plugins/Referrers/images/searchEngines/search.freecause.com.png" => array("951", "834f2c111eed35eaf997913c20ab56ff"), + "plugins/Referrers/images/searchEngines/search.free.fr.png" => array("881", "02f2297d212c26bd136a99fbc5488170"), + "plugins/Referrers/images/searchEngines/search.goo.ne.jp.png" => array("730", "8d650ba94af30886600ca236f9fbb443"), + "plugins/Referrers/images/searchEngines/search.imesh.com.png" => array("644", "2dd75a109986a2337893e3b0d0a621a1"), + "plugins/Referrers/images/searchEngines/search.ke.voila.fr.png" => array("331", "5a2240c18b117cf38cc00914d01ee34e"), + "plugins/Referrers/images/searchEngines/search.lycos.com.png" => array("367", "ad1b661599c12fdab08d92a5d35c3fa1"), + "plugins/Referrers/images/searchEngines/search.nate.com.png" => array("670", "a9c88ec2867945d90b9f68f927d1e2db"), + "plugins/Referrers/images/searchEngines/search.naver.com.png" => array("317", "5de3a5f618088b08fa92181ae4063b70"), + "plugins/Referrers/images/searchEngines/search.nifty.com.png" => array("565", "f38b9e99892cf2fdf146a8f6e2731858"), + "plugins/Referrers/images/searchEngines/search.peoplepc.com.png" => array("832", "67f88efd19919d2292405319f6d5092d"), + "plugins/Referrers/images/searchEngines/search.qip.ru.png" => array("556", "8bc854ef9a247905ed29938e69d2cea0"), + "plugins/Referrers/images/searchEngines/search.rr.com.png" => array("794", "3e9a94cd34825c4246eb666338b22e6b"), + "plugins/Referrers/images/searchEngines/searchservice.myspace.com.png" => array("610", "96ecdce6052dd3a535f95ed92c056bc2"), + "plugins/Referrers/images/searchEngines/search.seznam.cz.png" => array("553", "4b4f7b4eec38531fe662f678682041f8"), + "plugins/Referrers/images/searchEngines/search.smartaddressbar.com.png" => array("624", "13ce4ccf571cd2f09553318b719948b2"), + "plugins/Referrers/images/searchEngines/search.snap.do.png" => array("570", "efb27bff7c57883d2b7c9f1d96a4a65f"), + "plugins/Referrers/images/searchEngines/search.softonic.com.png" => array("716", "155c0a14700a0f57a40da2f4b43228a1"), + "plugins/Referrers/images/searchEngines/search.tiscali.it.png" => array("548", "f9fea4cbf8bc6f5748c7b1e8e5317b0e"), + "plugins/Referrers/images/searchEngines/search.winamp.com.png" => array("753", "3210bbc6d253913396ca23a76ef7ae51"), + "plugins/Referrers/images/searchEngines/search.www.ee.png" => array("625", "d0b310f299b5fe2f13f925b8a7fd8e66"), + "plugins/Referrers/images/searchEngines/search.yahoo.com.png" => array("522", "76ac9b6fda30c632b0c4258d72072680"), + "plugins/Referrers/images/searchEngines/search.yam.com.png" => array("186", "5585e8456efc56e4ccd870fce71c349a"), + "plugins/Referrers/images/searchEngines/search.yippy.com.png" => array("654", "348c872e7f49261c025074587210c494"), + "plugins/Referrers/images/searchEngines/sm.aport.ru.png" => array("469", "956d1ffb844dcd9c22fc0f82dd02f6d8"), + "plugins/Referrers/images/searchEngines/smart.delfi.lv.png" => array("543", "46e62fb3ebddb902b5b426dda74bdc07"), + "plugins/Referrers/images/searchEngines/so.360.cn.png" => array("480", "083a258a36d5571cbb3cdbca50231f89"), + "plugins/Referrers/images/searchEngines/startgoogle.startpagina.nl.png" => array("801", "f60a50c67088bd1387631ff895df1b9a"), + "plugins/Referrers/images/searchEngines/start.iplay.com.png" => array("293", "96909e49478da43937ced8de17bb7b43"), + "plugins/Referrers/images/searchEngines/suche.freenet.de.png" => array("719", "4fb7eeb8dbd2eefd6bb5572d87ca4be9"), + "plugins/Referrers/images/searchEngines/suche.info.png" => array("888", "9273755eccf191f5d6b332b43f316219"), + "plugins/Referrers/images/searchEngines/suche.t-online.de.png" => array("470", "c0e4b19d5d61b6d97befdb347ae53260"), + "plugins/Referrers/images/searchEngines/suche.web.de.png" => array("350", "bbfdec13aa9bf83b184327ad28ec98bd"), + "plugins/Referrers/images/searchEngines/surfcanyon.com.png" => array("686", "8c68cb01ee94ae8b2dce7f4f7b22d962"), + "plugins/Referrers/images/searchEngines/szukaj.onet.pl.png" => array("796", "23f87874093150c9cc5a3d72c7713aae"), + "plugins/Referrers/images/searchEngines/szukaj.wp.pl.png" => array("672", "a14bb3c9901c8158d7051ed50b40a6df"), + "plugins/Referrers/images/searchEngines/technorati.com.png" => array("567", "23fbfff1215e8bf0270b529849200214"), + "plugins/Referrers/images/searchEngines/translate.google.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/video.google.com.png" => array("545", "22c5e4db03b94c9ba501f522d8011a53"), + "plugins/Referrers/images/searchEngines/web.canoe.ca.png" => array("721", "7645213daa2213dc1b1361a42c11700c"), + "plugins/Referrers/images/searchEngines/websearch.cs.com.png" => array("806", "daf75065560d60f37511023d8b2a4212"), + "plugins/Referrers/images/searchEngines/websearch.rakuten.co.jp.png" => array("513", "e32baedde4b2a9b508336e2560a7b8ec"), + "plugins/Referrers/images/searchEngines/web.volny.cz.png" => array("619", "99d3a4f4a17741a150a388a3341c23e9"), + "plugins/Referrers/images/searchEngines/www.123people.com.png" => array("724", "a8ea0943911abdfd7978bae93da9fe16"), + "plugins/Referrers/images/searchEngines/www.1881.no.png" => array("528", "f5b12a0935ac022931e4285c9e6050bc"), + "plugins/Referrers/images/searchEngines/www1.dastelefonbuch.de.png" => array("556", "3579471b7521c789e0b60fcd5d956f86"), + "plugins/Referrers/images/searchEngines/www2.austronaut.at.png" => array("498", "bd9f63b819bfef1659dde292561e7ad6"), + "plugins/Referrers/images/searchEngines/www2.inbox.com.png" => array("569", "5b62059714bd57204e28fc5572b89fde"), + "plugins/Referrers/images/searchEngines/www3.zoek.nl.png" => array("1038", "bc78e6bf962a4e633fbb78dd5bb00a08"), + "plugins/Referrers/images/searchEngines/www.abacho.de.png" => array("545", "9f0d64f72999c8069842987b8713fac6"), + "plugins/Referrers/images/searchEngines/www.acoon.de.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/www.allesklar.de.png" => array("328", "d104c475b308bc29ca17d41dd852aa85"), + "plugins/Referrers/images/searchEngines/www.alltheweb.com.png" => array("458", "26f469c6d9701c54ccfed58d4188c6dc"), + "plugins/Referrers/images/searchEngines/www.altavista.com.png" => array("627", "06323034af3945170fbdcd2884ad0197"), + "plugins/Referrers/images/searchEngines/www.arcor.de.png" => array("924", "9b4af83fded3c2b4f29ca2c19de9d014"), + "plugins/Referrers/images/searchEngines/www.baidu.com.png" => array("725", "65946f31c95143d6f316b471f8a5f5eb"), + "plugins/Referrers/images/searchEngines/www.blogdigger.com.png" => array("863", "2a0c218c33c5e169e18a538d2b1d7aa6"), + "plugins/Referrers/images/searchEngines/www.blogpulse.com.png" => array("725", "6d46916da44302a96770d0379068bac7"), + "plugins/Referrers/images/searchEngines/www.charter.net.png" => array("861", "5a4105275865e5dce2c41afdb95f34ae"), + "plugins/Referrers/images/searchEngines/www.crawler.com.png" => array("392", "d9aba7a98b84a5f0eb74f5f9f26d9eca"), + "plugins/Referrers/images/searchEngines/www.cuil.com.png" => array("708", "17e2ef0a8adf65ddc02942c8b260b2a1"), + "plugins/Referrers/images/searchEngines/www.dasoertliche.de.png" => array("767", "6001417565edb464b5848a4b1ed4f3a4"), + "plugins/Referrers/images/searchEngines/www.eniro.se.png" => array("758", "3e28e3ac9105723fc9902af3b259a0bd"), + "plugins/Referrers/images/searchEngines/www.eurip.com.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/www.euroseek.com.png" => array("841", "1be6aefacc4275a656ab0dc82bd197cf"), + "plugins/Referrers/images/searchEngines/www.everyclick.com.png" => array("843", "b89c7db338a93d5a420ddbdea148501e"), + "plugins/Referrers/images/searchEngines/www.exalead.fr.png" => array("880", "6cb4ba9a2fda31f0063972e6d608412e"), + "plugins/Referrers/images/searchEngines/www.facebook.com.png" => array("349", "34811a0b31ca7dd2934cc02cffbcdc95"), + "plugins/Referrers/images/searchEngines/www.fastbrowsersearch.com.png" => array("965", "d1616dfa7bf9d20634b52857f1c91177"), + "plugins/Referrers/images/searchEngines/www.fireball.de.png" => array("275", "7831b2d7ebbbacacf8ae272bb407e599"), + "plugins/Referrers/images/searchEngines/www.firstsfind.com.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/www.fixsuche.de.png" => array("975", "4701f16ae042e18187b99ad23da4aef0"), + "plugins/Referrers/images/searchEngines/www.flix.de.png" => array("518", "86af8975ae1e5c87c91332155b294dc6"), + "plugins/Referrers/images/searchEngines/www.gigablast.com.png" => array("846", "e81d15ca1d53cf6a929fefa476ed144d"), + "plugins/Referrers/images/searchEngines/www.gnadenmeer.de.png" => array("895", "8e70fc1ecfb90fbb4d0be558c79c56c5"), + "plugins/Referrers/images/searchEngines/www.gomeo.com.png" => array("432", "8b06ecc277f5b2a0aa4ab46b19f2ca72"), + "plugins/Referrers/images/searchEngines/www.google.interia.pl.png" => array("690", "fa07f267bcb77246a0cf5e76a77b5c77"), + "plugins/Referrers/images/searchEngines/www.goyellow.de.png" => array("778", "d410a511b82055b8817585031292756c"), + "plugins/Referrers/images/searchEngines/www.gulesider.no.png" => array("575", "0eae2709e4afbe459883bcb690dbdeef"), + "plugins/Referrers/images/searchEngines/www.highbeam.com.png" => array("705", "d6fd2b1bd62893f37451bc0fe4d8b0a0"), + "plugins/Referrers/images/searchEngines/www.hooseek.com.png" => array("548", "610bbf7fd42486596a7edf046c0aa3ba"), + "plugins/Referrers/images/searchEngines/www.hotbot.com.png" => array("275", "7831b2d7ebbbacacf8ae272bb407e599"), + "plugins/Referrers/images/searchEngines/www.icq.com.png" => array("692", "08c5c23ac6d124387547282977787515"), + "plugins/Referrers/images/searchEngines/www.ilse.nl.png" => array("854", "7321baaf2ff095c525fb1a27dd8a3256"), + "plugins/Referrers/images/searchEngines/www.jungle-spider.de.png" => array("867", "6c675278c93d7f5145c116fd12b83076"), + "plugins/Referrers/images/searchEngines/www.kataweb.it.png" => array("273", "b9f2ffb235b4459c6e2b6f3f8c811b1c"), + "plugins/Referrers/images/searchEngines/www.kvasir.no.png" => array("395", "2be12aaef3172e501bb5c2960c4bde26"), + "plugins/Referrers/images/searchEngines/www.latne.lv.png" => array("756", "89197fc28e878fb017d1fe0f2be63b26"), + "plugins/Referrers/images/searchEngines/www.looksmart.com.png" => array("423", "c63999bc972ee4181b3aed5506727684"), + "plugins/Referrers/images/searchEngines/www.maailm.com.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/www.mamma.com.png" => array("931", "fbd287ad4cb8ff307ddfde1a61ba6b18"), + "plugins/Referrers/images/searchEngines/www.meinestadt.de.png" => array("545", "985a57518000ffa9ee74433b697be26b"), + "plugins/Referrers/images/searchEngines/www.mister-wong.com.png" => array("1065", "90af7f0ef98dd4a44a59298e6bd11287"), + "plugins/Referrers/images/searchEngines/www.monstercrawler.com.png" => array("992", "8d60a75253a6679cd88589e1f527eb31"), + "plugins/Referrers/images/searchEngines/www.mozbot.fr.png" => array("539", "a5454d5f9612b9be0592c46caaaf5a0f"), + "plugins/Referrers/images/searchEngines/www.mysearch.com.png" => array("754", "0ce5f3e478b44af9c0203820730d0782"), + "plugins/Referrers/images/searchEngines/www.najdi.si.png" => array("746", "d1419b2cbe8dc0a2685d71b284b2ff7f"), + "plugins/Referrers/images/searchEngines/www.neti.ee.png" => array("874", "a5b8bc123991996558acdae38e2f7edf"), + "plugins/Referrers/images/searchEngines/www.paperball.de.png" => array("275", "7831b2d7ebbbacacf8ae272bb407e599"), + "plugins/Referrers/images/searchEngines/www.picsearch.com.png" => array("661", "7fd004f30bee9ed392c3dfdd36bc7cce"), + "plugins/Referrers/images/searchEngines/www.plazoo.com.png" => array("471", "ce3c7526febd2d33468292a2cc18fd05"), + "plugins/Referrers/images/searchEngines/www.qualigo.at.png" => array("627", "c793911b3efed6122c975023c6c86369"), + "plugins/Referrers/images/searchEngines/www.searchcanvas.com.png" => array("867", "a148aa4f2ebd3a9d627b156c261b11f1"), + "plugins/Referrers/images/searchEngines/www.search.ch.png" => array("344", "cbf2de2cac7cf39897ed488301b88687"), + "plugins/Referrers/images/searchEngines/www.search.com.png" => array("517", "7675624d816df97773003fd968d3fb92"), + "plugins/Referrers/images/searchEngines/www.searchy.co.uk.png" => array("656", "e6018e106ad0190fff95a73bb3bb31d9"), + "plugins/Referrers/images/searchEngines/www.sharelook.fr.png" => array("433", "85be47a9b158874e7831982d2872c4f3"), + "plugins/Referrers/images/searchEngines/www.skynet.be.png" => array("855", "9561e59ff5068d31f7a3a95bbed6bb8f"), + "plugins/Referrers/images/searchEngines/www.sogou.com.png" => array("636", "babba0fbf8f3dc3f8e332a9bc0e0b616"), + "plugins/Referrers/images/searchEngines/www.soso.com.png" => array("895", "4cb24e3d5b5b4f434e9e77b78283fb6e"), + "plugins/Referrers/images/searchEngines/www.startsiden.no.png" => array("285", "4d50ad01809bb118e0e14b690c2898c2"), + "plugins/Referrers/images/searchEngines/www.suchmaschine.com.png" => array("605", "fec00799f617313dcec453d470bcc1ec"), + "plugins/Referrers/images/searchEngines/www.suchnase.de.png" => array("289", "c0f4d4e1a6d633a2de161d847e51ae38"), + "plugins/Referrers/images/searchEngines/www.talimba.com.png" => array("735", "a99e8625fb9bddd6a8bdcac8a8dfe492"), + "plugins/Referrers/images/searchEngines/www.talktalk.co.uk.png" => array("457", "26dd7805cb12703447280f3988a36662"), + "plugins/Referrers/images/searchEngines/www.teoma.com.png" => array("564", "6feee990d3cd058e302adac8e750428b"), + "plugins/Referrers/images/searchEngines/www.tixuma.de.png" => array("933", "ac483ba65b637e352bd9b7edf11e462e"), + "plugins/Referrers/images/searchEngines/www.toile.com.png" => array("868", "7e46a21790c2ca55a8adacaa3ecb860f"), + "plugins/Referrers/images/searchEngines/www.toolbarhome.com.png" => array("617", "d39570e56b848781f614ae70f8ccd8a9"), + "plugins/Referrers/images/searchEngines/www.trouvez.com.png" => array("498", "cfcae76baa6008dbdad8d65736ca9c07"), + "plugins/Referrers/images/searchEngines/www.trovarapido.com.png" => array("540", "25010fae9beae3454550b78cd1c62cca"), + "plugins/Referrers/images/searchEngines/www.trusted-search.com.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/www.twingly.com.png" => array("459", "2898c7f2d9f5b014299da9f9a6b3bef1"), + "plugins/Referrers/images/searchEngines/www.url.org.png" => array("584", "ac66fdc45b162af2e579875b516f151f"), + "plugins/Referrers/images/searchEngines/www.vinden.nl.png" => array("550", "9b0acbf516f9070d9f8644ae48e1e756"), + "plugins/Referrers/images/searchEngines/www.vindex.nl.png" => array("627", "0b2831d17790a99df8644b335baa923f"), + "plugins/Referrers/images/searchEngines/www.walhello.info.png" => array("811", "efa838f67dd8595a651e800500c72057"), + "plugins/Referrers/images/searchEngines/www.web.nl.png" => array("736", "64629e968a100f3d52bbd645f3eeeabd"), + "plugins/Referrers/images/searchEngines/www.weborama.fr.png" => array("978", "c56c4180238352c79c39105ca60c8d41"), + "plugins/Referrers/images/searchEngines/www.websearch.com.png" => array("830", "b7275242d17e492422677fd66e4fe38a"), + "plugins/Referrers/images/searchEngines/www.witch.de.png" => array("384", "3471981c242407d47d9786ee4b9dea4a"), + "plugins/Referrers/images/searchEngines/www.x-recherche.com.png" => array("546", "1f424da61da3eb848ecef393a6bccfc5"), + "plugins/Referrers/images/searchEngines/www.yasni.de.png" => array("747", "01c5182fc922793a5b408411b53a3c6e"), + "plugins/Referrers/images/searchEngines/www.yatedo.com.png" => array("647", "aa0d9cbbe207eaa5bcab22dee20185ce"), + "plugins/Referrers/images/searchEngines/www.yougoo.fr.png" => array("731", "d616bbdaae629c1268580b8191ce03de"), + "plugins/Referrers/images/searchEngines/www.zapmeta.com.png" => array("220", "1707e427049d54e0046fdbe8d6b90250"), + "plugins/Referrers/images/searchEngines/www.zoeken.nl.png" => array("598", "d26fe06a02e1b7e33e0f519af929d9a6"), + "plugins/Referrers/images/searchEngines/www.zoznam.sk.png" => array("349", "b690e483bd1a89acd54f0f21531453f6"), + "plugins/Referrers/images/searchEngines/xx.gif" => array("55", "a7bc48d335263fe532d80efb25afb2d5"), + "plugins/Referrers/images/searchEngines/xx.png" => array("265", "34652cdc25d70bba01ce9ac01d929d3b"), + "plugins/Referrers/images/searchEngines/yandex.ru.png" => array("497", "0ff0e2ee9e0b08fbfba2eaa9f4612848"), + "plugins/Referrers/images/searchEngines/yellowmap.de.png" => array("750", "4a18331804df464c10cc285c86ba9db4"), + "plugins/Referrers/images/searchEngines/zoohoo.cz.png" => array("305", "bf13a4db674a29b03b39763e4f525d80"), + "plugins/Referrers/images/socials/badoo.com.png" => array("1080", "ed2fa1b5028f7c6d07f2914a275ab33d"), + "plugins/Referrers/images/socials/bebo.com.png" => array("978", "668d898d7c6e0f46cce4c6770f06c238"), + "plugins/Referrers/images/socials/blackplanet.com.png" => array("432", "9ba7571d00f4a9aa65272ffd9ac7b538"), + "plugins/Referrers/images/socials/buzznet.com.png" => array("462", "3b3e4ee1fe990041080a93bff2d34f98"), + "plugins/Referrers/images/socials/classmates.com.png" => array("861", "fc6ae2a786dba146d8279e269e39bb48"), + "plugins/Referrers/images/socials/douban.com.png" => array("530", "be1b7fb618ab9ea1eeb04e15b4428b1a"), + "plugins/Referrers/images/socials/facebook.com.png" => array("300", "cadba1be0b6e8d4e3ef1755f3cee55ed"), + "plugins/Referrers/images/socials/flickr.com.png" => array("1120", "4bf2b10ac1b80eac5f749ea65ff808b0"), + "plugins/Referrers/images/socials/flixster.com.png" => array("908", "2011245e7a16ab25fc4f0c5d21487f6a"), + "plugins/Referrers/images/socials/fotolog.com.png" => array("184", "54ff02becc80358d072fbee8152d6543"), + "plugins/Referrers/images/socials/foursquare.com.png" => array("1026", "250fef7ff00c447df3bfad80c444473b"), + "plugins/Referrers/images/socials/friendsreunited.com.png" => array("1113", "840685b0402d54f176e99b954711b965"), + "plugins/Referrers/images/socials/friendster.com.png" => array("589", "225ac089a0b2a2f0398876e869139682"), + "plugins/Referrers/images/socials/gaiaonline.com.png" => array("1593", "c127b6a77505784b3f299e00913bbb10"), + "plugins/Referrers/images/socials/geni.com.png" => array("947", "ca77efda7cb46b3abcd32fb8cbd8e2c0"), + "plugins/Referrers/images/socials/github.com.png" => array("399", "0d8bf5bcf113db4141c7fbbdf54fccd9"), + "plugins/Referrers/images/socials/habbo.com.png" => array("1090", "6f9272e8ba7cd8499a07f747c2c323fb"), + "plugins/Referrers/images/socials/hi5.com.png" => array("282", "c20e494873e7ba853fa18b746dbc5586"), + "plugins/Referrers/images/socials/hyves.nl.png" => array("1183", "c5282f8e0520c5c9cff8598ae87135f5"), + "plugins/Referrers/images/socials/identi.ca.png" => array("1166", "16d4be83fe740a61e52920de197f62d3"), + "plugins/Referrers/images/socials/last.fm.png" => array("1265", "f61e7d729ac616ddd41b67aafe834cba"), + "plugins/Referrers/images/socials/linkedin.com.png" => array("694", "a91e0f79ef848cf6819b0461f5d11aaf"), + "plugins/Referrers/images/socials/livejournal.ru.png" => array("984", "a7f82911e90475d70f512dbbdd450cbc"), + "plugins/Referrers/images/socials/login.live.com.png" => array("1581", "44579a577af6d00245b3b02af4d848bb"), + "plugins/Referrers/images/socials/login.tagged.com.png" => array("286", "0d53cd2fa01a0861f302783249909960"), + "plugins/Referrers/images/socials/meinvz.net.png" => array("554", "f8d3351ef56c470712ec5c31e452e695"), + "plugins/Referrers/images/socials/mixi.jp.png" => array("592", "15330f429050fe69d2625885e89fae8f"), + "plugins/Referrers/images/socials/moikrug.ru.png" => array("1206", "c38b40ddd099378a3a2b04e2924eb8b9"), + "plugins/Referrers/images/socials/multiply.com.png" => array("586", "3f41adad9c975e2ec481bb5ee4ae97bb"), + "plugins/Referrers/images/socials/myheritage.com.png" => array("1138", "ebaf4513d68f0095d7040bee1df08be7"), + "plugins/Referrers/images/socials/mylife.ru.png" => array("465", "283214f37f52abc61adade0c2677bf12"), + "plugins/Referrers/images/socials/my.mail.ru.png" => array("750", "b593c60c47256140c22e8bb0f1a52c91"), + "plugins/Referrers/images/socials/myspace.com.png" => array("280", "1cc0e9fcd7d381a43d2d4759b3d75392"), + "plugins/Referrers/images/socials/myyearbook.com.png" => array("741", "5d63ebc165436f50a064ce8f03f69521"), + "plugins/Referrers/images/socials/netlog.com.png" => array("636", "fb047fbf0fd1c25d74504d4b53365bce"), + "plugins/Referrers/images/socials/news.ycombinator.com.png" => array("1166", "b95c1fafa6166e6c337f199b9b711baa"), + "plugins/Referrers/images/socials/nk.pl.png" => array("628", "136217dd7a6d7008e8196632274c9423"), + "plugins/Referrers/images/socials/odnoklassniki.ru.png" => array("583", "4b5a37acc6a433d465603edefcb0df9e"), + "plugins/Referrers/images/socials/orkut.com.png" => array("496", "a3002f600988602451adb45ae9d2292b"), + "plugins/Referrers/images/socials/pinterest.com.png" => array("3369", "12ea288c608f89d455c6ed69131e4e8f"), + "plugins/Referrers/images/socials/plaxo.com.png" => array("754", "cbc9763598e8e6ff132dd0ebdcc2402a"), + "plugins/Referrers/images/socials/qzone.qq.com.png" => array("604", "5d336e06dcf629dce2e2887d717ee62a"), + "plugins/Referrers/images/socials/reddit.com.png" => array("1166", "1a961c1f4b8085054090a5926d3f0366"), + "plugins/Referrers/images/socials/renren.com.png" => array("784", "aaa11f9bd4b58333f501136b4f82b59b"), + "plugins/Referrers/images/socials/ru.netlog.com.png" => array("1252", "fbafccfe19543363c449bae7e2f76279"), + "plugins/Referrers/images/socials/skyrock.com.png" => array("527", "12cf60ee8e93a8936fc8d8de92262568"), + "plugins/Referrers/images/socials/sonico.com.png" => array("1292", "113cb411c36df6dc36432ee0d5d62f5e"), + "plugins/Referrers/images/socials/sourceforge.net.png" => array("1166", "5bc6c9e7f0c467874a8d435dca140518"), + "plugins/Referrers/images/socials/stackoverflow.com.png" => array("1166", "dd2d4e47871aea283ea2beb07280db0a"), + "plugins/Referrers/images/socials/studivz.net.png" => array("1222", "9fd0e7479c96e750d2efbf5068598ec2"), + "plugins/Referrers/images/socials/stumbleupon.com.png" => array("584", "e12262acf466e1fc00e6f8549c00390b"), + "plugins/Referrers/images/socials/tagged.com.png" => array("282", "c20e494873e7ba853fa18b746dbc5586"), + "plugins/Referrers/images/socials/taringa.net.png" => array("716", "3b60deeeec0b87e127ae16c231e1e87f"), + "plugins/Referrers/images/socials/tuenti.com.png" => array("902", "07880a7cdcea70ad2be60129e56a754d"), + "plugins/Referrers/images/socials/tumblr.com.png" => array("443", "b0931c0a06b72e6c140982710c75495f"), + "plugins/Referrers/images/socials/twitter.com.png" => array("892", "0b4042cb3686dd166042066e5d586ab8"), + "plugins/Referrers/images/socials/url.google.com.png" => array("1231", "1e684d1e6c018b2aff40930c597ceaf9"), + "plugins/Referrers/images/socials/viadeo.com.png" => array("710", "09e5fa949178167278c39149c44cba21"), + "plugins/Referrers/images/socials/vimeo.com.png" => array("704", "c928a20a0b8bd5eb8134147536fc0c7b"), + "plugins/Referrers/images/socials/vk.com.png" => array("370", "247bccf89561fe2aaa7d722152497c08"), + "plugins/Referrers/images/socials/vkontakte.ru.png" => array("576", "094907c8a8d2120213855506e0f6ed08"), + "plugins/Referrers/images/socials/vkrugudruzei.ru.png" => array("788", "29aa370e0983aef3ffffea74e43d8cbc"), + "plugins/Referrers/images/socials/wayn.com.png" => array("648", "ebddcc58a0414031b2b82622a198fb8c"), + "plugins/Referrers/images/socials/weeworld.com.png" => array("492", "5f3332f20321d7e0c24ee57579a18822"), + "plugins/Referrers/images/socials/weibo.com.png" => array("1179", "c3eff4eb95677e26c555446bbd1c54fa"), + "plugins/Referrers/images/socials/xanga.com.png" => array("265", "f2820fe33cb5bafbc9f3a5205235a958"), + "plugins/Referrers/images/socials/xing.com.png" => array("675", "3764aea0fd7c26bf8bf8e95958d94fff"), + "plugins/Referrers/images/socials/xx.png" => array("265", "34652cdc25d70bba01ce9ac01d929d3b"), + "plugins/Referrers/images/socials/youtube.com.png" => array("695", "81125e13b69e308800a98d9752e3f48b"), + "plugins/Referrers/Referrers.php" => array("29947", "909f867aa6cc8daf48218c512f3c48de"), + "plugins/Referrers/templates/getSearchEnginesAndKeywords.twig" => array("264", "0dff654fb12896c6e24d6579f099c244"), + "plugins/Referrers/templates/index.twig" => array("6007", "644a0b2555e61f18dabca2e7239b800f"), + "plugins/Referrers/templates/indexWebsites.twig" => array("252", "bf3a7aa50faea341e0603b53ff14e4d5"), + "plugins/ScheduledReports/API.php" => array("36820", "527f23578d1ff32edacf572ebef32fb0"), + "plugins/ScheduledReports/config/tcpdf_config.php" => array("6402", "923e6b4e2b61bbc44fb7882d911e438c"), + "plugins/ScheduledReports/Controller.php" => array("3576", "10ce18981e80bb5f89eb9532805f46cb"), + "plugins/ScheduledReports/javascripts/pdf.js" => array("7001", "f42863d0418f199dcef8af7e46d1c0bc"), + "plugins/ScheduledReports/ScheduledReports.php" => array("25874", "523d4cdbf0cc7407353b5958875acb9e"), + "plugins/ScheduledReports/templates/_addReport.twig" => array("8636", "00ecae5fe8b8aa647bcd0efa248e2bc0"), + "plugins/ScheduledReports/templates/index.twig" => array("1937", "0aaa4e1d71cbcb85794f920f6530e953"), + "plugins/ScheduledReports/templates/_listReports.twig" => array("4267", "b554291be9228ef8cb7d250da9449288"), + "plugins/ScheduledReports/templates/reportParametersScheduledReports.twig" => array("3848", "4b32be0f5bb38a54ac06b276a962aae6"), + "plugins/SegmentEditor/API.php" => array("10874", "52a80f940f2bd60881732aa28d7def09"), + "plugins/SegmentEditor/Controller.php" => array("386", "f565883b4e48442253c328ab00016755"), + "plugins/SegmentEditor/images/ajax-loader.gif" => array("847", "30d8e72bfdae694b1938658e1b087df0"), + "plugins/SegmentEditor/images/bg-inverted-corners.png" => array("968", "b441529c920b6ce5ca36d62b08a73fa2"), + "plugins/SegmentEditor/images/bg-segment-search.png" => array("1068", "938234a77114aff5541b1b9a47acf1f9"), + "plugins/SegmentEditor/images/bg-select.png" => array("1035", "a4f45d333fbcbadfb3c3bf42d45bed0e"), + "plugins/SegmentEditor/images/close_btn.png" => array("928", "58c02c2bf48632f89a571f8065dfedfa"), + "plugins/SegmentEditor/images/close.png" => array("288", "122f9ccffeba88ccdc3eb2ef41b518bc"), + "plugins/SegmentEditor/images/dashboard_h_bg_hover.png" => array("378", "41c5d393f8c12cd2cc727fada94b154a"), + "plugins/SegmentEditor/images/icon-users.png" => array("1728", "89e68113ed647295901322eea82ff8ed"), + "plugins/SegmentEditor/images/reset_search.png" => array("1021", "7e761f3444bf4edd4cd1779801c963bd"), + "plugins/SegmentEditor/images/search_btn.png" => array("2825", "2f11b8a2a361aa4eb350d33048ea56c9"), + "plugins/SegmentEditor/images/segment-close.png" => array("1302", "28e429768ba7f35ea4c48930848c2a26"), + "plugins/SegmentEditor/images/segment-move.png" => array("1447", "5c8a0111446ce9eaf70cd0cb82211ded"), + "plugins/SegmentEditor/javascripts/Segmentation.js" => array("50044", "f8db88f49b88e433bb74ad004b81b2e1"), + "plugins/SegmentEditor/Model.php" => array("2610", "b8de12b8dcb6d85d01ce48c2c7d35f4d"), + "plugins/SegmentEditor/SegmentEditor.php" => array("3196", "88b61079a22b9489913274251b4b0539"), + "plugins/SegmentEditor/SegmentSelectorControl.php" => array("4496", "9f540610044cf02578bfdfbcf3d1edff"), + "plugins/SegmentEditor/stylesheets/segmentation.less" => array("14770", "3bccb8665591b5904b8fd1641aae90a2"), + "plugins/SegmentEditor/templates/_segmentSelector.twig" => array("9000", "46fd47d6e427123362e9f39dd2c58099"), + "plugins/SEO/API.php" => array("3604", "29f8089a50cfe5e5796c097813a92341"), + "plugins/SEO/Controller.php" => array("1195", "93cc25b095aa6bfe89d91088ee620964"), + "plugins/SEO/images/majesticseo.png" => array("674", "a771319c2aa22f4b4744f76e59ec5fb3"), + "plugins/SEO/images/whois.png" => array("928", "ef67a4e9689efda71625a2ef894fb700"), + "plugins/SEO/javascripts/rank.js" => array("802", "8a49f8635a4d501d73868dcfc415e4e5"), + "plugins/SEO/MajesticClient.php" => array("3132", "2973973ceba8173cab44fd0cac09f869"), + "plugins/SEO/RankChecker.php" => array("10231", "72e8c9795cb7a537ff80bee9e5890d77"), + "plugins/SEO/SEO.php" => array("1194", "bb40b45a7b5ef22684fc1370fd5553ba"), + "plugins/SEO/templates/getRank.twig" => array("2900", "a686edd3171736d456f4ec6062206819"), + "plugins/SitesManager/API.php" => array("55565", "1d1299fa3efe085855b42f4134a8b0fc"), + "plugins/SitesManager/Controller.php" => array("6317", "bac508220402a58b5c67442b641d8c09"), + "plugins/SitesManager/javascripts/SitesManager.js" => array("22790", "153a9bd5ec22c2f98791989235a5289d"), + "plugins/SitesManager/SitesManager.php" => array("7094", "4e0830e699db4d59d697efac994a8858"), + "plugins/SitesManager/stylesheets/SitesManager.less" => array("1059", "2c47402c996f5783383b5dd9bdb41048"), + "plugins/SitesManager/templates/displayJavascriptCode.twig" => array("121", "ab4d91574f554b6e4ff05cae26373902"), + "plugins/SitesManager/templates/_displayJavascriptCode.twig" => array("731", "cc93bf9bffdbd5efc0ff98a0bee83e86"), + "plugins/SitesManager/templates/index.twig" => array("19864", "71b4a9079d4e90951e1b83571f72a64f"), + "plugins/Transitions/API.php" => array("25342", "1d0bf029a35fa8cad6444271571d8388"), + "plugins/Transitions/Controller.php" => array("3778", "f926b1496c2db36deb6f7f2ee1d48e36"), + "plugins/Transitions/images/transitions_icon_hover.png" => array("647", "2ec41fe6df63da14f7c187b067b94cc9"), + "plugins/Transitions/images/transitions_icon.png" => array("643", "fbf901428436a1ba3bbcc5c3081b6f24"), + "plugins/Transitions/javascripts/transitions.js" => array("56161", "97aee28fdcf5a08f9ca8cc9e744c3eb8"), + "plugins/Transitions/stylesheets/_transitionColors.less" => array("1677", "4c6eb4b6425faabe6d2eb4e1821904ef"), + "plugins/Transitions/stylesheets/transitions.less" => array("3702", "b58c35379c79ce4060216479e0310c2f"), + "plugins/Transitions/templates/renderPopover.twig" => array("2576", "81bba58121f174ab9edf66c6560f1815"), + "plugins/Transitions/Transitions.php" => array("1125", "efe0685071d15cd7ce16c94116ed71bc"), + "plugins/UserCountry/API.php" => array("8863", "a79d65ab6a040442be62b43d687e01cb"), + "plugins/UserCountry/Archiver.php" => array("6221", "191beb3c68d3dddc8a3407f0e4c33594"), + "plugins/UserCountry/Controller.php" => array("14875", "3b186fb085a8147c30078f722f7bccfc"), + "plugins/UserCountry/functions.php" => array("5232", "de661ba6505b4d27286e6e8543295d65"), + "plugins/UserCountry/GeoIPAutoUpdater.php" => array("23171", "102290b02300a18c821aa1b46ef294d5"), + "plugins/UserCountry/images/flags/a1.png" => array("290", "f1c31042bade52bcf13d8bd778fcf37c"), + "plugins/UserCountry/images/flags/a2.png" => array("290", "f1c31042bade52bcf13d8bd778fcf37c"), + "plugins/UserCountry/images/flags/ac.png" => array("545", "373b8f3d6c92338b228a160e2a0d87fc"), + "plugins/UserCountry/images/flags/ad.png" => array("454", "e06c9805db8406f1dbdcaa3e1aabeb46"), + "plugins/UserCountry/images/flags/ae.png" => array("277", "96fb796bddf2d631ea759ca8c33f5ad4"), + "plugins/UserCountry/images/flags/af.png" => array("420", "ce511852782ff21c1b47a1669fb2a60b"), + "plugins/UserCountry/images/flags/ag.png" => array("456", "d85c0b66cf5b68d85b99d90ef87b62a6"), + "plugins/UserCountry/images/flags/ai.png" => array("516", "2e32ad81c92ea64e94d9841deeb9a3a9"), + "plugins/UserCountry/images/flags/al.png" => array("434", "6657f61f8ce5e361bc7d9028fae5f3fd"), + "plugins/UserCountry/images/flags/am.png" => array("332", "b2df641a3610e13f5c20fbbe34deef40"), + "plugins/UserCountry/images/flags/an.png" => array("365", "f13b1a70f214b13a633180def6e10950"), + "plugins/UserCountry/images/flags/ao.png" => array("395", "d2e36307da318dfa6ad8f4b4724152bd"), + "plugins/UserCountry/images/flags/ap.png" => array("290", "f1c31042bade52bcf13d8bd778fcf37c"), + "plugins/UserCountry/images/flags/aq.png" => array("376", "8558085a2c8fc10b891b7f0156e74e4d"), + "plugins/UserCountry/images/flags/ar.png" => array("367", "3262694d9da0651e4ba89f320f861045"), + "plugins/UserCountry/images/flags/as.png" => array("540", "04702221a1b3bd67f53f9cd7046db824"), + "plugins/UserCountry/images/flags/at.png" => array("290", "b046bc17a7da41bc1a2ee0b6dd08f5b1"), + "plugins/UserCountry/images/flags/au.png" => array("580", "a30514266c8337b5eba8b8273f75069a"), + "plugins/UserCountry/images/flags/aw.png" => array("393", "9fd3abfd00e9f343df80fdfcfacb54d8"), + "plugins/UserCountry/images/flags/ax.png" => array("480", "e1a53e9e759cb932145827601707e00a"), + "plugins/UserCountry/images/flags/az.png" => array("423", "4e3840fb6e5dbcba28b60634a7be9ec7"), + "plugins/UserCountry/images/flags/ba.png" => array("471", "48a30e57ae529784a396e1823f05efa5"), + "plugins/UserCountry/images/flags/bb.png" => array("403", "4faf161b0978a2a11774ced111c607d2"), + "plugins/UserCountry/images/flags/bd.png" => array("372", "6728d331503b3d1c305f685ff51a9b89"), + "plugins/UserCountry/images/flags/be.png" => array("294", "57fe6dca1d2136916139c69fdeef3a87"), + "plugins/UserCountry/images/flags/bf.png" => array("341", "740751253fbcad427d7b45078589cb5e"), + "plugins/UserCountry/images/flags/bg.png" => array("320", "90e45cf12b431458b940a73f7b9fcbc1"), + "plugins/UserCountry/images/flags/bh.png" => array("345", "f2adc039c2c9a247a4e632d7477b2ee4"), + "plugins/UserCountry/images/flags/bi.png" => array("566", "38a6aef240d5c49fac5a09677e085a33"), + "plugins/UserCountry/images/flags/bj.png" => array("311", "53dde6e55a419bd214c9eae169674d5b"), + "plugins/UserCountry/images/flags/bl.png" => array("369", "d9062600b752c4fea4f3561920154724"), + "plugins/UserCountry/images/flags/bm.png" => array("499", "8e6f49c8a8c9fb066ee497583c3258aa"), + "plugins/UserCountry/images/flags/bn.png" => array("502", "d5b2aa73a6996fe49a5f06f344048bed"), + "plugins/UserCountry/images/flags/bo.png" => array("341", "1af0cf8f742a60e8389dc99d3f5399bb"), + "plugins/UserCountry/images/flags/bq.png" => array("310", "ab1c7bbbd651992aa563ce983942f33a"), + "plugins/UserCountry/images/flags/br.png" => array("486", "8692d9a84ae751a1fe469a1a4f010b11"), + "plugins/UserCountry/images/flags/bs.png" => array("391", "d5f8d687941e6bb02668e83df22df0f4"), + "plugins/UserCountry/images/flags/bt.png" => array("471", "a465c0295b6c7acf73064f5bae4301ef"), + "plugins/UserCountry/images/flags/bu.png" => array("336", "d28c3b10b62ca4be38f92763c39af558"), + "plugins/UserCountry/images/flags/bv.png" => array("397", "6935c8100f0abefa6d95aa82306d4b16"), + "plugins/UserCountry/images/flags/bw.png" => array("327", "da36c5267bc44e5bb6cc42e39bfe50e0"), + "plugins/UserCountry/images/flags/by.png" => array("382", "d463ffed4445b3430d3b6fea26f22d63"), + "plugins/UserCountry/images/flags/bz.png" => array("476", "c7c1ad98b142f8613c32333555f02387"), + "plugins/UserCountry/images/flags/ca.png" => array("471", "73a7d298286803c509103e35e3e00c95"), + "plugins/UserCountry/images/flags/cat.png" => array("353", "bc71723d17f7f996855b4829a8d7c525"), + "plugins/UserCountry/images/flags/cc.png" => array("496", "78b4cbd583030bcba8aca726764ded1d"), + "plugins/UserCountry/images/flags/cd.png" => array("477", "3ba2370dec6811f52f8cc63006091a5a"), + "plugins/UserCountry/images/flags/cf.png" => array("456", "b918f09c201b31356cf0e30867486d37"), + "plugins/UserCountry/images/flags/cg.png" => array("380", "7bd1bcc513aac082fa6f6292b72edc0c"), + "plugins/UserCountry/images/flags/ch.png" => array("239", "5c062086332c658050ee206a95bc0529"), + "plugins/UserCountry/images/flags/ci.png" => array("306", "f23877742bb6680fd23e4c9105f8f3fa"), + "plugins/UserCountry/images/flags/ck.png" => array("495", "ea6f14f288c3ce733d1d83c6498d23a9"), + "plugins/UserCountry/images/flags/cl.png" => array("324", "a2bf641aa64e7abcefae3850894cb297"), + "plugins/UserCountry/images/flags/cm.png" => array("347", "c512b727f3c9f61511193c44f755fb84"), + "plugins/UserCountry/images/flags/cn.png" => array("349", "3e1c715cc9abdd7090532108f8219c33"), + "plugins/UserCountry/images/flags/co.png" => array("330", "35eafa1a7a61403bb1c3dc4c568fb058"), + "plugins/UserCountry/images/flags/cp.png" => array("369", "d9062600b752c4fea4f3561920154724"), + "plugins/UserCountry/images/flags/cr.png" => array("349", "21abdfe041c3718220bf0bf0abdaa432"), + "plugins/UserCountry/images/flags/cs.png" => array("321", "cd0e549ac62fab737357ab35c8494813"), + "plugins/UserCountry/images/flags/cu.png" => array("445", "96210e07d6e39e4a6bdf3c1a9dd7d3a6"), + "plugins/UserCountry/images/flags/cv.png" => array("441", "0d3f95379c078fd9ba13bc699b4f6bd0"), + "plugins/UserCountry/images/flags/cw.png" => array("308", "dd7f242eaabedb2beb99976d6a5c8dad"), + "plugins/UserCountry/images/flags/cx.png" => array("498", "f4489b88940e95b95df0516d9f5214b5"), + "plugins/UserCountry/images/flags/cy.png" => array("337", "f77e40cda1c65710b084e160377b9630"), + "plugins/UserCountry/images/flags/cz.png" => array("367", "cef49be4d099506a5498b98e08e77df4"), + "plugins/UserCountry/images/flags/de.png" => array("364", "659cdf8a9a4996dcb084df3e3f442fd4"), + "plugins/UserCountry/images/flags/dg.png" => array("658", "38afe5a0e9817027e1f1615028aca521"), + "plugins/UserCountry/images/flags/dj.png" => array("430", "a77093af69aa5141d24e4a07a6ea10af"), + "plugins/UserCountry/images/flags/dk.png" => array("352", "ac122dcf0c9d72093852a94df3f69001"), + "plugins/UserCountry/images/flags/dm.png" => array("508", "ede464c70c3965917c3bc08212e053c1"), + "plugins/UserCountry/images/flags/do.png" => array("368", "9de12f2fe8df051d0c6a461e8a6157d6"), + "plugins/UserCountry/images/flags/dz.png" => array("454", "a6d211fa9bff373bb1f7bf3480a1dd89"), + "plugins/UserCountry/images/flags/ea.png" => array("344", "2115a1177252059ffb47e8a5477dcd59"), + "plugins/UserCountry/images/flags/ec.png" => array("355", "a190e98f2d2d451ec8d893fe69f786f3"), + "plugins/UserCountry/images/flags/ee.png" => array("297", "d732e3bb710f9592f5c86c29c34ceeb8"), + "plugins/UserCountry/images/flags/eg.png" => array("348", "c18ffe9ace7b61e83a506faab3b41384"), + "plugins/UserCountry/images/flags/eh.png" => array("388", "2e31790bf2567befe1161e154009610f"), + "plugins/UserCountry/images/flags/er.png" => array("497", "6c7fbe82caa633621f911246ef9be03a"), + "plugins/UserCountry/images/flags/es.png" => array("344", "2115a1177252059ffb47e8a5477dcd59"), + "plugins/UserCountry/images/flags/et.png" => array("445", "d96a0ec31d7a3ead5ee9c357cfce053a"), + "plugins/UserCountry/images/flags/eu.png" => array("418", "fe5555fc4c82b7ac5a6a521464247122"), + "plugins/UserCountry/images/flags/fi.png" => array("368", "37777cbdf0781e0563757e48958e51e9"), + "plugins/UserCountry/images/flags/fj.png" => array("517", "1e6bf4a4e40b2b3d63781b1da19bedae"), + "plugins/UserCountry/images/flags/fk.png" => array("526", "0d646049eae5abbb52668c2894ee7ab7"), + "plugins/UserCountry/images/flags/fm.png" => array("409", "cdacefe7bf02449fdb58f280d1d9e55e"), + "plugins/UserCountry/images/flags/fo.png" => array("377", "42b46f18e7a9a33cd9096eba9a753625"), + "plugins/UserCountry/images/flags/fr.png" => array("369", "d9062600b752c4fea4f3561920154724"), + "plugins/UserCountry/images/flags/fx.png" => array("369", "d9062600b752c4fea4f3561920154724"), + "plugins/UserCountry/images/flags/ga.png" => array("342", "93a562701e165bf2d756e9278bc7d356"), + "plugins/UserCountry/images/flags/gb.png" => array("545", "373b8f3d6c92338b228a160e2a0d87fc"), + "plugins/UserCountry/images/flags/gd.png" => array("461", "917ce5e00553e6c2e179faa9548fa305"), + "plugins/UserCountry/images/flags/ge.png" => array("493", "da1fb67e7e2e295dd318cb0195bccf7e"), + "plugins/UserCountry/images/flags/gf.png" => array("369", "d9062600b752c4fea4f3561920154724"), + "plugins/UserCountry/images/flags/gg.png" => array("524", "519dbe9562e66af76134b2e1656bf7e7"), + "plugins/UserCountry/images/flags/gh.png" => array("336", "159033244c7f70c3ff47e8aab4c8b4b6"), + "plugins/UserCountry/images/flags/gi.png" => array("369", "d26ba11a107a358926d39a54f543c9f6"), + "plugins/UserCountry/images/flags/gl.png" => array("351", "ef0137e3eb36a2a2006e970a36530e36"), + "plugins/UserCountry/images/flags/gm.png" => array("363", "030330ba9fd1daf8c4ac38ea29451129"), + "plugins/UserCountry/images/flags/gn.png" => array("319", "a492e6802b6ad9ac04016bf90133d5fb"), + "plugins/UserCountry/images/flags/gp.png" => array("353", "84b6bb6ae1eae63fdaa0c080ce2ece8a"), + "plugins/UserCountry/images/flags/gq.png" => array("405", "9631ca35fc10a2bc0aaef06a25a0974b"), + "plugins/UserCountry/images/flags/gr.png" => array("391", "3410bc2892d3337e99c16e385dbc93ce"), + "plugins/UserCountry/images/flags/gs.png" => array("522", "660137680d6eecdd60d83ec520b3634c"), + "plugins/UserCountry/images/flags/gt.png" => array("333", "e362d2900d8948a98c1a978f149fd059"), + "plugins/UserCountry/images/flags/gu.png" => array("384", "ef393701f40bf95b9719cc964422ae54"), + "plugins/UserCountry/images/flags/gw.png" => array("346", "519905115aa28bdbdd23d093f3f8063f"), + "plugins/UserCountry/images/flags/gy.png" => array("521", "378b1e8c706951ad98055560d0909b92"), + "plugins/UserCountry/images/flags/hk.png" => array("393", "214af14f93dd43f8ce2801923a175bf1"), + "plugins/UserCountry/images/flags/hm.png" => array("580", "a30514266c8337b5eba8b8273f75069a"), + "plugins/UserCountry/images/flags/hn.png" => array("411", "f28d848f8e4907e6d2b6dfd553799e25"), + "plugins/UserCountry/images/flags/hr.png" => array("386", "8dad95795dd99d9f424434b1f1ea9514"), + "plugins/UserCountry/images/flags/ht.png" => array("327", "d5567c83055cd19b25bb72936a83c25c"), + "plugins/UserCountry/images/flags/hu.png" => array("293", "62260c58e5fa2b7c7a6ce2da699ef5a9"), + "plugins/UserCountry/images/flags/ic.png" => array("378", "e88eea0f0079926867fdd0f1bf9c7b93"), + "plugins/UserCountry/images/flags/id.png" => array("301", "716db907475765a2620dbb0f6642297e"), + "plugins/UserCountry/images/flags/ie.png" => array("333", "3ffe523ffe959e15d72ef743503d3f2f"), + "plugins/UserCountry/images/flags/il.png" => array("326", "e8873cb08623e3f481ccf77a690d35ff"), + "plugins/UserCountry/images/flags/im.png" => array("320", "771463291d57daf357ae6357c96ede8c"), + "plugins/UserCountry/images/flags/in.png" => array("377", "775d8be8bbb271e0864a7c1cab2b0999"), + "plugins/UserCountry/images/flags/io.png" => array("575", "2ec8170fa7e61e3c210fe934c9b227d8"), + "plugins/UserCountry/images/flags/iq.png" => array("403", "f53e799a3adfd26999067b47efe7228f"), + "plugins/UserCountry/images/flags/ir.png" => array("398", "37ce6f51ba93110101e0c98382d5d537"), + "plugins/UserCountry/images/flags/is.png" => array("410", "8b8c4469ceaea80d82fd7157b2afca5c"), + "plugins/UserCountry/images/flags/it.png" => array("283", "39df341cd39d68db5d66b15030c26cb2"), + "plugins/UserCountry/images/flags/je.png" => array("475", "ca59d84a34953e6a3c5109732c4cbe96"), + "plugins/UserCountry/images/flags/jm.png" => array("508", "4278b6211095c5fe7fc5ef4ff94ebbfa"), + "plugins/UserCountry/images/flags/jo.png" => array("353", "447e0a243bacbf21a93abadc217530b4"), + "plugins/UserCountry/images/flags/jp.png" => array("307", "a25e6ad4f410e17091a1098f4455b9bf"), + "plugins/UserCountry/images/flags/ke.png" => array("435", "d7b94971ba7293909b27c84c715f08d2"), + "plugins/UserCountry/images/flags/kg.png" => array("354", "2fc98fb293749508ba3bc5d478e885ce"), + "plugins/UserCountry/images/flags/kh.png" => array("422", "b30a25ac2f5ecfa88a9fc5a7e2ee389c"), + "plugins/UserCountry/images/flags/ki.png" => array("551", "6bb6b40309a404b95276ac8c1409b177"), + "plugins/UserCountry/images/flags/km.png" => array("456", "65b5f51c6bd37c49a3c5598df077c92e"), + "plugins/UserCountry/images/flags/kn.png" => array("480", "8f6f9a3f041f10a64406e812a86a0604"), + "plugins/UserCountry/images/flags/kp.png" => array("424", "e4d4914b068ec1d9a5aa30c27ce5dffa"), + "plugins/UserCountry/images/flags/kr.png" => array("507", "7fe839454cadda986f93835b311fafe8"), + "plugins/UserCountry/images/flags/kw.png" => array("351", "57273db3fee5cbed627a9fe4137d1800"), + "plugins/UserCountry/images/flags/ky.png" => array("532", "d7de64a8dbb7e92c75f57b152ab22a0e"), + "plugins/UserCountry/images/flags/kz.png" => array("459", "f392017f7085082d391fa219dc198c93"), + "plugins/UserCountry/images/flags/la.png" => array("415", "1f881c45b18afdd50e58aec5cbcc5dec"), + "plugins/UserCountry/images/flags/lb.png" => array("393", "b310dae884660aeda0ab764b748f3601"), + "plugins/UserCountry/images/flags/lc.png" => array("462", "abe2a1f2dc0082a4efcb074ae34bd316"), + "plugins/UserCountry/images/flags/li.png" => array("399", "9f60f2b3ac0e3fd8ed97b9995171dfb1"), + "plugins/UserCountry/images/flags/lk.png" => array("464", "b7b781bc919c6f07591ba4e5c4c82038"), + "plugins/UserCountry/images/flags/lr.png" => array("365", "45f7777dd613ff68e50b12ffe7d72993"), + "plugins/UserCountry/images/flags/ls.png" => array("500", "df909606b1ba95c418a0d16df7b85ba6"), + "plugins/UserCountry/images/flags/lt.png" => array("345", "f59a5d82915ad225f66d3db1536a5c48"), + "plugins/UserCountry/images/flags/lu.png" => array("338", "697bc4d6e01977c8e6cd07ad30ea3015"), + "plugins/UserCountry/images/flags/lv.png" => array("339", "066cd44f363225e08bdd3a55e8b28a56"), + "plugins/UserCountry/images/flags/ly.png" => array("277", "e830f941ef36e6d5d2cf6a7d04995a36"), + "plugins/UserCountry/images/flags/ma.png" => array("293", "e1de39a33dcb622a58bb4f0f8804f46b"), + "plugins/UserCountry/images/flags/mc.png" => array("254", "fc73ac65437d6546b67e9fb3032eb7c5"), + "plugins/UserCountry/images/flags/md.png" => array("404", "5096eaf480a14028af90c15a950e5b5c"), + "plugins/UserCountry/images/flags/me.png" => array("394", "7dcb9e23a649b7a476a51e3654d9c40a"), + "plugins/UserCountry/images/flags/mf.png" => array("369", "d9062600b752c4fea4f3561920154724"), + "plugins/UserCountry/images/flags/mg.png" => array("313", "87207a6990f372ffe13da8dbcde69dcb"), + "plugins/UserCountry/images/flags/mh.png" => array("518", "83a9baf30759566bc98a2756fa79645c"), + "plugins/UserCountry/images/flags/mk.png" => array("449", "9666fff48fb95187610cb71eab755e22"), + "plugins/UserCountry/images/flags/ml.png" => array("322", "d88965b3532e2c354919d05c1aafcc08"), + "plugins/UserCountry/images/flags/mm.png" => array("336", "d28c3b10b62ca4be38f92763c39af558"), + "plugins/UserCountry/images/flags/mn.png" => array("343", "c5f1b63d69c8224535abcf9acd240fe0"), + "plugins/UserCountry/images/flags/mo.png" => array("456", "f7de32c7fc01e95776d3179663f3e1ae"), + "plugins/UserCountry/images/flags/mp.png" => array("481", "ab89359dc8bddea8c3c76782ef02664f"), + "plugins/UserCountry/images/flags/mq.png" => array("541", "02bb4f3d37eaa907d0573d84abb2da0a"), + "plugins/UserCountry/images/flags/mr.png" => array("408", "521673db6268518284cc26e2b2e8d74f"), + "plugins/UserCountry/images/flags/ms.png" => array("497", "fa296f8bfb2e4ac54f96d11682e6ac52"), + "plugins/UserCountry/images/flags/mt.png" => array("296", "e2f1112757abd443f4ed22875535b37a"), + "plugins/UserCountry/images/flags/mu.png" => array("360", "87c04283d8ee559893ebda052e7b233e"), + "plugins/UserCountry/images/flags/mv.png" => array("391", "c32cf906304a45879ed373134010327b"), + "plugins/UserCountry/images/flags/mw.png" => array("365", "50d75d8b8809943a60a175545b6946de"), + "plugins/UserCountry/images/flags/mx.png" => array("424", "562a94f110a3488776f48563a8c25370"), + "plugins/UserCountry/images/flags/my.png" => array("464", "d24895e58d70a5c93dc9c05708a4c8c6"), + "plugins/UserCountry/images/flags/mz.png" => array("439", "710edfb3da21e88c1eaa8ea0fb664989"), + "plugins/UserCountry/images/flags/na.png" => array("559", "9fe8cd53bc6aa19d550d01abf5ec89b3"), + "plugins/UserCountry/images/flags/nc.png" => array("470", "5cbd7427200b4094f237bdd45d373bb0"), + "plugins/UserCountry/images/flags/ne.png" => array("393", "debda1a3728c9c4e31e9d6a100ebefc6"), + "plugins/UserCountry/images/flags/nf.png" => array("474", "730d26f59272f66b437923915ad22251"), + "plugins/UserCountry/images/flags/ng.png" => array("341", "b89c573ee8ba57bc8425320ca64afd1f"), + "plugins/UserCountry/images/flags/ni.png" => array("372", "08126d0ea55a7e49f9c07368cf0dc98d"), + "plugins/UserCountry/images/flags/nl.png" => array("310", "ab1c7bbbd651992aa563ce983942f33a"), + "plugins/UserCountry/images/flags/no.png" => array("397", "6935c8100f0abefa6d95aa82306d4b16"), + "plugins/UserCountry/images/flags/np.png" => array("349", "9c2e7fc45dd7e74893f232eef70f7bc0"), + "plugins/UserCountry/images/flags/nr.png" => array("391", "b4010849f42c30b54afc100f8788126a"), + "plugins/UserCountry/images/flags/nt.png" => array("198", "d9d0f882ce22e2d253ad8ba0cf6c852b"), + "plugins/UserCountry/images/flags/nu.png" => array("468", "25aa9e4913ad5ae9e60336f646fb689d"), + "plugins/UserCountry/images/flags/nz.png" => array("529", "e05cbdd067b2c7e80d34c14869d229db"), + "plugins/UserCountry/images/flags/o1.png" => array("290", "f1c31042bade52bcf13d8bd778fcf37c"), + "plugins/UserCountry/images/flags/om.png" => array("339", "c994c0cebaafb1c5fa87320823425908"), + "plugins/UserCountry/images/flags/pa.png" => array("390", "5e4e0de6fceba0ff1aba0f514e68f91d"), + "plugins/UserCountry/images/flags/pe.png" => array("264", "9a8ca1b7553c317c93e9265349c64ec3"), + "plugins/UserCountry/images/flags/pf.png" => array("379", "5299b7a40ba88d1b68fc40c1aa26d969"), + "plugins/UserCountry/images/flags/pg.png" => array("438", "3cc130dec98fd482f3866aa2433cfd4a"), + "plugins/UserCountry/images/flags/ph.png" => array("416", "21506e0e83093563757005b2abe41de3"), + "plugins/UserCountry/images/flags/pk.png" => array("448", "d528a749bc9e65e8d4f4cc14d8972ee3"), + "plugins/UserCountry/images/flags/pl.png" => array("243", "8a37cec158680c7540f46d570d033bc7"), + "plugins/UserCountry/images/flags/pm.png" => array("572", "48491aa92b33d90b79203cc6bfa3d335"), + "plugins/UserCountry/images/flags/pn.png" => array("547", "817dbdd6d096f075a3c9441228e7c189"), + "plugins/UserCountry/images/flags/pr.png" => array("445", "39b1a64d719e96bd8ec3e3552adc4ab8"), + "plugins/UserCountry/images/flags/ps.png" => array("348", "efc702c7067b0844aa0de8415fc32522"), + "plugins/UserCountry/images/flags/pt.png" => array("407", "3f346b92635e99725cd1599750e3707f"), + "plugins/UserCountry/images/flags/pw.png" => array("424", "f8898bb2f627f26b00908b2a092d5676"), + "plugins/UserCountry/images/flags/py.png" => array("344", "316d698d7a73710f1a4029b139affa5f"), + "plugins/UserCountry/images/flags/qa.png" => array("343", "8bf82817ab9c6bbe8b8efddf0e2d073a"), + "plugins/UserCountry/images/flags/re.png" => array("369", "d9062600b752c4fea4f3561920154724"), + "plugins/UserCountry/images/flags/ro.png" => array("333", "1339430b14e6eb3223afb99b380327b7"), + "plugins/UserCountry/images/flags/rs.png" => array("376", "919a2922411c55b7a937d3022ac5e8d8"), + "plugins/UserCountry/images/flags/ru.png" => array("299", "ba00aed092784334418dbe95d845c16e"), + "plugins/UserCountry/images/flags/rw.png" => array("382", "65a180a4f224a0ecdb81f82635f7d4c0"), + "plugins/UserCountry/images/flags/sa.png" => array("428", "3fb73b67cd560459084d0518665e4dce"), + "plugins/UserCountry/images/flags/sb.png" => array("520", "4b59f117a420d114a1da826ab5a5ae83"), + "plugins/UserCountry/images/flags/sc.png" => array("481", "dc3491160c1d96eebcf51e879ff87431"), + "plugins/UserCountry/images/flags/sd.png" => array("364", "d657c4d6ef980b8165291dbe2c946718"), + "plugins/UserCountry/images/flags/se.png" => array("389", "8aa93fc2143c4a2c4bdeb394e97eddd6"), + "plugins/UserCountry/images/flags/sf.png" => array("368", "37777cbdf0781e0563757e48958e51e9"), + "plugins/UserCountry/images/flags/sg.png" => array("350", "bf8d88d0c82a753cb0f2251ca1da737b"), + "plugins/UserCountry/images/flags/sh.png" => array("524", "2c4eecfe866d08445d02161b91dc968c"), + "plugins/UserCountry/images/flags/si.png" => array("383", "32086e3fb9c161a52ae27126941f8b05"), + "plugins/UserCountry/images/flags/sj.png" => array("397", "6935c8100f0abefa6d95aa82306d4b16"), + "plugins/UserCountry/images/flags/sk.png" => array("439", "35dc3d0a7ecd995a4886073854273ce8"), + "plugins/UserCountry/images/flags/sl.png" => array("321", "b826798d48c8bbca0a233d5a5a1c805c"), + "plugins/UserCountry/images/flags/sm.png" => array("396", "5cecf1a8c7b1e7cc701ac895f1d2339e"), + "plugins/UserCountry/images/flags/sn.png" => array("356", "753dd84a311206fc06e7a5ea9bef91e7"), + "plugins/UserCountry/images/flags/so.png" => array("376", "62708904359cd14179be31f35c0cb825"), + "plugins/UserCountry/images/flags/sr.png" => array("370", "a9a1c0ef23dd3a874616b8a0c7ce0b28"), + "plugins/UserCountry/images/flags/ss.png" => array("422", "5058328bd522fd528778ebab10a68277"), + "plugins/UserCountry/images/flags/st.png" => array("429", "5de079956c1e527db8f21c12f0f3b7ba"), + "plugins/UserCountry/images/flags/su.png" => array("273", "c363974eb7f9a50a58b1132ad6b1235f"), + "plugins/UserCountry/images/flags/sv.png" => array("373", "7e76cb3e4ddd2acdc6a74dbbbeb083d9"), + "plugins/UserCountry/images/flags/sx.png" => array("416", "2e2d4e66d402c89252773c492b81db2a"), + "plugins/UserCountry/images/flags/sy.png" => array("322", "38b241abe7b34e15560dd826e32b8c6a"), + "plugins/UserCountry/images/flags/sz.png" => array("508", "df08b36f98d56d9848249f9f23458251"), + "plugins/UserCountry/images/flags/ta.png" => array("512", "cdb7b60ea75c5f97aa16c6b0b1d6f40a"), + "plugins/UserCountry/images/flags/tc.png" => array("509", "bde929f2a84e0d654780a8192fcb09ca"), + "plugins/UserCountry/images/flags/td.png" => array("378", "e844177adfb2b257380fae3cafcaadae"), + "plugins/UserCountry/images/flags/tf.png" => array("399", "6e1989551034dab8f010ef273b992316"), + "plugins/UserCountry/images/flags/tg.png" => array("410", "14d78aff10308b333b6499ac483ef2da"), + "plugins/UserCountry/images/flags/th.png" => array("327", "75e627f1a1103f0061a89444fd967099"), + "plugins/UserCountry/images/flags/ti.png" => array("811", "fc18b7c184a4bbceba248fb586add8c0"), + "plugins/UserCountry/images/flags/tj.png" => array("367", "04aa8a0ca380ce6fe8e1d0eafcbe6070"), + "plugins/UserCountry/images/flags/tk.png" => array("522", "22268e11d7d4067b7d87fb79292bb307"), + "plugins/UserCountry/images/flags/tl.png" => array("395", "a2ed00b82bac63017de36d0c9223d32f"), + "plugins/UserCountry/images/flags/tm.png" => array("456", "76e44b117033521dcb3071c7f58b5f29"), + "plugins/UserCountry/images/flags/tn.png" => array("367", "9d838d48a0a2bf001d4c6d3cd9925c96"), + "plugins/UserCountry/images/flags/to.png" => array("302", "d310830a17d23bd04709535f136c265d"), + "plugins/UserCountry/images/flags/tp.png" => array("395", "a2ed00b82bac63017de36d0c9223d32f"), + "plugins/UserCountry/images/flags/tr.png" => array("366", "66ead8c0309aeba400db1b52e5e826f9"), + "plugins/UserCountry/images/flags/tt.png" => array("486", "17688174ffff0fc1377fe0003db3b4bd"), + "plugins/UserCountry/images/flags/tv.png" => array("443", "34d00fe909063cb40ebcb4b2da2c6a26"), + "plugins/UserCountry/images/flags/tw.png" => array("330", "672f72f068987c462bf8fe49421fb2f3"), + "plugins/UserCountry/images/flags/tz.png" => array("514", "e94f7371cc5773322b28f676e12ec258"), + "plugins/UserCountry/images/flags/ua.png" => array("304", "db4d976dddb471cef4948dbe872fd810"), + "plugins/UserCountry/images/flags/ug.png" => array("388", "3fde945762cea9cad16e0e1511b4d98b"), + "plugins/UserCountry/images/flags/uk.png" => array("545", "373b8f3d6c92338b228a160e2a0d87fc"), + "plugins/UserCountry/images/flags/um.png" => array("455", "fb0a3a027a3cd591b92d726b182fe6af"), + "plugins/UserCountry/images/flags/us.png" => array("492", "36eefb4c632941c12b48071111485b4b"), + "plugins/UserCountry/images/flags/uy.png" => array("411", "aafef31fbd168ea85768d604efe65f2f"), + "plugins/UserCountry/images/flags/uz.png" => array("411", "237681a54efe6934209aefbfec30b397"), + "plugins/UserCountry/images/flags/va.png" => array("419", "060fa2a1d8c86e0789fda81efc292cbf"), + "plugins/UserCountry/images/flags/vc.png" => array("412", "302707497da91992ac19638c67a79cf3"), + "plugins/UserCountry/images/flags/ve.png" => array("412", "59de75a614781f76617dc4cb1e755878"), + "plugins/UserCountry/images/flags/vg.png" => array("510", "dccf0db1a1faf55688624d5339313c3a"), + "plugins/UserCountry/images/flags/vi.png" => array("523", "741e37e202f4049d36ade5a4200148c6"), + "plugins/UserCountry/images/flags/vn.png" => array("324", "d083243d22c9099070dbe6335dfb22aa"), + "plugins/UserCountry/images/flags/vu.png" => array("450", "4f1124454a864d07d675bb196eddb934"), + "plugins/UserCountry/images/flags/wf.png" => array("438", "849d8c881f1f72bc9a5f3332dc817e39"), + "plugins/UserCountry/images/flags/ws.png" => array("352", "5c38a0321507c42dad39bdcfab373d3a"), + "plugins/UserCountry/images/flags/xx.png" => array("290", "f1c31042bade52bcf13d8bd778fcf37c"), + "plugins/UserCountry/images/flags/ye.png" => array("302", "9ad11f5fb4821eaea4215ee278d3d8c3"), + "plugins/UserCountry/images/flags/yt.png" => array("456", "95aa4f9d04f3f08ce8f5cc722c1f7c95"), + "plugins/UserCountry/images/flags/yu.png" => array("321", "cd0e549ac62fab737357ab35c8494813"), + "plugins/UserCountry/images/flags/za.png" => array("523", "38986661e79041f7388529a46da2d3c3"), + "plugins/UserCountry/images/flags/zm.png" => array("359", "96b272c89f9775f2a8aca88497d660ae"), + "plugins/UserCountry/images/flags/zr.png" => array("380", "7bd1bcc513aac082fa6f6292b72edc0c"), + "plugins/UserCountry/images/flags/zw.png" => array("462", "29cc6d7fa4fb68eb909da5b1ee142bdd"), + "plugins/UserCountry/javascripts/userCountry.js" => array("8251", "cba3f6cb36fe9e75404d48a96ec2349b"), + "plugins/UserCountry/LocationProvider/Default.php" => array("3251", "31708eccdddce97672027891f9a6888b"), + "plugins/UserCountry/LocationProvider/GeoIp/Pecl.php" => array("11193", "e705086e0d6a9ed88a0571e58c7ee291"), + "plugins/UserCountry/LocationProvider/GeoIp.php" => array("8983", "f2c5d51c4d954ba83a6b79f52142e270"), + "plugins/UserCountry/LocationProvider/GeoIp/Php.php" => array("13353", "524c43039566f77e0bb99b97d0d89408"), + "plugins/UserCountry/LocationProvider/GeoIp/ServerBased.php" => array("10066", "cd4ab8bff2cfc6a1de0b660622771679"), + "plugins/UserCountry/LocationProvider.php" => array("16287", "a884a71eb8a2c99dd5e856d0c0120710"), + "plugins/UserCountryMap/Controller.php" => array("11481", "c1083ef590a08229977f908487188697"), + "plugins/UserCountryMap/images/cities.png" => array("267", "05566bd830e4a3225fa746eafc2534b1"), + "plugins/UserCountryMap/images/realtimemap-loading.gif" => array("308", "a41ca826560fe6eaeb46dd69b6d9dba2"), + "plugins/UserCountryMap/images/regions.png" => array("296", "98c1643253f8198b91297c5db80be6b0"), + "plugins/UserCountryMap/images/zoom-out-disabled.png" => array("270", "153e42ba1158ec475aa0e7e0f67acaac"), + "plugins/UserCountryMap/javascripts/realtime-map.js" => array("27190", "de5fbfd35a558cc047e3491a7f4f2e08"), + "plugins/UserCountryMap/javascripts/vendor/chroma.min.js" => array("25434", "33d7d9ebf37530751174e8ac63d83c95"), + "plugins/UserCountryMap/javascripts/vendor/jquery.qtip.min.js" => array("24441", "9b50d81ac4cb3194777a05429abb39d3"), + "plugins/UserCountryMap/javascripts/vendor/kartograph.min.js" => array("67544", "457051a2a34be85bbc921d0fdcb94351"), + "plugins/UserCountryMap/javascripts/vendor/raphael.min.js" => array("90648", "3af49700d08ae8f43d613218eec1f754"), + "plugins/UserCountryMap/javascripts/visitor-map.js" => array("71971", "d9d908d5c59527221ccd4098fcbcea48"), + "plugins/UserCountryMap/stylesheets/map.css" => array("1504", "843c9412e98b9da0ece4b8533017ddd8"), + "plugins/UserCountryMap/stylesheets/realtime-map.less" => array("3192", "c367e7663058456db63ffdcac5af6fea"), + "plugins/UserCountryMap/stylesheets/visitor-map.less" => array("4529", "659ac501b37cd5b2ee9dbdf78829f8ed"), + "plugins/UserCountryMap/svg/AFG.svg" => array("23731", "2093e033509062e7928f7917fa7a33c5"), + "plugins/UserCountryMap/svg/AF.svg" => array("40727", "4bb303e7ee6eb2b29caec82db8843123"), + "plugins/UserCountryMap/svg/AGO.svg" => array("11968", "820cf71105c9c3c1339ba3740ccc9ea8"), + "plugins/UserCountryMap/svg/ALB.svg" => array("9932", "7f854f3d2059296108821e9d533b05ad"), + "plugins/UserCountryMap/svg/ARE.svg" => array("8336", "ac56cc1c2abe3a47961db80b6db176c1"), + "plugins/UserCountryMap/svg/ARG.svg" => array("15590", "5d311fe12916296a82a30e1016acec0f"), + "plugins/UserCountryMap/svg/ARM.svg" => array("9259", "65b3fb425f03a1f1f0d259c6dd549bc7"), + "plugins/UserCountryMap/svg/AS.svg" => array("49832", "4ce9cb0f2027fea500199bde3c74734f"), + "plugins/UserCountryMap/svg/AUS.svg" => array("8193", "b2324da92c1f67f2a8f013abf3f06581"), + "plugins/UserCountryMap/svg/AUT.svg" => array("11567", "00d3a4679c9b265ea6fb5205d7951ce8"), + "plugins/UserCountryMap/svg/AZE.svg" => array("26750", "76ff6e1729843054d172f3d528c09965"), + "plugins/UserCountryMap/svg/BDI.svg" => array("9071", "42ea3eecafdfc12bbe1a2129ed18ec4e"), + "plugins/UserCountryMap/svg/BEL.svg" => array("12982", "3a99b3abe49b13c8dc7edb92f9e13ebb"), + "plugins/UserCountryMap/svg/BEN.svg" => array("7495", "842badf56f297049dfa63243cfac1494"), + "plugins/UserCountryMap/svg/BFA.svg" => array("22092", "837025e1f04060a6ca78ea1407ccfc30"), + "plugins/UserCountryMap/svg/BGD.svg" => array("16829", "16d661876d9b416ba731822312ce15bc"), + "plugins/UserCountryMap/svg/BGR.svg" => array("18695", "bba6aae17a231dfed22122ca7a66467a"), + "plugins/UserCountryMap/svg/BIH.svg" => array("12239", "60d8be06db5b01d1e15a798815ee281d"), + "plugins/UserCountryMap/svg/BLR.svg" => array("10822", "a396566e089711c3e3a5c2649bcfe447"), + "plugins/UserCountryMap/svg/BLZ.svg" => array("5017", "277dcaba5f6388fcbc1101806038fd78"), + "plugins/UserCountryMap/svg/BOL.svg" => array("11485", "a4f0080f779ce2088988872a33aa0028"), + "plugins/UserCountryMap/svg/BRA.svg" => array("20787", "96be5f2df617bdfa33b7c8ca354db113"), + "plugins/UserCountryMap/svg/BRB.svg" => array("3422", "1672e26d0267fce35d916e07d3f068d7"), + "plugins/UserCountryMap/svg/BRN.svg" => array("3571", "fa632d5303e8b920d76a268060f80705"), + "plugins/UserCountryMap/svg/BTN.svg" => array("25375", "785161e8ec1cc331c189fcd0789ec2dc"), + "plugins/UserCountryMap/svg/BWA.svg" => array("6630", "b3ac298a6413a11ab6e4f7a5baa28cf2"), + "plugins/UserCountryMap/svg/CAF.svg" => array("12600", "30c7e170a0ad49ee29e4a3e08fb9bea7"), + "plugins/UserCountryMap/svg/CAN.svg" => array("37420", "0630624406a2d42ab07398f6af67e407"), + "plugins/UserCountryMap/svg/CHE.svg" => array("18235", "4ac55015c22ce6c864c29a34d191084c"), + "plugins/UserCountryMap/svg/CHL.svg" => array("17090", "23cec4aa7438083af5caa3aa5d9d4550"), + "plugins/UserCountryMap/svg/CHN.svg" => array("35787", "fcf1dd8e8daf9c633413e0d20d6fcbdc"), + "plugins/UserCountryMap/svg/CIV.svg" => array("14584", "e39a2e8252c854aa92455355a4e21116"), + "plugins/UserCountryMap/svg/CMR.svg" => array("10175", "3a5ceb16302b5de7eea867abe8d0c57d"), + "plugins/UserCountryMap/svg/COD.svg" => array("13916", "eb8cbda4671accad54902624f7958e9a"), + "plugins/UserCountryMap/svg/COG.svg" => array("11594", "607ed58ad290b5c55b61916863da2bf6"), + "plugins/UserCountryMap/svg/COL.svg" => array("21538", "75a311f5331d14612fbaf2a64272cc6f"), + "plugins/UserCountryMap/svg/CRI.svg" => array("8063", "260aba99939b461dd3832b3705a5077f"), + "plugins/UserCountryMap/svg/CUB.svg" => array("10311", "b0042681c42ea286f4edae229579cb79"), + "plugins/UserCountryMap/svg/CYP.svg" => array("4941", "ba56757a6aecef1e9d2bf6c495308b1c"), + "plugins/UserCountryMap/svg/CZE.svg" => array("12588", "a8c33fd6d40a496d588be46fd124f93e"), + "plugins/UserCountryMap/svg/DEU.svg" => array("22903", "f8e7e84b8ff3e8676c6f2734b07a41b1"), + "plugins/UserCountryMap/svg/DJI.svg" => array("4116", "79feff33fda4eb002a7bcfe3d4ee7cf8"), + "plugins/UserCountryMap/svg/DMA.svg" => array("3482", "de751b6b8dd58c266b0f53b6fcaff158"), + "plugins/UserCountryMap/svg/DNK.svg" => array("9978", "d37dbea01e44e3118b3dee05a12349f5"), + "plugins/UserCountryMap/svg/DOM.svg" => array("14160", "e50ee7a6ca6cafe54381dfefbe4bd270"), + "plugins/UserCountryMap/svg/DZA.svg" => array("17878", "c2c3da63bb4c62d62603362e2d91c745"), + "plugins/UserCountryMap/svg/ECU.svg" => array("12539", "46d4aa8cb9ef09ec89dd2b9ecac7ea00"), + "plugins/UserCountryMap/svg/EGY.svg" => array("11510", "57f29b3e9ed28faf642afeaa52f0ea52"), + "plugins/UserCountryMap/svg/ERI.svg" => array("6973", "1544b71d14f85199264d33015788e01c"), + "plugins/UserCountryMap/svg/ESP.svg" => array("12764", "e4ad35deab6afad37dfbce809c99114e"), + "plugins/UserCountryMap/svg/EST.svg" => array("11928", "d3603a128708ae2bedaf23ecb7fee1cb"), + "plugins/UserCountryMap/svg/ETH.svg" => array("14322", "ba96e2b01c5819a0c7b351a2d0299459"), + "plugins/UserCountryMap/svg/EU.svg" => array("37771", "f938be05f59da930474ef1ffa8d9b722"), + "plugins/UserCountryMap/svg/FIN.svg" => array("21076", "dc397dce4ea165a2340e4d86d5eddd78"), + "plugins/UserCountryMap/svg/FJI.svg" => array("3094", "8373dc52335d4bd13f7b1387f1367ef0"), + "plugins/UserCountryMap/svg/FRA.svg" => array("21479", "c430b20f88c1c541390f0786c4d4f24b"), + "plugins/UserCountryMap/svg/FRO.svg" => array("3931", "1ffdfd6531763d207f644a2af8c492b2"), + "plugins/UserCountryMap/svg/GAB.svg" => array("9212", "1bd7259df6d85563e0b94a692f5baf41"), + "plugins/UserCountryMap/svg/GBR.svg" => array("17933", "672532d6a3646ea21313b4fe35355b74"), + "plugins/UserCountryMap/svg/GEO.svg" => array("10043", "c98aeeb910d5239379fcd87417aae0cf"), + "plugins/UserCountryMap/svg/GHA.svg" => array("12455", "fcb4f5dfe83d978d606501ab10415fbe"), + "plugins/UserCountryMap/svg/GIN.svg" => array("11411", "1f0b064b1ae92d526c43516a823d0f8c"), + "plugins/UserCountryMap/svg/GMB.svg" => array("4682", "3c8d579078d005782115b305462076ae"), + "plugins/UserCountryMap/svg/GNB.svg" => array("10033", "2732f9970121ea276b11f2c7ab347bf2"), + "plugins/UserCountryMap/svg/GNQ.svg" => array("3860", "a72dc1d82fbb9a191e3d53e7e75df20a"), + "plugins/UserCountryMap/svg/GRC.svg" => array("21380", "6e773ddfec71392f4b12d52511465e1b"), + "plugins/UserCountryMap/svg/GRL.svg" => array("29909", "6f25a107701d35d72cc0e07a02266cef"), + "plugins/UserCountryMap/svg/GTM.svg" => array("11659", "56b23c7702d279d54199bf7db4693275"), + "plugins/UserCountryMap/svg/GUY.svg" => array("10473", "45b785bd1bb1c27d14271be087d25858"), + "plugins/UserCountryMap/svg/HND.svg" => array("12541", "dd0bee49d2939646662fc82f4cd68601"), + "plugins/UserCountryMap/svg/HRV.svg" => array("17755", "ad42b1e6c8995052c3220a19c20d3186"), + "plugins/UserCountryMap/svg/HTI.svg" => array("8205", "c201fc50f2b16728df0719fced87f6bb"), + "plugins/UserCountryMap/svg/HUN.svg" => array("15307", "dad18c1268029e49b32385a83011aac8"), + "plugins/UserCountryMap/svg/IDN.svg" => array("21243", "3c2ce292ee278726338c864b123a7248"), + "plugins/UserCountryMap/svg/IND.svg" => array("33242", "67bb8d7c4ea0e5e39e0fd0975eb8de62"), + "plugins/UserCountryMap/svg/IRL.svg" => array("21843", "188c415933b978928dab19be26334d19"), + "plugins/UserCountryMap/svg/IRN.svg" => array("19271", "8740584a94e528a316a735a64a33e1da"), + "plugins/UserCountryMap/svg/IRQ.svg" => array("11646", "ed8ceceb5f036c290e56cc664eddc6f7"), + "plugins/UserCountryMap/svg/ISL.svg" => array("15230", "ddbb0b48e5c1733da330d4a98bed6751"), + "plugins/UserCountryMap/svg/ISR.svg" => array("6700", "d82ce823e52a4929f958291dad45863b"), + "plugins/UserCountryMap/svg/ITA.svg" => array("20456", "13f79c748b1e7137385190fdbb1a17a6"), + "plugins/UserCountryMap/svg/JAM.svg" => array("5762", "4e45064fc839de5ad9fba0f4264fcb93"), + "plugins/UserCountryMap/svg/JOR.svg" => array("6856", "bea29d2ae20c173248e95e1f838eb973"), + "plugins/UserCountryMap/svg/JPN.svg" => array("13023", "76bffd77f2b0b547debffc9c4dd2c7b3"), + "plugins/UserCountryMap/svg/KAZ.svg" => array("15929", "35da71a3bbe0a9db3b457d60ea25f1f5"), + "plugins/UserCountryMap/svg/KEN.svg" => array("8926", "c046709428eed0f6357ac6774e4fe2ee"), + "plugins/UserCountryMap/svg/KGZ.svg" => array("11119", "5baa6549491f424686182b3ce58a1902"), + "plugins/UserCountryMap/svg/KHM.svg" => array("16542", "2331a87180f4214e4eefa3efdac13e7b"), + "plugins/UserCountryMap/svg/KOR.svg" => array("15668", "435ab9abe0563594e545a8a670b60904"), + "plugins/UserCountryMap/svg/KWT.svg" => array("4015", "7616986c0f50869cbd7f803479e9cd1b"), + "plugins/UserCountryMap/svg/LAO.svg" => array("16993", "2a20adc6e7355f5c78231b622586ade5"), + "plugins/UserCountryMap/svg/LBN.svg" => array("6185", "2c73a84358417f7b2a9e5fd8a0a895b9"), + "plugins/UserCountryMap/svg/LBR.svg" => array("9567", "d01ff6bbd8c7ad4b742076e8696bdc85"), + "plugins/UserCountryMap/svg/LBY.svg" => array("10633", "5269896a2f3249c72e4dca03942f64dc"), + "plugins/UserCountryMap/svg/LKA.svg" => array("24701", "1a40f9c005c0d7e221dfeec48cadb393"), + "plugins/UserCountryMap/svg/LSO.svg" => array("8233", "a8f41221b30f121a978a22993f5c60fd"), + "plugins/UserCountryMap/svg/LTU.svg" => array("10762", "79288cb5afc370cae5545046abd8c950"), + "plugins/UserCountryMap/svg/LUX.svg" => array("5508", "f011778f5fb5b257dad08ae442b5ede7"), + "plugins/UserCountryMap/svg/LVA.svg" => array("16373", "1f58c503f439d1a4eb503a71210bdace"), + "plugins/UserCountryMap/svg/MAR.svg" => array("9031", "ad553a3b9140b0a8d5416a5edc8b7a19"), + "plugins/UserCountryMap/svg/MDA.svg" => array("20701", "56361ce864b7769dc0f6a4b858fe5e49"), + "plugins/UserCountryMap/svg/MDG.svg" => array("7909", "0074f845fac4627eb62addc0d7e00bb6"), + "plugins/UserCountryMap/svg/MEX.svg" => array("18091", "8c2be2255541d900867b8a6af14da261"), + "plugins/UserCountryMap/svg/MKD.svg" => array("31645", "05a9065d09d3c927caf54e00253a6da9"), + "plugins/UserCountryMap/svg/MLI.svg" => array("10667", "2a83206dc55014733030b17c6ff3d0c9"), + "plugins/UserCountryMap/svg/MMR.svg" => array("23662", "538b7786bc8906b06cde59ffb00b3c13"), + "plugins/UserCountryMap/svg/MNE.svg" => array("11388", "8c7776e6e50a5b85f1a8ea00321696e7"), + "plugins/UserCountryMap/svg/MNG.svg" => array("13248", "4931476b7761f0afd5d62e03c8147a5e"), + "plugins/UserCountryMap/svg/MOZ.svg" => array("11874", "05b9e9783e1ec2e5c87fd470d7312089"), + "plugins/UserCountryMap/svg/MRT.svg" => array("7109", "9383cca951fabfb2310603499a9a5c29"), + "plugins/UserCountryMap/svg/MWI.svg" => array("13547", "f980015835b3b2c6aec4188b83e5ba7d"), + "plugins/UserCountryMap/svg/MYS.svg" => array("11560", "04b76af26905b4cc7d0dc88eb3ccab0c"), + "plugins/UserCountryMap/svg/NAM.svg" => array("8666", "0eff414f6cd6718f034983719f488e45"), + "plugins/UserCountryMap/svg/NA.svg" => array("20688", "0eab8eac03efa7496aefd47007c3c642"), + "plugins/UserCountryMap/svg/NCL.svg" => array("3512", "8cc2afb213ee4636d2a8097757bcff7d"), + "plugins/UserCountryMap/svg/NER.svg" => array("6853", "e03c20fd94e0e62daca6c44e67d3b047"), + "plugins/UserCountryMap/svg/NFK.svg" => array("1016", "2a686707837ba4350d5a6209bbad772c"), + "plugins/UserCountryMap/svg/NGA.svg" => array("22091", "a050f9a00e6564a405a60a9e686e2a25"), + "plugins/UserCountryMap/svg/NIC.svg" => array("11167", "71c5ff669f9559869a1dbe0e057fd138"), + "plugins/UserCountryMap/svg/NLD.svg" => array("13533", "87ac2120d90df9341cbb7152e165d292"), + "plugins/UserCountryMap/svg/NOR.svg" => array("24959", "a7bd303ba1a09f0671d900f3cd7bab3a"), + "plugins/UserCountryMap/svg/NPL.svg" => array("19029", "7b2e2ea575a176766423c1dce432bd1b"), + "plugins/UserCountryMap/svg/NZL.svg" => array("9557", "111db079f5c4fef71052eec06446b229"), + "plugins/UserCountryMap/svg/OC.svg" => array("12434", "9f66f814498cc881b9694b29ed867371"), + "plugins/UserCountryMap/svg/OMN.svg" => array("6845", "372b4839372036bed607ba8c57c1c122"), + "plugins/UserCountryMap/svg/PAK.svg" => array("17475", "7f415118635e8e6ea456315f4b785606"), + "plugins/UserCountryMap/svg/PAN.svg" => array("11485", "c5da412bf9299e9de716fdece711ffa4"), + "plugins/UserCountryMap/svg/PER.svg" => array("17155", "be452b05f5a8e8d0f944c707b30ebb5b"), + "plugins/UserCountryMap/svg/PHL.svg" => array("29008", "c5ff0c378ba8bfa5e5894c22a915e080"), + "plugins/UserCountryMap/svg/PNG.svg" => array("12054", "c47a2c78a172ea17074d251e1843afa7"), + "plugins/UserCountryMap/svg/POL.svg" => array("14906", "1ee62f5428cc9ed4900a7667934ea06d"), + "plugins/UserCountryMap/svg/PRK.svg" => array("10398", "32e4b3a83d4ce8a75676bf402bc742e0"), + "plugins/UserCountryMap/svg/PRT.svg" => array("12377", "b90eb86c4816e6e9e2e2d29bf48a4d37"), + "plugins/UserCountryMap/svg/PRY.svg" => array("10349", "566abd56870902313ca8d004d9dadd0a"), + "plugins/UserCountryMap/svg/QAT.svg" => array("4459", "e4b0d267c76f0280cd42783129ee03b3"), + "plugins/UserCountryMap/svg/ROU.svg" => array("22605", "5a53d3b6e98992cb8bc345f0b277a8c7"), + "plugins/UserCountryMap/svg/RUS.svg" => array("51418", "ddea31beacbf27b77997c70329850942"), + "plugins/UserCountryMap/svg/RWA.svg" => array("6166", "eba84185436f49ebb6ae86bf6940af77"), + "plugins/UserCountryMap/svg/SA.svg" => array("19974", "dc56c3dae75129b048a60082bc7ab1b3"), + "plugins/UserCountryMap/svg/SAU.svg" => array("12191", "8e90f6ae44f247688013e0afe1d9324d"), + "plugins/UserCountryMap/svg/SDN.svg" => array("10582", "fa4f019324b16748f036d7e0ac1be268"), + "plugins/UserCountryMap/svg/SDS.svg" => array("9392", "dcc613f18014f156f121f8c2f7132f5d"), + "plugins/UserCountryMap/svg/SEN.svg" => array("10343", "881f8e0aaac32bdc49a9d0d7b4a63b73"), + "plugins/UserCountryMap/svg/SLB.svg" => array("4919", "955697719301436339a43477018b7975"), + "plugins/UserCountryMap/svg/SLE.svg" => array("6559", "42b25def79d22704f6cc76e7c0099c31"), + "plugins/UserCountryMap/svg/SLV.svg" => array("9294", "2bf77afa3e8caa4f1b00ae56f1649d68"), + "plugins/UserCountryMap/svg/SOM.svg" => array("6512", "26891247c4cb7abd5799da27a04b0b4e"), + "plugins/UserCountryMap/svg/SRB.svg" => array("21289", "2726a37123fbf80667206d3b3c3a7fb0"), + "plugins/UserCountryMap/svg/SUR.svg" => array("9441", "101bf443ca5329dafe4e4e027f177a20"), + "plugins/UserCountryMap/svg/SVK.svg" => array("10004", "d334bb3ae89b539679805ae28708394f"), + "plugins/UserCountryMap/svg/SVN.svg" => array("10197", "1ede9888c6d7990215823280b869e000"), + "plugins/UserCountryMap/svg/SWE.svg" => array("24034", "dc7f02aad153f19b91a32d379740f509"), + "plugins/UserCountryMap/svg/SWZ.svg" => array("3979", "05c2a0a2fba646300b749f8ab994a6a4"), + "plugins/UserCountryMap/svg/SYR.svg" => array("8819", "921ec6d095338437c680a713adb7d035"), + "plugins/UserCountryMap/svg/TCD.svg" => array("10905", "06ff353ea4e6e8345092264ddda58113"), + "plugins/UserCountryMap/svg/TGO.svg" => array("6991", "9e0c754e5c2615df3307fcb146503774"), + "plugins/UserCountryMap/svg/THA.svg" => array("36264", "46fffedb3489c67a1390cedae073de1d"), + "plugins/UserCountryMap/svg/TIB.svg" => array("16179", "b234a01aa6eceecf3918aefe5d4b52eb"), + "plugins/UserCountryMap/svg/TJK.svg" => array("11230", "d8de7e9d549ee744d5d64b521af213b2"), + "plugins/UserCountryMap/svg/TKM.svg" => array("8157", "1613ad868776a419e9e1cd96544b99e5"), + "plugins/UserCountryMap/svg/TLS.svg" => array("7109", "142856e1184a849f7e6005563be08077"), + "plugins/UserCountryMap/svg/tmp.svg" => array("21076", "dc397dce4ea165a2340e4d86d5eddd78"), + "plugins/UserCountryMap/svg/TTO.svg" => array("5435", "cff25edb4b12be208181830adab5a7d7"), + "plugins/UserCountryMap/svg/TUN.svg" => array("12961", "e05a3732f54dbfb898569f6c934cb7a7"), + "plugins/UserCountryMap/svg/TUR.svg" => array("32959", "4a3b1ff120ba27f0ef59759959f00d54"), + "plugins/UserCountryMap/svg/TWN.svg" => array("11996", "2437ad3a020bc3ca36d5ea52970ca350"), + "plugins/UserCountryMap/svg/TZA.svg" => array("15761", "6f35aebaeb542e2321d8407835e06db8"), + "plugins/UserCountryMap/svg/UGA.svg" => array("29417", "628c72ea62f742ae52d24ce27dd2037e"), + "plugins/UserCountryMap/svg/UKR.svg" => array("19828", "37e8ea0894ea61c5bbdccd96277ba095"), + "plugins/UserCountryMap/svg/URY.svg" => array("11658", "3176fa145c40a311a2e008149062f073"), + "plugins/UserCountryMap/svg/USA.svg" => array("42615", "81e5d5548e22e69a2b9cbd3e0c0c0d2d"), + "plugins/UserCountryMap/svg/UZB.svg" => array("12320", "ed73e6f04614f80a9a465fcc3f23f47e"), + "plugins/UserCountryMap/svg/VEN.svg" => array("15675", "1917405c4f63d358a9cbb82db61d2bd8"), + "plugins/UserCountryMap/svg/VNM.svg" => array("27766", "83c7dea05690c6308d9a63fa0f1e2565"), + "plugins/UserCountryMap/svg/VUT.svg" => array("4912", "0856c96bc733bbcca28dd68991f097d7"), + "plugins/UserCountryMap/svg/world.svg" => array("111845", "5955072d8e13d9f074aeb1b0a7c4cd69"), + "plugins/UserCountryMap/svg/YEM.svg" => array("10632", "0b124919400cbb8587811fe5c23664ff"), + "plugins/UserCountryMap/svg/ZAF.svg" => array("10216", "ea4566c8b873e02a443efde301ebafd3"), + "plugins/UserCountryMap/svg/ZMB.svg" => array("11277", "e79d992f232139d9bb4c971c57fd1803"), + "plugins/UserCountryMap/svg/ZWE.svg" => array("10120", "00eb861cd623329b917a94aacaf09b97"), + "plugins/UserCountryMap/templates/realtimeMap.twig" => array("1455", "aa4b22cc760a6dcc5690a6420aaafce8"), + "plugins/UserCountryMap/templates/visitorMap.twig" => array("5543", "0b26ebd13591d082358483483427eaf6"), + "plugins/UserCountryMap/UserCountryMap.php" => array("2953", "6cf2a6f2c7e90fbbcfae3edd21419454"), + "plugins/UserCountry/stylesheets/userCountry.less" => array("1122", "17952f305542ff7aa15daf21f7d1deda"), + "plugins/UserCountry/templates/adminIndex.twig" => array("6899", "4ec9965bec4e48d45de0e029b51ab6d9"), + "plugins/UserCountry/templates/getGeoIpUpdaterManageScreen.twig" => array("48", "b3ffc6408921cada77599d32ae546511"), + "plugins/UserCountry/templates/index.twig" => array("785", "12f762bb3dff29bb477eff7fff7238c2"), + "plugins/UserCountry/templates/_updaterManage.twig" => array("3168", "87f3d8ef21402d491ec9c19203fd4ed7"), + "plugins/UserCountry/templates/_updaterNextRunTime.twig" => array("386", "9761b8bd24bd0ebd0461a560dcb23d22"), + "plugins/UserCountry/UserCountry.php" => array("21375", "6a03bbebc2f88b09ddc859d37e8b1aa3"), + "plugins/UserSettings/API.php" => array("10763", "f32d5ea9844711996d18085b7d5ec1b9"), + "plugins/UserSettings/Archiver.php" => array("6825", "1cd51e9b6b66f6a066e301b058c1fbd4"), + "plugins/UserSettings/Controller.php" => array("2023", "0628ed431135bd3fac8ad989088df785"), + "plugins/UserSettings/functions.php" => array("6653", "3444e02fdef24b992dd36e164e3fa10a"), + "plugins/UserSettings/images/browsers/AA.gif" => array("1092", "dc62eff78dac919b00e60c5fb3e6266d"), + "plugins/UserSettings/images/browsers/AB.gif" => array("1064", "1123da862a558b1ccd8f9008c5c4fdcb"), + "plugins/UserSettings/images/browsers/AG.gif" => array("351", "6e793ad6ad5c69abc499422d6a43d836"), + "plugins/UserSettings/images/browsers/AM.gif" => array("198", "18c54fc3197f6e1533c06b0923db3bd0"), + "plugins/UserSettings/images/browsers/AN.gif" => array("144", "a1264f47256ff5b0d4feeeac833e7a96"), + "plugins/UserSettings/images/browsers/AR.gif" => array("1057", "377249d199156ee602e669c0afc02945"), + "plugins/UserSettings/images/browsers/AV.gif" => array("151", "f459c84d6ab90fdf0f35d20f9f82626d"), + "plugins/UserSettings/images/browsers/AW.gif" => array("574", "e4e4fa2ef432f2a86086ec58f4b27ab1"), + "plugins/UserSettings/images/browsers/B2.gif" => array("1070", "e1b9f6b47cffbdf05b4159d6a31b21ae"), + "plugins/UserSettings/images/browsers/BB.gif" => array("576", "d40bd99ba1dd881b164907341ec2079d"), + "plugins/UserSettings/images/browsers/BD.gif" => array("1051", "4a8ebfcd7aad4c1004b7f82b954b0a69"), + "plugins/UserSettings/images/browsers/BE.gif" => array("1042", "103994d17de92aa261c8034a8e35a84f"), + "plugins/UserSettings/images/browsers/BP.gif" => array("1070", "e1b9f6b47cffbdf05b4159d6a31b21ae"), + "plugins/UserSettings/images/browsers/BX.gif" => array("522", "7302ad862f4007c23efb73acbd41f5c0"), + "plugins/UserSettings/images/browsers/CA.gif" => array("573", "739fca054b61f68657b0bd349c958a86"), + "plugins/UserSettings/images/browsers/CD.gif" => array("1045", "b5484f5fc254abd52cdc94f378be977a"), + "plugins/UserSettings/images/browsers/CF.gif" => array("1074", "e349a7dbdadf2fca33cca13287b0eba8"), + "plugins/UserSettings/images/browsers/CH.gif" => array("1074", "e349a7dbdadf2fca33cca13287b0eba8"), + "plugins/UserSettings/images/browsers/CK.gif" => array("1024", "b6d4ebb0394c48dfcb5f21475de5c79b"), + "plugins/UserSettings/images/browsers/CM.gif" => array("1074", "e349a7dbdadf2fca33cca13287b0eba8"), + "plugins/UserSettings/images/browsers/CN.gif" => array("998", "cd878afb8cc56e7c8c6caef6d5fb2ba4"), + "plugins/UserSettings/images/browsers/CO.gif" => array("1042", "195487c9db2e19ffae3fd443b9266e62"), + "plugins/UserSettings/images/browsers/CP.gif" => array("998", "af47e47253591c272a458c4645fbaf5c"), + "plugins/UserSettings/images/browsers/CS.gif" => array("549", "eb5151f2f46fc09d687ce27becefb831"), + "plugins/UserSettings/images/browsers/DF.gif" => array("545", "f4b65ebcf304f1675088d029ed613d28"), + "plugins/UserSettings/images/browsers/DI.gif" => array("1068", "4eceaf5fd7808c422b67026c9329e16f"), + "plugins/UserSettings/images/browsers/EL.gif" => array("90", "b515db820d883f921d05d15a34dda7f9"), + "plugins/UserSettings/images/browsers/EP.gif" => array("316", "660436cc97429ef52365a01084b40ee0"), + "plugins/UserSettings/images/browsers/ES.gif" => array("1013", "92ada780fce5e3ffc318de634eba4d21"), + "plugins/UserSettings/images/browsers/FB.gif" => array("254", "24663f1949bae4fb7ecd0504c9c872fd"), + "plugins/UserSettings/images/browsers/FD.gif" => array("1050", "a569dc9dbe1b95ce2763bee8dbdc5850"), + "plugins/UserSettings/images/browsers/FE.gif" => array("550", "a016a52d476c4be7943e68ccf2056db1"), + "plugins/UserSettings/images/browsers/FF.gif" => array("197", "6b4eecf673d5461aebdd432bca086e8c"), + "plugins/UserSettings/images/browsers/FL.gif" => array("1034", "0ce889dc81db377eeb00f76f72942274"), + "plugins/UserSettings/images/browsers/FN.gif" => array("1033", "e87473b14680939e58a9a94e9dcd39e3"), + "plugins/UserSettings/images/browsers/GA.gif" => array("159", "576c4646cf6938dd2079516293d3a3f9"), + "plugins/UserSettings/images/browsers/GE.gif" => array("997", "a3d96e8576f273ecc4a864d24620fbf2"), + "plugins/UserSettings/images/browsers/HA.gif" => array("1009", "fccae707e311bd009be90a7b38000082"), + "plugins/UserSettings/images/browsers/HJ.gif" => array("1022", "8c3019d1e0867e8455d0abf1ad3eb531"), + "plugins/UserSettings/images/browsers/IA.gif" => array("391", "1836a7db0bc6a4a4d8b3d6063346e3f5"), + "plugins/UserSettings/images/browsers/IB.gif" => array("168", "b091c3e8ce2789017d581089028fa1cc"), + "plugins/UserSettings/images/browsers/IC.gif" => array("131", "26a6ff98d316092214c9dc9e7be45224"), + "plugins/UserSettings/images/browsers/ID.gif" => array("1057", "6f1f33dc4bd104e0a60000ce49e719a9"), + "plugins/UserSettings/images/browsers/IE.gif" => array("999", "5e002ee72167a3a78e2252766fde1046"), + "plugins/UserSettings/images/browsers/IR.gif" => array("610", "565b1e3acd514c1c9b88d6c2b0ec0c4d"), + "plugins/UserSettings/images/browsers/IW.gif" => array("1066", "8d3376b6699ccd4533191b38d7f6b8c4"), + "plugins/UserSettings/images/browsers/KI.gif" => array("1050", "497b4bc9b58c113ae72763559321071b"), + "plugins/UserSettings/images/browsers/KM.gif" => array("180", "3daa5fa7553d448cd280612491e8a6ea"), + "plugins/UserSettings/images/browsers/KO.gif" => array("986", "0d15a2d4a73582d1df109e0ff3463fd3"), + "plugins/UserSettings/images/browsers/KP.gif" => array("1037", "f29fb41537f7df91d827c3f6eea076a0"), + "plugins/UserSettings/images/browsers/KZ.gif" => array("1061", "cdc654ad5f3f5fdda6ac69ea7d7dbe31"), + "plugins/UserSettings/images/browsers/LI.gif" => array("104", "a3b302b3fffc9ed032add149d4ace210"), + "plugins/UserSettings/images/browsers/LS.gif" => array("1086", "c61646736ea4872a7e58bcd29716d778"), + "plugins/UserSettings/images/browsers/LX.gif" => array("104", "a3b302b3fffc9ed032add149d4ace210"), + "plugins/UserSettings/images/browsers/MC.gif" => array("1023", "0e17db9ed1e06feb1d23d134ce34693d"), + "plugins/UserSettings/images/browsers/MI.gif" => array("1025", "5e63fceac90a88f1db14b4e8ee44201d"), + "plugins/UserSettings/images/browsers/MO.gif" => array("192", "67b5dac21e8f2243a955f1d9df7ef67e"), + "plugins/UserSettings/images/browsers/MS.gif" => array("1094", "265861a05c27b23013cb6ae3c428dff0"), + "plugins/UserSettings/images/browsers/MX.gif" => array("985", "4f6f87c42bf5c6bfc2b63925da5e40c1"), + "plugins/UserSettings/images/browsers/NB.gif" => array("977", "d2fac7549889df9f1c0863b424543c6f"), + "plugins/UserSettings/images/browsers/NF.gif" => array("612", "7cb0d2713e9faf25b766ca0d13cf456b"), + "plugins/UserSettings/images/browsers/NL.gif" => array("1081", "f66412328676120ba3cc0eb987c16158"), + "plugins/UserSettings/images/browsers/NP.gif" => array("1020", "ba7d68a0f9c11647abba2f8454a7c34c"), + "plugins/UserSettings/images/browsers/NS.gif" => array("98", "cd8d53ec12b64294d16769dfeeaf07c7"), + "plugins/UserSettings/images/browsers/OB.gif" => array("1010", "67b1d28deccc92200bdc3ce4a86612c3"), + "plugins/UserSettings/images/browsers/ON.gif" => array("635", "f7d7eb7f8cec24f0c192e30fe29ea320"), + "plugins/UserSettings/images/browsers/OP.gif" => array("987", "ac0432440ad48154a6434675b1d9c27a"), + "plugins/UserSettings/images/browsers/OR.gif" => array("1024", "30e874c346325cd40cf58f98944ea603"), + "plugins/UserSettings/images/browsers/OV.gif" => array("978", "7e98fecee01438d561b791339281bd47"), + "plugins/UserSettings/images/browsers/OW.gif" => array("197", "b66e88cbb941f9ac326f43e0d993e572"), + "plugins/UserSettings/images/browsers/PL.gif" => array("1058", "759fa0100429b3b3a4dc88894454fd8a"), + "plugins/UserSettings/images/browsers/PM.gif" => array("1082", "271bd4b89d06a9f9a9553b5da9053bd3"), + "plugins/UserSettings/images/browsers/PO.gif" => array("1065", "ac773ea28693335de8e8ac62f7d20a0d"), + "plugins/UserSettings/images/browsers/PU.gif" => array("1094", "1ea4a15e1b326c28158b137e4e8d07af"), + "plugins/UserSettings/images/browsers/PW.gif" => array("1082", "ac66922861d77e9949a72f86622e0c2f"), + "plugins/UserSettings/images/browsers/PX.gif" => array("170", "0bd86aa95e1ae0d5975cdc2d93210e30"), + "plugins/UserSettings/images/browsers/RK.gif" => array("1035", "6b87220087449e134062214fbf72f5a8"), + "plugins/UserSettings/images/browsers/SA.gif" => array("1008", "921467088fc56c5c9cdd0bb6e58250bf"), + "plugins/UserSettings/images/browsers/SF.gif" => array("190", "589361249f74319b57ea98d6408bc4b3"), + "plugins/UserSettings/images/browsers/SL.gif" => array("900", "1210e399e978978390cfdfd9d79159e6"), + "plugins/UserSettings/images/browsers/SM.gif" => array("391", "1836a7db0bc6a4a4d8b3d6063346e3f5"), + "plugins/UserSettings/images/browsers/TB.gif" => array("1014", "79bf7ed3ad92d3da09737d2fcd3913aa"), + "plugins/UserSettings/images/browsers/TI.gif" => array("595", "2c09db5f54b47472971d863e183159a8"), + "plugins/UserSettings/images/browsers/TZ.gif" => array("973", "5858a8b149e45749424bdf2da7ebefa3"), + "plugins/UserSettings/images/browsers/UC.gif" => array("994", "d9622ea01cb9093592858da53443c200"), + "plugins/UserSettings/images/browsers/UN.gif" => array("80", "c4a7f0e333a6079fdb0b6595e11bca74"), + "plugins/UserSettings/images/browsers/UNK.gif" => array("80", "c4a7f0e333a6079fdb0b6595e11bca74"), + "plugins/UserSettings/images/browsers/WE.gif" => array("1012", "cce9216ee7bd3ef52a46003d249ab540"), + "plugins/UserSettings/images/browsers/WO.gif" => array("1065", "ed1504717c9af523e30c33908126c4ad"), + "plugins/UserSettings/images/browsers/WP.gif" => array("982", "5bba1edfb42ce1b96551f81af0be08a1"), + "plugins/UserSettings/images/browsers/YA.gif" => array("1048", "8d94386ab4796664de7b897dd2106c9c"), + "plugins/UserSettings/images/os/3DS.gif" => array("1085", "262b44579aadcf90973653ff3e759cc7"), + "plugins/UserSettings/images/os/AIX.gif" => array("176", "58a60503a8e92493153694d1d97d2f6d"), + "plugins/UserSettings/images/os/AMG.gif" => array("1001", "5d67b7bb52ed746480573c1600d9a34c"), + "plugins/UserSettings/images/os/AMI.gif" => array("1055", "ef341c4cc2ec3bbf860c7d3bfe685326"), + "plugins/UserSettings/images/os/AND.gif" => array("144", "a1264f47256ff5b0d4feeeac833e7a96"), + "plugins/UserSettings/images/os/ARL.gif" => array("947", "913d273e01b9031f5113fb82ce63a591"), + "plugins/UserSettings/images/os/BBX.gif" => array("590", "e5cff6836abf100d9d8310d9dbb9f5d4"), + "plugins/UserSettings/images/os/BEO.gif" => array("1035", "ae4420933ac47a072d0f47759ef830c2"), + "plugins/UserSettings/images/os/BLB.gif" => array("576", "d40bd99ba1dd881b164907341ec2079d"), + "plugins/UserSettings/images/os/BSD.gif" => array("1016", "1dc9b76bb3fc8f5529e9abe9ac8841fa"), + "plugins/UserSettings/images/os/BTR.gif" => array("946", "cbf9b74ee2db7714ed6d6432eff3f7c8"), + "plugins/UserSettings/images/os/CES.gif" => array("1011", "0cdd142972c3cc89b2ceb3b7b97e1321"), + "plugins/UserSettings/images/os/COS.gif" => array("1074", "e349a7dbdadf2fca33cca13287b0eba8"), + "plugins/UserSettings/images/os/DFB.gif" => array("326", "d61f11a900d520ef515eaa139176a5f1"), + "plugins/UserSettings/images/os/DSI.gif" => array("1076", "5c475f3ba76f4ec3b626e720574bcb37"), + "plugins/UserSettings/images/os/FED.gif" => array("1022", "e86e6f5aec6c32de7eca913a9f91c3ab"), + "plugins/UserSettings/images/os/FOS.gif" => array("197", "6b4eecf673d5461aebdd432bca086e8c"), + "plugins/UserSettings/images/os/GNT.gif" => array("1075", "4196a85df43e6a5593941dcf8262416f"), + "plugins/UserSettings/images/os/GTV.gif" => array("1614", "a032dd001e1a5755201a6263a669ca49"), + "plugins/UserSettings/images/os/HPX.gif" => array("191", "999717c37d76ca099a06cf77a781bdbf"), + "plugins/UserSettings/images/os/IOS.gif" => array("591", "0bd1b8e09506ae6f9ed6ab1b18acf552"), + "plugins/UserSettings/images/os/IPA.gif" => array("587", "9f247437bc140cc6e70ec59f34bcddb1"), + "plugins/UserSettings/images/os/IPD.gif" => array("351", "a215ada2aefcbca876055fe7a2f7c039"), + "plugins/UserSettings/images/os/IPH.gif" => array("577", "49805f402375692d40635ada7cc0f472"), + "plugins/UserSettings/images/os/IRI.gif" => array("152", "5e631b5adc35a05ae0d85815829c6f48"), + "plugins/UserSettings/images/os/KBT.gif" => array("998", "6ecd8b978a51fb4fd6ec94f9b820ae1d"), + "plugins/UserSettings/images/os/KNO.gif" => array("985", "b4595a673edf60051636fedf418eda30"), + "plugins/UserSettings/images/os/LBT.gif" => array("951", "ed14ac9707a0e01f4557ac9e8508dea3"), + "plugins/UserSettings/images/os/LIN.gif" => array("170", "19039ee87d8fccdba6a391ada5656de7"), + "plugins/UserSettings/images/os/MAC.gif" => array("171", "03548481597f28751368e26be49aea99"), + "plugins/UserSettings/images/os/MAE.gif" => array("137", "84600277bad6751b68b15586bca50aef"), + "plugins/UserSettings/images/os/MDR.gif" => array("918", "62f5e501d28e25fff6c3a75631c7c208"), + "plugins/UserSettings/images/os/MIN.gif" => array("1009", "153385eff242c4e10ea3835a17a65f0e"), + "plugins/UserSettings/images/os/NBS.gif" => array("168", "fc0d4fcb57c98f3a4dd6eab8e71ebd6a"), + "plugins/UserSettings/images/os/NDS.gif" => array("1061", "16bc6e0960747b402441c40e419a7d53"), + "plugins/UserSettings/images/os/OBS.gif" => array("571", "fcdb547b7ab768e131ba592e8733c75c"), + "plugins/UserSettings/images/os/OS2.gif" => array("162", "ee37bab155ad46f2530e7586f9971656"), + "plugins/UserSettings/images/os/POS.gif" => array("1060", "96b06842dc1cc80a8bb283ee8f4be320"), + "plugins/UserSettings/images/os/PPY.gif" => array("1037", "1bc770c1bd83e6cfdc5087b00b44cb9d"), + "plugins/UserSettings/images/os/PS3.gif" => array("628", "7aca5b93e7cc8142e2ab578e7aae7dc8"), + "plugins/UserSettings/images/os/PSP.gif" => array("592", "f92da90c6c6ea808422184c314215465"), + "plugins/UserSettings/images/os/PSV.gif" => array("200", "d82e64a4f0aeec6e4931a41988680af3"), + "plugins/UserSettings/images/os/QNX.gif" => array("241", "51ceb87cd6268837d830b225cb6c8120"), + "plugins/UserSettings/images/os/RHT.gif" => array("952", "72c775bb0f2b8ad388ee513577abc8ec"), + "plugins/UserSettings/images/os/ROS.gif" => array("956", "9a294e5c701171c8c777ce563d88d924"), + "plugins/UserSettings/images/os/SAF.gif" => array("242", "600259fe739536945b6f5cd5c9d3489b"), + "plugins/UserSettings/images/os/SBA.gif" => array("990", "be3cfde24b6517884d73270a20e9a06f"), + "plugins/UserSettings/images/os/SLW.gif" => array("883", "37ca3a7bdb0eeea402252c80dca89369"), + "plugins/UserSettings/images/os/SOS.gif" => array("1036", "686261ea170398b2eb078b67a24f4276"), + "plugins/UserSettings/images/os/SSE.gif" => array("1066", "8a6b48a38ee8ecca0fd14092872dba12"), + "plugins/UserSettings/images/os/SYL.gif" => array("1017", "5c73fe766f50d4a697dbdeac254db9e2"), + "plugins/UserSettings/images/os/SYM.gif" => array("1042", "a8d404e43206c52a7e5f3e6e7e469eea"), + "plugins/UserSettings/images/os/T64.gif" => array("220", "8e42601e52784216ed71b8e8de17f82e"), + "plugins/UserSettings/images/os/TIZ.gif" => array("958", "dbc584b7603e8865c9477b18a6fbbb7d"), + "plugins/UserSettings/images/os/UBT.gif" => array("986", "56d67af2d61927c290b0e5af8e8ef6fc"), + "plugins/UserSettings/images/os/UNK.gif" => array("80", "c4a7f0e333a6079fdb0b6595e11bca74"), + "plugins/UserSettings/images/os/VMS.gif" => array("572", "da6881ce3b86fdbea70ae1b405f9c40a"), + "plugins/UserSettings/images/os/W2K.gif" => array("185", "5e7141d138d3f7afc0a113c21a8b2d9b"), + "plugins/UserSettings/images/os/W61.gif" => array("1060", "721b79d12ac825df25f356f5a7714f74"), + "plugins/UserSettings/images/os/W65.gif" => array("1060", "721b79d12ac825df25f356f5a7714f74"), + "plugins/UserSettings/images/os/W75.gif" => array("1089", "8d69225f3ebb1fe2d2b6d4e2e1687b80"), + "plugins/UserSettings/images/os/W95.gif" => array("185", "5e7141d138d3f7afc0a113c21a8b2d9b"), + "plugins/UserSettings/images/os/W98.gif" => array("185", "5e7141d138d3f7afc0a113c21a8b2d9b"), + "plugins/UserSettings/images/os/WCE.gif" => array("185", "5e7141d138d3f7afc0a113c21a8b2d9b"), + "plugins/UserSettings/images/os/WI7.gif" => array("191", "53d14246a8e5a46e927a87648111a0d3"), + "plugins/UserSettings/images/os/WI8.gif" => array("925", "d9f78bbd9009c721cf5d64b056679a19"), + "plugins/UserSettings/images/os/WII.gif" => array("617", "d8f2cae9e8e7723241c6c862f12c7511"), + "plugins/UserSettings/images/os/WIN.gif" => array("191", "53d14246a8e5a46e927a87648111a0d3"), + "plugins/UserSettings/images/os/WIU.gif" => array("310", "394c491524ac263e1c4fcedb1480d281"), + "plugins/UserSettings/images/os/WME.gif" => array("1025", "27c3894e3585f86f9283374ed7776c3a"), + "plugins/UserSettings/images/os/WMO.gif" => array("1060", "721b79d12ac825df25f356f5a7714f74"), + "plugins/UserSettings/images/os/WNT.gif" => array("185", "5e7141d138d3f7afc0a113c21a8b2d9b"), + "plugins/UserSettings/images/os/WOS.gif" => array("70", "31e5c59d2fc5b195c5ea4f1afd878e04"), + "plugins/UserSettings/images/os/WP7.gif" => array("1089", "8d69225f3ebb1fe2d2b6d4e2e1687b80"), + "plugins/UserSettings/images/os/WPH.gif" => array("1089", "8d69225f3ebb1fe2d2b6d4e2e1687b80"), + "plugins/UserSettings/images/os/WRT.gif" => array("925", "d9f78bbd9009c721cf5d64b056679a19"), + "plugins/UserSettings/images/os/WS3.gif" => array("191", "53d14246a8e5a46e927a87648111a0d3"), + "plugins/UserSettings/images/os/WVI.gif" => array("191", "53d14246a8e5a46e927a87648111a0d3"), + "plugins/UserSettings/images/os/WXP.gif" => array("191", "53d14246a8e5a46e927a87648111a0d3"), + "plugins/UserSettings/images/os/XBT.gif" => array("968", "c0f572e03ffaf7d38c78fcbb7fba84cf"), + "plugins/UserSettings/images/os/XBX.gif" => array("1043", "e3e0eaa5daa2903bab1e0e9ea3ef1d46"), + "plugins/UserSettings/images/os/YNS.gif" => array("913", "f4d8502e11b209c209fcee3312a33139"), + "plugins/UserSettings/images/plugins/cookie.gif" => array("211", "9e564884defc036134b19ab38c192a6b"), + "plugins/UserSettings/images/plugins/director.gif" => array("198", "952c4a0e083ed089ca5c8ab2804c35ab"), + "plugins/UserSettings/images/plugins/flash.gif" => array("1018", "f315906f425f089dd4664ee530c691bd"), + "plugins/UserSettings/images/plugins/gears.gif" => array("558", "d4ec944ef01420637a709619f067953e"), + "plugins/UserSettings/images/plugins/java.gif" => array("565", "b4d24f76a64e2df762292e892e9215c4"), + "plugins/UserSettings/images/plugins/pdf.gif" => array("1021", "79b3d68c112942cefbb24dda9b421464"), + "plugins/UserSettings/images/plugins/quicktime.gif" => array("1003", "0feda8dc4ddec39e35b6f4925603c8bd"), + "plugins/UserSettings/images/plugins/realplayer.gif" => array("1025", "95739f527d29cab050a3d4eff35e93c7"), + "plugins/UserSettings/images/plugins/silverlight.gif" => array("1012", "8449c3a43f42c44bc5b82948d179b4b4"), + "plugins/UserSettings/images/plugins/windowsmedia.gif" => array("1026", "a374aec2d5488c5dc8d4eaf21ec59728"), + "plugins/UserSettings/images/screens/dual.gif" => array("1082", "7903e070f4c4aa7933030e547e78073e"), + "plugins/UserSettings/images/screens/mobile.gif" => array("324", "04942ef60dd75380cfd682314e87e857"), + "plugins/UserSettings/images/screens/normal.gif" => array("1088", "b0c3de8704745e58c67844e54282ee42"), + "plugins/UserSettings/images/screens/unknown.gif" => array("80", "c4a7f0e333a6079fdb0b6595e11bca74"), + "plugins/UserSettings/images/screens/wide.gif" => array("1025", "c0104958e6fb23668d0406fd4d89095e"), + "plugins/UserSettings/templates/index.twig" => array("985", "64dbe064f25bce041a9ea0ac4b401509"), + "plugins/UserSettings/UserSettings.php" => array("16493", "546f136ebeb5cbe7e1ffa364be6f1fdc"), + "plugins/UsersManager/API.php" => array("21685", "ff11d871854f92e6e309b563ba4ed8cd"), + "plugins/UsersManager/Controller.php" => array("12751", "27d35da3855e76319f08f5cfeb4e24cd"), + "plugins/UsersManager/images/add.png" => array("1366", "79f8c85304af007fb571bd67b5b84a65"), + "plugins/UsersManager/images/no-access.png" => array("653", "e92421cec0f4c4f344b00a2ff72f96ec"), + "plugins/UsersManager/images/ok.png" => array("851", "94672a5d1482b1afaafce7802155f518"), + "plugins/UsersManager/javascripts/usersManager.js" => array("10779", "e4711ac89dcb705f5002c39a26608dbe"), + "plugins/UsersManager/javascripts/usersSettings.js" => array("3254", "6f10ea2cac2509e07988389078687af4"), + "plugins/UsersManager/LastSeenTimeLogger.php" => array("2296", "11eb62ec3cc2588fb571769330aefbba"), + "plugins/UsersManager/Model.php" => array("9294", "93aa4779b992fc0991aaa9a6cc8e533e"), + "plugins/UsersManager/stylesheets/usersManager.less" => array("400", "34e7c27854b8a5e2b5c2e55df5de755c"), + "plugins/UsersManager/templates/index.twig" => array("9706", "50e07c93f864be7e52418c05d96c5588"), + "plugins/UsersManager/templates/userSettings.twig" => array("9580", "25708d48635daf6f1114220df0c43fc2"), + "plugins/UsersManager/UsersManager.php" => array("5028", "42c49b25bd17cc81567855b42e0e4736"), + "plugins/VisitFrequency/API.php" => array("2542", "bd9a2e5d38ce65bc72af6abfe65166a6"), + "plugins/VisitFrequency/Controller.php" => array("4225", "9316375706f2da88c7243aa2e703d362"), + "plugins/VisitFrequency/templates/getSparklines.twig" => array("48", "1d2ec22a548fb6662979f19d9d3fc329"), + "plugins/VisitFrequency/templates/index.twig" => array("338", "3eca543636e9b14ecb0208b6e03bf283"), + "plugins/VisitFrequency/templates/_sparklines.twig" => array("1064", "09eecc1ac6d13181fbec549185cacb88"), + "plugins/VisitFrequency/VisitFrequency.php" => array("2625", "84fd90736051331df11ca37a64917cea"), + "plugins/VisitorInterest/API.php" => array("4802", "3ea0c6255ab8ebb2deb7c600e300460a"), + "plugins/VisitorInterest/Archiver.php" => array("5175", "66968c131f5b948ecb49dbf5bdb4fc5f"), + "plugins/VisitorInterest/Controller.php" => array("1730", "99828a062bfe59a5dccafa741e001bff"), + "plugins/VisitorInterest/templates/index.twig" => array("533", "5f1579ad5941e520b87275aff6cbb729"), + "plugins/VisitorInterest/VisitorInterest.php" => array("10661", "d019fd8854d7087dd4948f230f6ae158"), + "plugins/VisitsSummary/API.php" => array("6176", "0780ead1ba4050b502a18c37f8c0f322"), + "plugins/VisitsSummary/Controller.php" => array("8277", "31b7cdca8c5b9293af2e2eeedf6adf60"), + "plugins/VisitsSummary/stylesheets/datatable.less" => array("113", "8ffed702781ee6746a4701fc5f5ff9f1"), + "plugins/VisitsSummary/templates/getSparklines.twig" => array("47", "f8630d90a9c3ab9ba0d5929d537ec5a1"), + "plugins/VisitsSummary/templates/index.twig" => array("634", "6ff7b1f46d57ca9e0a4b7e6293ffa81c"), + "plugins/VisitsSummary/templates/_sparklines.twig" => array("3505", "6f587c7c41c00b990065c5cde5a505cd"), + "plugins/VisitsSummary/VisitsSummary.php" => array("2864", "820091576b5aaef9873de6b4d1a8ad43"), + "plugins/VisitTime/API.php" => array("5806", "04226b406a95196b3ac7d653cb79b814"), + "plugins/VisitTime/Archiver.php" => array("2924", "cb73804d891689ebc1a79cf3ff6d6e2a"), + "plugins/VisitTime/Controller.php" => array("960", "6dce46cb47d8892f3e96e13d606b2f99"), + "plugins/VisitTime/functions.php" => array("800", "02b598cca67def3060e538d359da9c76"), + "plugins/VisitTime/templates/index.twig" => array("316", "483e2fc50e9e905201576c1638326782"), + "plugins/VisitTime/VisitTime.php" => array("9503", "867e6b57fb7e4f64219471c6f4ffe6b1"), + "plugins/Widgetize/Controller.php" => array("2404", "0d55e61621a887b086c60d086b66a271"), + "plugins/Widgetize/javascripts/widgetize.js" => array("3815", "b142cb72e9609949b895f517f1f19271"), + "plugins/Widgetize/stylesheets/widgetize.less" => array("523", "92ccef42a032893e61115dc1804a9d64"), + "plugins/Widgetize/templates/iframe_empty.twig" => array("17", "8a9aa8c925c824c53de88a0feca71be5"), + "plugins/Widgetize/templates/iframe.twig" => array("740", "b0e678fca11277a139e66eeaba321684"), + "plugins/Widgetize/templates/index.twig" => array("3680", "2e7dd9cc68bf352c6a219c989f020b16"), + "plugins/Widgetize/templates/testJsInclude1.twig" => array("662", "be3cc3e650ab96eade1172057d2577dc"), + "plugins/Widgetize/templates/testJsInclude2.twig" => array("709", "429886f9491d07389f73d803a0737a9b"), + "plugins/Widgetize/Widgetize.php" => array("2089", "9f1aa8d49cd9bdc7dd6e42991c8b982a"), + "plugins/Zeitgeist/images/affix-arrow.png" => array("3179", "542723bef4d8a61f3dc217352dc1873a"), + "plugins/Zeitgeist/images/annotations.png" => array("1627", "d8dab24c4203b462aa522642fcdd6947"), + "plugins/Zeitgeist/images/annotations_starred.png" => array("1618", "9fcbf35415857fb2a89435106641b0be"), + "plugins/Zeitgeist/images/arr_r.png" => array("195", "2708a22e4d851aff01d4db4f2fddf1da"), + "plugins/Zeitgeist/images/background-submit.png" => array("1347", "b1822012b50008b3e478d7ae890ead92"), + "plugins/Zeitgeist/images/chart_bar.png" => array("170", "05be19310c2236ac9c62e661bae95989"), + "plugins/Zeitgeist/images/chart_line_edit.png" => array("993", "7d7239985b27ced21c55d6de9810284c"), + "plugins/Zeitgeist/images/chart_pie.png" => array("355", "de043ca76bd7419b20150ceafbcf9312"), + "plugins/Zeitgeist/images/close.png" => array("1089", "47fc9f47a998804a1cb4e5728377a1fe"), + "plugins/Zeitgeist/images/collapsed_arrows.gif" => array("54", "224b095cbca536e579119a47328badda"), + "plugins/Zeitgeist/images/configure-highlight.png" => array("933", "a975c7cf6f594786581d997c1b2cc485"), + "plugins/Zeitgeist/images/configure.png" => array("387", "1e0d61849e3012c025554edd97f9cdd8"), + "plugins/Zeitgeist/images/dashboard_h_bg_hover.png" => array("333", "d062d8b2b65f012a196878c2f05db335"), + "plugins/Zeitgeist/images/dashboard_h_bg.png" => array("162", "a6cf0f7cdf6d69edca6edf479aa888e3"), + "plugins/Zeitgeist/images/data_table_footer_active_item.png" => array("145", "70dcc41079a073d03fee2628dcd0cf74"), + "plugins/Zeitgeist/images/datepicker_arr_l.png" => array("191", "10c37d1f62b0c21bc99ef3c3fabe4f7e"), + "plugins/Zeitgeist/images/datepicker_arr_r.png" => array("196", "e2f8672222d50df21b718654c424dd5f"), + "plugins/Zeitgeist/images/delete.png" => array("2175", "b3b9cb547a0511ff15b5371b122a6f66"), + "plugins/Zeitgeist/images/download.png" => array("734", "0552d1746701df879d14c2fdf3d5ac41"), + "plugins/Zeitgeist/images/ecommerceAbandonedCart.gif" => array("369", "51974d5002afd9b7c8009d14a1207aad"), + "plugins/Zeitgeist/images/ecommerceOrder.gif" => array("570", "b0c1aa6141f0047b4bcd0cc665c945ad"), + "plugins/Zeitgeist/images/email.png" => array("754", "baaa6accd945fcb4480b29ab2e15bded"), + "plugins/Zeitgeist/images/error_medium.png" => array("2622", "d789ca042860b20782cfb8c2bfd458e0"), + "plugins/Zeitgeist/images/error.png" => array("1150", "16ac5f1c769e78a074144f1fe9073dfb"), + "plugins/Zeitgeist/images/event.png" => array("164", "8e1d701795486cdc53cbf7a5c3b4d069"), + "plugins/Zeitgeist/images/expanded_arrows.gif" => array("60", "a9afa92168dbbe6f693f3ff71fa27b32"), + "plugins/Zeitgeist/images/export.png" => array("219", "bfcbff8f64765ec78685d04283857b9b"), + "plugins/Zeitgeist/images/feed.png" => array("691", "55bc1130d360583e2aecbcebfbf6eda7"), + "plugins/Zeitgeist/images/fullscreen.png" => array("346", "629df6e9cfdf1bb8d0a612f651da69e9"), + "plugins/Zeitgeist/images/goal.png" => array("270", "bc6edcd9d776933ea6b6cea5be1c6383"), + "plugins/Zeitgeist/images/help.png" => array("942", "471c83040c41ec0922eb4540992bcd1f"), + "plugins/Zeitgeist/images/html_icon.png" => array("3503", "45a005cf3fa96037df5d7935d26b5f7c"), + "plugins/Zeitgeist/images/ico_alert.png" => array("1112", "63d124bf79386ebf6285926956ff7829"), + "plugins/Zeitgeist/images/ico_delete.png" => array("231", "4065203e7b7471539eeff819b4cf443b"), + "plugins/Zeitgeist/images/ico_edit.png" => array("255", "32e1c11ad294948cfedff7c4daf76dad"), + "plugins/Zeitgeist/images/ico_info.png" => array("978", "362b1589e75a151c9e4d509615851950"), + "plugins/Zeitgeist/images/icon-calendar.gif" => array("331", "a0cffdd9fcf6552dea43658fc217732b"), + "plugins/Zeitgeist/images/image.png" => array("306", "2ea0351a26cdc37e0359ab011b832902"), + "plugins/Zeitgeist/images/inp_bg.png" => array("137", "1f4b8e7288c5d4dc52e44c50e0d02a9b"), + "plugins/Zeitgeist/images/li_dbl_gray.gif" => array("48", "5b0a692984ac5b04acc0886cd374bb85"), + "plugins/Zeitgeist/images/link.gif" => array("75", "b8de0b2b517e1999b32353209be4e976"), + "plugins/Zeitgeist/images/loading-blue.gif" => array("723", "6ce8f9a2c650cf90261acfc98b2edf90"), + "plugins/Zeitgeist/images/login-sprite.png" => array("10200", "a2a3520f448277c3efdb871d637fc34b"), + "plugins/Zeitgeist/images/logo-header.png" => array("3682", "f3a7921898ffcd44e413015b596c4e68"), + "plugins/Zeitgeist/images/logo-marketplace.png" => array("2927", "aa3dc0cd04c23a654e7a96ceabe2a6c1"), + "plugins/Zeitgeist/images/logo.png" => array("11152", "bc2b73b0541589e617fbb66b54ccc7ad"), + "plugins/Zeitgeist/images/logo.svg" => array("4044", "7f9586818f589f658c4dd7e7e5802274"), + "plugins/Zeitgeist/images/maximise.png" => array("3182", "e0018bf302603d234ae3d31c44fb3d1e"), + "plugins/Zeitgeist/images/minimise.png" => array("2869", "e746d4f1bfcc4d565a526e40918bcd0e"), + "plugins/Zeitgeist/images/minus.png" => array("176", "e010a638230ff96610f0ebffdfced0ac"), + "plugins/Zeitgeist/images/newtab.png" => array("509", "994c19f51192a18f7b3e0bc0775313f2"), + "plugins/Zeitgeist/images/ok.png" => array("626", "28501b0877ea15b49c6ca58677e186c3"), + "plugins/Zeitgeist/images/paypal_subscribe.gif" => array("3080", "a74a883239713fb5050593c20d9fd2a5"), + "plugins/Zeitgeist/images/plus_blue.png" => array("157", "9d61acb98c3ac639715aba6703997ad9"), + "plugins/Zeitgeist/images/plus.png" => array("174", "f867099b8f18cd9f989f1fcfb77cba37"), + "plugins/Zeitgeist/images/refresh.png" => array("2978", "b821bde25f3e4dcd0a7f6b9bd37f57b3"), + "plugins/Zeitgeist/images/reload.png" => array("892", "5a0360408c248f9cde4e0d4bad31ac00"), + "plugins/Zeitgeist/images/row_evolution_hover.png" => array("601", "7f6833f656aaad475e02a96ae3c0adb9"), + "plugins/Zeitgeist/images/row_evolution.png" => array("1934", "0e3fe13d82bc0526ed94bc079152f2f2"), + "plugins/Zeitgeist/images/search_bg.png" => array("384", "3105cf7e5419bef110507a682f28fab7"), + "plugins/Zeitgeist/images/search_ico.png" => array("175", "b74de7cb2b8cfddb82b95e63cd492286"), + "plugins/Zeitgeist/images/sites_selection.png" => array("120", "f8f6f62a17616adce09791ffb51aab1b"), + "plugins/Zeitgeist/images/smileyprog_0.png" => array("4045", "0b105851f9dfc4e5a3efb933c4fa01af"), + "plugins/Zeitgeist/images/smileyprog_1.png" => array("4268", "cd518d27567dda069dd5fff2e3c2291f"), + "plugins/Zeitgeist/images/smileyprog_2.png" => array("4292", "8e61661161afe5ac8a7e2caf085fb200"), + "plugins/Zeitgeist/images/smileyprog_3.png" => array("4589", "1eb75d0f84042d492b7ff4a515e23ff3"), + "plugins/Zeitgeist/images/smileyprog_4.png" => array("4733", "1f31e1ac3c6b3602cecabf0f0c15fd1b"), + "plugins/Zeitgeist/images/sortasc.png" => array("173", "bf15b38f5921052cd7d402a5a6e7542d"), + "plugins/Zeitgeist/images/sortdesc.png" => array("171", "3c918bc6390e75b19094de3b3db01578"), + "plugins/Zeitgeist/images/sort_subtable_asc_light.png" => array("2866", "a8d4e15a81c63b3d7f8ddde1ad5fc78d"), + "plugins/Zeitgeist/images/sort_subtable_asc.png" => array("173", "457908cb087009a946c99bef46643c69"), + "plugins/Zeitgeist/images/sort_subtable_desc_light.png" => array("286", "ba6261eca430661a8b84155460424106"), + "plugins/Zeitgeist/images/sort_subtable_desc.png" => array("171", "63b74c05d158e1bf7ed0172142991b24"), + "plugins/Zeitgeist/images/star_empty.png" => array("658", "31809a80055eb2aa02b51e6c11ecb02d"), + "plugins/Zeitgeist/images/star.png" => array("757", "872b7a1a8101bcf7ef6c7cf7c8f78ff7"), + "plugins/Zeitgeist/images/success_medium.png" => array("1346", "28e0ba1f8492374db4946d42c69e477b"), + "plugins/Zeitgeist/images/table_more.png" => array("200", "a8d8a758c97a1342864b646469059a02"), + "plugins/Zeitgeist/images/table.png" => array("151", "327ee0e75605ab865796053f2c0aebf1"), + "plugins/Zeitgeist/images/tagcloud.png" => array("202", "127389e4f7146b1322dd1873ee89235b"), + "plugins/Zeitgeist/images/video_play.png" => array("517", "29fd1c103c9ac9987b85e053e621ab20"), + "plugins/Zeitgeist/images/warning_medium.png" => array("1283", "24bc193a073997740e4aa459b2bbbbdf"), + "plugins/Zeitgeist/images/warning.png" => array("571", "8c4ef759f46a90e7a00e1db65e49edc9"), + "plugins/Zeitgeist/images/warning_small.png" => array("1083", "5ff491ccd2f32beb35d96fd79a4d7329"), + "plugins/Zeitgeist/images/zoom-out.png" => array("289", "b91cfbc280cfbf59fddc41348a78f2b6"), + "plugins/Zeitgeist/javascripts/ajaxHelper.js" => array("11827", "15e4b7bb70b671885e1597b7a61221bf"), + "plugins/Zeitgeist/javascripts/piwikHelper.js" => array("14986", "56e3acb719973a17f35316b74c155a3c"), + "plugins/Zeitgeist/plugin.json" => array("156", "90381b40a6f3b6e60f3d214bccdac5ab"), + "plugins/Zeitgeist/stylesheets/base.less" => array("555", "8bc4b5c0409a957679d70f19ab14a660"), + "plugins/Zeitgeist/stylesheets/general/_default.less" => array("2087", "47a06608a3652d1fd86254e314f81a5c"), + "plugins/Zeitgeist/stylesheets/general/_form.less" => array("2389", "6d43004e4fad124d15e45246e77bac1b"), + "plugins/Zeitgeist/stylesheets/general/_jqueryUI.less" => array("5147", "130ef76ab7dc45bbd50fcb4318aa5ada"), + "plugins/Zeitgeist/stylesheets/general/_misc.less" => array("576", "214e501d23f7e3f95f71644fbb50c886"), + "plugins/Zeitgeist/stylesheets/general/_utils.less" => array("391", "75d6698e72c26ffd6b562c3d738021e0"), + "plugins/Zeitgeist/stylesheets/ieonly.css" => array("506", "6a9862fb951ee731f968fee99d2480b3"), + "plugins/Zeitgeist/stylesheets/rtl.css" => array("53", "1be4a6f544e3ed971b966692b07d2c2d"), + "plugins/Zeitgeist/stylesheets/simple_structure.css" => array("1592", "e46a7d0ea302017e776fccc7b3db6060"), + "plugins/Zeitgeist/stylesheets/ui/_dataTable.less" => array("46", "29c2aa49f73efe255d4202c37a942ef5"), + "plugins/Zeitgeist/stylesheets/ui/_header.less" => array("739", "969c90051d99a050d9588f0bd02c4f82"), + "plugins/Zeitgeist/stylesheets/ui/_headerMessage.less" => array("1179", "16bf5e43fce5c2c39ff393c6c743a1aa"), + "plugins/Zeitgeist/stylesheets/ui/_languageSelect.less" => array("745", "6dd7f3399125f54809bda579420c1d6c"), + "plugins/Zeitgeist/stylesheets/ui/_loading.less" => array("360", "a48e9d0b63da915547a353ca0ace1656"), + "plugins/Zeitgeist/stylesheets/ui/_periodSelect.less" => array("1453", "07709490ca6206728d5274115a4cf71e"), + "plugins/Zeitgeist/stylesheets/ui/_siteSelect.less" => array("3503", "d0f0fec0d21d3013cc5e2540aff9f95c"), + "plugins/Zeitgeist/templates/admin.twig" => array("2040", "3801afc5349d0540bf749b5133d25ce0"), + "plugins/Zeitgeist/templates/ajaxMacros.twig" => array("632", "b6179aa5cdabd0f26b15ba6387f5af22"), + "plugins/Zeitgeist/templates/dashboard.twig" => array("2097", "0bb68e2e0d617932e57f3390d35aaccc"), + "plugins/Zeitgeist/templates/empty.twig" => array("35", "2c6a1dccfb394fef9ef03849c39a5bec"), + "plugins/Zeitgeist/templates/genericForm.twig" => array("991", "9e6a247b129a8b3932e2928273cc2b2f"), + "plugins/Zeitgeist/templates/_iframeBuster.twig" => array("385", "1c780792c51f5d2fecbf53c0ce0d547f"), + "plugins/Zeitgeist/templates/javascriptCode.tpl" => array("631", "8b239b1782acbb256597e965c49a7f92"), + "plugins/Zeitgeist/templates/_jsCssIncludes.twig" => array("278", "64e8a45c1d762222f4d3ac0fc072afbe"), + "plugins/Zeitgeist/templates/_jsGlobalVariables.twig" => array("1668", "4a163e13cc7cbf4bbf2723260f38765e"), + "plugins/Zeitgeist/templates/macros.twig" => array("891", "3343e6b0da8c13cd7f5d73bffcc86b49"), + "plugins/Zeitgeist/templates/_piwikTag.twig" => array("1217", "4d267c8a76a1c2212f160267095998ae"), + "plugins/Zeitgeist/templates/simpleLayoutFooter.tpl" => array("23", "c64111f363b9e1a0b0ea6d1c8ccbb9bd"), + "plugins/Zeitgeist/templates/simpleLayoutHeader.tpl" => array("511", "422d59273c966199cc15449891d697bb"), + "plugins/Zeitgeist/templates/_sparklineFooter.twig" => array("102", "a9e46848aaf5613b971827cf12ab6eaa"), + "README.md" => array("3813", "5ba34ead653bdb77ce52a3a4eeb221ba"), + "tests/README.md" => array("5681", "d9e5829add2c89dc0066f0299d82d7ed"), + "vendor/composer/autoload_classmap.php" => array("510", "91d089f53c9e426510a6f0292c655676"), + "vendor/composer/autoload_files.php" => array("255", "2cc6f4ba7d74bf4e09bff1ad7ed04816"), + "vendor/composer/autoload_namespaces.php" => array("341", "41fba06e8ccbd2eb2cf63e44a65345cd"), + "vendor/composer/autoload_psr4.php" => array("143", "dd3a00f0d13eb29781edd8c77d4c5100"), + "vendor/composer/ClassLoader.php" => array("11571", "3adcacc118804f98f1fd888e2575f00a"), + "vendor/composer/installed.json" => array("9348", "a1d1990a1a293648031fd49bc098b419"), + "vendor/leafo/lessphp/composer.json" => array("544", "e0d0ef78bbb2d2ea3db1be3ea7a34253"), + "vendor/leafo/lessphp/docs/docs.md" => array("38626", "b2eb5cf232f2d136fd4d14ba4f9c45f5"), + "vendor/leafo/lessphp/.gitignore" => array("71", "a51238606af0d1eb55ce05237e6778eb"), + "vendor/leafo/lessphp/lessc.inc.php" => array("91826", "b8ddd7795cbca49cad99e4a977d0b13d"), + "vendor/leafo/lessphp/lessify" => array("414", "e8e87d48dd91f4838219419395f1b996"), + "vendor/leafo/lessphp/lessify.inc.php" => array("9696", "e0c246fc5d113d6c42417b96f2e27b54"), + "vendor/leafo/lessphp/LICENSE" => array("33650", "2887747a1404ae4fb71a95d440d3778f"), + "vendor/leafo/lessphp/Makefile" => array("57", "bf733f8f889f8351526b07f50abf1dc1"), + "vendor/leafo/lessphp/package.sh" => array("758", "6bcb58ac88b574f30424245f65b2655d"), + "vendor/leafo/lessphp/plessc" => array("4911", "fb3df9045ff35eed6f8202c27a86ef14"), + "vendor/leafo/lessphp/README.md" => array("2817", "1fae9319ae33c7596e67d6adba644e13"), + "vendor/leafo/lessphp/.travis.yml" => array("57", "a37e9778c469a928db223337a03a9f04"), + "vendor/mustangostang/spyc/composer.json" => array("588", "c3ed21823389befa4e8c8b81d0f3be63"), + "vendor/mustangostang/spyc/COPYING" => array("1077", "b0987325db5fa8e52b7076860d8e2e0a"), + "vendor/mustangostang/spyc/examples/yaml-dump.php" => array("974", "c18b833f0057beb6a5845bf4335c68e0"), + "vendor/mustangostang/spyc/examples/yaml-load.php" => array("390", "10189cd25b80e661fd5ed7019942a7df"), + "vendor/mustangostang/spyc/php4/5to4.php" => array("728", "1bee498adf3521f220ef2f22aff84389"), + "vendor/mustangostang/spyc/php4/spyc.php4" => array("30025", "013ea7fe6257e8cebe0f17dbf2115bb7"), + "vendor/mustangostang/spyc/php4/test.php4" => array("4487", "e7f605110f596ff0344431a08dc77b10"), + "vendor/mustangostang/spyc/README" => array("5772", "d426a4028be21f70da24b9593df400a1"), + "vendor/mustangostang/spyc/Spyc.php" => array("31690", "1ff392881801c81f27aa8ea2e2bd8f2a"), + "vendor/mustangostang/spyc/spyc.yaml" => array("3609", "796845442758ffc5887b517580d960f1"), + "vendor/piwik/device-detector/composer.json" => array("958", "8c2246083e5b6d8752263c1074522890"), + "vendor/piwik/device-detector/DeviceDetector.php" => array("31360", "54677c9e65f2db309b04c0ef43d856b8"), + "vendor/piwik/device-detector/.gitignore" => array("30", "5a959e73a428396446cf2fc274e91c26"), + "vendor/piwik/device-detector/README.md" => array("391", "0a04395636c6a38d3f2b5fbaddaf054c"), + "vendor/piwik/device-detector/regexes/browsers.yml" => array("10214", "a745684b695696c1318751816b6ca365"), + "vendor/piwik/device-detector/regexes/mobiles.yml" => array("27043", "a9b5c80b5d331af8f1e8734265993ac0"), + "vendor/piwik/device-detector/regexes/oss.yml" => array("8511", "1b49d6cab78daeb3d7fe5fdca86a2ce8"), + "vendor/piwik/device-detector/regexes/televisions.yml" => array("3886", "6ed102a54f0be2e7413a5799b0f32a02"), + "vendor/piwik/device-detector/.travis.yml" => array("155", "f7214c64927a7b580f239487842ddc32"), + "vendor/symfony/console/Symfony/Component/Console/Application.php" => array("35538", "d7fa5d3e678f32b207a275d566bd226b"), + "vendor/symfony/console/Symfony/Component/Console/CHANGELOG.md" => array("1682", "e2b61d6b18ded64f0686599db5c7fb5b"), + "vendor/symfony/console/Symfony/Component/Console/Command/Command.php" => array("16439", "80d4d8e858f5a83230701aed15e6f9ff"), + "vendor/symfony/console/Symfony/Component/Console/Command/HelpCommand.php" => array("2655", "d10467979ea85e92951425fd351347ac"), + "vendor/symfony/console/Symfony/Component/Console/Command/ListCommand.php" => array("2752", "7f0bd50020e8b9c39d79692f3e32db8f"), + "vendor/symfony/console/Symfony/Component/Console/composer.json" => array("869", "9e146f5ae7085917809444efe2d868c6"), + "vendor/symfony/console/Symfony/Component/Console/ConsoleEvents.php" => array("1528", "10001de60b8e31092612e73dc02300d7"), + "vendor/symfony/console/Symfony/Component/Console/Descriptor/ApplicationDescription.php" => array("3513", "e5ffa840297bdf9232762c38218846dc"), + "vendor/symfony/console/Symfony/Component/Console/Descriptor/DescriptorInterface.php" => array("737", "d5b9abe215ed28ac8ca7fe9d72fc9ddd"), + "vendor/symfony/console/Symfony/Component/Console/Descriptor/Descriptor.php" => array("3516", "e08a534aa99b2e90d1538a0c166973e5"), + "vendor/symfony/console/Symfony/Component/Console/Descriptor/JsonDescriptor.php" => array("4981", "a997223049fcf34294263e3a6c37e230"), + "vendor/symfony/console/Symfony/Component/Console/Descriptor/MarkdownDescriptor.php" => array("4910", "bec9d13cc9cb6e778c4f4083199137b6"), + "vendor/symfony/console/Symfony/Component/Console/Descriptor/TextDescriptor.php" => array("8184", "97b571928164645af31610ea97bc43fb"), + "vendor/symfony/console/Symfony/Component/Console/Descriptor/XmlDescriptor.php" => array("9712", "a9214dd6cf39faa121dc4de62996071a"), + "vendor/symfony/console/Symfony/Component/Console/Event/ConsoleCommandEvent.php" => array("448", "2832db2aee4207de5d53d012b314436d"), + "vendor/symfony/console/Symfony/Component/Console/Event/ConsoleEvent.php" => array("1464", "aaaa8c390cd99e01d7bb2649a735e469"), + "vendor/symfony/console/Symfony/Component/Console/Event/ConsoleExceptionEvent.php" => array("1601", "22e5c027468f33b0bd20540b41af7a74"), + "vendor/symfony/console/Symfony/Component/Console/Event/ConsoleTerminateEvent.php" => array("1318", "82fbfa586f39a859d66a45221cb773c1"), + "vendor/symfony/console/Symfony/Component/Console/Formatter/OutputFormatterInterface.php" => array("1763", "75e7364468e427f080f86354f9bab9b9"), + "vendor/symfony/console/Symfony/Component/Console/Formatter/OutputFormatter.php" => array("6341", "304b90bba47ad3ed5534a37ec1647980"), + "vendor/symfony/console/Symfony/Component/Console/Formatter/OutputFormatterStyleInterface.php" => array("1439", "091bf3ec019e14a48b1f61ef7cb6871a"), + "vendor/symfony/console/Symfony/Component/Console/Formatter/OutputFormatterStyle.php" => array("5926", "4d0b8e5a84926a6013be77aa123e61eb"), + "vendor/symfony/console/Symfony/Component/Console/Formatter/OutputFormatterStyleStack.php" => array("2788", "27952fd939968e3615ce7ada4c761e0e"), + "vendor/symfony/console/Symfony/Component/Console/.gitignore" => array("34", "a1155c508134e9bda943ae266aee1819"), + "vendor/symfony/console/Symfony/Component/Console/Helper/DescriptorHelper.php" => array("2561", "d5bb73eb73014de416365c07cd81cac5"), + "vendor/symfony/console/Symfony/Component/Console/Helper/DialogHelper.php" => array("16446", "ae0bb3641414453024a3d71275abd1c1"), + "vendor/symfony/console/Symfony/Component/Console/Helper/FormatterHelper.php" => array("2236", "86ebeb32b3353bc7ba4363bf7a05fa2f"), + "vendor/symfony/console/Symfony/Component/Console/Helper/HelperInterface.php" => array("1011", "e9012e6f3559129d513da4ba75ebf4e9"), + "vendor/symfony/console/Symfony/Component/Console/Helper/Helper.php" => array("1451", "426298ce1ccd85ab527a5cef78f9a33b"), + "vendor/symfony/console/Symfony/Component/Console/Helper/HelperSet.php" => array("2526", "9c0badab04f9b052cec6a06c3f11fb07"), + "vendor/symfony/console/Symfony/Component/Console/Helper/InputAwareHelper.php" => array("747", "c75592e1f3b10c1f35961c50d55ae58d"), + "vendor/symfony/console/Symfony/Component/Console/Helper/ProgressHelper.php" => array("11887", "c6c85dbb2f08fa52bc6739ba6896bdc9"), + "vendor/symfony/console/Symfony/Component/Console/Helper/TableHelper.php" => array("12820", "1671e0f028279a642e6c0f7dcaa474c4"), + "vendor/symfony/console/Symfony/Component/Console/Input/ArgvInput.php" => array("10680", "e95b97c2b870d6223e84a995c93f16ce"), + "vendor/symfony/console/Symfony/Component/Console/Input/ArrayInput.php" => array("5925", "1e51135fb1c605381452c4d0cae3f1c4"), + "vendor/symfony/console/Symfony/Component/Console/Input/InputArgument.php" => array("3287", "c2f88afd2a0f1601dcfd3cce07092197"), + "vendor/symfony/console/Symfony/Component/Console/Input/InputAwareInterface.php" => array("606", "a67cb5e626983b20e2c03b618d7ab012"), + "vendor/symfony/console/Symfony/Component/Console/Input/InputDefinition.php" => array("12328", "74e92dce0c01082eb3d96959f3268509"), + "vendor/symfony/console/Symfony/Component/Console/Input/InputInterface.php" => array("4150", "9a40ecfaca79fa6bed0e459c035fdb5c"), + "vendor/symfony/console/Symfony/Component/Console/Input/InputOption.php" => array("5974", "2712657bb89ede768dfb9d0eb1f1d256"), + "vendor/symfony/console/Symfony/Component/Console/Input/Input.php" => array("6144", "87ba519b169574bdcaabaef68740989a"), + "vendor/symfony/console/Symfony/Component/Console/Input/StringInput.php" => array("2811", "eaf496e1188492448d9cfe8dfbcede6a"), + "vendor/symfony/console/Symfony/Component/Console/LICENSE" => array("1065", "09ce405e925cdeb923da1789121864c7"), + "vendor/symfony/console/Symfony/Component/Console/Output/BufferedOutput.php" => array("872", "07bca2bc77753b0b0cc4502f109c4a8e"), + "vendor/symfony/console/Symfony/Component/Console/Output/ConsoleOutputInterface.php" => array("843", "9cf400160437ff90febb665841a968fe"), + "vendor/symfony/console/Symfony/Component/Console/Output/ConsoleOutput.php" => array("2941", "64e00ce49a3a47e8ea7b8885f10c1ee6"), + "vendor/symfony/console/Symfony/Component/Console/Output/NullOutput.php" => array("1748", "561f460b93be0c27aa9be546af49e9ec"), + "vendor/symfony/console/Symfony/Component/Console/Output/OutputInterface.php" => array("2893", "ac6d776cf82c8b887395c54482f17bd7"), + "vendor/symfony/console/Symfony/Component/Console/Output/Output.php" => array("4155", "15c209a7581a632e6979d5441a02f241"), + "vendor/symfony/console/Symfony/Component/Console/Output/StreamOutput.php" => array("3149", "dc57630f625779ad86f6f3d8240ad0c3"), + "vendor/symfony/console/Symfony/Component/Console/phpunit.xml.dist" => array("873", "c94fc23909e8f1d27908cd0486653611"), + "vendor/symfony/console/Symfony/Component/Console/README.md" => array("1959", "70a92aecafcdc1f689a2a798acccefe4"), + "vendor/symfony/console/Symfony/Component/Console/Shell.php" => array("6395", "fedc847f6c992a242e45a167b0cf1866"), + "vendor/symfony/console/Symfony/Component/Console/Tester/ApplicationTester.php" => array("3430", "f2c7b88716f13cb54825a933832a74a8"), + "vendor/symfony/console/Symfony/Component/Console/Tester/CommandTester.php" => array("3612", "9b7bf197b61b1b3c23772c57a2ec3ee5"), + "vendor/tedivm/jshrink/composer.json" => array("418", "b7603f6ba06352f101cc74dc6b72c5b1"), + "vendor/tedivm/jshrink/.gitignore" => array("70", "416a3b6d772480ee1f7833d8a790a6a2"), + "vendor/tedivm/jshrink/LICENSE" => array("1493", "0a8fd596034db85a8ae22c1d2330edfb"), + "vendor/tedivm/jshrink/README.md" => array("745", "57a8f87308e7b475d9b34689d8c1f381"), + "vendor/tedivm/jshrink/src/JShrink/Minifier.php" => array("11416", "d245eafc47812bd47bf18fb4ede5508d"), + "vendor/tedivm/jshrink/.travis.yml" => array("59", "cbac6ff01d60d6cf2045eb78150dabe3"), + "vendor/twig/twig/CHANGELOG" => array("32353", "03b21b00ace91062257dd2855ee196cf"), + "vendor/twig/twig/composer.json" => array("1102", "b2d4cf29abf14c07d3eace63816c58c3"), + "vendor/twig/twig/.editorconfig" => array("224", "c1be7a30bae43bc6616f3f266e199030"), + "vendor/twig/twig/ext/twig/config.m4" => array("221", "20ad1d1005402766ddd16b7110aaf4ca"), + "vendor/twig/twig/ext/twig/config.w32" => array("149", "2b1bdfd1a2b8c54966b04f7d143c6632"), + "vendor/twig/twig/ext/twig/.gitignore" => array("328", "7567b95d7259dea2d26ea326730c357d"), + "vendor/twig/twig/ext/twig/LICENSE" => array("1527", "c74e1ac7caf1b0a49bd267b04f9205de"), + "vendor/twig/twig/ext/twig/php_twig.h" => array("1113", "dfc045ec57553713c0db3688a1e69500"), + "vendor/twig/twig/ext/twig/twig.c" => array("30216", "0c2a8fd5aabd9417c98c271b625ab36d"), + "vendor/twig/twig/.gitignore" => array("27", "e8e8b05dcada28af47c4a7f92a2106c0"), + "vendor/twig/twig/lib/Twig/Autoloader.php" => array("1161", "636fed90535196cf757198769e7f26bd"), + "vendor/twig/twig/lib/Twig/CompilerInterface.php" => array("779", "5c6bd37854846a6b1df5e948f2129d53"), + "vendor/twig/twig/lib/Twig/Compiler.php" => array("6906", "61f080c47c7a7f8cd7b9b19a0b418aaf"), + "vendor/twig/twig/lib/Twig/Environment.php" => array("36964", "1a9da7fe09974a4423d8a844fc9de7bb"), + "vendor/twig/twig/lib/Twig/Error/Loader.php" => array("946", "0297a085a7341500baa1706db2917a59"), + "vendor/twig/twig/lib/Twig/Error.php" => array("7496", "08acca21a4a57fc7bdecf80411e6d00e"), + "vendor/twig/twig/lib/Twig/Error/Runtime.php" => array("395", "3a4c02d5a2b37fb343b6e9fb7056e3e7"), + "vendor/twig/twig/lib/Twig/Error/Syntax.php" => array("428", "41d4a9282175957c316b0ffb313d03e0"), + "vendor/twig/twig/lib/Twig/ExistsLoaderInterface.php" => array("692", "2dcb498891d0bea0a55754869618e032"), + "vendor/twig/twig/lib/Twig/ExpressionParser.php" => array("23710", "3fa6eab1d6d8c63b5014f7cadaeb3b58"), + "vendor/twig/twig/lib/Twig/Extension/Core.php" => array("49755", "479a2f77269f5b255b7896096aa8f2d8"), + "vendor/twig/twig/lib/Twig/Extension/Debug.php" => array("2009", "01bfdc7bef74ae207b06874d839acb43"), + "vendor/twig/twig/lib/Twig/Extension/Escaper.php" => array("2788", "1f2be1edc3e5c9fba1aa58976638d8ff"), + "vendor/twig/twig/lib/Twig/ExtensionInterface.php" => array("2091", "8bed99e211ba66f478342efc9faa4013"), + "vendor/twig/twig/lib/Twig/Extension/Optimizer.php" => array("664", "90d559bc3f34fd2952905ddd72f906b3"), + "vendor/twig/twig/lib/Twig/Extension.php" => array("2132", "4a3d6c03eb002ca4e15bb4bd87f33679"), + "vendor/twig/twig/lib/Twig/Extension/Sandbox.php" => array("2636", "1cdfeb7346b86fa17eddfd8717d57667"), + "vendor/twig/twig/lib/Twig/Extension/Staging.php" => array("2096", "6fc289bff03eacfd4a2136ba9a503633"), + "vendor/twig/twig/lib/Twig/Extension/StringLoader.php" => array("1474", "15f299f6e4c3ce7acf21f0fda6d63e37"), + "vendor/twig/twig/lib/Twig/FilterCallableInterface.php" => array("473", "557ea6fcaa5b7b2c503a497a3b931267"), + "vendor/twig/twig/lib/Twig/Filter/Function.php" => array("748", "260ca9d38bdc329f4a82405dc11bb1a4"), + "vendor/twig/twig/lib/Twig/FilterInterface.php" => array("846", "6e43136a50b94a80951dd34da661ccc8"), + "vendor/twig/twig/lib/Twig/Filter/Method.php" => array("930", "98d91cb41f5a5a9a4d67f7e967698141"), + "vendor/twig/twig/lib/Twig/Filter/Node.php" => array("731", "6e30432a47d6bff1ddea8e428c2b3276"), + "vendor/twig/twig/lib/Twig/Filter.php" => array("1869", "ffcbc10fbc3456d605d3f8510c60210d"), + "vendor/twig/twig/lib/Twig/FunctionCallableInterface.php" => array("479", "2f8d67c0e76b701a5e4c76f8bf685797"), + "vendor/twig/twig/lib/Twig/Function/Function.php" => array("784", "3aff76b4acc885f1f0f34f915c718daf"), + "vendor/twig/twig/lib/Twig/FunctionInterface.php" => array("804", "1a989db9f04110dff68441ab95849661"), + "vendor/twig/twig/lib/Twig/Function/Method.php" => array("966", "9f5db96bdcddf2cdecf4ac03cffe5072"), + "vendor/twig/twig/lib/Twig/Function/Node.php" => array("739", "e429ee124350f3a9b95f9d6d461d1ca3"), + "vendor/twig/twig/lib/Twig/Function.php" => array("1628", "cdf7c64b75e20ebe85739da5b308addc"), + "vendor/twig/twig/lib/Twig/LexerInterface.php" => array("761", "cd9cf96833fea0cc3e1657a1abb62e83"), + "vendor/twig/twig/lib/Twig/Lexer.php" => array("16213", "f3ebd10d22c844d1ba4f73326b5b77ba"), + "vendor/twig/twig/lib/Twig/Loader/Array.php" => array("2364", "47e8b8acf9d123975c3336f739911375"), + "vendor/twig/twig/lib/Twig/Loader/Chain.php" => array("3636", "82fc84ebb25255ca44b0451ea00a8e03"), + "vendor/twig/twig/lib/Twig/Loader/Filesystem.php" => array("6040", "38c92a7d80cae890eed1bb48fede872a"), + "vendor/twig/twig/lib/Twig/LoaderInterface.php" => array("1334", "72f395318a031756c876a56cc37a5260"), + "vendor/twig/twig/lib/Twig/Loader/String.php" => array("1334", "d60a08d13f3b0183be2602df9fdfcf6a"), + "vendor/twig/twig/lib/Twig/Markup.php" => array("764", "7ebff11af28eeedb1fdfe31d92132d20"), + "vendor/twig/twig/lib/Twig/Node/AutoEscape.php" => array("950", "25ae0b7054bb57ea1228774e9e64910a"), + "vendor/twig/twig/lib/Twig/Node/Block.php" => array("1080", "756f2c4dc092046f77c40c0a18e15308"), + "vendor/twig/twig/lib/Twig/Node/BlockReference.php" => array("915", "602786b6a5c05060cc5d55b384cb3f43"), + "vendor/twig/twig/lib/Twig/Node/Body.php" => array("337", "3768ca70d202fd3b29e02f0049ae5432"), + "vendor/twig/twig/lib/Twig/Node/Do.php" => array("839", "033e1b69c2ba4b06b81a00f9a6b34964"), + "vendor/twig/twig/lib/Twig/Node/Embed.php" => array("1181", "b155f10a68234505e1b9da02e2521116"), + "vendor/twig/twig/lib/Twig/Node/Expression/Array.php" => array("2350", "00d9b12693a06114f73ec464d9ef6099"), + "vendor/twig/twig/lib/Twig/Node/Expression/AssignName.php" => array("616", "42e94b8e2a7a849e517ba1f4762feb6a"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Add.php" => array("413", "5d16cd53c83be9b5d332f2e6a80d124f"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/And.php" => array("414", "308e349bc7dae1d6e8ffe563a0e059fc"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/BitwiseAnd.php" => array("420", "621c82ab6fafa611309b08ce5f060f0c"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/BitwiseOr.php" => array("419", "b68c97739cb7bc6ff64242e0a3411fab"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/BitwiseXor.php" => array("420", "ff650ff1dcad63ac4dbc9dd57df5eef2"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Concat.php" => array("416", "702c3a6603b0138b5a6b3d82e80e2a05"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Div.php" => array("413", "5b28cc3d88ddfae12b04f22a4f2860c2"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/EndsWith.php" => array("758", "0147cde001fa855a79e976f5110745fc"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Equal.php" => array("389", "3d26fb48b35b0f5ad315d296299a4c20"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/FloorDiv.php" => array("673", "0910da99e3e01329fec591b7950dfb4f"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/GreaterEqual.php" => array("396", "eecadb39bce3c07af85c8a03e735a326"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Greater.php" => array("390", "6209c99dc1bce32340da0bebb5895bf3"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/In.php" => array("772", "1c6b54c6128846b57bab85686a34bc73"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/LessEqual.php" => array("393", "e4fd68cfbded83d91cfafafc81e80311"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Less.php" => array("387", "d2df3932fc9d04720399302098258a18"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Matches.php" => array("662", "ab9d3177aa110a6de020e466a6db65b3"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Mod.php" => array("413", "4e61aee836424a723f1c0314f32b3ec3"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Mul.php" => array("413", "a8d6077b84b988aef093159e4521969f"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/NotEqual.php" => array("392", "e5cf8bb39c1bce092fb84cbe5dbcf1f6"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/NotIn.php" => array("780", "99281fa54772d040df9301b9018875e6"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Or.php" => array("413", "835aa4edcbe9290749ca3e6f56c1fcd7"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary.php" => array("1028", "5753c84484d2fd6b4ca0920fb30513da"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Power.php" => array("764", "c960e3c92ae6311d280f491658355c63"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Range.php" => array("766", "3362365c37d9cf6d3e56c1eb0dbd104f"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/StartsWith.php" => array("669", "8d5900c492c79a667e2739a36e354d56"), + "vendor/twig/twig/lib/Twig/Node/Expression/Binary/Sub.php" => array("413", "47650251c8b7e7c2b82df3a699d10cd6"), + "vendor/twig/twig/lib/Twig/Node/Expression/BlockReference.php" => array("1390", "b5cdd67c38367d62735ea829a4be6c53"), + "vendor/twig/twig/lib/Twig/Node/Expression/Call.php" => array("6426", "145a35b399a10d2d893802028d4d236f"), + "vendor/twig/twig/lib/Twig/Node/Expression/Conditional.php" => array("902", "94611e1b6bbc76388011855f0348fc2e"), + "vendor/twig/twig/lib/Twig/Node/Expression/Constant.php" => array("557", "b3703810a66fd8d2fe792ed95e9949ea"), + "vendor/twig/twig/lib/Twig/Node/Expression/ExtensionReference.php" => array("806", "11d53bdac885a7604336297228ac7091"), + "vendor/twig/twig/lib/Twig/Node/Expression/Filter/Default.php" => array("1583", "81eb255cf59793aa37f7ab73df5b8f47"), + "vendor/twig/twig/lib/Twig/Node/Expression/Filter.php" => array("1378", "859a893a66e17778142c059668f5ffd2"), + "vendor/twig/twig/lib/Twig/Node/Expression/Function.php" => array("1249", "f5dce5675c390ae1d35497c7f76cf22a"), + "vendor/twig/twig/lib/Twig/Node/Expression/GetAttr.php" => array("2240", "bf8ac92138c4f5cdab11de9a7aadff46"), + "vendor/twig/twig/lib/Twig/Node/Expression/MethodCall.php" => array("1203", "ec007ddb0878bb9bc7017a12c6e4c080"), + "vendor/twig/twig/lib/Twig/Node/Expression/Name.php" => array("2872", "09b1ba6d503efb4d48a166fcf5c74a6f"), + "vendor/twig/twig/lib/Twig/Node/Expression/Parent.php" => array("1230", "c6628a0c055557b1ac412590ce76e4af"), + "vendor/twig/twig/lib/Twig/Node/Expression.php" => array("415", "d2fcc744c0d3ece6f32c398ae50e2cb5"), + "vendor/twig/twig/lib/Twig/Node/Expression/TempName.php" => array("594", "312be4fbbf9f33caec6b64733e8f7c35"), + "vendor/twig/twig/lib/Twig/Node/Expression/Test/Constant.php" => array("1130", "cea24b966c64017afa46413e189956b7"), + "vendor/twig/twig/lib/Twig/Node/Expression/Test/Defined.php" => array("1607", "28345503b665a4d6edbbd7b06428718a"), + "vendor/twig/twig/lib/Twig/Node/Expression/Test/Divisibleby.php" => array("747", "cf1043b7f1479d71150e6202be527bef"), + "vendor/twig/twig/lib/Twig/Node/Expression/Test/Even.php" => array("636", "b67fd353edb6c3804d8ab2d41c6f8cbb"), + "vendor/twig/twig/lib/Twig/Node/Expression/Test/Null.php" => array("618", "d0fe20539e14edd2085ec6b0e139b415"), + "vendor/twig/twig/lib/Twig/Node/Expression/Test/Odd.php" => array("633", "5bb79634c28ab1e54b7ca7ded7ee9f28"), + "vendor/twig/twig/lib/Twig/Node/Expression/Test.php" => array("1036", "a0b2bd4d99f906c4857f057c7bb4864e"), + "vendor/twig/twig/lib/Twig/Node/Expression/Test/Sameas.php" => array("690", "0126ce6f962cf906f8f2370133c3ddff"), + "vendor/twig/twig/lib/Twig/Node/Expression/Unary/Neg.php" => array("404", "08e8112e6fabf7eba63b3a19672706c9"), + "vendor/twig/twig/lib/Twig/Node/Expression/Unary/Not.php" => array("404", "0d3a4717baffc2ec33b373bb265a6413"), + "vendor/twig/twig/lib/Twig/Node/Expression/Unary.php" => array("754", "b712815036f4e0cbca846e3088cb3fc6"), + "vendor/twig/twig/lib/Twig/Node/Expression/Unary/Pos.php" => array("404", "e35ab856982e8b07351a893609751c26"), + "vendor/twig/twig/lib/Twig/Node/Flush.php" => array("731", "4fd771f76093c1b60f1ddc1535295c19"), + "vendor/twig/twig/lib/Twig/Node/ForLoop.php" => array("1623", "f2fabfada4aac6f3358d99e39170508d"), + "vendor/twig/twig/lib/Twig/Node/For.php" => array("4367", "687d9e44460eaa3b47e2848b79e5011a"), + "vendor/twig/twig/lib/Twig/Node/If.php" => array("1723", "2263178b12b0fa6609b90cd5ee7dba8d"), + "vendor/twig/twig/lib/Twig/Node/Import.php" => array("1289", "479ef20c51f0e633c7044adead4d0456"), + "vendor/twig/twig/lib/Twig/Node/Include.php" => array("2897", "37a4f90abba8755171ada5732c4bda64"), + "vendor/twig/twig/lib/Twig/NodeInterface.php" => array("649", "2105584cf35b89563c3f86b647ce1f43"), + "vendor/twig/twig/lib/Twig/Node/Macro.php" => array("2655", "8cbf723f3f7518f120f1c17fc033b62e"), + "vendor/twig/twig/lib/Twig/Node/Module.php" => array("12182", "1702dccd13b5a0bfc63a7096327eea0b"), + "vendor/twig/twig/lib/Twig/NodeOutputInterface.php" => array("351", "2125948f894bcba40e4dd4bc906d47e2"), + "vendor/twig/twig/lib/Twig/Node.php" => array("5801", "e24ef0947fe4eb47966813c035b21058"), + "vendor/twig/twig/lib/Twig/Node/Print.php" => array("934", "fe93dc5534fd3c96eccfc4b6f3d5f6b9"), + "vendor/twig/twig/lib/Twig/Node/SandboxedModule.php" => array("2042", "c0d63404cf94209400098023f29d35c4"), + "vendor/twig/twig/lib/Twig/Node/SandboxedPrint.php" => array("1597", "b465d85ba2b5016df5c4f78a14bb184c"), + "vendor/twig/twig/lib/Twig/Node/Sandbox.php" => array("1259", "3dae5d4756cd400f5f26b18d744212cd"), + "vendor/twig/twig/lib/Twig/Node/Set.php" => array("3224", "d0e25c6c5329877865cc4d18cd53c473"), + "vendor/twig/twig/lib/Twig/Node/SetTemp.php" => array("840", "5c93ae290cfe346e9a2d155494f28626"), + "vendor/twig/twig/lib/Twig/Node/Spaceless.php" => array("972", "6b38e3713a5ffda4e7197602f31122e7"), + "vendor/twig/twig/lib/Twig/Node/Text.php" => array("872", "a4dc26dca8d39676f578401e19a05e12"), + "vendor/twig/twig/lib/Twig/NodeTraverser.php" => array("2354", "2a1673a9e56b086c2773d07ca47b9d02"), + "vendor/twig/twig/lib/Twig/NodeVisitor/Escaper.php" => array("5366", "9fe8e3ad07965534c4724211c33daafc"), + "vendor/twig/twig/lib/Twig/NodeVisitorInterface.php" => array("1328", "5efd03cd67a21bf09a6e09be86b570a8"), + "vendor/twig/twig/lib/Twig/NodeVisitor/Optimizer.php" => array("7982", "b57d389aa29b600141e2e3e94327c9c6"), + "vendor/twig/twig/lib/Twig/NodeVisitor/SafeAnalysis.php" => array("4594", "cba1963c52ef6763d47fff8cd7ee6d05"), + "vendor/twig/twig/lib/Twig/NodeVisitor/Sandbox.php" => array("2600", "c823c1317891b559dae63ee717a1b2fe"), + "vendor/twig/twig/lib/Twig/ParserInterface.php" => array("733", "eca931dfd8457bbbb98720da37ae683b"), + "vendor/twig/twig/lib/Twig/Parser.php" => array("11763", "b0d037510f563ebbef2fd4e87eb470ec"), + "vendor/twig/twig/lib/Twig/Sandbox/SecurityError.php" => array("384", "2038673c16bf93db76dd4e8085bb1199"), + "vendor/twig/twig/lib/Twig/Sandbox/SecurityPolicyInterface.php" => array("560", "d556afa8416c83b79752040a10ece3d4"), + "vendor/twig/twig/lib/Twig/Sandbox/SecurityPolicy.php" => array("3708", "28281244b087a7de31fde6203829fd83"), + "vendor/twig/twig/lib/Twig/SimpleFilter.php" => array("2117", "f539a1cf697d6df8dbe0a1ab20fa60c4"), + "vendor/twig/twig/lib/Twig/SimpleFunction.php" => array("1872", "3288da22d048babb201c4c8b21383672"), + "vendor/twig/twig/lib/Twig/SimpleTest.php" => array("920", "67c237535f937d19f9acbabe4bee2b7c"), + "vendor/twig/twig/lib/Twig/TemplateInterface.php" => array("1244", "58ada114eb5a42306e3b510af39dfeae"), + "vendor/twig/twig/lib/Twig/Template.php" => array("15939", "259e9b0fa9da7dc774a9eaf5c8a5667f"), + "vendor/twig/twig/lib/Twig/TestCallableInterface.php" => array("432", "680324d83f36b80a71cc3e18ce6da3bf"), + "vendor/twig/twig/lib/Twig/Test/Function.php" => array("705", "c5256cfda774aad3796e83c5ccc76df1"), + "vendor/twig/twig/lib/Twig/Test/IntegrationTestCase.php" => array("5645", "4e4581eabad75b9cbeb41c4f716e62eb"), + "vendor/twig/twig/lib/Twig/TestInterface.php" => array("506", "c64d3221a3e0ac3a1a27f629529ca22f"), + "vendor/twig/twig/lib/Twig/Test/Method.php" => array("887", "b05886b7c3bb0d5a4b4fd871a0de612e"), + "vendor/twig/twig/lib/Twig/Test/Node.php" => array("688", "41a3b3e4657168e15d1b1613e5ae689a"), + "vendor/twig/twig/lib/Twig/Test/NodeTestCase.php" => array("1593", "fcf420a35fd3b0a9117bff01ec7df9d2"), + "vendor/twig/twig/lib/Twig/Test.php" => array("753", "907df6428c547e9a5af3a07ea99c31a1"), + "vendor/twig/twig/lib/Twig/TokenParser/AutoEscape.php" => array("2611", "999df38e7f5a2dc1a0d255515e35f6d5"), + "vendor/twig/twig/lib/Twig/TokenParser/Block.php" => array("2622", "91f80e924e22f40739703995e18d6bcb"), + "vendor/twig/twig/lib/Twig/TokenParserBrokerInterface.php" => array("1270", "0a586d723c9c4a0eee1fbb8e39bad88e"), + "vendor/twig/twig/lib/Twig/TokenParserBroker.php" => array("3879", "29a8948f912da59f4bcab2a5d718bb59"), + "vendor/twig/twig/lib/Twig/TokenParser/Do.php" => array("980", "87cfd1e3e9c357fa6707126c4708f5e0"), + "vendor/twig/twig/lib/Twig/TokenParser/Embed.php" => array("1947", "f59d933b6e44590ed33aa6dff014fcc1"), + "vendor/twig/twig/lib/Twig/TokenParser/Extends.php" => array("1355", "83f589057c3b2ed4b0ee6fcd9c7140ce"), + "vendor/twig/twig/lib/Twig/TokenParser/Filter.php" => array("1702", "947049e09760ed46c9195e435fe4bc52"), + "vendor/twig/twig/lib/Twig/TokenParser/Flush.php" => array("905", "b6f0a3ec7581d0414899fc50bdaad814"), + "vendor/twig/twig/lib/Twig/TokenParser/For.php" => array("4799", "c4ae472d2b3821f8ae56f86464cc295e"), + "vendor/twig/twig/lib/Twig/TokenParser/From.php" => array("1793", "e180cccf0c62b7cad73c63edb4005baa"), + "vendor/twig/twig/lib/Twig/TokenParser/If.php" => array("2709", "35fdb85b56353849c394a9e4da7fe715"), + "vendor/twig/twig/lib/Twig/TokenParser/Import.php" => array("1296", "3e7397da9c81999b32a1b637871db37c"), + "vendor/twig/twig/lib/Twig/TokenParser/Include.php" => array("1846", "aafd1e3b2b6e6b4223ddc3c0eff599a0"), + "vendor/twig/twig/lib/Twig/TokenParserInterface.php" => array("953", "62c183068343c7dbcad42a1cc4b27581"), + "vendor/twig/twig/lib/Twig/TokenParser/Macro.php" => array("2023", "5f0a590691c64a79a1031a7ebe1da557"), + "vendor/twig/twig/lib/Twig/TokenParser.php" => array("662", "83224f13e3f3e14ed5e7c0d1c567a9c6"), + "vendor/twig/twig/lib/Twig/TokenParser/Sandbox.php" => array("1950", "4e7a32b6b2436967b85ac5bee63f99da"), + "vendor/twig/twig/lib/Twig/TokenParser/Set.php" => array("2286", "9bb035bb1ad955d96257a8a5e0e6b496"), + "vendor/twig/twig/lib/Twig/TokenParser/Spaceless.php" => array("1392", "ab7d1d53dd9b4e7628837e7af93b3db3"), + "vendor/twig/twig/lib/Twig/TokenParser/Use.php" => array("2135", "7bd09009867e76e0b006586fa3fc6275"), + "vendor/twig/twig/lib/Twig/Token.php" => array("6211", "4b3f25c7d4929c29712d25711bc87768"), + "vendor/twig/twig/lib/Twig/TokenStream.php" => array("3989", "8eb8e69d38ec4f6ee0b73d716d064957"), + "vendor/twig/twig/LICENSE" => array("1497", "1886505263500ef827db124cf26c2408"), + "vendor/twig/twig/phpunit.xml.dist" => array("651", "64f59fc76504c822331e5e5eccc3e1cf"), + "vendor/twig/twig/README.rst" => array("486", "32d5a3ca77dace5b9255842b15c55699"), + "vendor/twig/twig/.travis.yml" => array("440", "c60b7a77686a5d1a4201f9fbc4c88dbb"), + ); +} diff --git a/www/analytics/console b/www/analytics/console new file mode 100755 index 00000000..0e3dc3ff --- /dev/null +++ b/www/analytics/console @@ -0,0 +1,28 @@ +#!/usr/bin/env php +init(); +$console->run(); \ No newline at end of file diff --git a/www/analytics/core/.htaccess b/www/analytics/core/.htaccess new file mode 100644 index 00000000..6cd2e134 --- /dev/null +++ b/www/analytics/core/.htaccess @@ -0,0 +1,13 @@ + + +Deny from all + + + +Deny from all + + + +Deny from all + + diff --git a/www/analytics/core/API/DataTableGenericFilter.php b/www/analytics/core/API/DataTableGenericFilter.php new file mode 100644 index 00000000..965e4fde --- /dev/null +++ b/www/analytics/core/API/DataTableGenericFilter.php @@ -0,0 +1,149 @@ +request = $request; + } + + /** + * Filters the given data table + * + * @param DataTable $table + */ + public function filter($table) + { + $this->applyGenericFilters($table); + } + + /** + * Returns an array containing the information of the generic Filter + * to be applied automatically to the data resulting from the API calls. + * + * Order to apply the filters: + * 1 - Filter that remove filtered rows + * 2 - Filter that sort the remaining rows + * 3 - Filter that keep only a subset of the results + * 4 - Presentation filters + * + * @return array See the code for spec + */ + public static function getGenericFiltersInformation() + { + if (is_null(self::$genericFiltersInfo)) { + self::$genericFiltersInfo = array( + 'Pattern' => array( + 'filter_column' => array('string', 'label'), + 'filter_pattern' => array('string'), + ), + 'PatternRecursive' => array( + 'filter_column_recursive' => array('string', 'label'), + 'filter_pattern_recursive' => array('string'), + ), + 'ExcludeLowPopulation' => array( + 'filter_excludelowpop' => array('string'), + 'filter_excludelowpop_value' => array('float', '0'), + ), + 'AddColumnsProcessedMetrics' => array( + 'filter_add_columns_when_show_all_columns' => array('integer') + ), + 'AddColumnsProcessedMetricsGoal' => array( + 'filter_update_columns_when_show_all_goals' => array('integer'), + 'idGoal' => array('string', AddColumnsProcessedMetricsGoal::GOALS_OVERVIEW), + ), + 'Sort' => array( + 'filter_sort_column' => array('string'), + 'filter_sort_order' => array('string', 'desc'), + ), + 'Truncate' => array( + 'filter_truncate' => array('integer'), + ), + 'Limit' => array( + 'filter_offset' => array('integer', '0'), + 'filter_limit' => array('integer'), + 'keep_summary_row' => array('integer', '0'), + ), + ); + } + + return self::$genericFiltersInfo; + } + + /** + * Apply generic filters to the DataTable object resulting from the API Call. + * Disable this feature by setting the parameter disable_generic_filters to 1 in the API call request. + * + * @param DataTable $datatable + * @return bool + */ + protected function applyGenericFilters($datatable) + { + if ($datatable instanceof DataTable\Map) { + $tables = $datatable->getDataTables(); + foreach ($tables as $table) { + $this->applyGenericFilters($table); + } + return; + } + + $genericFilters = self::getGenericFiltersInformation(); + + $filterApplied = false; + foreach ($genericFilters as $filterName => $parameters) { + $filterParameters = array(); + $exceptionRaised = false; + foreach ($parameters as $name => $info) { + // parameter type to cast to + $type = $info[0]; + + // default value if specified, when the parameter doesn't have a value + $defaultValue = null; + if (isset($info[1])) { + $defaultValue = $info[1]; + } + + // third element in the array, if it exists, overrides the name of the request variable + $varName = $name; + if (isset($info[2])) { + $varName = $info[2]; + } + + try { + $value = Common::getRequestVar($name, $defaultValue, $type, $this->request); + settype($value, $type); + $filterParameters[] = $value; + } catch (Exception $e) { + $exceptionRaised = true; + break; + } + } + + if (!$exceptionRaised) { + $datatable->filter($filterName, $filterParameters); + $filterApplied = true; + } + } + return $filterApplied; + } +} diff --git a/www/analytics/core/API/DataTableManipulator.php b/www/analytics/core/API/DataTableManipulator.php new file mode 100644 index 00000000..5ebdd2fb --- /dev/null +++ b/www/analytics/core/API/DataTableManipulator.php @@ -0,0 +1,192 @@ +apiModule = $apiModule; + $this->apiMethod = $apiMethod; + $this->request = $request; + } + + /** + * This method can be used by subclasses to iterate over data tables that might be + * data table maps. It calls back the template method self::doManipulate for each table. + * This way, data table arrays can be handled in a transparent fashion. + * + * @param DataTable\Map|DataTable $dataTable + * @throws Exception + * @return DataTable\Map|DataTable + */ + protected function manipulate($dataTable) + { + if ($dataTable instanceof DataTable\Map) { + return $this->manipulateDataTableMap($dataTable); + } else if ($dataTable instanceof DataTable) { + return $this->manipulateDataTable($dataTable); + } else { + return $dataTable; + } + } + + /** + * Manipulates child DataTables of a DataTable\Map. See @manipulate for more info. + * + * @param DataTable\Map $dataTable + * @return DataTable\Map + */ + protected function manipulateDataTableMap($dataTable) + { + $result = $dataTable->getEmptyClone(); + foreach ($dataTable->getDataTables() as $tableLabel => $childTable) { + $newTable = $this->manipulate($childTable); + $result->addTable($newTable, $tableLabel); + } + return $result; + } + + /** + * Manipulates a single DataTable instance. Derived classes must define + * this function. + */ + protected abstract function manipulateDataTable($dataTable); + + /** + * Load the subtable for a row. + * Returns null if none is found. + * + * @param DataTable $dataTable + * @param Row $row + * + * @return DataTable + */ + protected function loadSubtable($dataTable, $row) + { + if (!($this->apiModule && $this->apiMethod && count($this->request))) { + return null; + } + + $request = $this->request; + + $idSubTable = $row->getIdSubDataTable(); + if ($idSubTable === null) { + return null; + } + + $request['idSubtable'] = $idSubTable; + if ($dataTable) { + $period = $dataTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX); + if ($period instanceof Range) { + $request['date'] = $period->getDateStart() . ',' . $period->getDateEnd(); + } else { + $request['date'] = $period->getDateStart()->toString(); + } + } + + $method = $this->getApiMethodForSubtable(); + return $this->callApiAndReturnDataTable($this->apiModule, $method, $request); + } + + /** + * In this method, subclasses can clean up the request array for loading subtables + * in order to make ResponseBuilder behave correctly (e.g. not trigger the + * manipulator again). + * + * @param $request + * @return + */ + protected abstract function manipulateSubtableRequest($request); + + /** + * Extract the API method for loading subtables from the meta data + * + * @return string + */ + private function getApiMethodForSubtable() + { + if (!$this->apiMethodForSubtable) { + $meta = API::getInstance()->getMetadata('all', $this->apiModule, $this->apiMethod); + + if(empty($meta)) { + throw new Exception(sprintf( + "The DataTable cannot be manipulated: Metadata for report %s.%s could not be found. You can define the metadata in a hook, see example at: http://developer.piwik.org/api-reference/events#apigetreportmetadata", + $this->apiModule, $this->apiMethod + )); + } + + if (isset($meta[0]['actionToLoadSubTables'])) { + $this->apiMethodForSubtable = $meta[0]['actionToLoadSubTables']; + } else { + $this->apiMethodForSubtable = $this->apiMethod; + } + } + return $this->apiMethodForSubtable; + } + + protected function callApiAndReturnDataTable($apiModule, $method, $request) + { + $class = Request::getClassNameAPI($apiModule); + + $request = $this->manipulateSubtableRequest($request); + $request['serialize'] = 0; + $request['expanded'] = 0; + + // don't want to run recursive filters on the subtables as they are loaded, + // otherwise the result will be empty in places (or everywhere). instead we + // run it on the flattened table. + unset($request['filter_pattern_recursive']); + + $dataTable = Proxy::getInstance()->call($class, $method, $request); + $response = new ResponseBuilder($format = 'original', $request); + $dataTable = $response->getResponse($dataTable); + + if (Common::getRequestVar('disable_queued_filters', 0, 'int', $request) == 0) { + if (method_exists($dataTable, 'applyQueuedFilters')) { + $dataTable->applyQueuedFilters(); + } + } + + return $dataTable; + } +} diff --git a/www/analytics/core/API/DataTableManipulator/Flattener.php b/www/analytics/core/API/DataTableManipulator/Flattener.php new file mode 100644 index 00000000..20fe2c02 --- /dev/null +++ b/www/analytics/core/API/DataTableManipulator/Flattener.php @@ -0,0 +1,137 @@ +includeAggregateRows = true; + } + + /** + * Separator for building recursive labels (or paths) + * @var string + */ + public $recursiveLabelSeparator = ' - '; + + /** + * @param DataTable $dataTable + * @return DataTable|DataTable\Map + */ + public function flatten($dataTable) + { + if ($this->apiModule == 'Actions' || $this->apiMethod == 'getWebsites') { + $this->recursiveLabelSeparator = '/'; + } + + return $this->manipulate($dataTable); + } + + /** + * Template method called from self::manipulate. + * Flatten each data table. + * + * @param DataTable $dataTable + * @return DataTable + */ + protected function manipulateDataTable($dataTable) + { + // apply filters now since subtables have their filters applied before generic filters. if we don't do this + // now, we'll try to apply filters to rows that have already been manipulated. this results in errors like + // 'column ... already exists'. + $keepFilters = true; + if (Common::getRequestVar('disable_queued_filters', 0, 'int', $this->request) == 0) { + $dataTable->applyQueuedFilters(); + $keepFilters = false; + } + + $newDataTable = $dataTable->getEmptyClone($keepFilters); + foreach ($dataTable->getRows() as $row) { + $this->flattenRow($row, $newDataTable); + } + return $newDataTable; + } + + /** + * @param Row $row + * @param DataTable $dataTable + * @param string $labelPrefix + * @param bool $parentLogo + */ + private function flattenRow(Row $row, DataTable $dataTable, + $labelPrefix = '', $parentLogo = false) + { + $label = $row->getColumn('label'); + if ($label !== false) { + $label = trim($label); + if (substr($label, 0, 1) == '/' && $this->recursiveLabelSeparator == '/') { + $label = substr($label, 1); + } + $label = $labelPrefix . $label; + $row->setColumn('label', $label); + } + + $logo = $row->getMetadata('logo'); + if ($logo === false && $parentLogo !== false) { + $logo = $parentLogo; + $row->setMetadata('logo', $logo); + } + + $subTable = $this->loadSubtable($dataTable, $row); + $row->removeSubtable(); + + if ($subTable === null) { + if ($this->includeAggregateRows) { + $row->setMetadata('is_aggregate', 0); + } + $dataTable->addRow($row); + } else { + if ($this->includeAggregateRows) { + $row->setMetadata('is_aggregate', 1); + $dataTable->addRow($row); + } + $prefix = $label . $this->recursiveLabelSeparator; + foreach ($subTable->getRows() as $row) { + $this->flattenRow($row, $dataTable, $prefix, $logo); + } + } + } + + /** + * Remove the flat parameter from the subtable request + * + * @param array $request + */ + protected function manipulateSubtableRequest($request) + { + unset($request['flat']); + + return $request; + } +} diff --git a/www/analytics/core/API/DataTableManipulator/LabelFilter.php b/www/analytics/core/API/DataTableManipulator/LabelFilter.php new file mode 100644 index 00000000..05c594b3 --- /dev/null +++ b/www/analytics/core/API/DataTableManipulator/LabelFilter.php @@ -0,0 +1,167 @@ + to join them. + */ +class LabelFilter extends DataTableManipulator +{ + const SEPARATOR_RECURSIVE_LABEL = '>'; + + private $labels; + private $addLabelIndex; + const FLAG_IS_ROW_EVOLUTION = 'label_index'; + + /** + * Filter a data table by label. + * The filtered table is returned, which might be a new instance. + * + * $apiModule, $apiMethod and $request are needed load sub-datatables + * for the recursive search. If the label is not recursive, these parameters + * are not needed. + * + * @param string $labels the labels to search for + * @param DataTable $dataTable the data table to be filtered + * @param bool $addLabelIndex Whether to add label_index metadata describing which + * label a row corresponds to. + * @return DataTable + */ + public function filter($labels, $dataTable, $addLabelIndex = false) + { + if (!is_array($labels)) { + $labels = array($labels); + } + + $this->labels = $labels; + $this->addLabelIndex = (bool)$addLabelIndex; + return $this->manipulate($dataTable); + } + + /** + * Method for the recursive descend + * + * @param array $labelParts + * @param DataTable $dataTable + * @return Row|bool + */ + private function doFilterRecursiveDescend($labelParts, $dataTable) + { + // search for the first part of the tree search + $labelPart = array_shift($labelParts); + + $row = false; + foreach ($this->getLabelVariations($labelPart) as $labelPart) { + $row = $dataTable->getRowFromLabel($labelPart); + if ($row !== false) { + break; + } + } + + if ($row === false) { + // not found + return false; + } + + // end of tree search reached + if (count($labelParts) == 0) { + return $row; + } + + $subTable = $this->loadSubtable($dataTable, $row); + if ($subTable === null) { + // no more subtables but label parts left => no match found + return false; + } + + return $this->doFilterRecursiveDescend($labelParts, $subTable); + } + + /** + * Clean up request for ResponseBuilder to behave correctly + * + * @param $request + */ + protected function manipulateSubtableRequest($request) + { + unset($request['label']); + + return $request; + } + + /** + * Use variations of the label to make it easier to specify the desired label + * + * Note: The HTML Encoded version must be tried first, since in ResponseBuilder the $label is unsanitized + * via Common::unsanitizeLabelParameter. + * + * @param string $label + * @return array + */ + private function getLabelVariations($label) + { + static $pageTitleReports = array('getPageTitles', 'getEntryPageTitles', 'getExitPageTitles'); + + $variations = array(); + $label = urldecode($label); + $label = trim($label); + + $sanitizedLabel = Common::sanitizeInputValue($label); + $variations[] = $sanitizedLabel; + + if ($this->apiModule == 'Actions' + && in_array($this->apiMethod, $pageTitleReports) + ) { + // special case: the Actions.getPageTitles report prefixes some labels with a blank. + // the blank might be passed by the user but is removed in Request::getRequestArrayFromString. + $variations[] = ' ' . $sanitizedLabel; + $variations[] = ' ' . $label; + } + $variations[] = $label; + + return $variations; + } + + /** + * Filter a DataTable instance. See @filter for more info. + * + * @param DataTable\Simple|DataTable\Map $dataTable + * @return mixed + */ + protected function manipulateDataTable($dataTable) + { + $result = $dataTable->getEmptyClone(); + foreach ($this->labels as $labelIndex => $label) { + $row = null; + foreach ($this->getLabelVariations($label) as $labelVariation) { + $labelVariation = explode(self::SEPARATOR_RECURSIVE_LABEL, $labelVariation); + + $row = $this->doFilterRecursiveDescend($labelVariation, $dataTable); + if ($row) { + if ($this->addLabelIndex) { + $row->setMetadata(self::FLAG_IS_ROW_EVOLUTION, $labelIndex); + } + $result->addRow($row); + break; + } + } + } + return $result; + } +} diff --git a/www/analytics/core/API/DataTableManipulator/ReportTotalsCalculator.php b/www/analytics/core/API/DataTableManipulator/ReportTotalsCalculator.php new file mode 100644 index 00000000..ee289d8b --- /dev/null +++ b/www/analytics/core/API/DataTableManipulator/ReportTotalsCalculator.php @@ -0,0 +1,250 @@ +apiModule) || empty($this->apiMethod)) { + return $table; + } + + try { + return $this->manipulate($table); + } catch(\Exception $e) { + // eg. requests with idSubtable may trigger this exception + // (where idSubtable was removed in + // ?module=API&method=Events.getNameFromCategoryId&idSubtable=1&secondaryDimension=eventName&format=XML&idSite=1&period=day&date=yesterday&flat=0 + return $table; + } + } + + /** + * Adds ratio metrics if possible. + * + * @param DataTable $dataTable + * @return DataTable + */ + protected function manipulateDataTable($dataTable) + { + $report = $this->findCurrentReport(); + + if (!empty($report) && empty($report['dimension'])) { + // we currently do not calculate the total value for reports having no dimension + return $dataTable; + } + + // Array [readableMetric] => [summed value] + $totalValues = array(); + + $firstLevelTable = $this->makeSureToWorkOnFirstLevelDataTable($dataTable); + $metricsToCalculate = Metrics::getMetricIdsToProcessReportTotal(); + + foreach ($metricsToCalculate as $metricId) { + if (!$this->hasDataTableMetric($firstLevelTable, $metricId)) { + continue; + } + + foreach ($firstLevelTable->getRows() as $row) { + $totalValues = $this->sumColumnValueToTotal($row, $metricId, $totalValues); + } + } + + $dataTable->setMetadata('totals', $totalValues); + + return $dataTable; + } + + private function hasDataTableMetric(DataTable $dataTable, $metricId) + { + $firstRow = $dataTable->getFirstRow(); + + if (empty($firstRow)) { + return false; + } + + if (false === $this->getColumn($firstRow, $metricId)) { + return false; + } + + return true; + } + + /** + * Returns column from a given row. + * Will work with 2 types of datatable + * - raw datatables coming from the archive DB, which columns are int indexed + * - datatables processed resulting of API calls, which columns have human readable english names + * + * @param Row|array $row + * @param int $columnIdRaw see consts in Metrics:: + * @return mixed Value of column, false if not found + */ + private function getColumn($row, $columnIdRaw) + { + $columnIdReadable = Metrics::getReadableColumnName($columnIdRaw); + + if ($row instanceof Row) { + $raw = $row->getColumn($columnIdRaw); + if ($raw !== false) { + return $raw; + } + + return $row->getColumn($columnIdReadable); + } + + return false; + } + + private function makeSureToWorkOnFirstLevelDataTable($table) + { + if (!array_key_exists('idSubtable', $this->request)) { + return $table; + } + + $firstLevelReport = $this->findFirstLevelReport(); + + if (empty($firstLevelReport)) { + // it is not a subtable report + $module = $this->apiModule; + $action = $this->apiMethod; + } else { + $module = $firstLevelReport['module']; + $action = $firstLevelReport['action']; + } + + $request = $this->request; + + /** @var \Piwik\Period $period */ + $period = $table->getMetadata('period'); + + if (!empty($period)) { + // we want a dataTable, not a dataTable\map + if (Period::isMultiplePeriod($request['date'], $request['period']) || 'range' == $period->getLabel()) { + $request['date'] = $period->getRangeString(); + $request['period'] = 'range'; + } else { + $request['date'] = $period->getDateStart()->toString(); + $request['period'] = $period->getLabel(); + } + } + + return $this->callApiAndReturnDataTable($module, $action, $request); + } + + private function sumColumnValueToTotal(Row $row, $metricId, $totalValues) + { + $value = $this->getColumn($row, $metricId); + + if (false === $value) { + + return $totalValues; + } + + $metricName = Metrics::getReadableColumnName($metricId); + + if (array_key_exists($metricName, $totalValues)) { + $totalValues[$metricName] += $value; + } else { + $totalValues[$metricName] = $value; + } + + return $totalValues; + } + + /** + * Make sure to get all rows of the first level table. + * + * @param array $request + */ + protected function manipulateSubtableRequest($request) + { + $request['totals'] = 0; + $request['expanded'] = 0; + $request['filter_limit'] = -1; + $request['filter_offset'] = 0; + + $parametersToRemove = array('flat'); + + if (!array_key_exists('idSubtable', $this->request)) { + $parametersToRemove[] = 'idSubtable'; + } + + foreach ($parametersToRemove as $param) { + if (array_key_exists($param, $request)) { + unset($request[$param]); + } + } + return $request; + } + + private function getReportMetadata() + { + if (!empty(static::$reportMetadata)) { + return static::$reportMetadata; + } + + static::$reportMetadata = API::getInstance()->getReportMetadata(); + + return static::$reportMetadata; + } + + private function findCurrentReport() + { + foreach ($this->getReportMetadata() as $report) { + if ($this->apiMethod == $report['action'] + && $this->apiModule == $report['module']) { + + return $report; + } + } + } + + private function findFirstLevelReport() + { + foreach ($this->getReportMetadata() as $report) { + if (!empty($report['actionToLoadSubTables']) + && $this->apiMethod == $report['actionToLoadSubTables'] + && $this->apiModule == $report['module'] + ) { + + return $report; + } + } + } +} diff --git a/www/analytics/core/API/DocumentationGenerator.php b/www/analytics/core/API/DocumentationGenerator.php new file mode 100644 index 00000000..ecfece70 --- /dev/null +++ b/www/analytics/core/API/DocumentationGenerator.php @@ -0,0 +1,238 @@ +getLoadedPluginsName(); + foreach ($plugins as $plugin) { + try { + $className = Request::getClassNameAPI($plugin); + Proxy::getInstance()->registerClass($className); + } catch (Exception $e) { + } + } + } + + /** + * Returns a HTML page containing help for all the successfully loaded APIs. + * For each module it will return a mini help with the method names, parameters to give, + * links to get the result in Xml/Csv/etc + * + * @param bool $outputExampleUrls + * @param string $prefixUrls + * @return string + */ + public function getAllInterfaceString($outputExampleUrls = true, $prefixUrls = '') + { + if (!empty($prefixUrls)) { + $prefixUrls = 'http://demo.piwik.org/'; + } + $str = $toc = ''; + $token_auth = "&token_auth=" . Piwik::getCurrentUserTokenAuth(); + $parametersToSet = array( + 'idSite' => Common::getRequestVar('idSite', 1, 'int'), + 'period' => Common::getRequestVar('period', 'day', 'string'), + 'date' => Common::getRequestVar('date', 'today', 'string') + ); + + foreach (Proxy::getInstance()->getMetadata() as $class => $info) { + $moduleName = Proxy::getInstance()->getModuleNameFromClassName($class); + if (in_array($moduleName, $this->modulesToHide)) { + continue; + } + $toc .= "$moduleName
    "; + $str .= "\n

    Module " . $moduleName . "

    "; + $str .= "
    " . $info['__documentation'] . "
    "; + foreach ($info as $methodName => $infoMethod) { + if ($methodName == '__documentation') { + continue; + } + $params = $this->getParametersString($class, $methodName); + $str .= "\n
    - $moduleName.$methodName " . $params . ""; + $str .= ''; + + if ($outputExampleUrls) { + // we prefix all URLs with $prefixUrls + // used when we include this output in the Piwik official documentation for example + $str .= ""; + $exampleUrl = $this->getExampleUrl($class, $methodName, $parametersToSet); + if ($exampleUrl !== false) { + $lastNUrls = ''; + if (preg_match('/(&period)|(&date)/', $exampleUrl)) { + $exampleUrlRss1 = $prefixUrls . $this->getExampleUrl($class, $methodName, array('date' => 'last10', 'period' => 'day') + $parametersToSet); + $exampleUrlRss2 = $prefixUrls . $this->getExampleUrl($class, $methodName, array('date' => 'last5', 'period' => 'week',) + $parametersToSet); + $lastNUrls = ", RSS of the last 10 days"; + } + $exampleUrl = $prefixUrls . $exampleUrl; + $str .= " [ Example in + XML, + Json, + Tsv (Excel) + $lastNUrls + ]"; + } else { + $str .= " [ No example available ]"; + } + $str .= ""; + } + $str .= ''; + $str .= "
    \n"; + } + $str .= ''; + } + + $str = "

    Quick access to APIs

    + $toc + $str"; + return $str; + } + + /** + * Returns a string containing links to examples on how to call a given method on a given API + * It will export links to XML, CSV, HTML, JSON, PHP, etc. + * It will not export links for methods such as deleteSite or deleteUser + * + * @param string $class the class + * @param string $methodName the method + * @param array $parametersToSet parameters to set + * @return string|bool when not possible + */ + public function getExampleUrl($class, $methodName, $parametersToSet = array()) + { + $knowExampleDefaultParametersValues = array( + 'access' => 'view', + 'userLogin' => 'test', + 'passwordMd5ied' => 'passwordExample', + 'email' => 'test@example.org', + + 'languageCode' => 'fr', + 'url' => 'http://forum.piwik.org/', + 'pageUrl' => 'http://forum.piwik.org/', + 'apiModule' => 'UserCountry', + 'apiAction' => 'getCountry', + 'lastMinutes' => '30', + 'abandonedCarts' => '0', + 'segmentName' => 'pageTitle', + 'ip' => '194.57.91.215', + 'idSites' => '1,2', + 'idAlert' => '1', +// 'segmentName' => 'browserCode', + ); + + foreach ($parametersToSet as $name => $value) { + $knowExampleDefaultParametersValues[$name] = $value; + } + + // no links for these method names + $doNotPrintExampleForTheseMethods = array( + //Sites + 'deleteSite', + 'addSite', + 'updateSite', + 'addSiteAliasUrls', + //Users + 'deleteUser', + 'addUser', + 'updateUser', + 'setUserAccess', + //Goals + 'addGoal', + 'updateGoal', + 'deleteGoal', + ); + + if (in_array($methodName, $doNotPrintExampleForTheseMethods)) { + return false; + } + + // we try to give an URL example to call the API + $aParameters = Proxy::getInstance()->getParametersList($class, $methodName); + // Kindly force some known generic parameters to appear in the final list + // the parameter 'format' can be set to all API methods (used in tests) + // the parameter 'hideIdSubDatable' is used for integration tests only + // the parameter 'serialize' sets php outputs human readable, used in integration tests and debug + // the parameter 'language' sets the language for the response (eg. country names) + // the parameter 'flat' reduces a hierarchical table to a single level by concatenating labels + // the parameter 'include_aggregate_rows' can be set to include inner nodes in flat reports + // the parameter 'translateColumnNames' can be set to translate metric names in csv/tsv exports + $aParameters['format'] = false; + $aParameters['hideIdSubDatable'] = false; + $aParameters['serialize'] = false; + $aParameters['language'] = false; + $aParameters['translateColumnNames'] = false; + $aParameters['label'] = false; + $aParameters['flat'] = false; + $aParameters['include_aggregate_rows'] = false; + $aParameters['filter_limit'] = false; //@review without adding this, I can not set filter_limit in $otherRequestParameters integration tests + $aParameters['filter_sort_column'] = false; //@review without adding this, I can not set filter_sort_column in $otherRequestParameters integration tests + $aParameters['filter_truncate'] = false; + $aParameters['hideColumns'] = false; + $aParameters['showColumns'] = false; + $aParameters['filter_pattern_recursive'] = false; + + $moduleName = Proxy::getInstance()->getModuleNameFromClassName($class); + $aParameters = array_merge(array('module' => 'API', 'method' => $moduleName . '.' . $methodName), $aParameters); + + foreach ($aParameters as $nameVariable => &$defaultValue) { + if (isset($knowExampleDefaultParametersValues[$nameVariable])) { + $defaultValue = $knowExampleDefaultParametersValues[$nameVariable]; + } // if there isn't a default value for a given parameter, + // we need a 'know default value' or we can't generate the link + elseif ($defaultValue instanceof NoDefaultValue) { + return false; + } + } + return '?' . Url::getQueryStringFromParameters($aParameters); + } + + /** + * Returns the methods $class.$name parameters (and default value if provided) as a string. + * + * @param string $class The class name + * @param string $name The method name + * @return string For example "(idSite, period, date = 'today')" + */ + public function getParametersString($class, $name) + { + $aParameters = Proxy::getInstance()->getParametersList($class, $name); + $asParameters = array(); + foreach ($aParameters as $nameVariable => $defaultValue) { + // Do not show API parameters starting with _ + // They are supposed to be used only in internal API calls + if (strpos($nameVariable, '_') === 0) { + continue; + } + $str = $nameVariable; + if (!($defaultValue instanceof NoDefaultValue)) { + if (is_array($defaultValue)) { + $str .= " = 'Array'"; + } else { + $str .= " = '$defaultValue'"; + } + } + $asParameters[] = $str; + } + $sParameters = implode(", ", $asParameters); + return "($sParameters)"; + } +} diff --git a/www/analytics/core/API/Proxy.php b/www/analytics/core/API/Proxy.php new file mode 100644 index 00000000..8f1e20c9 --- /dev/null +++ b/www/analytics/core/API/Proxy.php @@ -0,0 +1,514 @@ +noDefaultValue = new NoDefaultValue(); + } + + /** + * Returns array containing reflection meta data for all the loaded classes + * eg. number of parameters, method names, etc. + * + * @return array + */ + public function getMetadata() + { + ksort($this->metadataArray); + return $this->metadataArray; + } + + /** + * Registers the API information of a given module. + * + * The module to be registered must be + * - a singleton (providing a getInstance() method) + * - the API file must be located in plugins/ModuleName/API.php + * for example plugins/Referrers/API.php + * + * The method will introspect the methods, their parameters, etc. + * + * @param string $className ModuleName eg. "API" + */ + public function registerClass($className) + { + if (isset($this->alreadyRegistered[$className])) { + return; + } + $this->includeApiFile($className); + $this->checkClassIsSingleton($className); + + $rClass = new ReflectionClass($className); + foreach ($rClass->getMethods() as $method) { + $this->loadMethodMetadata($className, $method); + } + + $this->setDocumentation($rClass, $className); + $this->alreadyRegistered[$className] = true; + } + + /** + * Will be displayed in the API page + * + * @param ReflectionClass $rClass Instance of ReflectionClass + * @param string $className Name of the class + */ + private function setDocumentation($rClass, $className) + { + // Doc comment + $doc = $rClass->getDocComment(); + $doc = str_replace(" * " . PHP_EOL, "
    ", $doc); + + // boldify the first line only if there is more than one line, otherwise too much bold + if (substr_count($doc, '
    ') > 1) { + $firstLineBreak = strpos($doc, "
    "); + $doc = "
    " . substr($doc, 0, $firstLineBreak) . "
    " . substr($doc, $firstLineBreak + strlen("
    ")); + } + $doc = preg_replace("/(@package)[a-z _A-Z]*/", "", $doc); + $doc = preg_replace("/(@method).*/", "", $doc); + $doc = str_replace(array("\t", "\n", "/**", "*/", " * ", " *", " ", "\t*", " * @package"), " ", $doc); + $this->metadataArray[$className]['__documentation'] = $doc; + } + + /** + * Returns number of classes already loaded + * @return int + */ + public function getCountRegisteredClasses() + { + return count($this->alreadyRegistered); + } + + /** + * Will execute $className->$methodName($parametersValues) + * If any error is detected (wrong number of parameters, method not found, class not found, etc.) + * it will throw an exception + * + * It also logs the API calls, with the parameters values, the returned value, the performance, etc. + * You can enable logging in config/global.ini.php (log_api_call) + * + * @param string $className The class name (eg. API) + * @param string $methodName The method name + * @param array $parametersRequest The parameters pairs (name=>value) + * + * @return mixed|null + * @throws Exception|\Piwik\NoAccessException + */ + public function call($className, $methodName, $parametersRequest) + { + $returnedValue = null; + + // Temporarily sets the Request array to this API call context + $saveGET = $_GET; + $saveQUERY_STRING = @$_SERVER['QUERY_STRING']; + foreach ($parametersRequest as $param => $value) { + $_GET[$param] = $value; + } + + try { + $this->registerClass($className); + + // instanciate the object + $object = $className::getInstance(); + + // check method exists + $this->checkMethodExists($className, $methodName); + + // get the list of parameters required by the method + $parameterNamesDefaultValues = $this->getParametersList($className, $methodName); + + // load parameters in the right order, etc. + $finalParameters = $this->getRequestParametersArray($parameterNamesDefaultValues, $parametersRequest); + + // allow plugins to manipulate the value + $pluginName = $this->getModuleNameFromClassName($className); + + /** + * Triggered before an API request is dispatched. + * + * This event can be used to modify the arguments passed to one or more API methods. + * + * **Example** + * + * Piwik::addAction('API.Request.dispatch', function (&$parameters, $pluginName, $methodName) { + * if ($pluginName == 'Actions') { + * if ($methodName == 'getPageUrls') { + * // ... do something ... + * } else { + * // ... do something else ... + * } + * } + * }); + * + * @param array &$finalParameters List of parameters that will be passed to the API method. + * @param string $pluginName The name of the plugin the API method belongs to. + * @param string $methodName The name of the API method that will be called. + */ + Piwik::postEvent('API.Request.dispatch', array(&$finalParameters, $pluginName, $methodName)); + + /** + * Triggered before an API request is dispatched. + * + * This event exists for convenience and is triggered directly after the {@hook API.Request.dispatch} + * event is triggered. It can be used to modify the arguments passed to a **single** API method. + * + * _Note: This is can be accomplished with the {@hook API.Request.dispatch} event as well, however + * event handlers for that event will have to do more work._ + * + * **Example** + * + * Piwik::addAction('API.Actions.getPageUrls', function (&$parameters) { + * // force use of a single website. for some reason. + * $parameters['idSite'] = 1; + * }); + * + * @param array &$finalParameters List of parameters that will be passed to the API method. + */ + Piwik::postEvent(sprintf('API.%s.%s', $pluginName, $methodName), array(&$finalParameters)); + + // call the method + $returnedValue = call_user_func_array(array($object, $methodName), $finalParameters); + + $endHookParams = array( + &$returnedValue, + array('className' => $className, + 'module' => $pluginName, + 'action' => $methodName, + 'parameters' => $finalParameters) + ); + + /** + * Triggered directly after an API request is dispatched. + * + * This event exists for convenience and is triggered immediately before the + * {@hook API.Request.dispatch.end} event. It can be used to modify the output of a **single** + * API method. + * + * _Note: This can be accomplished with the {@hook API.Request.dispatch.end} event as well, + * however event handlers for that event will have to do more work._ + * + * **Example** + * + * // append (0 hits) to the end of row labels whose row has 0 hits + * Piwik::addAction('API.Actions.getPageUrls', function (&$returnValue, $info)) { + * $returnValue->filter('ColumnCallbackReplace', 'label', function ($label, $hits) { + * if ($hits === 0) { + * return $label . " (0 hits)"; + * } else { + * return $label; + * } + * }, null, array('nb_hits')); + * } + * + * @param mixed &$returnedValue The API method's return value. Can be an object, such as a + * {@link Piwik\DataTable DataTable} instance. + * could be a {@link Piwik\DataTable DataTable}. + * @param array $extraInfo An array holding information regarding the API request. Will + * contain the following data: + * + * - **className**: The namespace-d class name of the API instance + * that's being called. + * - **module**: The name of the plugin the API request was + * dispatched to. + * - **action**: The name of the API method that was executed. + * - **parameters**: The array of parameters passed to the API + * method. + */ + Piwik::postEvent(sprintf('API.%s.%s.end', $pluginName, $methodName), $endHookParams); + + /** + * Triggered directly after an API request is dispatched. + * + * This event can be used to modify the output of any API method. + * + * **Example** + * + * // append (0 hits) to the end of row labels whose row has 0 hits for any report that has the 'nb_hits' metric + * Piwik::addAction('API.Actions.getPageUrls', function (&$returnValue, $info)) { + * // don't process non-DataTable reports and reports that don't have the nb_hits column + * if (!($returnValue instanceof DataTableInterface) + * || in_array('nb_hits', $returnValue->getColumns()) + * ) { + * return; + * } + * + * $returnValue->filter('ColumnCallbackReplace', 'label', function ($label, $hits) { + * if ($hits === 0) { + * return $label . " (0 hits)"; + * } else { + * return $label; + * } + * }, null, array('nb_hits')); + * } + * + * @param mixed &$returnedValue The API method's return value. Can be an object, such as a + * {@link Piwik\DataTable DataTable} instance. + * @param array $extraInfo An array holding information regarding the API request. Will + * contain the following data: + * + * - **className**: The namespace-d class name of the API instance + * that's being called. + * - **module**: The name of the plugin the API request was + * dispatched to. + * - **action**: The name of the API method that was executed. + * - **parameters**: The array of parameters passed to the API + * method. + */ + Piwik::postEvent('API.Request.dispatch.end', $endHookParams); + + // Restore the request + $_GET = $saveGET; + $_SERVER['QUERY_STRING'] = $saveQUERY_STRING; + } catch (Exception $e) { + $_GET = $saveGET; + throw $e; + } + + return $returnedValue; + } + + /** + * Returns the parameters names and default values for the method $name + * of the class $class + * + * @param string $class The class name + * @param string $name The method name + * @return array Format array( + * 'testParameter' => null, // no default value + * 'life' => 42, // default value = 42 + * 'date' => 'yesterday', + * ); + */ + public function getParametersList($class, $name) + { + return $this->metadataArray[$class][$name]['parameters']; + } + + /** + * Returns the 'moduleName' part of '\\Piwik\\Plugins\\moduleName\\API' + * + * @param string $className "API" + * @return string "Referrers" + */ + public function getModuleNameFromClassName($className) + { + return str_replace(array('\\Piwik\\Plugins\\', '\\API'), '', $className); + } + + public function isExistingApiAction($pluginName, $apiAction) + { + $namespacedApiClassName = "\\Piwik\\Plugins\\$pluginName\\API"; + $api = $namespacedApiClassName::getInstance(); + + return method_exists($api, $apiAction); + } + + public function buildApiActionName($pluginName, $apiAction) + { + return sprintf("%s.%s", $pluginName, $apiAction); + } + + /** + * Sets whether to hide '@ignore'd functions from method metadata or not. + * + * @param bool $hideIgnoredFunctions + */ + public function setHideIgnoredFunctions($hideIgnoredFunctions) + { + $this->hideIgnoredFunctions = $hideIgnoredFunctions; + + // make sure metadata gets reloaded + $this->alreadyRegistered = array(); + $this->metadataArray = array(); + } + + /** + * Returns an array containing the values of the parameters to pass to the method to call + * + * @param array $requiredParameters array of (parameter name, default value) + * @param array $parametersRequest + * @throws Exception + * @return array values to pass to the function call + */ + private function getRequestParametersArray($requiredParameters, $parametersRequest) + { + $finalParameters = array(); + foreach ($requiredParameters as $name => $defaultValue) { + try { + if ($defaultValue instanceof NoDefaultValue) { + $requestValue = Common::getRequestVar($name, null, null, $parametersRequest); + } else { + try { + + if ($name == 'segment' && !empty($parametersRequest['segment'])) { + // segment parameter is an exception: we do not want to sanitize user input or it would break the segment encoding + $requestValue = ($parametersRequest['segment']); + } else { + $requestValue = Common::getRequestVar($name, $defaultValue, null, $parametersRequest); + } + } catch (Exception $e) { + // Special case: empty parameter in the URL, should return the empty string + if (isset($parametersRequest[$name]) + && $parametersRequest[$name] === '' + ) { + $requestValue = ''; + } else { + $requestValue = $defaultValue; + } + } + } + } catch (Exception $e) { + throw new Exception(Piwik::translate('General_PleaseSpecifyValue', array($name))); + } + $finalParameters[] = $requestValue; + } + return $finalParameters; + } + + /** + * Includes the class API by looking up plugins/UserSettings/API.php + * + * @param string $fileName api class name eg. "API" + * @throws Exception + */ + private function includeApiFile($fileName) + { + $module = self::getModuleNameFromClassName($fileName); + $path = PIWIK_INCLUDE_PATH . '/plugins/' . $module . '/API.php'; + + if (is_readable($path)) { + require_once $path; // prefixed by PIWIK_INCLUDE_PATH + } else { + throw new Exception("API module $module not found."); + } + } + + /** + * @param string $class name of a class + * @param ReflectionMethod $method instance of ReflectionMethod + */ + private function loadMethodMetadata($class, $method) + { + if ($method->isPublic() + && !$method->isConstructor() + && $method->getName() != 'getInstance' + && false === strstr($method->getDocComment(), '@deprecated') + && (!$this->hideIgnoredFunctions || false === strstr($method->getDocComment(), '@ignore')) + ) { + $name = $method->getName(); + $parameters = $method->getParameters(); + + $aParameters = array(); + foreach ($parameters as $parameter) { + $nameVariable = $parameter->getName(); + + $defaultValue = $this->noDefaultValue; + if ($parameter->isDefaultValueAvailable()) { + $defaultValue = $parameter->getDefaultValue(); + } + + $aParameters[$nameVariable] = $defaultValue; + } + $this->metadataArray[$class][$name]['parameters'] = $aParameters; + $this->metadataArray[$class][$name]['numberOfRequiredParameters'] = $method->getNumberOfRequiredParameters(); + } + } + + /** + * Checks that the method exists in the class + * + * @param string $className The class name + * @param string $methodName The method name + * @throws Exception If the method is not found + */ + private function checkMethodExists($className, $methodName) + { + if (!$this->isMethodAvailable($className, $methodName)) { + throw new Exception(Piwik::translate('General_ExceptionMethodNotFound', array($methodName, $className))); + } + } + + /** + * Returns the number of required parameters (parameters without default values). + * + * @param string $class The class name + * @param string $name The method name + * @return int The number of required parameters + */ + private function getNumberOfRequiredParameters($class, $name) + { + return $this->metadataArray[$class][$name]['numberOfRequiredParameters']; + } + + /** + * Returns true if the method is found in the API of the given class name. + * + * @param string $className The class name + * @param string $methodName The method name + * @return bool + */ + private function isMethodAvailable($className, $methodName) + { + return isset($this->metadataArray[$className][$methodName]); + } + + /** + * Checks that the class is a Singleton (presence of the getInstance() method) + * + * @param string $className The class name + * @throws Exception If the class is not a Singleton + */ + private function checkClassIsSingleton($className) + { + if (!method_exists($className, "getInstance")) { + throw new Exception("$className that provide an API must be Singleton and have a 'static public function getInstance()' method."); + } + } +} + +/** + * To differentiate between "no value" and default value of null + * + */ +class NoDefaultValue +{ +} diff --git a/www/analytics/core/API/Request.php b/www/analytics/core/API/Request.php new file mode 100644 index 00000000..68cd3fff --- /dev/null +++ b/www/analytics/core/API/Request.php @@ -0,0 +1,398 @@ +process(); + * echo $result; + * + * **Getting a unrendered DataTable** + * + * // use the convenience method 'processRequest' + * $dataTable = Request::processRequest('UserSettings.getWideScreen', array( + * 'idSite' => 1, + * 'date' => 'yesterday', + * 'period' => 'week', + * 'filter_limit' => 5, + * 'filter_offset' => 0 + * + * 'format' => 'original', // this is the important bit + * )); + * echo "This DataTable has " . $dataTable->getRowsCount() . " rows."; + * + * @see http://piwik.org/docs/analytics-api + * @api + */ +class Request +{ + protected $request = null; + + /** + * Converts the supplied request string into an array of query paramater name/value + * mappings. The current query parameters (everything in `$_GET` and `$_POST`) are + * forwarded to request array before it is returned. + * + * @param string|array $request The base request string or array, eg, + * `'module=UserSettings&action=getWidescreen'`. + * @return array + */ + static public function getRequestArrayFromString($request) + { + $defaultRequest = $_GET + $_POST; + + $requestRaw = self::getRequestParametersGET(); + if (!empty($requestRaw['segment'])) { + $defaultRequest['segment'] = $requestRaw['segment']; + } + + $requestArray = $defaultRequest; + + if (!is_null($request)) { + if (is_array($request)) { + $url = array(); + foreach ($request as $key => $value) { + $url[] = $key . "=" . $value; + } + $request = implode("&", $url); + } + + $request = trim($request); + $request = str_replace(array("\n", "\t"), '', $request); + + $requestParsed = UrlHelper::getArrayFromQueryString($request); + $requestArray = $requestParsed + $defaultRequest; + } + + foreach ($requestArray as &$element) { + if (!is_array($element)) { + $element = trim($element); + } + } + return $requestArray; + } + + /** + * Constructor. + * + * @param string|array $request Query string that defines the API call (must at least contain a **method** parameter), + * eg, `'method=UserSettings.getWideScreen&idSite=1&date=yesterday&period=week&format=xml'` + * If a request is not provided, then we use the values in the `$_GET` and `$_POST` + * superglobals. + */ + public function __construct($request = null) + { + $this->request = self::getRequestArrayFromString($request); + $this->sanitizeRequest(); + } + + /** + * For backward compatibility: Piwik API still works if module=Referers, + * we rewrite to correct renamed plugin: Referrers + * + * @param $module + * @return string + * @ignore + */ + public static function renameModule($module) + { + $moduleToRedirect = array( + 'Referers' => 'Referrers', + 'PDFReports' => 'ScheduledReports', + ); + if (isset($moduleToRedirect[$module])) { + return $moduleToRedirect[$module]; + } + return $module; + } + + /** + * Make sure that the request contains no logical errors + */ + private function sanitizeRequest() + { + // The label filter does not work with expanded=1 because the data table IDs have a different meaning + // depending on whether the table has been loaded yet. expanded=1 causes all tables to be loaded, which + // is why the label filter can't descend when a recursive label has been requested. + // To fix this, we remove the expanded parameter if a label parameter is set. + if (isset($this->request['label']) && !empty($this->request['label']) + && isset($this->request['expanded']) && $this->request['expanded'] + ) { + unset($this->request['expanded']); + } + } + + /** + * Dispatches the API request to the appropriate API method and returns the result + * after post-processing. + * + * Post-processing includes: + * + * - flattening if **flat** is 0 + * - running generic filters unless **disable_generic_filters** is set to 1 + * - URL decoding label column values + * - running queued filters unless **disable_queued_filters** is set to 1 + * - removing columns based on the values of the **hideColumns** and **showColumns** query parameters + * - filtering rows if the **label** query parameter is set + * - converting the result to the appropriate format (ie, XML, JSON, etc.) + * + * If `'original'` is supplied for the output format, the result is returned as a PHP + * object. + * + * @throws PluginDeactivatedException if the module plugin is not activated. + * @throws Exception if the requested API method cannot be called, if required parameters for the + * API method are missing or if the API method throws an exception and the **format** + * query parameter is **original**. + * @return DataTable|Map|string The data resulting from the API call. + */ + public function process() + { + // read the format requested for the output data + $outputFormat = strtolower(Common::getRequestVar('format', 'xml', 'string', $this->request)); + + // create the response + $response = new ResponseBuilder($outputFormat, $this->request); + + try { + // read parameters + $moduleMethod = Common::getRequestVar('method', null, 'string', $this->request); + + list($module, $method) = $this->extractModuleAndMethod($moduleMethod); + + $module = $this->renameModule($module); + + if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated($module)) { + throw new PluginDeactivatedException($module); + } + $apiClassName = $this->getClassNameAPI($module); + + self::reloadAuthUsingTokenAuth($this->request); + + // call the method + $returnedValue = Proxy::getInstance()->call($apiClassName, $method, $this->request); + + $toReturn = $response->getResponse($returnedValue, $module, $method); + } catch (Exception $e) { + $toReturn = $response->getResponseException($e); + } + return $toReturn; + } + + /** + * Returns the name of a plugin's API class by plugin name. + * + * @param string $plugin The plugin name, eg, `'Referrers'`. + * @return string The fully qualified API class name, eg, `'\Piwik\Plugins\Referrers\API'`. + */ + static public function getClassNameAPI($plugin) + { + return sprintf('\Piwik\Plugins\%s\API', $plugin); + } + + /** + * If the token_auth is found in the $request parameter, + * the current session will be authenticated using this token_auth. + * It will overwrite the previous Auth object. + * + * @param array $request If null, uses the default request ($_GET) + * @return void + * @ignore + */ + static public function reloadAuthUsingTokenAuth($request = null) + { + // if a token_auth is specified in the API request, we load the right permissions + $token_auth = Common::getRequestVar('token_auth', '', 'string', $request); + if ($token_auth) { + + /** + * Triggered when authenticating an API request, but only if the **token_auth** + * query parameter is found in the request. + * + * Plugins that provide authentication capabilities should subscribe to this event + * and make sure the global authentication object (the object returned by `Registry::get('auth')`) + * is setup to use `$token_auth` when its `authenticate()` method is executed. + * + * @param string $token_auth The value of the **token_auth** query parameter. + */ + Piwik::postEvent('API.Request.authenticate', array($token_auth)); + Access::getInstance()->reloadAccess(); + SettingsServer::raiseMemoryLimitIfNecessary(); + } + } + + /** + * Returns array($class, $method) from the given string $class.$method + * + * @param string $parameter + * @throws Exception + * @return array + */ + private function extractModuleAndMethod($parameter) + { + $a = explode('.', $parameter); + if (count($a) != 2) { + throw new Exception("The method name is invalid. Expected 'module.methodName'"); + } + return $a; + } + + /** + * Helper method that processes an API request in one line using the variables in `$_GET` + * and `$_POST`. + * + * @param string $method The API method to call, ie, `'Actions.getPageTitles'`. + * @param array $paramOverride The parameter name-value pairs to use instead of what's + * in `$_GET` & `$_POST`. + * @return mixed The result of the API request. See {@link process()}. + */ + public static function processRequest($method, $paramOverride = array()) + { + $params = array(); + $params['format'] = 'original'; + $params['module'] = 'API'; + $params['method'] = $method; + $params = $paramOverride + $params; + + // process request + $request = new Request($params); + return $request->process(); + } + + /** + * Returns the original request parameters in the current query string as an array mapping + * query parameter names with values. The result of this function will not be affected + * by any modifications to `$_GET` and will not include parameters in `$_POST`. + * + * @return array + */ + public static function getRequestParametersGET() + { + if (empty($_SERVER['QUERY_STRING'])) { + return array(); + } + $GET = UrlHelper::getArrayFromQueryString($_SERVER['QUERY_STRING']); + return $GET; + } + + /** + * Returns the URL for the current requested report w/o any filter parameters. + * + * @param string $module The API module. + * @param string $action The API action. + * @param array $queryParams Query parameter overrides. + * @return string + */ + public static function getBaseReportUrl($module, $action, $queryParams = array()) + { + $params = array_merge($queryParams, array('module' => $module, 'action' => $action)); + return Request::getCurrentUrlWithoutGenericFilters($params); + } + + /** + * Returns the current URL without generic filter query parameters. + * + * @param array $params Query parameter values to override in the new URL. + * @return string + */ + public static function getCurrentUrlWithoutGenericFilters($params) + { + // unset all filter query params so the related report will show up in its default state, + // unless the filter param was in $queryParams + $genericFiltersInfo = DataTableGenericFilter::getGenericFiltersInformation(); + foreach ($genericFiltersInfo as $filter) { + foreach ($filter as $queryParamName => $queryParamInfo) { + if (!isset($params[$queryParamName])) { + $params[$queryParamName] = null; + } + } + } + + return Url::getCurrentQueryStringWithParametersModified($params); + } + + /** + * Returns whether the DataTable result will have to be expanded for the + * current request before rendering. + * + * @return bool + * @ignore + */ + public static function shouldLoadExpanded() + { + // if filter_column_recursive & filter_pattern_recursive are supplied, and flat isn't supplied + // we have to load all the child subtables. + return Common::getRequestVar('filter_column_recursive', false) !== false + && Common::getRequestVar('filter_pattern_recursive', false) !== false + && !self::shouldLoadFlatten(); + } + + /** + * @return bool + */ + public static function shouldLoadFlatten() + { + return Common::getRequestVar('flat', false) == 1; + } + + /** + * Returns the segment query parameter from the original request, without modifications. + * + * @return array|bool + */ + static public function getRawSegmentFromRequest() + { + // we need the URL encoded segment parameter, we fetch it from _SERVER['QUERY_STRING'] instead of default URL decoded _GET + $segmentRaw = false; + $segment = Common::getRequestVar('segment', '', 'string'); + if (!empty($segment)) { + $request = Request::getRequestParametersGET(); + if (!empty($request['segment'])) { + $segmentRaw = $request['segment']; + } + } + return $segmentRaw; + } +} diff --git a/www/analytics/core/API/ResponseBuilder.php b/www/analytics/core/API/ResponseBuilder.php new file mode 100644 index 00000000..692a2ceb --- /dev/null +++ b/www/analytics/core/API/ResponseBuilder.php @@ -0,0 +1,478 @@ +request = $request; + $this->outputFormat = $outputFormat; + } + + /** + * This method processes the data resulting from the API call. + * + * - If the data resulted from the API call is a DataTable then + * - we apply the standard filters if the parameters have been found + * in the URL. For example to offset,limit the Table you can add the following parameters to any API + * call that returns a DataTable: filter_limit=10&filter_offset=20 + * - we apply the filters that have been previously queued on the DataTable + * @see DataTable::queueFilter() + * - we apply the renderer that generate the DataTable in a given format (XML, PHP, HTML, JSON, etc.) + * the format can be changed using the 'format' parameter in the request. + * Example: format=xml + * + * - If there is nothing returned (void) we display a standard success message + * + * - If there is a PHP array returned, we try to convert it to a dataTable + * It is then possible to convert this datatable to any requested format (xml/etc) + * + * - If a bool is returned we convert to a string (true is displayed as 'true' false as 'false') + * + * - If an integer / float is returned, we simply return it + * + * @param mixed $value The initial returned value, before post process. If set to null, success response is returned. + * @param bool|string $apiModule The API module that was called + * @param bool|string $apiMethod The API method that was called + * @return mixed Usually a string, but can still be a PHP data structure if the format requested is 'original' + */ + public function getResponse($value = null, $apiModule = false, $apiMethod = false) + { + $this->apiModule = $apiModule; + $this->apiMethod = $apiMethod; + + if($this->outputFormat == 'original') { + @header('Content-Type: text/plain; charset=utf-8'); + } + return $this->renderValue($value); + } + + /** + * Returns an error $message in the requested $format + * + * @param Exception $e + * @throws Exception + * @return string + */ + public function getResponseException(Exception $e) + { + $format = strtolower($this->outputFormat); + + if ($format == 'original') { + throw $e; + } + + try { + $renderer = Renderer::factory($format); + } catch (Exception $exceptionRenderer) { + return "Error: " . $e->getMessage() . " and: " . $exceptionRenderer->getMessage(); + } + + $e = $this->decorateExceptionWithDebugTrace($e); + + $renderer->setException($e); + + if ($format == 'php') { + $renderer->setSerialize($this->caseRendererPHPSerialize()); + } + + return $renderer->renderException(); + } + + /** + * @param $value + * @return string + */ + protected function renderValue($value) + { + // when null or void is returned from the api call, we handle it as a successful operation + if (!isset($value)) { + return $this->handleSuccess(); + } + + // If the returned value is an object DataTable we + // apply the set of generic filters if asked in the URL + // and we render the DataTable according to the format specified in the URL + if ($value instanceof DataTable + || $value instanceof DataTable\Map + ) { + return $this->handleDataTable($value); + } + + // Case an array is returned from the API call, we convert it to the requested format + // - if calling from inside the application (format = original) + // => the data stays unchanged (ie. a standard php array or whatever data structure) + // - if any other format is requested, we have to convert this data structure (which we assume + // to be an array) to a DataTable in order to apply the requested DataTable_Renderer (for example XML) + if (is_array($value)) { + return $this->handleArray($value); + } + + // original data structure requested, we return without process + if ($this->outputFormat == 'original') { + return $value; + } + + if (is_object($value) + || is_resource($value) + ) { + return $this->getResponseException(new Exception('The API cannot handle this data structure.')); + } + + // bool // integer // float // serialized object + return $this->handleScalar($value); + } + + /** + * @param Exception $e + * @return Exception + */ + protected function decorateExceptionWithDebugTrace(Exception $e) + { + // If we are in tests, show full backtrace + if (defined('PIWIK_PATH_TEST_TO_ROOT')) { + if (\Piwik_ShouldPrintBackTraceWithMessage()) { + $message = $e->getMessage() . " in \n " . $e->getFile() . ":" . $e->getLine() . " \n " . $e->getTraceAsString(); + } else { + $message = $e->getMessage() . "\n \n --> To temporarily debug this error further, set const PIWIK_PRINT_ERROR_BACKTRACE=true; in index.php"; + } + return new Exception($message); + } + return $e; + } + + /** + * Returns true if the user requested to serialize the output data (&serialize=1 in the request) + * + * @param mixed $defaultSerializeValue Default value in case the user hasn't specified a value + * @return bool + */ + protected function caseRendererPHPSerialize($defaultSerializeValue = 1) + { + $serialize = Common::getRequestVar('serialize', $defaultSerializeValue, 'int', $this->request); + if ($serialize) { + return true; + } + return false; + } + + /** + * Apply the specified renderer to the DataTable + * + * @param DataTable|array $dataTable + * @return string + */ + protected function getRenderedDataTable($dataTable) + { + $format = strtolower($this->outputFormat); + + // if asked for original dataStructure + if ($format == 'original') { + // by default "original" data is not serialized + if ($this->caseRendererPHPSerialize($defaultSerialize = 0)) { + $dataTable = serialize($dataTable); + } + return $dataTable; + } + + $method = Common::getRequestVar('method', '', 'string', $this->request); + + $renderer = Renderer::factory($format); + $renderer->setTable($dataTable); + $renderer->setRenderSubTables(Common::getRequestVar('expanded', false, 'int', $this->request)); + $renderer->setHideIdSubDatableFromResponse(Common::getRequestVar('hideIdSubDatable', false, 'int', $this->request)); + + if ($format == 'php') { + $renderer->setSerialize($this->caseRendererPHPSerialize()); + $renderer->setPrettyDisplay(Common::getRequestVar('prettyDisplay', false, 'int', $this->request)); + } else if ($format == 'html') { + $renderer->setTableId($this->request['method']); + } else if ($format == 'csv' || $format == 'tsv') { + $renderer->setConvertToUnicode(Common::getRequestVar('convertToUnicode', true, 'int', $this->request)); + } + + // prepare translation of column names + if ($format == 'html' || $format == 'csv' || $format == 'tsv' || $format = 'rss') { + $renderer->setApiMethod($method); + $renderer->setIdSite(Common::getRequestVar('idSite', false, 'int', $this->request)); + $renderer->setTranslateColumnNames(Common::getRequestVar('translateColumnNames', false, 'int', $this->request)); + } + + return $renderer->render(); + } + + /** + * Returns a success $message in the requested $format + * + * @param string $message + * @return string + */ + protected function handleSuccess($message = 'ok') + { + // return a success message only if no content has already been buffered, useful when APIs return raw text or html content to the browser + if (!ob_get_contents()) { + switch ($this->outputFormat) { + case 'xml': + @header("Content-Type: text/xml;charset=utf-8"); + $return = + "\n" . + "\n" . + "\t\n" . + ""; + break; + case 'json': + @header("Content-Type: application/json"); + $return = '{"result":"success", "message":"' . $message . '"}'; + break; + case 'php': + $return = array('result' => 'success', 'message' => $message); + if ($this->caseRendererPHPSerialize()) { + $return = serialize($return); + } + break; + + case 'csv': + @header("Content-Type: application/vnd.ms-excel"); + @header("Content-Disposition: attachment; filename=piwik-report-export.csv"); + $return = "message\n" . $message; + break; + + default: + $return = 'Success:' . $message; + break; + } + return $return; + } + } + + /** + * Converts the given scalar to an data table + * + * @param mixed $scalar + * @return string + */ + protected function handleScalar($scalar) + { + $dataTable = new Simple(); + $dataTable->addRowsFromArray(array($scalar)); + return $this->getRenderedDataTable($dataTable); + } + + /** + * Handles the given data table + * + * @param DataTable $datatable + * @return string + */ + protected function handleDataTable($datatable) + { + // if requested, flatten nested tables + if (Common::getRequestVar('flat', '0', 'string', $this->request) == '1') { + $flattener = new Flattener($this->apiModule, $this->apiMethod, $this->request); + if (Common::getRequestVar('include_aggregate_rows', '0', 'string', $this->request) == '1') { + $flattener->includeAggregateRows(); + } + $datatable = $flattener->flatten($datatable); + } + + if (1 == Common::getRequestVar('totals', '1', 'integer', $this->request)) { + $genericFilter = new ReportTotalsCalculator($this->apiModule, $this->apiMethod, $this->request); + $datatable = $genericFilter->calculate($datatable); + } + + // if the flag disable_generic_filters is defined we skip the generic filters + if (0 == Common::getRequestVar('disable_generic_filters', '0', 'string', $this->request)) { + $genericFilter = new DataTableGenericFilter($this->request); + $genericFilter->filter($datatable); + } + + // we automatically safe decode all datatable labels (against xss) + $datatable->queueFilter('SafeDecodeLabel'); + + // if the flag disable_queued_filters is defined we skip the filters that were queued + if (Common::getRequestVar('disable_queued_filters', 0, 'int', $this->request) == 0) { + $datatable->applyQueuedFilters(); + } + + // use the ColumnDelete filter if hideColumns/showColumns is provided (must be done + // after queued filters are run so processed metrics can be removed, too) + $hideColumns = Common::getRequestVar('hideColumns', '', 'string', $this->request); + $showColumns = Common::getRequestVar('showColumns', '', 'string', $this->request); + if ($hideColumns !== '' || $showColumns !== '') { + $datatable->filter('ColumnDelete', array($hideColumns, $showColumns)); + } + + // apply label filter: only return rows matching the label parameter (more than one if more than one label) + $label = $this->getLabelFromRequest($this->request); + if (!empty($label)) { + $addLabelIndex = Common::getRequestVar('labelFilterAddLabelIndex', 0, 'int', $this->request) == 1; + + $filter = new LabelFilter($this->apiModule, $this->apiMethod, $this->request); + $datatable = $filter->filter($label, $datatable, $addLabelIndex); + } + return $this->getRenderedDataTable($datatable); + } + + /** + * Converts the given simple array to a data table + * + * @param array $array + * @return string + */ + protected function handleArray($array) + { + if ($this->outputFormat == 'original') { + // we handle the serialization. Because some php array have a very special structure that + // couldn't be converted with the automatic DataTable->addRowsFromSimpleArray + // the user may want to request the original PHP data structure serialized by the API + // in case he has to setup serialize=1 in the URL + if ($this->caseRendererPHPSerialize($defaultSerialize = 0)) { + return serialize($array); + } + return $array; + } + + $multiDimensional = $this->handleMultiDimensionalArray($array); + if ($multiDimensional !== false) { + return $multiDimensional; + } + + return $this->getRenderedDataTable($array); + } + + /** + * Is this a multi dimensional array? + * Multi dim arrays are not supported by the Datatable renderer. + * We manually render these. + * + * array( + * array( + * 1, + * 2 => array( 1, + * 2 + * ) + * ), + * array( 2, + * 3 + * ) + * ); + * + * @param array $array + * @return string|bool false if it isn't a multidim array + */ + protected function handleMultiDimensionalArray($array) + { + $first = reset($array); + foreach ($array as $first) { + if (is_array($first)) { + foreach ($first as $key => $value) { + // Yes, this is a multi dim array + if (is_array($value)) { + switch ($this->outputFormat) { + case 'json': + @header("Content-Type: application/json"); + return self::convertMultiDimensionalArrayToJson($array); + break; + + case 'php': + if ($this->caseRendererPHPSerialize($defaultSerialize = 0)) { + return serialize($array); + } + return $array; + + case 'xml': + @header("Content-Type: text/xml;charset=utf-8"); + return $this->getRenderedDataTable($array); + default: + break; + } + } + } + } + } + return false; + } + + /** + * Render a multidimensional array to Json + * Handle DataTable|Set elements in the first dimension only, following case does not work: + * array( + * array( + * DataTable, + * 2 => array( + * 1, + * 2 + * ), + * ), + * ); + * + * @param array $array can contain scalar, arrays, DataTable and Set + * @return string + */ + public static function convertMultiDimensionalArrayToJson($array) + { + $jsonRenderer = new Json(); + $jsonRenderer->setTable($array); + $renderedReport = $jsonRenderer->render(); + return $renderedReport; + } + + /** + * Returns the value for the label query parameter which can be either a string + * (ie, label=...) or array (ie, label[]=...). + * + * @param array $request + * @return array + */ + static public function getLabelFromRequest($request) + { + $label = Common::getRequestVar('label', array(), 'array', $request); + if (empty($label)) { + $label = Common::getRequestVar('label', '', 'string', $request); + if (!empty($label)) { + $label = array($label); + } + } + + $label = self::unsanitizeLabelParameter($label); + return $label; + } + + static public function unsanitizeLabelParameter($label) + { + // this is needed because Proxy uses Common::getRequestVar which in turn + // uses Common::sanitizeInputValue. This causes the > that separates recursive labels + // to become > and we need to undo that here. + $label = Common::unsanitizeInputValues($label); + return $label; + } +} diff --git a/www/analytics/core/Access.php b/www/analytics/core/Access.php new file mode 100644 index 00000000..71a5dd5f --- /dev/null +++ b/www/analytics/core/Access.php @@ -0,0 +1,418 @@ +idsitesByAccess = array( + 'view' => array(), + 'admin' => array(), + 'superuser' => array() + ); + } + + /** + * Loads the access levels for the current user. + * + * Calls the authentication method to try to log the user in the system. + * If the user credentials are not correct we don't load anything. + * If the login/password is correct the user is either the SuperUser or a normal user. + * We load the access levels for this user for all the websites. + * + * @param null|Auth $auth Auth adapter + * @return bool true on success, false if reloading access failed (when auth object wasn't specified and user is not enforced to be Super User) + */ + public function reloadAccess(Auth $auth = null) + { + if (!is_null($auth)) { + $this->auth = $auth; + } + + // if the Auth wasn't set, we may be in the special case of setSuperUser(), otherwise we fail + if (is_null($this->auth)) { + if ($this->hasSuperUserAccess()) { + return $this->reloadAccessSuperUser(); + } + return false; + } + + // access = array ( idsite => accessIdSite, idsite2 => accessIdSite2) + $result = $this->auth->authenticate(); + + if (!$result->wasAuthenticationSuccessful()) { + return false; + } + $this->login = $result->getIdentity(); + $this->token_auth = $result->getTokenAuth(); + + // case the superUser is logged in + if ($result->hasSuperUserAccess()) { + return $this->reloadAccessSuperUser(); + } + // in case multiple calls to API using different tokens, we ensure we reset it as not SU + $this->setSuperUserAccess(false); + + // we join with site in case there are rows in access for an idsite that doesn't exist anymore + // (backward compatibility ; before we deleted the site without deleting rows in _access table) + $accessRaw = $this->getRawSitesWithSomeViewAccess($this->login); + foreach ($accessRaw as $access) { + $this->idsitesByAccess[$access['access']][] = $access['idsite']; + } + return true; + } + + public function getRawSitesWithSomeViewAccess($login) + { + return Db::fetchAll(self::getSqlAccessSite("access, t2.idsite"), $login); + } + + /** + * Returns the SQL query joining sites and access table for a given login + * + * @param string $select Columns or expression to SELECT FROM table, eg. "MIN(ts_created)" + * @return string SQL query + */ + public static function getSqlAccessSite($select) + { + return "SELECT " . $select . " + FROM " . Common::prefixTable('access') . " as t1 + JOIN " . Common::prefixTable('site') . " as t2 USING (idsite) " . + " WHERE login = ?"; + } + + /** + * Reload Super User access + * + * @return bool + */ + protected function reloadAccessSuperUser() + { + $this->hasSuperUserAccess = true; + + try { + $allSitesId = Plugins\SitesManager\API::getInstance()->getAllSitesId(); + } catch (\Exception $e) { + $allSitesId = array(); + } + $this->idsitesByAccess['superuser'] = $allSitesId; + + return true; + } + + /** + * We bypass the normal auth method and give the current user Super User rights. + * This should be very carefully used. + * + * @param bool $bool + */ + public function setSuperUserAccess($bool = true) + { + if ($bool) { + $this->reloadAccessSuperUser(); + } else { + $this->hasSuperUserAccess = false; + $this->idsitesByAccess['superuser'] = array(); + + } + } + + /** + * Returns true if the current user is logged in as the Super User + * + * @return bool + */ + public function hasSuperUserAccess() + { + return $this->hasSuperUserAccess; + } + + /** + * Returns the current user login + * + * @return string|null + */ + public function getLogin() + { + return $this->login; + } + + /** + * Returns the token_auth used to authenticate this user in the API + * + * @return string|null + */ + public function getTokenAuth() + { + return $this->token_auth; + } + + /** + * Returns an array of ID sites for which the user has at least a VIEW access. + * Which means VIEW or ADMIN or SUPERUSER. + * + * @return array Example if the user is ADMIN for 4 + * and has VIEW access for 1 and 7, it returns array(1, 4, 7); + */ + public function getSitesIdWithAtLeastViewAccess() + { + return array_unique(array_merge( + $this->idsitesByAccess['view'], + $this->idsitesByAccess['admin'], + $this->idsitesByAccess['superuser']) + ); + } + + /** + * Returns an array of ID sites for which the user has an ADMIN access. + * + * @return array Example if the user is ADMIN for 4 and 8 + * and has VIEW access for 1 and 7, it returns array(4, 8); + */ + public function getSitesIdWithAdminAccess() + { + return array_unique(array_merge( + $this->idsitesByAccess['admin'], + $this->idsitesByAccess['superuser']) + ); + } + + + /** + * Returns an array of ID sites for which the user has a VIEW access only. + * + * @return array Example if the user is ADMIN for 4 + * and has VIEW access for 1 and 7, it returns array(1, 7); + * @see getSitesIdWithAtLeastViewAccess() + */ + public function getSitesIdWithViewAccess() + { + return $this->idsitesByAccess['view']; + } + + /** + * Throws an exception if the user is not the SuperUser + * + * @throws \Piwik\NoAccessException + */ + public function checkUserHasSuperUserAccess() + { + if (!$this->hasSuperUserAccess()) { + throw new NoAccessException(Piwik::translate('General_ExceptionPrivilege', array("'superuser'"))); + } + } + + /** + * If the user doesn't have an ADMIN access for at least one website, throws an exception + * + * @throws \Piwik\NoAccessException + */ + public function checkUserHasSomeAdminAccess() + { + if ($this->hasSuperUserAccess()) { + return; + } + $idSitesAccessible = $this->getSitesIdWithAdminAccess(); + if (count($idSitesAccessible) == 0) { + throw new NoAccessException(Piwik::translate('General_ExceptionPrivilegeAtLeastOneWebsite', array('admin'))); + } + } + + /** + * If the user doesn't have any view permission, throw exception + * + * @throws \Piwik\NoAccessException + */ + public function checkUserHasSomeViewAccess() + { + if ($this->hasSuperUserAccess()) { + return; + } + $idSitesAccessible = $this->getSitesIdWithAtLeastViewAccess(); + if (count($idSitesAccessible) == 0) { + throw new NoAccessException(Piwik::translate('General_ExceptionPrivilegeAtLeastOneWebsite', array('view'))); + } + } + + /** + * This method checks that the user has ADMIN access for the given list of websites. + * If the user doesn't have ADMIN access for at least one website of the list, we throw an exception. + * + * @param int|array $idSites List of ID sites to check + * @throws \Piwik\NoAccessException If for any of the websites the user doesn't have an ADMIN access + */ + public function checkUserHasAdminAccess($idSites) + { + if ($this->hasSuperUserAccess()) { + return; + } + $idSites = $this->getIdSites($idSites); + $idSitesAccessible = $this->getSitesIdWithAdminAccess(); + foreach ($idSites as $idsite) { + if (!in_array($idsite, $idSitesAccessible)) { + throw new NoAccessException(Piwik::translate('General_ExceptionPrivilegeAccessWebsite', array("'admin'", $idsite))); + } + } + } + + /** + * This method checks that the user has VIEW or ADMIN access for the given list of websites. + * If the user doesn't have VIEW or ADMIN access for at least one website of the list, we throw an exception. + * + * @param int|array|string $idSites List of ID sites to check (integer, array of integers, string comma separated list of integers) + * @throws \Piwik\NoAccessException If for any of the websites the user doesn't have an VIEW or ADMIN access + */ + public function checkUserHasViewAccess($idSites) + { + if ($this->hasSuperUserAccess()) { + return; + } + $idSites = $this->getIdSites($idSites); + $idSitesAccessible = $this->getSitesIdWithAtLeastViewAccess(); + foreach ($idSites as $idsite) { + if (!in_array($idsite, $idSitesAccessible)) { + throw new NoAccessException(Piwik::translate('General_ExceptionPrivilegeAccessWebsite', array("'view'", $idsite))); + } + } + } + + /** + * @param int|array|string $idSites + * @return array + * @throws \Piwik\NoAccessException + */ + protected function getIdSites($idSites) + { + if ($idSites === 'all') { + $idSites = $this->getSitesIdWithAtLeastViewAccess(); + } + + $idSites = Site::getIdSitesFromIdSitesString($idSites); + if (empty($idSites)) { + throw new NoAccessException("The parameter 'idSite=' is missing from the request."); + } + return $idSites; + } +} + +/** + * Exception thrown when a user doesn't have sufficient access to a resource. + * + * @api + */ +class NoAccessException extends \Exception +{ +} diff --git a/www/analytics/core/Archive.php b/www/analytics/core/Archive.php new file mode 100644 index 00000000..e5884aea --- /dev/null +++ b/www/analytics/core/Archive.php @@ -0,0 +1,809 @@ +getDataTableFromNumeric(array('nb_visits', 'nb_actions')); + * + * // all sites and multiple dates + * $archive = Archive::build($idSite = 'all', $period = 'month', $date = '2013-01-02,2013-03-08'); + * return $archive->getDataTableFromNumeric(array('nb_visits', 'nb_actions')); + * + * **_Querying and using metrics immediately_** + * + * // one site and one period + * $archive = Archive::build($idSite = 1, $period = 'week', $date = '2013-03-08'); + * $data = $archive->getNumeric(array('nb_visits', 'nb_actions')); + * + * $visits = $data['nb_visits']; + * $actions = $data['nb_actions']; + * + * // ... do something w/ metric data ... + * + * // multiple sites and multiple dates + * $archive = Archive::build($idSite = '1,2,3', $period = 'month', $date = '2013-01-02,2013-03-08'); + * $data = $archive->getNumeric('nb_visits'); + * + * $janSite1Visits = $data['1']['2013-01-01,2013-01-31']['nb_visits']; + * $febSite1Visits = $data['1']['2013-02-01,2013-02-28']['nb_visits']; + * // ... etc. + * + * **_Querying for reports_** + * + * $archive = Archive::build($idSite = 1, $period = 'week', $date = '2013-03-08'); + * $dataTable = $archive->getDataTable('MyPlugin_MyReport'); + * // ... manipulate $dataTable ... + * return $dataTable; + * + * **_Querying a report for an API method_** + * + * public function getMyReport($idSite, $period, $date, $segment = false, $expanded = false) + * { + * $dataTable = Archive::getDataTableFromArchive('MyPlugin_MyReport', $idSite, $period, $date, $segment, $expanded); + * $dataTable->queueFilter('ReplaceColumnNames'); + * return $dataTable; + * } + * + * **_Querying data for multiple range periods_** + * + * // get data for first range + * $archive = Archive::build($idSite = 1, $period = 'range', $date = '2013-03-08,2013-03-12'); + * $dataTable = $archive->getDataTableFromNumeric(array('nb_visits', 'nb_actions')); + * + * // get data for second range + * $archive = Archive::build($idSite = 1, $period = 'range', $date = '2013-03-15,2013-03-20'); + * $dataTable = $archive->getDataTableFromNumeric(array('nb_visits', 'nb_actions')); + * + * + * [1]: The archiving process will not be launched if browser archiving is disabled + * and the current request came from a browser (and not the **archive.php** cron + * script). + * + * + * @api + */ +class Archive +{ + const REQUEST_ALL_WEBSITES_FLAG = 'all'; + const ARCHIVE_ALL_PLUGINS_FLAG = 'all'; + const ID_SUBTABLE_LOAD_ALL_SUBTABLES = 'all'; + + /** + * List of archive IDs for the site, periods and segment we are querying with. + * Archive IDs are indexed by done flag and period, ie: + * + * array( + * 'done.Referrers' => array( + * '2010-01-01' => 1, + * '2010-01-02' => 2, + * ), + * 'done.VisitsSummary' => array( + * '2010-01-01' => 3, + * '2010-01-02' => 4, + * ), + * ) + * + * or, + * + * array( + * 'done.all' => array( + * '2010-01-01' => 1, + * '2010-01-02' => 2 + * ) + * ) + * + * @var array + */ + private $idarchives = array(); + + /** + * If set to true, the result of all get functions (ie, getNumeric, getBlob, etc.) + * will be indexed by the site ID, even if we're only querying data for one site. + * + * @var bool + */ + private $forceIndexedBySite; + + /** + * If set to true, the result of all get functions (ie, getNumeric, getBlob, etc.) + * will be indexed by the period, even if we're only querying data for one period. + * + * @var bool + */ + private $forceIndexedByDate; + + /** + * @var Parameters + */ + private $params; + + /** + * @param Parameters $params + * @param bool $forceIndexedBySite Whether to force index the result of a query by site ID. + * @param bool $forceIndexedByDate Whether to force index the result of a query by period. + */ + protected function __construct(Parameters $params, $forceIndexedBySite = false, + $forceIndexedByDate = false) + { + $this->params = $params; + $this->forceIndexedBySite = $forceIndexedBySite; + $this->forceIndexedByDate = $forceIndexedByDate; + } + + /** + * Returns a new Archive instance that will query archive data for the given set of + * sites and periods, using an optional Segment. + * + * This method uses data that is found in query parameters, so the parameters to this + * function can be string values. + * + * If you want to create an Archive instance with an array of Period instances, use + * {@link Archive::factory()}. + * + * @param string|int|array $idSites A single ID (eg, `'1'`), multiple IDs (eg, `'1,2,3'` or `array(1, 2, 3)`), + * or `'all'`. + * @param string $period 'day', `'week'`, `'month'`, `'year'` or `'range'` + * @param Date|string $strDate 'YYYY-MM-DD', magic keywords (ie, 'today'; {@link Date::factory()} + * or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD'). + * @param bool|false|string $segment Segment definition or false if no segment should be used. {@link Piwik\Segment} + * @param bool|false|string $_restrictSitesToLogin Used only when running as a scheduled task. + * @param bool $skipAggregationOfSubTables Whether the archive, when it is processed, should also aggregate all sub-tables + * @return Archive + */ + public static function build($idSites, $period, $strDate, $segment = false, $_restrictSitesToLogin = false, $skipAggregationOfSubTables = false) + { + $websiteIds = Site::getIdSitesFromIdSitesString($idSites, $_restrictSitesToLogin); + + if (Period::isMultiplePeriod($strDate, $period)) { + $oPeriod = new Range($period, $strDate); + $allPeriods = $oPeriod->getSubperiods(); + } else { + $timezone = count($websiteIds) == 1 ? Site::getTimezoneFor($websiteIds[0]) : false; + $oPeriod = Period::makePeriodFromQueryParams($timezone, $period, $strDate); + $allPeriods = array($oPeriod); + } + $segment = new Segment($segment, $websiteIds); + $idSiteIsAll = $idSites == self::REQUEST_ALL_WEBSITES_FLAG; + $isMultipleDate = Period::isMultiplePeriod($strDate, $period); + return Archive::factory($segment, $allPeriods, $websiteIds, $idSiteIsAll, $isMultipleDate, $skipAggregationOfSubTables); + } + + /** + * Returns a new Archive instance that will query archive data for the given set of + * sites and periods, using an optional segment. + * + * This method uses an array of Period instances and a Segment instance, instead of strings + * like {@link build()}. + * + * If you want to create an Archive instance using data found in query parameters, + * use {@link build()}. + * + * @param Segment $segment The segment to use. For no segment, use `new Segment('', $idSites)`. + * @param array $periods An array of Period instances. + * @param array $idSites An array of site IDs (eg, `array(1, 2, 3)`). + * @param bool $idSiteIsAll Whether `'all'` sites are being queried or not. If true, then + * the result of querying functions will be indexed by site, regardless + * of whether `count($idSites) == 1`. + * @param bool $isMultipleDate Whether multiple dates are being queried or not. If true, then + * the result of querying functions will be indexed by period, + * regardless of whether `count($periods) == 1`. + * @param bool $skipAggregationOfSubTables Whether the archive should skip aggregation of all sub-tables + * + * @return Archive + */ + public static function factory(Segment $segment, array $periods, array $idSites, $idSiteIsAll = false, $isMultipleDate = false, $skipAggregationOfSubTables = false) + { + $forceIndexedBySite = false; + $forceIndexedByDate = false; + if ($idSiteIsAll || count($idSites) > 1) { + $forceIndexedBySite = true; + } + if (count($periods) > 1 || $isMultipleDate) { + $forceIndexedByDate = true; + } + + $params = new Parameters($idSites, $periods, $segment, $skipAggregationOfSubTables); + + return new Archive($params, $forceIndexedBySite, $forceIndexedByDate); + } + + /** + * Queries and returns metric data in an array. + * + * If multiple sites were requested in {@link build()} or {@link factory()} the result will + * be indexed by site ID. + * + * If multiple periods were requested in {@link build()} or {@link factory()} the result will + * be indexed by period. + * + * The site ID index is always first, so if multiple sites & periods were requested, the result + * will be indexed by site ID first, then period. + * + * @param string|array $names One or more archive names, eg, `'nb_visits'`, `'Referrers_distinctKeywords'`, + * etc. + * @return false|numeric|array `false` if there is no data to return, a single numeric value if we're not querying + * for multiple sites/periods, or an array if multiple sites, periods or names are + * queried for. + */ + public function getNumeric($names) + { + $data = $this->get($names, 'numeric'); + + $resultIndices = $this->getResultIndices(); + $result = $data->getIndexedArray($resultIndices); + + // if only one metric is returned, just return it as a numeric value + if (empty($resultIndices) + && count($result) <= 1 + && (!is_array($names) || count($names) == 1) + ) { + $result = (float)reset($result); // convert to float in case $result is empty + } + + return $result; + } + + /** + * Queries and returns blob data in an array. + * + * Reports are stored in blobs as serialized arrays of {@link DataTable\Row} instances, but this + * data can technically be anything. In other words, you can store whatever you want + * as archive data blobs. + * + * If multiple sites were requested in {@link build()} or {@link factory()} the result will + * be indexed by site ID. + * + * If multiple periods were requested in {@link build()} or {@link factory()} the result will + * be indexed by period. + * + * The site ID index is always first, so if multiple sites & periods were requested, the result + * will be indexed by site ID first, then period. + * + * @param string|array $names One or more archive names, eg, `'Referrers_keywordBySearchEngine'`. + * @param null|string $idSubtable If we're returning serialized DataTable data, then this refers + * to the subtable ID to return. If set to 'all', all subtables + * of each requested report are returned. + * @return array An array of appropriately indexed blob data. + */ + public function getBlob($names, $idSubtable = null) + { + $data = $this->get($names, 'blob', $idSubtable); + return $data->getIndexedArray($this->getResultIndices()); + } + + /** + * Queries and returns metric data in a DataTable instance. + * + * If multiple sites were requested in {@link build()} or {@link factory()} the result will + * be a DataTable\Map that is indexed by site ID. + * + * If multiple periods were requested in {@link build()} or {@link factory()} the result will + * be a {@link DataTable\Map} that is indexed by period. + * + * The site ID index is always first, so if multiple sites & periods were requested, the result + * will be a {@link DataTable\Map} indexed by site ID which contains {@link DataTable\Map} instances that are + * indexed by period. + * + * _Note: Every DataTable instance returned will have at most one row in it. The contents of each + * row will be the requested metrics for the appropriate site and period._ + * + * @param string|array $names One or more archive names, eg, 'nb_visits', 'Referrers_distinctKeywords', + * etc. + * @return DataTable|DataTable\Map A DataTable if multiple sites and periods were not requested. + * An appropriately indexed DataTable\Map if otherwise. + */ + public function getDataTableFromNumeric($names) + { + $data = $this->get($names, 'numeric'); + return $data->getDataTable($this->getResultIndices()); + } + + /** + * Queries and returns one or more reports as DataTable instances. + * + * This method will query blob data that is a serialized array of of {@link DataTable\Row}'s and + * unserialize it. + * + * If multiple sites were requested in {@link build()} or {@link factory()} the result will + * be a {@link DataTable\Map} that is indexed by site ID. + * + * If multiple periods were requested in {@link build()} or {@link factory()} the result will + * be a DataTable\Map that is indexed by period. + * + * The site ID index is always first, so if multiple sites & periods were requested, the result + * will be a {@link DataTable\Map} indexed by site ID which contains {@link DataTable\Map} instances that are + * indexed by period. + * + * @param string $name The name of the record to get. This method can only query one record at a time. + * @param int|string|null $idSubtable The ID of the subtable to get (if any). + * @return DataTable|DataTable\Map A DataTable if multiple sites and periods were not requested. + * An appropriately indexed {@link DataTable\Map} if otherwise. + */ + public function getDataTable($name, $idSubtable = null) + { + $data = $this->get($name, 'blob', $idSubtable); + return $data->getDataTable($this->getResultIndices()); + } + + /** + * Queries and returns one report with all of its subtables loaded. + * + * If multiple sites were requested in {@link build()} or {@link factory()} the result will + * be a DataTable\Map that is indexed by site ID. + * + * If multiple periods were requested in {@link build()} or {@link factory()} the result will + * be a DataTable\Map that is indexed by period. + * + * The site ID index is always first, so if multiple sites & periods were requested, the result + * will be a {@link DataTable\Map indexed} by site ID which contains {@link DataTable\Map} instances that are + * indexed by period. + * + * @param string $name The name of the record to get. + * @param int|string|null $idSubtable The ID of the subtable to get (if any). The subtable will be expanded. + * @param int|null $depth The maximum number of subtable levels to load. If null, all levels are loaded. + * For example, if `1` is supplied, then the DataTable returned will have its subtables + * loaded. Those subtables, however, will NOT have their subtables loaded. + * @param bool $addMetadataSubtableId Whether to add the database subtable ID as metadata to each datatable, + * or not. + * @return DataTable|DataTable\Map + */ + public function getDataTableExpanded($name, $idSubtable = null, $depth = null, $addMetadataSubtableId = true) + { + $data = $this->get($name, 'blob', self::ID_SUBTABLE_LOAD_ALL_SUBTABLES); + return $data->getExpandedDataTable($this->getResultIndices(), $idSubtable, $depth, $addMetadataSubtableId); + } + + /** + * Returns the list of plugins that archive the given reports. + * + * @param array $archiveNames + * @return array + */ + private function getRequestedPlugins($archiveNames) + { + $result = array(); + foreach ($archiveNames as $name) { + $result[] = self::getPluginForReport($name); + } + return array_unique($result); + } + + /** + * Returns an object describing the set of sites, the set of periods and the segment + * this Archive will query data for. + * + * @return Parameters + */ + public function getParams() + { + return $this->params; + } + + /** + * Helper function that creates an Archive instance and queries for report data using + * query parameter data. API methods can use this method to reduce code redundancy. + * + * @param string $name The name of the report to return. + * @param int|string|array $idSite @see {@link build()} + * @param string $period @see {@link build()} + * @param string $date @see {@link build()} + * @param string $segment @see {@link build()} + * @param bool $expanded If true, loads all subtables. See {@link getDataTableExpanded()} + * @param int|null $idSubtable See {@link getDataTableExpanded()} + * @param bool $skipAggregationOfSubTables Whether or not we should skip the aggregation of all sub-tables and only aggregate parent DataTable. + * @param int|null $depth See {@link getDataTableExpanded()} + * @return DataTable|DataTable\Map See {@link getDataTable()} and + * {@link getDataTableExpanded()} for more + * information + */ + public static function getDataTableFromArchive($name, $idSite, $period, $date, $segment, $expanded, + $idSubtable = null, $skipAggregationOfSubTables = false, $depth = null) + { + Piwik::checkUserHasViewAccess($idSite); + + if($skipAggregationOfSubTables && ($expanded || $idSubtable)) { + throw new \Exception("Not expected to skipAggregationOfSubTables when expanded=1 or idSubtable is set."); + } + $archive = Archive::build($idSite, $period, $date, $segment, $_restrictSitesToLogin = false, $skipAggregationOfSubTables); + if ($idSubtable === false) { + $idSubtable = null; + } + + if ($expanded) { + $dataTable = $archive->getDataTableExpanded($name, $idSubtable, $depth); + } else { + $dataTable = $archive->getDataTable($name, $idSubtable); + } + + $dataTable->queueFilter('ReplaceSummaryRowLabel'); + + return $dataTable; + } + + private function appendIdSubtable($recordName, $id) + { + return $recordName . "_" . $id; + } + + /** + * Queries archive tables for data and returns the result. + * @param array|string $archiveNames + * @param $archiveDataType + * @param null|int $idSubtable + * @return Archive\DataCollection + */ + private function get($archiveNames, $archiveDataType, $idSubtable = null) + { + if (!is_array($archiveNames)) { + $archiveNames = array($archiveNames); + } + + // apply idSubtable + if ($idSubtable !== null + && $idSubtable != self::ID_SUBTABLE_LOAD_ALL_SUBTABLES + ) { + foreach ($archiveNames as &$name) { + $name = $this->appendIdsubtable($name, $idSubtable); + } + } + + $result = new Archive\DataCollection( + $archiveNames, $archiveDataType, $this->params->getIdSites(), $this->params->getPeriods(), $defaultRow = null); + + $archiveIds = $this->getArchiveIds($archiveNames); + if (empty($archiveIds)) { + return $result; + } + + $loadAllSubtables = $idSubtable == self::ID_SUBTABLE_LOAD_ALL_SUBTABLES; + $archiveData = ArchiveSelector::getArchiveData($archiveIds, $archiveNames, $archiveDataType, $loadAllSubtables); + foreach ($archiveData as $row) { + // values are grouped by idsite (site ID), date1-date2 (date range), then name (field name) + $idSite = $row['idsite']; + $periodStr = $row['date1'] . "," . $row['date2']; + + if ($archiveDataType == 'numeric') { + $value = $this->formatNumericValue($row['value']); + } else { + $value = $this->uncompress($row['value']); + $result->addMetadata($idSite, $periodStr, 'ts_archived', $row['ts_archived']); + } + + $resultRow = & $result->get($idSite, $periodStr); + $resultRow[$row['name']] = $value; + } + + return $result; + } + + /** + * Returns archive IDs for the sites, periods and archive names that are being + * queried. This function will use the idarchive cache if it has the right data, + * query archive tables for IDs w/o launching archiving, or launch archiving and + * get the idarchive from ArchiveProcessor instances. + */ + private function getArchiveIds($archiveNames) + { + $plugins = $this->getRequestedPlugins($archiveNames); + + // figure out which archives haven't been processed (if an archive has been processed, + // then we have the archive IDs in $this->idarchives) + $doneFlags = array(); + $archiveGroups = array(); + foreach ($plugins as $plugin) { + $doneFlag = $this->getDoneStringForPlugin($plugin); + + $doneFlags[$doneFlag] = true; + if (!isset($this->idarchives[$doneFlag])) { + $archiveGroup = $this->getArchiveGroupOfPlugin($plugin); + + if($archiveGroup == self::ARCHIVE_ALL_PLUGINS_FLAG) { + $archiveGroup = reset($plugins); + } + $archiveGroups[] = $archiveGroup; + } + } + + $archiveGroups = array_unique($archiveGroups); + + // cache id archives for plugins we haven't processed yet + if (!empty($archiveGroups)) { + if (!Rules::isArchivingDisabledFor($this->params->getIdSites(), $this->params->getSegment(), $this->getPeriodLabel())) { + $this->cacheArchiveIdsAfterLaunching($archiveGroups, $plugins); + } else { + $this->cacheArchiveIdsWithoutLaunching($plugins); + } + } + + // order idarchives by the table month they belong to + $idArchivesByMonth = array(); + foreach (array_keys($doneFlags) as $doneFlag) { + if (empty($this->idarchives[$doneFlag])) { + continue; + } + + foreach ($this->idarchives[$doneFlag] as $dateRange => $idarchives) { + foreach ($idarchives as $id) { + $idArchivesByMonth[$dateRange][] = $id; + } + } + } + + return $idArchivesByMonth; + } + + /** + * Gets the IDs of the archives we're querying for and stores them in $this->archives. + * This function will launch the archiving process for each period/site/plugin if + * metrics/reports have not been calculated/archived already. + * + * @param array $archiveGroups @see getArchiveGroupOfReport + * @param array $plugins List of plugin names to archive. + */ + private function cacheArchiveIdsAfterLaunching($archiveGroups, $plugins) + { + $today = Date::today(); + + foreach ($this->params->getPeriods() as $period) { + $twoDaysBeforePeriod = $period->getDateStart()->subDay(2); + $twoDaysAfterPeriod = $period->getDateEnd()->addDay(2); + + foreach ($this->params->getIdSites() as $idSite) { + $site = new Site($idSite); + + // if the END of the period is BEFORE the website creation date + // we already know there are no stats for this period + // we add one day to make sure we don't miss the day of the website creation + if ($twoDaysAfterPeriod->isEarlier($site->getCreationDate())) { + Log::verbose("Archive site %s, %s (%s) skipped, archive is before the website was created.", + $idSite, $period->getLabel(), $period->getPrettyString()); + continue; + } + + // if the starting date is in the future we know there is no visiidsite = ?t + if ($twoDaysBeforePeriod->isLater($today)) { + Log::verbose("Archive site %s, %s (%s) skipped, archive is after today.", + $idSite, $period->getLabel(), $period->getPrettyString()); + continue; + } + + $this->prepareArchive($archiveGroups, $site, $period); + } + } + } + + /** + * Gets the IDs of the archives we're querying for and stores them in $this->archives. + * This function will not launch the archiving process (and is thus much, much faster + * than cacheArchiveIdsAfterLaunching). + * + * @param array $plugins List of plugin names from which data is being requested. + */ + private function cacheArchiveIdsWithoutLaunching($plugins) + { + $idarchivesByReport = ArchiveSelector::getArchiveIds( + $this->params->getIdSites(), $this->params->getPeriods(), $this->params->getSegment(), $plugins, $this->params->isSkipAggregationOfSubTables()); + + // initialize archive ID cache for each report + foreach ($plugins as $plugin) { + $doneFlag = $this->getDoneStringForPlugin($plugin); + $this->initializeArchiveIdCache($doneFlag); + } + + foreach ($idarchivesByReport as $doneFlag => $idarchivesByDate) { + foreach ($idarchivesByDate as $dateRange => $idarchives) { + foreach ($idarchives as $idarchive) { + $this->idarchives[$doneFlag][$dateRange][] = $idarchive; + } + } + } + } + + /** + * Returns the done string flag for a plugin using this instance's segment & periods. + * @param string $plugin + * @return string + */ + private function getDoneStringForPlugin($plugin) + { + return Rules::getDoneStringFlagFor( + $this->params->getIdSites(), + $this->params->getSegment(), + $this->getPeriodLabel(), + $plugin, + $this->params->isSkipAggregationOfSubTables() + ); + } + + private function getPeriodLabel() + { + $periods = $this->params->getPeriods(); + return reset($periods)->getLabel(); + } + + /** + * Returns an array describing what metadata to use when indexing a query result. + * For use with DataCollection. + * + * @return array + */ + private function getResultIndices() + { + $indices = array(); + + if (count($this->params->getIdSites()) > 1 + || $this->forceIndexedBySite + ) { + $indices['site'] = 'idSite'; + } + + if (count($this->params->getPeriods()) > 1 + || $this->forceIndexedByDate + ) { + $indices['period'] = 'date'; + } + + return $indices; + } + + private function formatNumericValue($value) + { + // If there is no dot, we return as is + // Note: this could be an integer bigger than 32 bits + if (strpos($value, '.') === false) { + if ($value === false) { + return 0; + } + return (float)$value; + } + + // Round up the value with 2 decimals + // we cast the result as float because returns false when no visitors + return round((float)$value, 2); + } + + private function uncompress($data) + { + return @gzuncompress($data); + } + + /** + * Initializes the archive ID cache ($this->idarchives) for a particular 'done' flag. + * + * It is necessary that each archive ID caching function call this method for each + * unique 'done' flag it encounters, since the getArchiveIds function determines + * whether archiving should be launched based on whether $this->idarchives has a + * an entry for a specific 'done' flag. + * + * If this function is not called, then periods with no visits will not add + * entries to the cache. If the archive is used again, SQL will be executed to + * try and find the archive IDs even though we know there are none. + */ + private function initializeArchiveIdCache($doneFlag) + { + if (!isset($this->idarchives[$doneFlag])) { + $this->idarchives[$doneFlag] = array(); + } + } + + /** + * Returns the archiving group identifier given a plugin. + * + * More than one plugin can be called at once when archiving. In such a case + * we don't want to launch archiving three times for three plugins if doing + * it once is enough, so getArchiveIds makes sure to get the archive group of + * all reports. + * + * If the period isn't a range, then all plugins' archiving code is executed. + * If the period is a range, then archiving code is executed individually for + * each plugin. + */ + private function getArchiveGroupOfPlugin($plugin) + { + if ($this->getPeriodLabel() != 'range') { + return self::ARCHIVE_ALL_PLUGINS_FLAG; + } + + return $plugin; + } + + /** + * Returns the name of the plugin that archives a given report. + * + * @param string $report Archive data name, eg, `'nb_visits'`, `'UserSettings_...'`, etc. + * @return string Plugin name. + * @throws \Exception If a plugin cannot be found or if the plugin for the report isn't + * activated. + */ + private static function getPluginForReport($report) + { + // Core metrics are always processed in Core, for the requested date/period/segment + if (in_array($report, Metrics::getVisitsMetricNames())) { + $report = 'VisitsSummary_CoreMetrics'; + } // Goal_* metrics are processed by the Goals plugin (HACK) + else if (strpos($report, 'Goal_') === 0) { + $report = 'Goals_Metrics'; + } else if (strrpos($report, '_returning') === strlen($report) - strlen('_returning')) { // HACK + $report = 'VisitFrequency_Metrics'; + } + + $plugin = substr($report, 0, strpos($report, '_')); + if (empty($plugin) + || !\Piwik\Plugin\Manager::getInstance()->isPluginActivated($plugin) + ) { + throw new \Exception("Error: The report '$report' was requested but it is not available at this stage." + . " (Plugin '$plugin' is not activated.)"); + } + return $plugin; + } + + /** + * @param $archiveGroups + * @param $site + * @param $period + */ + private function prepareArchive(array $archiveGroups, Site $site, Period $period) + { + $parameters = new ArchiveProcessor\Parameters($site, $period, $this->params->getSegment(), $this->params->isSkipAggregationOfSubTables()); + $archiveLoader = new ArchiveProcessor\Loader($parameters); + + $periodString = $period->getRangeString(); + + // process for each plugin as well + foreach ($archiveGroups as $plugin) { + $doneFlag = $this->getDoneStringForPlugin($plugin); + $this->initializeArchiveIdCache($doneFlag); + + $idArchive = $archiveLoader->prepareArchive($plugin); + + if($idArchive) { + $this->idarchives[$doneFlag][$periodString][] = $idArchive; + } + } + } +} diff --git a/www/analytics/core/Archive/DataCollection.php b/www/analytics/core/Archive/DataCollection.php new file mode 100644 index 00000000..5f57e6c3 --- /dev/null +++ b/www/analytics/core/Archive/DataCollection.php @@ -0,0 +1,336 @@ + array( + * array( + * '2012-01-01,2012-01-01' => array(...), + * '2012-01-02,2012-01-02' => array(...), + * ) + * ), + * '1' => array( + * array( + * '2012-01-01,2012-01-01' => array(...), + * ) + * ) + * ) + * + * Archive data can be either a numeric value or a serialized string blob. Every + * piece of archive data is associated by it's archive name. For example, + * the array(...) above could look like: + * + * array( + * 'nb_visits' => 1, + * 'nb_actions' => 2 + * ) + * + * There is a special element '_metadata' in data rows that holds values treated + * as DataTable metadata. + */ + private $data = array(); + + /** + * The whole list of metric/record names that were used in the archive query. + * + * @var array + */ + private $dataNames; + + /** + * The type of data that was queried for (ie, "blob" or "numeric"). + * + * @var string + */ + private $dataType; + + /** + * The default values to use for each metric/record name that's being queried + * for. + * + * @var array + */ + private $defaultRow; + + /** + * The list of all site IDs that were queried for. + * + * @var array + */ + private $sitesId; + + /** + * The list of all periods that were queried for. Each period is associated with + * the period's range string. Eg, + * + * array( + * '2012-01-01,2012-01-31' => new Period(...), + * '2012-02-01,2012-02-28' => new Period(...), + * ) + * + * @var \Piwik\Period[] + */ + private $periods; + + /** + * Constructor. + * + * @param array $dataNames @see $this->dataNames + * @param string $dataType @see $this->dataType + * @param array $sitesId @see $this->sitesId + * @param \Piwik\Period[] $periods @see $this->periods + * @param array $defaultRow @see $this->defaultRow + */ + public function __construct($dataNames, $dataType, $sitesId, $periods, $defaultRow = null) + { + $this->dataNames = $dataNames; + $this->dataType = $dataType; + + if ($defaultRow === null) { + $defaultRow = array_fill_keys($dataNames, 0); + } + + $this->sitesId = $sitesId; + + foreach ($periods as $period) { + $this->periods[$period->getRangeString()] = $period; + } + $this->defaultRow = $defaultRow; + } + + /** + * Returns a reference to the data for a specific site & period. If there is + * no data for the given site ID & period, it is set to the default row. + * + * @param int $idSite + * @param string $period eg, '2012-01-01,2012-01-31' + */ + public function &get($idSite, $period) + { + if (!isset($this->data[$idSite][$period])) { + $this->data[$idSite][$period] = $this->defaultRow; + } + return $this->data[$idSite][$period]; + } + + /** + * Adds a new metadata to the data for specific site & period. If there is no + * data for the given site ID & period, it is set to the default row. + * + * Note: Site ID and period range string are two special types of metadata. Since + * the data stored in this class is indexed by site & period, this metadata is not + * stored in individual data rows. + * + * @param int $idSite + * @param string $period eg, '2012-01-01,2012-01-31' + * @param string $name The metadata name. + * @param mixed $value The metadata name. + */ + public function addMetadata($idSite, $period, $name, $value) + { + $row = & $this->get($idSite, $period); + $row[self::METADATA_CONTAINER_ROW_KEY][$name] = $value; + } + + /** + * Returns archive data as an array indexed by metadata. + * + * @param array $resultIndices An array mapping metadata names to pretty labels + * for them. Each archive data row will be indexed + * by the metadata specified here. + * + * Eg, array('site' => 'idSite', 'period' => 'Date') + * @return array + */ + public function getIndexedArray($resultIndices) + { + $indexKeys = array_keys($resultIndices); + + $result = $this->createOrderedIndex($indexKeys); + foreach ($this->data as $idSite => $rowsByPeriod) { + foreach ($rowsByPeriod as $period => $row) { + // FIXME: This hack works around a strange bug that occurs when getting + // archive IDs through ArchiveProcessing instances. When a table + // does not already exist, for some reason the archive ID for + // today (or from two days ago) will be added to the Archive + // instances list. The Archive instance will then select data + // for periods outside of the requested set. + // working around the bug here, but ideally, we need to figure + // out why incorrect idarchives are being selected. + if (empty($this->periods[$period])) { + continue; + } + + $this->putRowInIndex($result, $indexKeys, $row, $idSite, $period); + } + } + return $result; + } + + /** + * Returns archive data as a DataTable indexed by metadata. Indexed data will + * be represented by Map instances. + * + * @param array $resultIndices An array mapping metadata names to pretty labels + * for them. Each archive data row will be indexed + * by the metadata specified here. + * + * Eg, array('site' => 'idSite', 'period' => 'Date') + * @return DataTable|DataTable\Map + */ + public function getDataTable($resultIndices) + { + $dataTableFactory = new DataTableFactory( + $this->dataNames, $this->dataType, $this->sitesId, $this->periods, $this->defaultRow); + + $index = $this->getIndexedArray($resultIndices); + return $dataTableFactory->make($index, $resultIndices); + } + + /** + * Returns archive data as a DataTable indexed by metadata. Indexed data will + * be represented by Map instances. Each DataTable will have + * its subtable IDs set. + * + * This function will only work if blob data was loaded and only one record + * was loaded (not including subtables of the record). + * + * @param array $resultIndices An array mapping metadata names to pretty labels + * for them. Each archive data row will be indexed + * by the metadata specified here. + * + * Eg, array('site' => 'idSite', 'period' => 'Date') + * @param int|null $idSubTable The subtable to return. + * @param int|null $depth max depth for subtables. + * @param bool $addMetadataSubTableId Whether to add the DB subtable ID as metadata + * to each datatable, or not. + * @throws Exception + * @return DataTable|DataTable\Map + */ + public function getExpandedDataTable($resultIndices, $idSubTable = null, $depth = null, $addMetadataSubTableId = false) + { + if ($this->dataType != 'blob') { + throw new Exception("DataCollection: cannot call getExpandedDataTable with " + . "{$this->dataType} data types. Only works with blob data."); + } + + if (count($this->dataNames) !== 1) { + throw new Exception("DataCollection: cannot call getExpandedDataTable with " + . "more than one record."); + } + + $dataTableFactory = new DataTableFactory( + $this->dataNames, 'blob', $this->sitesId, $this->periods, $this->defaultRow); + $dataTableFactory->expandDataTable($depth, $addMetadataSubTableId); + $dataTableFactory->useSubtable($idSubTable); + + $index = $this->getIndexedArray($resultIndices); + return $dataTableFactory->make($index, $resultIndices); + } + + /** + * Returns metadata for a data row. + * + * @param array $data The data row. + * @return array + */ + public static function getDataRowMetadata($data) + { + if (isset($data[self::METADATA_CONTAINER_ROW_KEY])) { + return $data[self::METADATA_CONTAINER_ROW_KEY]; + } else { + return array(); + } + } + + /** + * Removes all table metadata from a data row. + * + * @param array $data The data row. + */ + public static function removeMetadataFromDataRow(&$data) + { + unset($data[self::METADATA_CONTAINER_ROW_KEY]); + } + + /** + * Creates an empty index using a list of metadata names. If the 'site' and/or + * 'period' metadata names are supplied, empty rows are added for every site/period + * that was queried for. + * + * Using this function ensures consistent ordering in the indexed result. + * + * @param array $metadataNamesToIndexBy List of metadata names to index archive data by. + * @return array + */ + private function createOrderedIndex($metadataNamesToIndexBy) + { + $result = array(); + + if (!empty($metadataNamesToIndexBy)) { + $metadataName = array_shift($metadataNamesToIndexBy); + + if ($metadataName == DataTableFactory::TABLE_METADATA_SITE_INDEX) { + $indexKeyValues = array_values($this->sitesId); + } else if ($metadataName == DataTableFactory::TABLE_METADATA_PERIOD_INDEX) { + $indexKeyValues = array_keys($this->periods); + } + + foreach ($indexKeyValues as $key) { + $result[$key] = $this->createOrderedIndex($metadataNamesToIndexBy); + } + } + + return $result; + } + + /** + * Puts an archive data row in an index. + */ + private function putRowInIndex(&$index, $metadataNamesToIndexBy, $row, $idSite, $period) + { + $currentLevel = & $index; + + foreach ($metadataNamesToIndexBy as $metadataName) { + if ($metadataName == DataTableFactory::TABLE_METADATA_SITE_INDEX) { + $key = $idSite; + } else if ($metadataName == DataTableFactory::TABLE_METADATA_PERIOD_INDEX) { + $key = $period; + } else { + $key = $row[self::METADATA_CONTAINER_ROW_KEY][$metadataName]; + } + + if (!isset($currentLevel[$key])) { + $currentLevel[$key] = array(); + } + + $currentLevel = & $currentLevel[$key]; + } + + $currentLevel = $row; + } +} diff --git a/www/analytics/core/Archive/DataTableFactory.php b/www/analytics/core/Archive/DataTableFactory.php new file mode 100644 index 00000000..44beba02 --- /dev/null +++ b/www/analytics/core/Archive/DataTableFactory.php @@ -0,0 +1,426 @@ +dataNames = $dataNames; + $this->dataType = $dataType; + $this->sitesId = $sitesId; + + //here index period by string only + $this->periods = $periods; + $this->defaultRow = $defaultRow; + } + + /** + * Tells the factory instance to expand the DataTables that are created by + * creating subtables and setting the subtable IDs of rows w/ subtables correctly. + * + * @param null|int $maxSubtableDepth max depth for subtables. + * @param bool $addMetadataSubtableId Whether to add the subtable ID used in the + * database to the in-memory DataTables as + * metadata or not. + */ + public function expandDataTable($maxSubtableDepth = null, $addMetadataSubtableId = false) + { + $this->expandDataTable = true; + $this->maxSubtableDepth = $maxSubtableDepth; + $this->addMetadataSubtableId = $addMetadataSubtableId; + } + + /** + * Tells the factory instance to create a DataTable using a blob with the + * supplied subtable ID. + * + * @param int $idSubtable An in-database subtable ID. + * @throws \Exception + */ + public function useSubtable($idSubtable) + { + if (count($this->dataNames) !== 1) { + throw new \Exception("DataTableFactory: Getting subtables for multiple records in one" + . " archive query is not currently supported."); + } + + $this->idSubtable = $idSubtable; + } + + /** + * Creates a DataTable|Set instance using an index of + * archive data. + * + * @param array $index @see DataCollection + * @param array $resultIndices an array mapping metadata names with pretty metadata + * labels. + * @return DataTable|DataTable\Map + */ + public function make($index, $resultIndices) + { + if (empty($resultIndices)) { + // for numeric data, if there's no index (and thus only 1 site & period in the query), + // we want to display every queried metric name + if (empty($index) + && $this->dataType == 'numeric' + ) { + $index = $this->defaultRow; + } + + $dataTable = $this->createDataTable($index, $keyMetadata = array()); + } else { + $dataTable = $this->createDataTableMapFromIndex($index, $resultIndices, $keyMetadata = array()); + } + + $this->transformMetadata($dataTable); + return $dataTable; + } + + /** + * Creates a DataTable|Set instance using an array + * of blobs. + * + * If only one record is being queried, a single DataTable will + * be returned. Otherwise, a DataTable\Map is returned that indexes + * DataTables by record name. + * + * If expandDataTable was called, and only one record is being queried, + * the created DataTable's subtables will be expanded. + * + * @param array $blobRow + * @return DataTable|DataTable\Map + */ + private function makeFromBlobRow($blobRow) + { + if ($blobRow === false) { + return new DataTable(); + } + + if (count($this->dataNames) === 1) { + return $this->makeDataTableFromSingleBlob($blobRow); + } else { + return $this->makeIndexedByRecordNameDataTable($blobRow); + } + } + + /** + * Creates a DataTable for one record from an archive data row. + * + * @see makeFromBlobRow + * + * @param array $blobRow + * @return DataTable + */ + private function makeDataTableFromSingleBlob($blobRow) + { + $recordName = reset($this->dataNames); + if ($this->idSubtable !== null) { + $recordName .= '_' . $this->idSubtable; + } + + if (!empty($blobRow[$recordName])) { + $table = DataTable::fromSerializedArray($blobRow[$recordName]); + } else { + $table = new DataTable(); + } + + // set table metadata + $table->setMetadataValues(DataCollection::getDataRowMetadata($blobRow)); + + if ($this->expandDataTable) { + $table->enableRecursiveFilters(); + $this->setSubtables($table, $blobRow); + } + + return $table; + } + + /** + * Creates a DataTable for every record in an archive data row and puts them + * in a DataTable\Map instance. + * + * @param array $blobRow + * @return DataTable\Map + */ + private function makeIndexedByRecordNameDataTable($blobRow) + { + $table = new DataTable\Map(); + $table->setKeyName('recordName'); + + $tableMetadata = DataCollection::getDataRowMetadata($blobRow); + + foreach ($blobRow as $name => $blob) { + $newTable = DataTable::fromSerializedArray($blob); + $newTable->setAllTableMetadata($tableMetadata); + + $table->addTable($newTable, $name); + } + + return $table; + } + + /** + * Creates a Set from an array index. + * + * @param array $index @see DataCollection + * @param array $resultIndices @see make + * @param array $keyMetadata The metadata to add to the table when it's created. + * @return DataTable\Map + */ + private function createDataTableMapFromIndex($index, $resultIndices, $keyMetadata = array()) + { + $resultIndexLabel = reset($resultIndices); + $resultIndex = key($resultIndices); + + array_shift($resultIndices); + + $result = new DataTable\Map(); + $result->setKeyName($resultIndexLabel); + + foreach ($index as $label => $value) { + $keyMetadata[$resultIndex] = $label; + + if (empty($resultIndices)) { + $newTable = $this->createDataTable($value, $keyMetadata); + } else { + $newTable = $this->createDataTableMapFromIndex($value, $resultIndices, $keyMetadata); + } + + $result->addTable($newTable, $this->prettifyIndexLabel($resultIndex, $label)); + } + + return $result; + } + + /** + * Creates a DataTable instance from an index row. + * + * @param array $data An archive data row. + * @param array $keyMetadata The metadata to add to the table(s) when created. + * @return DataTable|DataTable\Map + */ + private function createDataTable($data, $keyMetadata) + { + if ($this->dataType == 'blob') { + $result = $this->makeFromBlobRow($data); + } else { + $result = $this->makeFromMetricsArray($data); + } + $this->setTableMetadata($keyMetadata, $result); + return $result; + } + + /** + * Creates DataTables from $dataTable's subtable blobs (stored in $blobRow) and sets + * the subtable IDs of each DataTable row. + * + * @param DataTable $dataTable + * @param array $blobRow An array associating record names (w/ subtable if applicable) + * with blob values. This should hold every subtable blob for + * the loaded DataTable. + * @param int $treeLevel + */ + private function setSubtables($dataTable, $blobRow, $treeLevel = 0) + { + if ($this->maxSubtableDepth + && $treeLevel >= $this->maxSubtableDepth + ) { + // unset the subtables so DataTableManager doesn't throw + foreach ($dataTable->getRows() as $row) { + $row->removeSubtable(); + } + + return; + } + + $dataName = reset($this->dataNames); + + foreach ($dataTable->getRows() as $row) { + $sid = $row->getIdSubDataTable(); + if ($sid === null) { + continue; + } + + $blobName = $dataName . "_" . $sid; + if (isset($blobRow[$blobName])) { + $subtable = DataTable::fromSerializedArray($blobRow[$blobName]); + $this->setSubtables($subtable, $blobRow, $treeLevel + 1); + + // we edit the subtable ID so that it matches the newly table created in memory + // NB: we dont overwrite the datatableid in the case we are displaying the table expanded. + if ($this->addMetadataSubtableId) { + // this will be written back to the column 'idsubdatatable' just before rendering, + // see Renderer/Php.php + $row->addMetadata('idsubdatatable_in_db', $row->getIdSubDataTable()); + } + + $row->setSubtable($subtable); + } + } + } + + /** + * Converts site IDs and period string ranges into Site instances and + * Period instances in DataTable metadata. + */ + private function transformMetadata($table) + { + $periods = $this->periods; + $table->filter(function ($table) use ($periods) { + $table->setMetadata(DataTableFactory::TABLE_METADATA_SITE_INDEX, new Site($table->getMetadata(DataTableFactory::TABLE_METADATA_SITE_INDEX))); + $table->setMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX, $periods[$table->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)]); + }); + } + + /** + * Returns the pretty version of an index label. + * + * @param string $labelType eg, 'site', 'period', etc. + * @param string $label eg, '0', '1', '2012-01-01,2012-01-31', etc. + * @return string + */ + private function prettifyIndexLabel($labelType, $label) + { + if ($labelType == self::TABLE_METADATA_PERIOD_INDEX) { // prettify period labels + return $this->periods[$label]->getPrettyString(); + } + return $label; + } + + /** + * @param $keyMetadata + * @param $result + */ + private function setTableMetadata($keyMetadata, $result) + { + if (!isset($keyMetadata[DataTableFactory::TABLE_METADATA_SITE_INDEX])) { + $keyMetadata[DataTableFactory::TABLE_METADATA_SITE_INDEX] = reset($this->sitesId); + } + + if (!isset($keyMetadata[DataTableFactory::TABLE_METADATA_PERIOD_INDEX])) { + reset($this->periods); + $keyMetadata[DataTableFactory::TABLE_METADATA_PERIOD_INDEX] = key($this->periods); + } + + // Note: $result can be a DataTable\Map + $result->filter(function ($table) use ($keyMetadata) { + foreach ($keyMetadata as $name => $value) { + $table->setMetadata($name, $value); + } + }); + } + + /** + * @param $data + * @return DataTable\Simple + */ + private function makeFromMetricsArray($data) + { + $table = new DataTable\Simple(); + + if (!empty($data)) { + $table->setAllTableMetadata(DataCollection::getDataRowMetadata($data)); + + DataCollection::removeMetadataFromDataRow($data); + + $table->addRow(new Row(array(Row::COLUMNS => $data))); + } else { + // if we're querying numeric data, we couldn't find any, and we're only + // looking for one metric, add a row w/ one column w/ value 0. this is to + // ensure that the PHP renderer outputs 0 when only one column is queried. + // w/o this code, an empty array would be created, and other parts of Piwik + // would break. + if (count($this->dataNames) == 1 + && $this->dataType == 'numeric' + ) { + $name = reset($this->dataNames); + $table->addRow(new Row(array(Row::COLUMNS => array($name => 0)))); + } + } + + $result = $table; + return $result; + } +} + diff --git a/www/analytics/core/Archive/Parameters.php b/www/analytics/core/Archive/Parameters.php new file mode 100644 index 00000000..1f700819 --- /dev/null +++ b/www/analytics/core/Archive/Parameters.php @@ -0,0 +1,73 @@ +segment; + } + + public function __construct($idSites, $periods, Segment $segment, $skipAggregationOfSubTables) + { + $this->idSites = $idSites; + $this->periods = $periods; + $this->segment = $segment; + $this->skipAggregationOfSubTables = $skipAggregationOfSubTables; + } + + public function getPeriods() + { + return $this->periods; + } + + public function getIdSites() + { + return $this->idSites; + } + + public function isSkipAggregationOfSubTables() + { + return $this->skipAggregationOfSubTables; + } + +} + diff --git a/www/analytics/core/ArchiveProcessor.php b/www/analytics/core/ArchiveProcessor.php new file mode 100644 index 00000000..20c4c2df --- /dev/null +++ b/www/analytics/core/ArchiveProcessor.php @@ -0,0 +1,489 @@ +getProcessor(); + * + * $myFancyMetric = // ... calculate the metric value ... + * $archiveProcessor->insertNumericRecord('MyPlugin_myFancyMetric', $myFancyMetric); + * } + * + * **Inserting serialized DataTables** + * + * // function in an Archiver descendant + * public function aggregateDayReport() + * { + * $archiveProcessor = $this->getProcessor(); + * + * $maxRowsInTable = Config::getInstance()->General['datatable_archiving_maximum_rows_standard'];j + * + * $dataTable = // ... build by aggregating visits ... + * $serializedData = $dataTable->getSerialized($maxRowsInTable, $maxRowsInSubtable = $maxRowsInTable, + * $columnToSortBy = Metrics::INDEX_NB_VISITS); + * + * $archiveProcessor->insertBlobRecords('MyPlugin_myFancyReport', $serializedData); + * } + * + * **Aggregating archive data** + * + * // function in Archiver descendant + * public function aggregateMultipleReports() + * { + * $archiveProcessor = $this->getProcessor(); + * + * // aggregate a metric + * $archiveProcessor->aggregateNumericMetrics('MyPlugin_myFancyMetric'); + * $archiveProcessor->aggregateNumericMetrics('MyPlugin_mySuperFancyMetric', 'max'); + * + * // aggregate a report + * $archiveProcessor->aggregateDataTableRecords('MyPlugin_myFancyReport'); + * } + * + */ +class ArchiveProcessor +{ + /** + * @var \Piwik\DataAccess\ArchiveWriter + */ + protected $archiveWriter; + + /** + * @var \Piwik\DataAccess\LogAggregator + */ + protected $logAggregator; + + /** + * @var Archive + */ + public $archive = null; + + /** + * @var Parameters + */ + protected $params; + + /** + * @var int + */ + protected $numberOfVisits = false; + protected $numberOfVisitsConverted = false; + + public function __construct(Parameters $params, ArchiveWriter $archiveWriter) + { + $this->params = $params; + $this->logAggregator = new LogAggregator($params); + $this->archiveWriter = $archiveWriter; + } + + protected function getArchive() + { + if(empty($this->archive)) { + $subPeriods = $this->params->getSubPeriods(); + $idSites = $this->params->getIdSites(); + $this->archive = Archive::factory($this->params->getSegment(), $subPeriods, $idSites); + } + return $this->archive; + } + + public function setNumberOfVisits($visits, $visitsConverted) + { + $this->numberOfVisits = $visits; + $this->numberOfVisitsConverted = $visitsConverted; + } + + /** + * Returns the {@link Parameters} object containing the site, period and segment we're archiving + * data for. + * + * @return Parameters + * @api + */ + public function getParams() + { + return $this->params; + } + + /** + * Returns a `{@link Piwik\DataAccess\LogAggregator}` instance for the site, period and segment this + * ArchiveProcessor will insert archive data for. + * + * @return LogAggregator + * @api + */ + public function getLogAggregator() + { + return $this->logAggregator; + } + + /** + * Array of (column name before => column name renamed) of the columns for which sum operation is invalid. + * These columns will be renamed as per this mapping. + * @var array + */ + protected static $columnsToRenameAfterAggregation = array( + Metrics::INDEX_NB_UNIQ_VISITORS => Metrics::INDEX_SUM_DAILY_NB_UNIQ_VISITORS + ); + + /** + * Sums records for every subperiod of the current period and inserts the result as the record + * for this period. + * + * DataTables are summed recursively so subtables will be summed as well. + * + * @param string|array $recordNames Name(s) of the report we are aggregating, eg, `'Referrers_type'`. + * @param int $maximumRowsInDataTableLevelZero Maximum number of rows allowed in the top level DataTable. + * @param int $maximumRowsInSubDataTable Maximum number of rows allowed in each subtable. + * @param string $columnToSortByBeforeTruncation The name of the column to sort by before truncating a DataTable. + * @param array $columnsAggregationOperation Operations for aggregating columns, see {@link Row::sumRow()}. + * @param array $columnsToRenameAfterAggregation Columns mapped to new names for columns that must change names + * when summed because they cannot be summed, eg, + * `array('nb_uniq_visitors' => 'sum_daily_nb_uniq_visitors')`. + * @return array Returns the row counts of each aggregated report before truncation, eg, + * + * array( + * 'report1' => array('level0' => $report1->getRowsCount, + * 'recursive' => $report1->getRowsCountRecursive()), + * 'report2' => array('level0' => $report2->getRowsCount, + * 'recursive' => $report2->getRowsCountRecursive()), + * ... + * ) + * @api + */ + public function aggregateDataTableRecords($recordNames, + $maximumRowsInDataTableLevelZero = null, + $maximumRowsInSubDataTable = null, + $columnToSortByBeforeTruncation = null, + &$columnsAggregationOperation = null, + $columnsToRenameAfterAggregation = null) + { + if (!is_array($recordNames)) { + $recordNames = array($recordNames); + } + $nameToCount = array(); + foreach ($recordNames as $recordName) { + $latestUsedTableId = Manager::getInstance()->getMostRecentTableId(); + + $table = $this->aggregateDataTableRecord($recordName, $columnsAggregationOperation, $columnsToRenameAfterAggregation); + + $rowsCount = $table->getRowsCount(); + $nameToCount[$recordName]['level0'] = $rowsCount; + + $rowsCountRecursive = $rowsCount; + if($this->isAggregateSubTables()) { + $rowsCountRecursive = $table->getRowsCountRecursive(); + } + $nameToCount[$recordName]['recursive'] = $rowsCountRecursive; + + $blob = $table->getSerialized($maximumRowsInDataTableLevelZero, $maximumRowsInSubDataTable, $columnToSortByBeforeTruncation); + Common::destroy($table); + $this->insertBlobRecord($recordName, $blob); + + unset($blob); + DataTable\Manager::getInstance()->deleteAll($latestUsedTableId); + } + + return $nameToCount; + } + + /** + * Aggregates one or more metrics for every subperiod of the current period and inserts the results + * as metrics for the current period. + * + * @param array|string $columns Array of metric names to aggregate. + * @param bool|string $operationToApply The operation to apply to the metric. Either `'sum'`, `'max'` or `'min'`. + * @return array|int Returns the array of aggregate values. If only one metric was aggregated, + * the aggregate value will be returned as is, not in an array. + * For example, if `array('nb_visits', 'nb_hits')` is supplied for `$columns`, + * + * array( + * 'nb_visits' => 3040, + * 'nb_hits' => 405 + * ) + * + * could be returned. If `array('nb_visits')` or `'nb_visits'` is used for `$columns`, + * then `3040` would be returned. + * @api + */ + public function aggregateNumericMetrics($columns, $operationToApply = false) + { + $metrics = $this->getAggregatedNumericMetrics($columns, $operationToApply); + + foreach($metrics as $column => $value) { + $this->archiveWriter->insertRecord($column, $value); + } + // if asked for only one field to sum + if (count($metrics) == 1) { + return reset($metrics); + } + + // returns the array of records once summed + return $metrics; + } + + public function getNumberOfVisits() + { + if($this->numberOfVisits === false) { + throw new Exception("visits should have been set here"); + } + return $this->numberOfVisits; + } + + public function getNumberOfVisitsConverted() + { + return $this->numberOfVisitsConverted; + } + + /** + * Caches multiple numeric records in the archive for this processor's site, period + * and segment. + * + * @param array $numericRecords A name-value mapping of numeric values that should be + * archived, eg, + * + * array('Referrers_distinctKeywords' => 23, 'Referrers_distinctCampaigns' => 234) + * @api + */ + public function insertNumericRecords($numericRecords) + { + foreach ($numericRecords as $name => $value) { + $this->insertNumericRecord($name, $value); + } + } + + /** + * Caches a single numeric record in the archive for this processor's site, period and + * segment. + * + * Numeric values are not inserted if they equal `0`. + * + * @param string $name The name of the numeric value, eg, `'Referrers_distinctKeywords'`. + * @param float $value The numeric value. + * @api + */ + public function insertNumericRecord($name, $value) + { + $value = round($value, 2); + $this->archiveWriter->insertRecord($name, $value); + } + + /** + * Caches one or more blob records in the archive for this processor's site, period + * and segment. + * + * @param string $name The name of the record, eg, 'Referrers_type'. + * @param string|array $values A blob string or an array of blob strings. If an array + * is used, the first element in the array will be inserted + * with the `$name` name. The others will be inserted with + * `$name . '_' . $index` as the record name (where $index is + * the index of the blob record in `$values`). + * @api + */ + public function insertBlobRecord($name, $values) + { + $this->archiveWriter->insertBlobRecord($name, $values); + } + + /** + * This method selects all DataTables that have the name $name over the period. + * All these DataTables are then added together, and the resulting DataTable is returned. + * + * @param string $name + * @param array $columnsAggregationOperation Operations for aggregating columns, @see Row::sumRow() + * @param array $columnsToRenameAfterAggregation columns in the array (old name, new name) to be renamed as the sum operation is not valid on them (eg. nb_uniq_visitors->sum_daily_nb_uniq_visitors) + * @return DataTable + */ + protected function aggregateDataTableRecord($name, $columnsAggregationOperation = null, $columnsToRenameAfterAggregation = null) + { + if($this->isAggregateSubTables()) { + // By default we shall aggregate all sub-tables. + $dataTable = $this->getArchive()->getDataTableExpanded($name, $idSubTable = null, $depth = null, $addMetadataSubtableId = false); + } else { + // In some cases (eg. Actions plugin when period=range), + // for better performance we will only aggregate the parent table + $dataTable = $this->getArchive()->getDataTable($name, $idSubTable = null); + } + + $dataTable = $this->getAggregatedDataTableMap($dataTable, $columnsAggregationOperation); + $this->renameColumnsAfterAggregation($dataTable, $columnsToRenameAfterAggregation); + return $dataTable; + } + + protected function getOperationForColumns($columns, $defaultOperation) + { + $operationForColumn = array(); + foreach ($columns as $name) { + $operation = $defaultOperation; + if (empty($operation)) { + $operation = $this->guessOperationForColumn($name); + } + $operationForColumn[$name] = $operation; + } + return $operationForColumn; + } + + protected function enrichWithUniqueVisitorsMetric(Row $row) + { + if(!$this->getParams()->isSingleSite() ) { + // we only compute unique visitors for a single site + return; + } + if ( $row->getColumn('nb_uniq_visitors') !== false) { + if (SettingsPiwik::isUniqueVisitorsEnabled($this->getParams()->getPeriod()->getLabel())) { + $uniqueVisitors = (float)$this->computeNbUniqVisitors(); + $row->setColumn('nb_uniq_visitors', $uniqueVisitors); + } else { + $row->deleteColumn('nb_uniq_visitors'); + } + } + } + + protected function guessOperationForColumn($column) + { + if (strpos($column, 'max_') === 0) { + return 'max'; + } + if (strpos($column, 'min_') === 0) { + return 'min'; + } + return 'sum'; + } + + /** + * Processes number of unique visitors for the given period + * + * This is the only Period metric (ie. week/month/year/range) that we process from the logs directly, + * since unique visitors cannot be summed like other metrics. + * + * @return int + */ + protected function computeNbUniqVisitors() + { + $logAggregator = $this->getLogAggregator(); + $query = $logAggregator->queryVisitsByDimension(array(), false, array(), array(Metrics::INDEX_NB_UNIQ_VISITORS)); + $data = $query->fetch(); + return $data[Metrics::INDEX_NB_UNIQ_VISITORS]; + } + + /** + * If the DataTable is a Map, sums all DataTable in the map and return the DataTable. + * + * + * @param $data DataTable|DataTable\Map + * @param $columnsToRenameAfterAggregation array + * @return DataTable + */ + protected function getAggregatedDataTableMap($data, $columnsAggregationOperation) + { + $table = new DataTable(); + if (!empty($columnsAggregationOperation)) { + $table->setMetadata(DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME, $columnsAggregationOperation); + } + if ($data instanceof DataTable\Map) { + // as $date => $tableToSum + $this->aggregatedDataTableMapsAsOne($data, $table); + } else { + $table->addDataTable($data, $this->isAggregateSubTables()); + } + return $table; + } + + /** + * Aggregates the DataTable\Map into the destination $aggregated + * @param $map + * @param $aggregated + */ + protected function aggregatedDataTableMapsAsOne(Map $map, DataTable $aggregated) + { + foreach ($map->getDataTables() as $tableToAggregate) { + if($tableToAggregate instanceof Map) { + $this->aggregatedDataTableMapsAsOne($tableToAggregate, $aggregated); + } else { + $aggregated->addDataTable($tableToAggregate, $this->isAggregateSubTables()); + } + } + } + + protected function renameColumnsAfterAggregation(DataTable $table, $columnsToRenameAfterAggregation = null) + { + // Rename columns after aggregation + if (is_null($columnsToRenameAfterAggregation)) { + $columnsToRenameAfterAggregation = self::$columnsToRenameAfterAggregation; + } + foreach ($columnsToRenameAfterAggregation as $oldName => $newName) { + $table->renameColumn($oldName, $newName, $this->isAggregateSubTables()); + } + } + + protected function getAggregatedNumericMetrics($columns, $operationToApply) + { + if (!is_array($columns)) { + $columns = array($columns); + } + $operationForColumn = $this->getOperationForColumns($columns, $operationToApply); + + $dataTable = $this->getArchive()->getDataTableFromNumeric($columns); + + $results = $this->getAggregatedDataTableMap($dataTable, $operationForColumn); + if ($results->getRowsCount() > 1) { + throw new Exception("A DataTable is an unexpected state:" . var_export($results, true)); + } + + $rowMetrics = $results->getFirstRow(); + if($rowMetrics === false) { + $rowMetrics = new Row; + } + $this->enrichWithUniqueVisitorsMetric($rowMetrics); + $this->renameColumnsAfterAggregation($results); + + $metrics = $rowMetrics->getColumns(); + + foreach ($columns as $name) { + if (!isset($metrics[$name])) { + $metrics[$name] = 0; + } + } + return $metrics; + } + + /** + * @return bool + */ + protected function isAggregateSubTables() + { + return !$this->getParams()->isSkipAggregationOfSubTables(); + } +} diff --git a/www/analytics/core/ArchiveProcessor/Loader.php b/www/analytics/core/ArchiveProcessor/Loader.php new file mode 100644 index 00000000..03a054a6 --- /dev/null +++ b/www/analytics/core/ArchiveProcessor/Loader.php @@ -0,0 +1,214 @@ +params = $params; + } + + /** + * @return bool + */ + protected function isThereSomeVisits($visits) + { + return $visits > 0; + } + + /** + * @return bool + */ + protected function mustProcessVisitCount($visits) + { + return $visits === false; + } + + public function prepareArchive($pluginName) + { + $this->params->setRequestedPlugin($pluginName); + + list($idArchive, $visits, $visitsConverted) = $this->loadExistingArchiveIdFromDb(); + if (!empty($idArchive)) { + return $idArchive; + } + + list($visits, $visitsConverted) = $this->prepareCoreMetricsArchive($visits, $visitsConverted); + list($idArchive, $visits) = $this->prepareAllPluginsArchive($visits, $visitsConverted); + + if ($this->isThereSomeVisits($visits)) { + return $idArchive; + } + return false; + } + + /** + * Prepares the core metrics if needed. + * + * @param $visits + */ + protected function prepareCoreMetricsArchive($visits, $visitsConverted) + { + $createSeparateArchiveForCoreMetrics = $this->mustProcessVisitCount($visits) + && !$this->doesRequestedPluginIncludeVisitsSummary(); + + if ($createSeparateArchiveForCoreMetrics) { + $requestedPlugin = $this->params->getRequestedPlugin(); + + $this->params->setRequestedPlugin('VisitsSummary'); + + $pluginsArchiver = new PluginsArchiver($this->params, $this->isArchiveTemporary()); + $metrics = $pluginsArchiver->callAggregateCoreMetrics(); + $pluginsArchiver->finalizeArchive(); + + $this->params->setRequestedPlugin($requestedPlugin); + + $visits = $metrics['nb_visits']; + $visitsConverted = $metrics['nb_visits_converted']; + } + return array($visits, $visitsConverted); + } + + protected function prepareAllPluginsArchive($visits, $visitsConverted) + { + $pluginsArchiver = new PluginsArchiver($this->params, $this->isArchiveTemporary()); + if ($this->mustProcessVisitCount($visits) + || $this->doesRequestedPluginIncludeVisitsSummary() + ) { + $metrics = $pluginsArchiver->callAggregateCoreMetrics(); + $visits = $metrics['nb_visits']; + $visitsConverted = $metrics['nb_visits_converted']; + } + if ($this->isThereSomeVisits($visits)) { + $pluginsArchiver->callAggregateAllPlugins($visits, $visitsConverted); + } + $idArchive = $pluginsArchiver->finalizeArchive(); + + if (!$this->params->isSingleSiteDayArchive() && $visits) { + ArchiveSelector::purgeOutdatedArchives($this->params->getPeriod()->getDateStart()); + } + + return array($idArchive, $visits); + } + + protected function doesRequestedPluginIncludeVisitsSummary() + { + $processAllReportsIncludingVisitsSummary = + Rules::shouldProcessReportsAllPlugins($this->params->getIdSites(), $this->params->getSegment(), $this->params->getPeriod()->getLabel()); + $doesRequestedPluginIncludeVisitsSummary = $processAllReportsIncludingVisitsSummary + || $this->params->getRequestedPlugin() == 'VisitsSummary'; + return $doesRequestedPluginIncludeVisitsSummary; + } + + protected function isArchivingForcedToTrigger() + { + $period = $this->params->getPeriod()->getLabel(); + $debugSetting = 'always_archive_data_period'; // default + if ($period == 'day') { + $debugSetting = 'always_archive_data_day'; + } elseif ($period == 'range') { + $debugSetting = 'always_archive_data_range'; + } + return (bool) Config::getInstance()->Debug[$debugSetting]; + } + + /** + * Returns the idArchive if the archive is available in the database for the requested plugin. + * Returns false if the archive needs to be processed. + * + * @return array + */ + protected function loadExistingArchiveIdFromDb() + { + $noArchiveFound = array(false, false, false); + + // see isArchiveTemporary() + $minDatetimeArchiveProcessedUTC = $this->getMinTimeArchiveProcessed(); + + if ($this->isArchivingForcedToTrigger()) { + return $noArchiveFound; + } + + $idAndVisits = ArchiveSelector::getArchiveIdAndVisits($this->params, $minDatetimeArchiveProcessedUTC); + if (!$idAndVisits) { + return $noArchiveFound; + } + return $idAndVisits; + } + + /** + * Returns the minimum archive processed datetime to look at. Only public for tests. + * + * @return int|bool Datetime timestamp, or false if must look at any archive available + */ + protected function getMinTimeArchiveProcessed() + { + $endDateTimestamp = self::determineIfArchivePermanent($this->params->getDateEnd()); + $isArchiveTemporary = ($endDateTimestamp === false); + $this->temporaryArchive = $isArchiveTemporary; + + if ($endDateTimestamp) { + // Permanent archive + return $endDateTimestamp; + } + // Temporary archive + return Rules::getMinTimeProcessedForTemporaryArchive($this->params->getDateStart(), $this->params->getPeriod(), $this->params->getSegment(), $this->params->getSite()); + } + + protected static function determineIfArchivePermanent(Date $dateEnd) + { + $now = time(); + $endTimestampUTC = strtotime($dateEnd->getDateEndUTC()); + if ($endTimestampUTC <= $now) { + // - if the period we are looking for is finished, we look for a ts_archived that + // is greater than the last day of the archive + return $endTimestampUTC; + } + return false; + } + + protected function isArchiveTemporary() + { + if (is_null($this->temporaryArchive)) { + throw new \Exception("getMinTimeArchiveProcessed() should be called prior to isArchiveTemporary()"); + } + return $this->temporaryArchive; + } + +} + diff --git a/www/analytics/core/ArchiveProcessor/Parameters.php b/www/analytics/core/ArchiveProcessor/Parameters.php new file mode 100644 index 00000000..9d9a9088 --- /dev/null +++ b/www/analytics/core/ArchiveProcessor/Parameters.php @@ -0,0 +1,194 @@ +site = $site; + $this->period = $period; + $this->segment = $segment; + $this->skipAggregationOfSubTables = $skipAggregationOfSubTables; + } + + /** + * @ignore + */ + public function setRequestedPlugin($plugin) + { + $this->requestedPlugin = $plugin; + } + + /** + * @ignore + */ + public function getRequestedPlugin() + { + return $this->requestedPlugin; + } + + /** + * Returns the period we are computing statistics for. + * + * @return Period + * @api + */ + public function getPeriod() + { + return $this->period; + } + + /** + * Returns the array of Period which make up this archive. + * + * @return \Piwik\Period[] + * @ignore + */ + public function getSubPeriods() + { + if($this->getPeriod()->getLabel() == 'day') { + return array( $this->getPeriod() ); + } + return $this->getPeriod()->getSubperiods(); + } + + /** + * @return array + * @ignore + */ + public function getIdSites() + { + $idSite = $this->getSite()->getId(); + + $idSites = array($idSite); + + Piwik::postEvent('ArchiveProcessor.Parameters.getIdSites', array(&$idSites, $this->getPeriod())); + + return $idSites; + } + + /** + * Returns the site we are computing statistics for. + * + * @return Site + * @api + */ + public function getSite() + { + return $this->site; + } + + /** + * The Segment used to limit the set of visits that are being aggregated. + * + * @return Segment + * @api + */ + public function getSegment() + { + return $this->segment; + } + + /** + * Returns the end day of the period in the site's timezone. + * + * @return Date + */ + public function getDateEnd() + { + return $this->getPeriod()->getDateEnd()->setTimezone($this->getSite()->getTimezone()); + } + + /** + * Returns the start day of the period in the site's timezone. + * + * @return Date + */ + public function getDateStart() + { + return $this->getPeriod()->getDateStart()->setTimezone($this->getSite()->getTimezone()); + } + + /** + * @return bool + */ + public function isSingleSiteDayArchive() + { + $oneSite = $this->isSingleSite(); + $oneDay = $this->getPeriod()->getLabel() == 'day'; + return $oneDay && $oneSite; + } + + public function isSingleSite() + { + return count($this->getIdSites()) == 1; + } + + public function isSkipAggregationOfSubTables() + { + return $this->skipAggregationOfSubTables; + } + + public function logStatusDebug($isTemporary) + { + $temporary = 'definitive archive'; + if ($isTemporary) { + $temporary = 'temporary archive'; + } + Log::verbose( + "%s archive, idSite = %d (%s), segment '%s', report = '%s', UTC datetime [%s -> %s]", + $this->getPeriod()->getLabel(), + $this->getSite()->getId(), + $temporary, + $this->getSegment()->getString(), + $this->getRequestedPlugin(), + $this->getDateStart()->getDateStartUTC(), + $this->getDateEnd()->getDateEndUTC() + ); + } +} diff --git a/www/analytics/core/ArchiveProcessor/PluginsArchiver.php b/www/analytics/core/ArchiveProcessor/PluginsArchiver.php new file mode 100644 index 00000000..82f361f7 --- /dev/null +++ b/www/analytics/core/ArchiveProcessor/PluginsArchiver.php @@ -0,0 +1,197 @@ +params = $params; + + $this->archiveWriter = new ArchiveWriter($this->params, $isTemporaryArchive); + $this->archiveWriter->initNewArchive(); + + $this->archiveProcessor = new ArchiveProcessor($this->params, $this->archiveWriter); + + $this->isSingleSiteDayArchive = $this->params->isSingleSiteDayArchive(); + } + + /** + * If period is day, will get the core metrics (including visits) from the logs. + * If period is != day, will sum the core metrics from the existing archives. + * @return array Core metrics + */ + public function callAggregateCoreMetrics() + { + if($this->isSingleSiteDayArchive) { + $metrics = $this->aggregateDayVisitsMetrics(); + } else { + $metrics = $this->aggregateMultipleVisitsMetrics(); + } + + if (empty($metrics)) { + return array( + 'nb_visits' => false, + 'nb_visits_converted' => false + ); + } + return array( + 'nb_visits' => $metrics['nb_visits'], + 'nb_visits_converted' => $metrics['nb_visits_converted'] + ); + } + + /** + * Instantiates the Archiver class in each plugin that defines it, + * and triggers Aggregation processing on these plugins. + */ + public function callAggregateAllPlugins($visits, $visitsConverted) + { + $this->archiveProcessor->setNumberOfVisits($visits, $visitsConverted); + + $archivers = $this->getPluginArchivers(); + + foreach($archivers as $pluginName => $archiverClass) { + + // We clean up below all tables created during this function call (and recursive calls) + $latestUsedTableId = Manager::getInstance()->getMostRecentTableId(); + + /** @var Archiver $archiver */ + $archiver = new $archiverClass($this->archiveProcessor); + + if(!$archiver->isEnabled()) { + continue; + } + if($this->shouldProcessReportsForPlugin($pluginName)) { + if($this->isSingleSiteDayArchive) { + $archiver->aggregateDayReport(); + } else { + $archiver->aggregateMultipleReports(); + } + } + + Manager::getInstance()->deleteAll($latestUsedTableId); + unset($archiver); + } + } + + public function finalizeArchive() + { + $this->params->logStatusDebug( $this->archiveWriter->isArchiveTemporary ); + $this->archiveWriter->finalizeArchive(); + return $this->archiveWriter->getIdArchive(); + } + + /** + * Loads Archiver class from any plugin that defines one. + * + * @return \Piwik\Plugin\Archiver[] + */ + protected function getPluginArchivers() + { + if (empty(static::$archivers)) { + $pluginNames = \Piwik\Plugin\Manager::getInstance()->getActivatedPlugins(); + $archivers = array(); + foreach ($pluginNames as $pluginName) { + $archivers[$pluginName] = self::getPluginArchiverClass($pluginName); + } + static::$archivers = array_filter($archivers); + } + return static::$archivers; + } + + private static function getPluginArchiverClass($pluginName) + { + $klassName = 'Piwik\\Plugins\\' . $pluginName . '\\Archiver'; + if (class_exists($klassName) + && is_subclass_of($klassName, 'Piwik\\Plugin\\Archiver')) { + return $klassName; + } + return false; + } + + /** + * Whether the specified plugin's reports should be archived + * @param string $pluginName + * @return bool + */ + protected function shouldProcessReportsForPlugin($pluginName) + { + if ($this->params->getRequestedPlugin() == $pluginName) { + return true; + } + if (Rules::shouldProcessReportsAllPlugins( + $this->params->getIdSites(), + $this->params->getSegment(), + $this->params->getPeriod()->getLabel())) { + return true; + } + + if (!\Piwik\Plugin\Manager::getInstance()->isPluginLoaded($this->params->getRequestedPlugin())) { + return true; + } + return false; + } + + protected function aggregateDayVisitsMetrics() + { + $query = $this->archiveProcessor->getLogAggregator()->queryVisitsByDimension(); + $data = $query->fetch(); + + $metrics = $this->convertMetricsIdToName($data); + $this->archiveProcessor->insertNumericRecords($metrics); + return $metrics; + } + + protected function convertMetricsIdToName($data) + { + $metrics = array(); + foreach ($data as $metricId => $value) { + $readableMetric = Metrics::$mappingFromIdToName[$metricId]; + $metrics[$readableMetric] = $value; + } + return $metrics; + } + + protected function aggregateMultipleVisitsMetrics() + { + $toSum = Metrics::getVisitsMetricNames(); + $metrics = $this->archiveProcessor->aggregateNumericMetrics($toSum); + return $metrics; + } + +} diff --git a/www/analytics/core/ArchiveProcessor/Rules.php b/www/analytics/core/ArchiveProcessor/Rules.php new file mode 100644 index 00000000..6bcd5fbd --- /dev/null +++ b/www/analytics/core/ArchiveProcessor/Rules.php @@ -0,0 +1,304 @@ +isEmpty() && $periodLabel != 'range') { + return true; + } + + return self::isSegmentPreProcessed($idSites, $segment); + } + + /** + * @param $idSites + * @return array + */ + private static function getSegmentsToProcess($idSites) + { + $knownSegmentsToArchiveAllSites = SettingsPiwik::getKnownSegmentsToArchive(); + + $segmentsToProcess = $knownSegmentsToArchiveAllSites; + foreach ($idSites as $idSite) { + $segmentForThisWebsite = SettingsPiwik::getKnownSegmentsToArchiveForSite($idSite); + $segmentsToProcess = array_merge($segmentsToProcess, $segmentForThisWebsite); + } + $segmentsToProcess = array_unique($segmentsToProcess); + return $segmentsToProcess; + } + + public static function getDoneFlagArchiveContainsOnePlugin(Segment $segment, $plugin, $isSkipAggregationOfSubTables = false) + { + $partial = self::isFlagArchivePartial($plugin, $isSkipAggregationOfSubTables); + return 'done' . $segment->getHash() . '.' . $plugin . $partial ; + } + + private static function getDoneFlagArchiveContainsAllPlugins(Segment $segment) + { + return 'done' . $segment->getHash(); + } + + /** + * @param $plugin + * @param $isSkipAggregationOfSubTables + * @return string + */ + private static function isFlagArchivePartial($plugin, $isSkipAggregationOfSubTables) + { + $partialArchive = ''; + if ($plugin != "VisitsSummary" // VisitsSummary is always called when segmenting and should not have its own .partial archive + && $isSkipAggregationOfSubTables + ) { + $partialArchive = '.partial'; + } + return $partialArchive; + } + + /** + * @param array $plugins + * @param $segment + * @return array + */ + public static function getDoneFlags(array $plugins, Segment $segment, $isSkipAggregationOfSubTables) + { + $doneFlags = array(); + $doneAllPlugins = self::getDoneFlagArchiveContainsAllPlugins($segment); + $doneFlags[$doneAllPlugins] = $doneAllPlugins; + + $plugins = array_unique($plugins); + foreach ($plugins as $plugin) { + $doneOnePlugin = self::getDoneFlagArchiveContainsOnePlugin($segment, $plugin, $isSkipAggregationOfSubTables); + $doneFlags[$plugin] = $doneOnePlugin; + } + return $doneFlags; + } + + /** + * Given a monthly archive table, will delete all reports that are now outdated, + * or reports that ended with an error + * + * @param \Piwik\Date $date + * @return int|bool False, or timestamp indicating which archives to delete + */ + public static function shouldPurgeOutdatedArchives(Date $date) + { + if (self::$purgeDisabledByTests) { + return false; + } + $key = self::FLAG_TABLE_PURGED . "blob_" . $date->toString('Y_m'); + $timestamp = Option::get($key); + + // we shall purge temporary archives after their timeout is finished, plus an extra 6 hours + // in case archiving is disabled or run once a day, we give it this extra time to run + // and re-process more recent records... + $temporaryArchivingTimeout = self::getTodayArchiveTimeToLive(); + $hoursBetweenPurge = 6; + $purgeEveryNSeconds = max($temporaryArchivingTimeout, $hoursBetweenPurge * 3600); + + // we only delete archives if we are able to process them, otherwise, the browser might process reports + // when &segment= is specified (or custom date range) and would below, delete temporary archives that the + // browser is not able to process until next cron run (which could be more than 1 hour away) + if (self::isRequestAuthorizedToArchive() + && (!$timestamp + || $timestamp < time() - $purgeEveryNSeconds) + ) { + Option::set($key, time()); + + if (self::isBrowserTriggerEnabled()) { + // If Browser Archiving is enabled, it is likely there are many more temporary archives + // We delete more often which is safe, since reports are re-processed on demand + $purgeArchivesOlderThan = Date::factory(time() - 2 * $temporaryArchivingTimeout)->getDateTime(); + } else { + // If archive.php via Cron is building the reports, we should keep all temporary reports from today + $purgeArchivesOlderThan = Date::factory('today')->getDateTime(); + } + return $purgeArchivesOlderThan; + } + + Log::info("Purging temporary archives: skipped."); + return false; + } + + public static function getMinTimeProcessedForTemporaryArchive( + Date $dateStart, \Piwik\Period $period, Segment $segment, Site $site) + { + $now = time(); + $minimumArchiveTime = $now - Rules::getTodayArchiveTimeToLive(); + + $idSites = array($site->getId()); + $isArchivingDisabled = Rules::isArchivingDisabledFor($idSites, $segment, $period->getLabel()); + if ($isArchivingDisabled) { + if ($period->getNumberOfSubperiods() == 0 + && $dateStart->getTimestamp() <= $now + ) { + // Today: accept any recent enough archive + $minimumArchiveTime = false; + } else { + // This week, this month, this year: + // accept any archive that was processed today after 00:00:01 this morning + $timezone = $site->getTimezone(); + $minimumArchiveTime = Date::factory(Date::factory('now', $timezone)->getDateStartUTC())->setTimezone($timezone)->getTimestamp(); + } + } + return $minimumArchiveTime; + } + + public static function setTodayArchiveTimeToLive($timeToLiveSeconds) + { + $timeToLiveSeconds = (int)$timeToLiveSeconds; + if ($timeToLiveSeconds <= 0) { + throw new Exception(Piwik::translate('General_ExceptionInvalidArchiveTimeToLive')); + } + Option::set(self::OPTION_TODAY_ARCHIVE_TTL, $timeToLiveSeconds, $autoLoad = true); + } + + public static function getTodayArchiveTimeToLive() + { + $uiSettingIsEnabled = Controller::isGeneralSettingsAdminEnabled(); + + if($uiSettingIsEnabled) { + $timeToLive = Option::get(self::OPTION_TODAY_ARCHIVE_TTL); + if ($timeToLive !== false) { + return $timeToLive; + } + } + return Config::getInstance()->General['time_before_today_archive_considered_outdated']; + } + + public static function isArchivingDisabledFor(array $idSites, Segment $segment, $periodLabel) + { + if ($periodLabel == 'range') { + return false; + } + $processOneReportOnly = !self::shouldProcessReportsAllPlugins($idSites, $segment, $periodLabel); + $isArchivingDisabled = !self::isRequestAuthorizedToArchive() || self::$archivingDisabledByTests; + + if ($processOneReportOnly) { + + // When there is a segment, we disable archiving when browser_archiving_disabled_enforce applies + if (!$segment->isEmpty() + && $isArchivingDisabled + && Config::getInstance()->General['browser_archiving_disabled_enforce'] + && !SettingsServer::isArchivePhpTriggered() // Only applies when we are not running archive.php + ) { + Log::debug("Archiving is disabled because of config setting browser_archiving_disabled_enforce=1"); + return true; + } + + // Always allow processing one report + return false; + } + return $isArchivingDisabled; + } + + protected static function isRequestAuthorizedToArchive() + { + return Rules::isBrowserTriggerEnabled() || SettingsServer::isArchivePhpTriggered(); + } + + public static function isBrowserTriggerEnabled() + { + $uiSettingIsEnabled = Controller::isGeneralSettingsAdminEnabled(); + + if($uiSettingIsEnabled) { + $browserArchivingEnabled = Option::get(self::OPTION_BROWSER_TRIGGER_ARCHIVING); + if ($browserArchivingEnabled !== false) { + return (bool)$browserArchivingEnabled; + } + } + return (bool)Config::getInstance()->General['enable_browser_archiving_triggering']; + } + + public static function setBrowserTriggerArchiving($enabled) + { + if (!is_bool($enabled)) { + throw new Exception('Browser trigger archiving must be set to true or false.'); + } + Option::set(self::OPTION_BROWSER_TRIGGER_ARCHIVING, (int)$enabled, $autoLoad = true); + Cache::clearCacheGeneral(); + } + + /** + * @param array $idSites + * @param Segment $segment + * @return bool + */ + protected static function isSegmentPreProcessed(array $idSites, Segment $segment) + { + $segmentsToProcess = self::getSegmentsToProcess($idSites); + + if (empty($segmentsToProcess)) { + return false; + } + // If the requested segment is one of the segments to pre-process + // we ensure that any call to the API will trigger archiving of all reports for this segment + $segment = $segment->getString(); + + // Turns out the getString() above returns the URL decoded segment string + $segmentsToProcessUrlDecoded = array_map('urldecode', $segmentsToProcess); + + if (in_array($segment, $segmentsToProcess) + || in_array($segment, $segmentsToProcessUrlDecoded) + ) { + return true; + } + return false; + } +} diff --git a/www/analytics/core/AssetManager.php b/www/analytics/core/AssetManager.php new file mode 100644 index 00000000..972c84c3 --- /dev/null +++ b/www/analytics/core/AssetManager.php @@ -0,0 +1,405 @@ +\n"; + const JS_IMPORT_DIRECTIVE = "\n"; + const GET_CSS_MODULE_ACTION = "index.php?module=Proxy&action=getCss"; + const GET_CORE_JS_MODULE_ACTION = "index.php?module=Proxy&action=getCoreJs"; + const GET_NON_CORE_JS_MODULE_ACTION = "index.php?module=Proxy&action=getNonCoreJs"; + + /** + * @var UIAssetCacheBuster + */ + private $cacheBuster; + + /** + * @var UIAssetFetcher + */ + private $minimalStylesheetFetcher; + + /** + * @var Theme + */ + private $theme; + + function __construct() + { + $this->cacheBuster = UIAssetCacheBuster::getInstance(); + $this->minimalStylesheetFetcher = new StaticUIAssetFetcher(array('plugins/Zeitgeist/stylesheets/base.less'), array(), $this->theme); + + $theme = Manager::getInstance()->getThemeEnabled(); + if(!empty($theme)) { + $this->theme = new Theme(); + } + } + + /** + * @param UIAssetCacheBuster $cacheBuster + */ + public function setCacheBuster($cacheBuster) + { + $this->cacheBuster = $cacheBuster; + } + + /** + * @param UIAssetFetcher $minimalStylesheetFetcher + */ + public function setMinimalStylesheetFetcher($minimalStylesheetFetcher) + { + $this->minimalStylesheetFetcher = $minimalStylesheetFetcher; + } + + /** + * @param Theme $theme + */ + public function setTheme($theme) + { + $this->theme = $theme; + } + + /** + * Return CSS file inclusion directive(s) using the markup + * + * @return string + */ + public function getCssInclusionDirective() + { + return sprintf(self::CSS_IMPORT_DIRECTIVE, self::GET_CSS_MODULE_ACTION); + } + + /** + * Return JS file inclusion directive(s) using the markup "; + + if ($this->isMergedAssetsDisabled()) { + + $this->getMergedCoreJSAsset()->delete(); + $this->getMergedNonCoreJSAsset()->delete(); + + $result .= $this->getIndividualJsIncludes(); + + } else { + + $result .= sprintf(self::JS_IMPORT_DIRECTIVE, self::GET_CORE_JS_MODULE_ACTION); + $result .= sprintf(self::JS_IMPORT_DIRECTIVE, self::GET_NON_CORE_JS_MODULE_ACTION); + } + + return $result; + } + + /** + * Return the base.less compiled to css + * + * @return UIAsset + */ + public function getCompiledBaseCss() + { + $mergedAsset = new InMemoryUIAsset(); + + $assetMerger = new StylesheetUIAssetMerger($mergedAsset, $this->minimalStylesheetFetcher, $this->cacheBuster); + + $assetMerger->generateFile(); + + return $mergedAsset; + } + + /** + * Return the css merged file absolute location. + * If there is none, the generation process will be triggered. + * + * @return UIAsset + */ + public function getMergedStylesheet() + { + $mergedAsset = $this->getMergedStylesheetAsset(); + + $assetFetcher = new StylesheetUIAssetFetcher(Manager::getInstance()->getLoadedPluginsName(), $this->theme); + + $assetMerger = new StylesheetUIAssetMerger($mergedAsset, $assetFetcher, $this->cacheBuster); + + $assetMerger->generateFile(); + + return $mergedAsset; + } + + /** + * Return the core js merged file absolute location. + * If there is none, the generation process will be triggered. + * + * @return UIAsset + */ + public function getMergedCoreJavaScript() + { + return $this->getMergedJavascript($this->getCoreJScriptFetcher(), $this->getMergedCoreJSAsset()); + } + + /** + * Return the non core js merged file absolute location. + * If there is none, the generation process will be triggered. + * + * @return UIAsset + */ + public function getMergedNonCoreJavaScript() + { + return $this->getMergedJavascript($this->getNonCoreJScriptFetcher(), $this->getMergedNonCoreJSAsset()); + } + + /** + * @param boolean $core + * @return string[] + */ + public function getLoadedPlugins($core) + { + $loadedPlugins = array(); + + foreach(Manager::getInstance()->getPluginsLoadedAndActivated() as $plugin) { + + $pluginName = $plugin->getPluginName(); + $pluginIsCore = Manager::getInstance()->isPluginBundledWithCore($pluginName); + + if(($pluginIsCore && $core) || (!$pluginIsCore && !$core)) + $loadedPlugins[] = $pluginName; + } + + return $loadedPlugins; + } + + /** + * Remove previous merged assets + */ + public function removeMergedAssets($pluginName = false) + { + $assetsToRemove = array($this->getMergedStylesheetAsset()); + + if($pluginName) { + + if($this->pluginContainsJScriptAssets($pluginName)) { + + PiwikConfig::getInstance()->init(); + if(Manager::getInstance()->isPluginBundledWithCore($pluginName)) { + + $assetsToRemove[] = $this->getMergedCoreJSAsset(); + + } else { + + $assetsToRemove[] = $this->getMergedNonCoreJSAsset(); + } + } + + } else { + + $assetsToRemove[] = $this->getMergedCoreJSAsset(); + $assetsToRemove[] = $this->getMergedNonCoreJSAsset(); + } + + $this->removeAssets($assetsToRemove); + } + + /** + * Check if the merged file directory exists and is writable. + * + * @return string The directory location + * @throws Exception if directory is not writable. + */ + public function getAssetDirectory() + { + $mergedFileDirectory = PIWIK_USER_PATH . "/tmp/assets"; + $mergedFileDirectory = SettingsPiwik::rewriteTmpPathWithHostname($mergedFileDirectory); + + if (!is_dir($mergedFileDirectory)) { + Filesystem::mkdir($mergedFileDirectory); + } + + if (!is_writable($mergedFileDirectory)) { + throw new Exception("Directory " . $mergedFileDirectory . " has to be writable."); + } + + return $mergedFileDirectory; + } + + /** + * Return the global option disable_merged_assets + * + * @return boolean + */ + public function isMergedAssetsDisabled() + { + return Config::getInstance()->Debug['disable_merged_assets']; + } + + /** + * @param UIAssetFetcher $assetFetcher + * @param UIAsset $mergedAsset + * @return UIAsset + */ + private function getMergedJavascript($assetFetcher, $mergedAsset) + { + $assetMerger = new JScriptUIAssetMerger($mergedAsset, $assetFetcher, $this->cacheBuster); + + $assetMerger->generateFile(); + + return $mergedAsset; + } + + /** + * Return individual JS file inclusion directive(s) using the markup '; + + /** + * @var Twig_Environment + */ + private $twig; + + public function __construct() + { + $loader = $this->getDefaultThemeLoader(); + + $this->addPluginNamespaces($loader); + + // If theme != default we need to chain + $chainLoader = new Twig_Loader_Chain(array($loader)); + + // Create new Twig Environment and set cache dir + $templatesCompiledPath = PIWIK_USER_PATH . '/tmp/templates_c'; + $templatesCompiledPath = SettingsPiwik::rewriteTmpPathWithHostname($templatesCompiledPath); + + $this->twig = new Twig_Environment($chainLoader, + array( + 'debug' => true, // to use {{ dump(var) }} in twig templates + 'strict_variables' => true, // throw an exception if variables are invalid + 'cache' => $templatesCompiledPath, + ) + ); + $this->twig->addExtension(new Twig_Extension_Debug()); + $this->twig->clearTemplateCache(); + + $this->addFilter_translate(); + $this->addFilter_urlRewriteWithParameters(); + $this->addFilter_sumTime(); + $this->addFilter_money(); + $this->addFilter_truncate(); + $this->addFilter_notificiation(); + $this->addFilter_percentage(); + $this->addFilter_prettyDate(); + $this->twig->addFilter(new Twig_SimpleFilter('implode', 'implode')); + $this->twig->addFilter(new Twig_SimpleFilter('ucwords', 'ucwords')); + + $this->addFunction_includeAssets(); + $this->addFunction_linkTo(); + $this->addFunction_sparkline(); + $this->addFunction_postEvent(); + $this->addFunction_isPluginLoaded(); + $this->addFunction_getJavascriptTranslations(); + + $this->twig->addTokenParser(new RenderTokenParser()); + } + + protected function addFunction_getJavascriptTranslations() + { + $getJavascriptTranslations = new Twig_SimpleFunction( + 'getJavascriptTranslations', + array('Piwik\\Translate', 'getJavascriptTranslations') + ); + $this->twig->addFunction($getJavascriptTranslations); + } + + protected function addFunction_isPluginLoaded() + { + $isPluginLoadedFunction = new Twig_SimpleFunction('isPluginLoaded', function ($pluginName) { + return \Piwik\Plugin\Manager::getInstance()->isPluginLoaded($pluginName); + }); + $this->twig->addFunction($isPluginLoadedFunction); + } + + protected function addFunction_includeAssets() + { + $includeAssetsFunction = new Twig_SimpleFunction('includeAssets', function ($params) { + if (!isset($params['type'])) { + throw new Exception("The function includeAssets needs a 'type' parameter."); + } + + $assetType = strtolower($params['type']); + switch ($assetType) { + case 'css': + return AssetManager::getInstance()->getCssInclusionDirective(); + case 'js': + return AssetManager::getInstance()->getJsInclusionDirective(); + default: + throw new Exception("The twig function includeAssets 'type' parameter needs to be either 'css' or 'js'."); + } + }); + $this->twig->addFunction($includeAssetsFunction); + } + + protected function addFunction_postEvent() + { + $postEventFunction = new Twig_SimpleFunction('postEvent', function ($eventName) { + // get parameters to twig function + $params = func_get_args(); + // remove the first value (event name) + array_shift($params); + + // make the first value the string that will get output in the template + // plugins can modify this string + $str = ''; + $params = array_merge( array( &$str ), $params); + + Piwik::postEvent($eventName, $params); + return $str; + }, array('is_safe' => array('html'))); + $this->twig->addFunction($postEventFunction); + } + + protected function addFunction_sparkline() + { + $sparklineFunction = new Twig_SimpleFunction('sparkline', function ($src) { + $width = Sparkline::DEFAULT_WIDTH; + $height = Sparkline::DEFAULT_HEIGHT; + return sprintf(Twig::SPARKLINE_TEMPLATE, $src, $width, $height); + }, array('is_safe' => array('html'))); + $this->twig->addFunction($sparklineFunction); + } + + protected function addFunction_linkTo() + { + $urlFunction = new Twig_SimpleFunction('linkTo', function ($params) { + return 'index.php' . Url::getCurrentQueryStringWithParametersModified($params); + }); + $this->twig->addFunction($urlFunction); + } + + /** + * @return Twig_Loader_Filesystem + */ + private function getDefaultThemeLoader() + { + $themeLoader = new Twig_Loader_Filesystem(array( + sprintf("%s/plugins/%s/templates/", PIWIK_INCLUDE_PATH, \Piwik\Plugin\Manager::DEFAULT_THEME) + )); + + return $themeLoader; + } + + public function getTwigEnvironment() + { + return $this->twig; + } + + protected function addFilter_notificiation() + { + $twigEnv = $this->getTwigEnvironment(); + $notificationFunction = new Twig_SimpleFilter('notification', function ($message, $options) use ($twigEnv) { + + $template = '
    $value) { + if (ctype_alpha($key)) { + $template .= sprintf('data-%s="%s" ', $key, twig_escape_filter($twigEnv, $value, 'html_attr')); + } + } + + $template .= '>'; + + if (!empty($options['raw'])) { + $template .= $message; + } else { + $template .= twig_escape_filter($twigEnv, $message, 'html'); + } + + $template .= '
    '; + + return $template; + + }, array('is_safe' => array('html'))); + $this->twig->addFilter($notificationFunction); + } + + protected function addFilter_prettyDate() + { + $prettyDate = new Twig_SimpleFilter('prettyDate', function ($dateString, $period) { + return Range::factory($period, $dateString)->getLocalizedShortString(); + }); + $this->twig->addFilter($prettyDate); + } + + protected function addFilter_percentage() + { + $percentage = new Twig_SimpleFilter('percentage', function ($string, $totalValue, $precision = 1) { + return Piwik::getPercentageSafe($string, $totalValue, $precision) . '%'; + }); + $this->twig->addFilter($percentage); + } + + protected function addFilter_truncate() + { + $truncateFilter = new Twig_SimpleFilter('truncate', function ($string, $size) { + if (strlen($string) < $size) { + return $string; + } else { + $array = str_split($string, $size); + return array_shift($array) . "..."; + } + }); + $this->twig->addFilter($truncateFilter); + } + + protected function addFilter_money() + { + $moneyFilter = new Twig_SimpleFilter('money', function ($amount) { + if (func_num_args() != 2) { + throw new Exception('the money modifier expects one parameter: the idSite.'); + } + $idSite = func_get_args(); + $idSite = $idSite[1]; + return MetricsFormatter::getPrettyMoney($amount, $idSite); + }); + $this->twig->addFilter($moneyFilter); + } + + protected function addFilter_sumTime() + { + $sumtimeFilter = new Twig_SimpleFilter('sumtime', function ($numberOfSeconds) { + return MetricsFormatter::getPrettyTimeFromSeconds($numberOfSeconds); + }); + $this->twig->addFilter($sumtimeFilter); + } + + protected function addFilter_urlRewriteWithParameters() + { + $urlRewriteFilter = new Twig_SimpleFilter('urlRewriteWithParameters', function ($parameters) { + $parameters['updated'] = null; + $url = Url::getCurrentQueryStringWithParametersModified($parameters); + return $url; + }); + $this->twig->addFilter($urlRewriteFilter); + } + + protected function addFilter_translate() + { + $translateFilter = new Twig_SimpleFilter('translate', function ($stringToken) { + if (func_num_args() <= 1) { + $aValues = array(); + } else { + $aValues = func_get_args(); + array_shift($aValues); + } + + try { + $stringTranslated = Piwik::translate($stringToken, $aValues); + } catch (Exception $e) { + $stringTranslated = $stringToken; + } + return $stringTranslated; + }); + $this->twig->addFilter($translateFilter); + } + + private function addPluginNamespaces(Twig_Loader_Filesystem $loader) + { + $plugins = \Piwik\Plugin\Manager::getInstance()->getAllPluginsNames(); + foreach ($plugins as $name) { + $path = sprintf("%s/plugins/%s/templates/", PIWIK_INCLUDE_PATH, $name); + if (is_dir($path)) { + $loader->addPath(PIWIK_INCLUDE_PATH . '/plugins/' . $name . '/templates', $name); + } + } + } + + /** + * Prepend relative paths with absolute Piwik path + * + * @param string $value relative path (pass by reference) + * @param int $key (don't care) + * @param string $path Piwik root + */ + public static function addPiwikPath(&$value, $key, $path) + { + if ($value[0] != '/' && $value[0] != DIRECTORY_SEPARATOR) { + $value = $path . "/$value"; + } + } +} diff --git a/www/analytics/core/Unzip.php b/www/analytics/core/Unzip.php new file mode 100644 index 00000000..2ba8cfcd --- /dev/null +++ b/www/analytics/core/Unzip.php @@ -0,0 +1,52 @@ +filename = $filename; + } + + /** + * Extracts the contents of the .gz file to $pathExtracted. + * + * @param string $pathExtracted Must be file, not directory. + * @return bool true if successful, false if otherwise. + */ + public function extract($pathExtracted) + { + $file = gzopen($this->filename, 'r'); + if ($file === false) { + $this->error = "gzopen failed"; + return false; + } + + $output = fopen($pathExtracted, 'w'); + while (!feof($file)) { + fwrite($output, fread($file, 1024 * 1024)); + } + fclose($output); + + $success = gzclose($file); + if ($success === false) { + $this->error = "gzclose failed"; + return false; + } + + return true; + } + + /** + * Get error status string for the latest error. + * + * @return string + */ + public function errorInfo() + { + return $this->error; + } +} + diff --git a/www/analytics/core/Unzip/PclZip.php b/www/analytics/core/Unzip/PclZip.php new file mode 100644 index 00000000..79fd7963 --- /dev/null +++ b/www/analytics/core/Unzip/PclZip.php @@ -0,0 +1,89 @@ +pclzip = new \PclZip($filename); + $this->filename = $filename; + } + + /** + * Extract files from archive to target directory + * + * @param string $pathExtracted Absolute path of target directory + * @return mixed Array of filenames if successful; or 0 if an error occurred + */ + public function extract($pathExtracted) + { + $pathExtracted = str_replace('\\', '/', $pathExtracted); + $list = $this->pclzip->listContent(); + if (empty($list)) { + return 0; + } + + foreach ($list as $entry) { + $filename = str_replace('\\', '/', $entry['stored_filename']); + $parts = explode('/', $filename); + + if (!strncmp($filename, '/', 1) || + array_search('..', $parts) !== false || + strpos($filename, ':') !== false + ) { + return 0; + } + } + + // PCLZIP_CB_PRE_EXTRACT callback returns 0 to skip, 1 to resume, or 2 to abort + return $this->pclzip->extract( + PCLZIP_OPT_PATH, $pathExtracted, + PCLZIP_OPT_STOP_ON_ERROR, + PCLZIP_OPT_REPLACE_NEWER, + PCLZIP_CB_PRE_EXTRACT, function ($p_event, &$p_header) use ($pathExtracted) { + return strncmp($p_header['filename'], $pathExtracted, strlen($pathExtracted)) ? 0 : 1; + } + ); + } + + /** + * Get error status string for the latest error + * + * @return string + */ + public function errorInfo() + { + return $this->pclzip->errorInfo(true); + } +} diff --git a/www/analytics/core/Unzip/Tar.php b/www/analytics/core/Unzip/Tar.php new file mode 100755 index 00000000..919fcc91 --- /dev/null +++ b/www/analytics/core/Unzip/Tar.php @@ -0,0 +1,84 @@ +tarArchive = new Archive_Tar($filename, $compression); + } + + /** + * Extracts the contents of the tar file to $pathExtracted. + * + * @param string $pathExtracted Directory to extract into. + * @return bool true if successful, false if otherwise. + */ + public function extract($pathExtracted) + { + return $this->tarArchive->extract($pathExtracted); + } + + /** + * Extracts one file held in a tar archive and returns the deflated file + * as a string. + * + * @param string $inArchivePath Path to file in the tar archive. + * @return bool true if successful, false if otherwise. + */ + public function extractInString($inArchivePath) + { + return $this->tarArchive->extractInString($inArchivePath); + } + + /** + * Lists the files held in the tar archive. + * + * @return array List of paths describing everything held in the tar archive. + */ + public function listContent() + { + return $this->tarArchive->listContent(); + } + + /** + * Get error status string for the latest error. + * + * @return string + */ + public function errorInfo() + { + return $this->tarArchive->error_object->getMessage(); + } +} diff --git a/www/analytics/core/Unzip/UncompressInterface.php b/www/analytics/core/Unzip/UncompressInterface.php new file mode 100644 index 00000000..d3dccf20 --- /dev/null +++ b/www/analytics/core/Unzip/UncompressInterface.php @@ -0,0 +1,39 @@ +filename = $filename; + $this->ziparchive = new \ZipArchive; + if ($this->ziparchive->open($filename) !== true) { + throw new Exception('Error opening ' . $filename); + } + } + + /** + * Extract files from archive to target directory + * + * @param string $pathExtracted Absolute path of target directory + * @return mixed Array of filenames if successful; or 0 if an error occurred + */ + public function extract($pathExtracted) + { + if (substr_compare($pathExtracted, '/', -1)) + $pathExtracted .= '/'; + + $fileselector = array(); + $list = array(); + $count = $this->ziparchive->numFiles; + if ($count === 0) { + return 0; + } + + for ($i = 0; $i < $count; $i++) { + $entry = $this->ziparchive->statIndex($i); + + $filename = str_replace('\\', '/', $entry['name']); + $parts = explode('/', $filename); + + if (!strncmp($filename, '/', 1) || + array_search('..', $parts) !== false || + strpos($filename, ':') !== false + ) { + return 0; + } + $fileselector[] = $entry['name']; + $list[] = array( + 'filename' => $pathExtracted . $entry['name'], + 'stored_filename' => $entry['name'], + 'size' => $entry['size'], + 'compressed_size' => $entry['comp_size'], + 'mtime' => $entry['mtime'], + 'index' => $i, + 'crc' => $entry['crc'], + ); + } + + $res = $this->ziparchive->extractTo($pathExtracted, $fileselector); + if ($res === false) + return 0; + return $list; + } + + /** + * Get error status string for the latest error + * + * @return string + */ + public function errorInfo() + { + static $statusStrings = array( + \ZIPARCHIVE::ER_OK => 'No error', + \ZIPARCHIVE::ER_MULTIDISK => 'Multi-disk zip archives not supported', + \ZIPARCHIVE::ER_RENAME => 'Renaming temporary file failed', + \ZIPARCHIVE::ER_CLOSE => 'Closing zip archive failed', + \ZIPARCHIVE::ER_SEEK => 'Seek error', + \ZIPARCHIVE::ER_READ => 'Read error', + \ZIPARCHIVE::ER_WRITE => 'Write error', + \ZIPARCHIVE::ER_CRC => 'CRC error', + \ZIPARCHIVE::ER_ZIPCLOSED => 'Containing zip archive was closed', + \ZIPARCHIVE::ER_NOENT => 'No such file', + \ZIPARCHIVE::ER_EXISTS => 'File already exists', + \ZIPARCHIVE::ER_OPEN => 'Can\'t open file', + \ZIPARCHIVE::ER_TMPOPEN => 'Failure to create temporary file', + \ZIPARCHIVE::ER_ZLIB => 'Zlib error', + \ZIPARCHIVE::ER_MEMORY => 'Malloc failure', + \ZIPARCHIVE::ER_CHANGED => 'Entry has been changed', + \ZIPARCHIVE::ER_COMPNOTSUPP => 'Compression method not supported', + \ZIPARCHIVE::ER_EOF => 'Premature EOF', + \ZIPARCHIVE::ER_INVAL => 'Invalid argument', + \ZIPARCHIVE::ER_NOZIP => 'Not a zip archive', + \ZIPARCHIVE::ER_INTERNAL => 'Internal error', + \ZIPARCHIVE::ER_INCONS => 'Zip archive inconsistent', + \ZIPARCHIVE::ER_REMOVE => 'Can\'t remove file', + \ZIPARCHIVE::ER_DELETED => 'Entry has been deleted', + ); + + if (isset($statusStrings[$this->ziparchive->status])) { + $statusString = $statusStrings[$this->ziparchive->status]; + } else { + $statusString = 'Unknown status'; + } + return $statusString . '(' . $this->ziparchive->status . ')'; + } +} diff --git a/www/analytics/core/UpdateCheck.php b/www/analytics/core/UpdateCheck.php new file mode 100644 index 00000000..76b3a248 --- /dev/null +++ b/www/analytics/core/UpdateCheck.php @@ -0,0 +1,110 @@ +General['enable_auto_update']; + } + + /** + * Check for a newer version + * + * @param bool $force Force check + * @param int $interval Interval used for update checks + */ + public static function check($force = false, $interval = null) + { + if(!self::isAutoUpdateEnabled()) { + return; + } + + if ($interval === null) { + $interval = self::CHECK_INTERVAL; + } + + $lastTimeChecked = Option::get(self::LAST_TIME_CHECKED); + if ($force + || $lastTimeChecked === false + || time() - $interval > $lastTimeChecked + ) { + // set the time checked first, so that parallel Piwik requests don't all trigger the http requests + Option::set(self::LAST_TIME_CHECKED, time(), $autoLoad = 1); + $parameters = array( + 'piwik_version' => Version::VERSION, + 'php_version' => PHP_VERSION, + 'url' => Url::getCurrentUrlWithoutQueryString(), + 'trigger' => Common::getRequestVar('module', '', 'string'), + 'timezone' => API::getInstance()->getDefaultTimezone(), + ); + + $url = Config::getInstance()->General['api_service_url'] + . '/1.0/getLatestVersion/' + . '?' . http_build_query($parameters, '', '&'); + $timeout = self::SOCKET_TIMEOUT; + + if (@Config::getInstance()->Debug['allow_upgrades_to_beta']) { + $url = 'http://builds.piwik.org/LATEST_BETA'; + } + + try { + $latestVersion = Http::sendHttpRequest($url, $timeout); + if (!preg_match('~^[0-9][0-9a-zA-Z_.-]*$~D', $latestVersion)) { + $latestVersion = ''; + } + } catch (Exception $e) { + // e.g., disable_functions = fsockopen; allow_url_open = Off + $latestVersion = ''; + } + Option::set(self::LATEST_VERSION, $latestVersion); + } + } + + /** + * Returns the latest available version number. Does not perform a check whether a later version is available. + * + * @return false|string + */ + public static function getLatestVersion() + { + return Option::get(self::LATEST_VERSION); + } + + /** + * Returns version number of a newer Piwik release. + * + * @return string|bool false if current version is the latest available, + * or the latest version number if a newest release is available + */ + public static function isNewestVersionAvailable() + { + $latestVersion = self::getLatestVersion(); + if (!empty($latestVersion) + && version_compare(Version::VERSION, $latestVersion) == -1 + ) { + return $latestVersion; + } + return false; + } +} diff --git a/www/analytics/core/Updater.php b/www/analytics/core/Updater.php new file mode 100644 index 00000000..a1f39fb7 --- /dev/null +++ b/www/analytics/core/Updater.php @@ -0,0 +1,335 @@ +pathUpdateFileCore = PIWIK_INCLUDE_PATH . '/core/Updates/'; + $this->pathUpdateFilePlugins = PIWIK_INCLUDE_PATH . '/plugins/%s/Updates/'; + } + + /** + * Add component to check + * + * @param string $name + * @param string $version + */ + public function addComponentToCheck($name, $version) + { + $this->componentsToCheck[$name] = $version; + } + + /** + * Record version of successfully completed component update + * + * @param string $name + * @param string $version + */ + public static function recordComponentSuccessfullyUpdated($name, $version) + { + try { + Option::set(self::getNameInOptionTable($name), $version, $autoLoad = 1); + } catch (\Exception $e) { + // case when the option table is not yet created (before 0.2.10) + } + } + + /** + * Returns the flag name to use in the option table to record current schema version + * @param string $name + * @return string + */ + private static function getNameInOptionTable($name) + { + return 'version_' . $name; + } + + /** + * Returns a list of components (core | plugin) that need to run through the upgrade process. + * + * @return array( componentName => array( file1 => version1, [...]), [...]) + */ + public function getComponentsWithUpdateFile() + { + $this->componentsWithNewVersion = $this->getComponentsWithNewVersion(); + $this->componentsWithUpdateFile = $this->loadComponentsWithUpdateFile(); + return $this->componentsWithUpdateFile; + } + + /** + * Component has a new version? + * + * @param string $componentName + * @return bool TRUE if compoment is to be updated; FALSE if not + */ + public function hasNewVersion($componentName) + { + return isset($this->componentsWithNewVersion) && + isset($this->componentsWithNewVersion[$componentName]); + } + + /** + * Does one of the new versions involve a major database update? + * Note: getSqlQueriesToExecute() must be called before this method! + * + * @return bool + */ + public function hasMajorDbUpdate() + { + return $this->hasMajorDbUpdate; + } + + /** + * Returns the list of SQL queries that would be executed during the update + * + * @return array of SQL queries + * @throws \Exception + */ + public function getSqlQueriesToExecute() + { + $queries = array(); + foreach ($this->componentsWithUpdateFile as $componentName => $componentUpdateInfo) { + foreach ($componentUpdateInfo as $file => $fileVersion) { + require_once $file; // prefixed by PIWIK_INCLUDE_PATH + + $className = $this->getUpdateClassName($componentName, $fileVersion); + if (!class_exists($className, false)) { + throw new \Exception("The class $className was not found in $file"); + } + $queriesForComponent = call_user_func(array($className, 'getSql')); + foreach ($queriesForComponent as $query => $error) { + $queries[] = $query . ';'; + } + $this->hasMajorDbUpdate = $this->hasMajorDbUpdate || call_user_func(array($className, 'isMajorUpdate')); + } + // unfortunately had to extract this query from the Option class + $queries[] = 'UPDATE `' . Common::prefixTable('option') . '` '. + 'SET option_value = \'' . $fileVersion . '\' '. + 'WHERE option_name = \'' . self::getNameInOptionTable($componentName) . '\';'; + } + return $queries; + } + + private function getUpdateClassName($componentName, $fileVersion) + { + $suffix = strtolower(str_replace(array('-', '.'), '_', $fileVersion)); + $className = 'Updates_' . $suffix; + + if ($componentName == 'core') { + return '\\Piwik\\Updates\\' . $className; + } + return '\\Piwik\\Plugins\\' . $componentName . '\\' . $className; + } + + /** + * Update the named component + * + * @param string $componentName 'core', or plugin name + * @throws \Exception|UpdaterErrorException + * @return array of warning strings if applicable + */ + public function update($componentName) + { + $warningMessages = array(); + foreach ($this->componentsWithUpdateFile[$componentName] as $file => $fileVersion) { + try { + require_once $file; // prefixed by PIWIK_INCLUDE_PATH + + $className = $this->getUpdateClassName($componentName, $fileVersion); + if (class_exists($className, false)) { + // update() + call_user_func(array($className, 'update')); + } + + self::recordComponentSuccessfullyUpdated($componentName, $fileVersion); + } catch (UpdaterErrorException $e) { + throw $e; + } catch (\Exception $e) { + $warningMessages[] = $e->getMessage(); + } + } + + // to debug, create core/Updates/X.php, update the core/Version.php, throw an Exception in the try, and comment the following line + self::recordComponentSuccessfullyUpdated($componentName, $this->componentsWithNewVersion[$componentName][self::INDEX_NEW_VERSION]); + return $warningMessages; + } + + /** + * Construct list of update files for the outdated components + * + * @return array( componentName => array( file1 => version1, [...]), [...]) + */ + private function loadComponentsWithUpdateFile() + { + $componentsWithUpdateFile = array(); + foreach ($this->componentsWithNewVersion as $name => $versions) { + $currentVersion = $versions[self::INDEX_CURRENT_VERSION]; + $newVersion = $versions[self::INDEX_NEW_VERSION]; + + if ($name == 'core') { + $pathToUpdates = $this->pathUpdateFileCore . '*.php'; + } else { + $pathToUpdates = sprintf($this->pathUpdateFilePlugins, $name) . '*.php'; + } + + $files = _glob($pathToUpdates); + if ($files == false) { + $files = array(); + } + + foreach ($files as $file) { + $fileVersion = basename($file, '.php'); + if ( // if the update is from a newer version + version_compare($currentVersion, $fileVersion) == -1 + // but we don't execute updates from non existing future releases + && version_compare($fileVersion, $newVersion) <= 0 + ) { + $componentsWithUpdateFile[$name][$file] = $fileVersion; + } + } + + if (isset($componentsWithUpdateFile[$name])) { + // order the update files by version asc + uasort($componentsWithUpdateFile[$name], "version_compare"); + } else { + // there are no update file => nothing to do, update to the new version is successful + self::recordComponentSuccessfullyUpdated($name, $newVersion); + } + } + return $componentsWithUpdateFile; + } + + /** + * Construct list of outdated components + * + * @throws \Exception + * @return array array( componentName => array( oldVersion, newVersion), [...]) + */ + public function getComponentsWithNewVersion() + { + $componentsToUpdate = array(); + + // we make sure core updates are processed before any plugin updates + if (isset($this->componentsToCheck['core'])) { + $coreVersions = $this->componentsToCheck['core']; + unset($this->componentsToCheck['core']); + $this->componentsToCheck = array_merge(array('core' => $coreVersions), $this->componentsToCheck); + } + + foreach ($this->componentsToCheck as $name => $version) { + try { + $currentVersion = Option::get(self::getNameInOptionTable($name)); + } catch (\Exception $e) { + // mysql error 1146: table doesn't exist + if (Db::get()->isErrNo($e, '1146')) { + // case when the option table is not yet created (before 0.2.10) + $currentVersion = false; + } else { + // failed for some other reason + throw $e; + } + } + if ($currentVersion === false) { + if ($name === 'core') { + // This should not happen + $currentVersion = Version::VERSION; + } else { + // When plugins have been installed since Piwik 2.0 this should not happen + // We "fix" the data for any plugin that may have been ported from Piwik 1.x + $currentVersion = $version; + } + self::recordComponentSuccessfullyUpdated($name, $currentVersion); + } + + $versionCompare = version_compare($currentVersion, $version); + if ($versionCompare == -1) { + $componentsToUpdate[$name] = array( + self::INDEX_CURRENT_VERSION => $currentVersion, + self::INDEX_NEW_VERSION => $version + ); + } else if ($versionCompare == 1) { + // the version in the DB is newest.. we choose to ignore + } + } + return $componentsToUpdate; + } + + /** + * Performs database update(s) + * + * @param string $file Update script filename + * @param array $sqlarray An array of SQL queries to be executed + * @throws UpdaterErrorException + */ + static function updateDatabase($file, $sqlarray) + { + foreach ($sqlarray as $update => $ignoreError) { + self::executeMigrationQuery($update, $ignoreError, $file); + } + } + + /** + * Executes a database update query. + * + * @param string $updateSql Update SQL query. + * @param int|false $errorToIgnore A MySQL error code to ignore. + * @param string $file The Update file that's calling this method. + */ + public static function executeMigrationQuery($updateSql, $errorToIgnore, $file) + { + try { + Db::exec($updateSql); + } catch (\Exception $e) { + self::handleQueryError($e, $updateSql, $errorToIgnore, $file); + } + } + + /** + * Handle an error that is thrown from a database query. + * + * @param \Exception $e the exception thrown. + * @param string $updateSql Update SQL query. + * @param int|false $errorToIgnore A MySQL error code to ignore. + * @param string $file The Update file that's calling this method. + */ + public static function handleQueryError($e, $updateSql, $errorToIgnore, $file) + { + if (($errorToIgnore === false) + || !Db::get()->isErrNo($e, $errorToIgnore) + ) { + $message = $file . ":\nError trying to execute the query '" . $updateSql . "'.\nThe error was: " . $e->getMessage(); + throw new UpdaterErrorException($message); + } + } +} + +/** + * Exception thrown by updater if a non-recoverable error occurs + * + */ +class UpdaterErrorException extends \Exception +{ +} diff --git a/www/analytics/core/Updates.php b/www/analytics/core/Updates.php new file mode 100644 index 00000000..ff989cdd --- /dev/null +++ b/www/analytics/core/Updates.php @@ -0,0 +1,108 @@ + '1234', // if the query fails, it will be ignored if the error code is 1234 + * 'ALTER .... ' => false, // if an error occurs, the update will stop and fail + * // and user will have to manually run the query + * ) + */ + static function getSql() + { + return array(); + } + + /** + * Incremental version update + */ + static function update() + { + } + + /** + * Tell the updater that this is a major update. + * Leads to a more visible notice. + * + * @return bool + */ + static function isMajorUpdate() + { + return false; + } + + /** + * Helper method to enable maintenance mode during large updates + */ + static function enableMaintenanceMode() + { + $config = Config::getInstance(); + $config->init(); + + $tracker = $config->Tracker; + $tracker['record_statistics'] = 0; + $config->Tracker = $tracker; + + $general = $config->General; + $general['maintenance_mode'] = 1; + $config->General = $general; + + $config->forceSave(); + } + + /** + * Helper method to disable maintenance mode after large updates + */ + static function disableMaintenanceMode() + { + $config = Config::getInstance(); + $config->init(); + + $tracker = $config->Tracker; + $tracker['record_statistics'] = 1; + $config->Tracker = $tracker; + + $general = $config->General; + $general['maintenance_mode'] = 0; + $config->General = $general; + + $config->forceSave(); + } + + public static function deletePluginFromConfigFile($pluginToDelete) + { + $config = Config::getInstance(); + $config->init(); + if (isset($config->Plugins['Plugins'])) { + $plugins = $config->Plugins['Plugins']; + if (($key = array_search($pluginToDelete, $plugins)) !== false) { + unset($plugins[$key]); + } + $config->Plugins['Plugins'] = $plugins; + + $pluginsInstalled = $config->PluginsInstalled['PluginsInstalled']; + if (($key = array_search($pluginToDelete, $pluginsInstalled)) !== false) { + unset($pluginsInstalled[$key]); + } + $config->PluginsInstalled = array('PluginsInstalled' => $pluginsInstalled); + + $config->forceSave(); + } + } +} diff --git a/www/analytics/core/Updates/0.2.10.php b/www/analytics/core/Updates/0.2.10.php new file mode 100644 index 00000000..bb3bba97 --- /dev/null +++ b/www/analytics/core/Updates/0.2.10.php @@ -0,0 +1,73 @@ + false, + + // 0.1.7 [463] + 'ALTER IGNORE TABLE `' . Common::prefixTable('log_visit') . '` + CHANGE `location_provider` `location_provider` VARCHAR( 100 ) DEFAULT NULL' => '1054', + + // 0.1.7 [470] + 'ALTER TABLE `' . Common::prefixTable('logger_api_call') . '` + CHANGE `parameter_names_default_values` `parameter_names_default_values` TEXT, + CHANGE `parameter_values` `parameter_values` TEXT, + CHANGE `returned_value` `returned_value` TEXT' => false, + 'ALTER TABLE `' . Common::prefixTable('logger_error') . '` + CHANGE `message` `message` TEXT' => false, + 'ALTER TABLE `' . Common::prefixTable('logger_exception') . '` + CHANGE `message` `message` TEXT' => false, + 'ALTER TABLE `' . Common::prefixTable('logger_message') . '` + CHANGE `message` `message` TEXT' => false, + + // 0.2.2 [489] + 'ALTER IGNORE TABLE `' . Common::prefixTable('site') . '` + CHANGE `feedburnerName` `feedburnerName` VARCHAR( 100 ) DEFAULT NULL' => '1054', + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + + $obsoleteFile = '/plugins/ExamplePlugin/API.php'; + if (file_exists(PIWIK_INCLUDE_PATH . $obsoleteFile)) { + @unlink(PIWIK_INCLUDE_PATH . $obsoleteFile); + } + + $obsoleteDirectories = array( + '/plugins/AdminHome', + '/plugins/Home', + '/plugins/PluginsAdmin', + ); + foreach ($obsoleteDirectories as $dir) { + if (file_exists(PIWIK_INCLUDE_PATH . $dir)) { + Filesystem::unlinkRecursive(PIWIK_INCLUDE_PATH . $dir, true); + } + } + } +} diff --git a/www/analytics/core/Updates/0.2.12.php b/www/analytics/core/Updates/0.2.12.php new file mode 100644 index 00000000..ff8fa18b --- /dev/null +++ b/www/analytics/core/Updates/0.2.12.php @@ -0,0 +1,38 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + DROP `config_color_depth`' => false, + + // 0.2.12 [673] + // Note: requires INDEX privilege + 'DROP INDEX index_idaction ON `' . Common::prefixTable('log_action') . '`' => '1091', + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.2.13.php b/www/analytics/core/Updates/0.2.13.php new file mode 100644 index 00000000..93228ad6 --- /dev/null +++ b/www/analytics/core/Updates/0.2.13.php @@ -0,0 +1,38 @@ + false, + + 'CREATE TABLE `' . Common::prefixTable('option') . "` ( + option_name VARCHAR( 64 ) NOT NULL , + option_value LONGTEXT NOT NULL , + autoload TINYINT NOT NULL DEFAULT '1', + PRIMARY KEY ( option_name ) + )" => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.2.24.php b/www/analytics/core/Updates/0.2.24.php new file mode 100644 index 00000000..e627f64f --- /dev/null +++ b/www/analytics/core/Updates/0.2.24.php @@ -0,0 +1,36 @@ + false, + 'CREATE INDEX index_idsite_date + ON ' . Common::prefixTable('log_visit') . ' (idsite, visit_server_date)' => false, + 'DROP INDEX index_idsite ON ' . Common::prefixTable('log_visit') => false, + 'DROP INDEX index_visit_server_date ON ' . Common::prefixTable('log_visit') => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.2.27.php b/www/analytics/core/Updates/0.2.27.php new file mode 100644 index 00000000..4b505194 --- /dev/null +++ b/www/analytics/core/Updates/0.2.27.php @@ -0,0 +1,81 @@ + false, + // 0.2.27 [826] + 'ALTER IGNORE TABLE `' . Common::prefixTable('log_visit') . '` + CHANGE `visit_goal_converted` `visit_goal_converted` TINYINT(1) NOT NULL' => false, + + 'CREATE TABLE `' . Common::prefixTable('goal') . "` ( + `idsite` int(11) NOT NULL, + `idgoal` int(11) NOT NULL, + `name` varchar(50) NOT NULL, + `match_attribute` varchar(20) NOT NULL, + `pattern` varchar(255) NOT NULL, + `pattern_type` varchar(10) NOT NULL, + `case_sensitive` tinyint(4) NOT NULL, + `revenue` float NOT NULL, + `deleted` tinyint(4) NOT NULL default '0', + PRIMARY KEY (`idsite`,`idgoal`) + )" => false, + + 'CREATE TABLE `' . Common::prefixTable('log_conversion') . '` ( + `idvisit` int(10) unsigned NOT NULL, + `idsite` int(10) unsigned NOT NULL, + `visitor_idcookie` char(32) NOT NULL, + `server_time` datetime NOT NULL, + `visit_server_date` date NOT NULL, + `idaction` int(11) NOT NULL, + `idlink_va` int(11) NOT NULL, + `referer_idvisit` int(10) unsigned default NULL, + `referer_visit_server_date` date default NULL, + `referer_type` int(10) unsigned default NULL, + `referer_name` varchar(70) default NULL, + `referer_keyword` varchar(255) default NULL, + `visitor_returning` tinyint(1) NOT NULL, + `location_country` char(3) NOT NULL, + `location_continent` char(3) NOT NULL, + `url` text NOT NULL, + `idgoal` int(10) unsigned NOT NULL, + `revenue` float default NULL, + PRIMARY KEY (`idvisit`,`idgoal`), + KEY `index_idsite_date` (`idsite`,`visit_server_date`) + )' => false, + ); + + $tables = DbHelper::getTablesInstalled(); + foreach ($tables as $tableName) { + if (preg_match('/archive_/', $tableName) == 1) { + $sqlarray['CREATE INDEX index_all ON ' . $tableName . ' (`idsite`,`date1`,`date2`,`name`,`ts_archived`)'] = false; + } + } + + return $sqlarray; + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.2.32.php b/www/analytics/core/Updates/0.2.32.php new file mode 100644 index 00000000..7f697cc4 --- /dev/null +++ b/www/analytics/core/Updates/0.2.32.php @@ -0,0 +1,39 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('user') . '` + CHANGE `login` `login` VARCHAR( 100 ) NOT NULL' => false, + 'ALTER TABLE `' . Common::prefixTable('user_dashboard') . '` + CHANGE `login` `login` VARCHAR( 100 ) NOT NULL' => '1146', + 'ALTER TABLE `' . Common::prefixTable('user_language') . '` + CHANGE `login` `login` VARCHAR( 100 ) NOT NULL' => '1146', + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.2.33.php b/www/analytics/core/Updates/0.2.33.php new file mode 100644 index 00000000..0b4d281f --- /dev/null +++ b/www/analytics/core/Updates/0.2.33.php @@ -0,0 +1,45 @@ + '1146', + 'ALTER TABLE `' . Common::prefixTable('user_language') . '` + CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci ' => '1146', + ); + + // alter table to set the utf8 collation + $tablesToAlter = DbHelper::getTablesInstalled(true); + foreach ($tablesToAlter as $table) { + $sqlarray['ALTER TABLE `' . $table . '` + CONVERT TO CHARACTER SET utf8 COLLATE utf8_general_ci '] = false; + } + + return $sqlarray; + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.2.34.php b/www/analytics/core/Updates/0.2.34.php new file mode 100644 index 00000000..49c128f1 --- /dev/null +++ b/www/analytics/core/Updates/0.2.34.php @@ -0,0 +1,28 @@ +getAllSitesId(); + Cache::regenerateCacheWebsiteAttributes($allSiteIds); + } +} diff --git a/www/analytics/core/Updates/0.2.35.php b/www/analytics/core/Updates/0.2.35.php new file mode 100644 index 00000000..4c0c53ff --- /dev/null +++ b/www/analytics/core/Updates/0.2.35.php @@ -0,0 +1,32 @@ + false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.2.37.php b/www/analytics/core/Updates/0.2.37.php new file mode 100644 index 00000000..47c8e78d --- /dev/null +++ b/www/analytics/core/Updates/0.2.37.php @@ -0,0 +1,33 @@ + false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.4.1.php b/www/analytics/core/Updates/0.4.1.php new file mode 100644 index 00000000..b4562d4f --- /dev/null +++ b/www/analytics/core/Updates/0.4.1.php @@ -0,0 +1,34 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('log_conversion') . '` + CHANGE `idaction` `idaction` INT(11) DEFAULT NULL' => '1054', + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.4.2.php b/www/analytics/core/Updates/0.4.2.php new file mode 100644 index 00000000..15039367 --- /dev/null +++ b/www/analytics/core/Updates/0.4.2.php @@ -0,0 +1,38 @@ + '1060', + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + ADD `config_quicktime` TINYINT(1) NOT NULL AFTER `config_director`' => '1060', + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + ADD `config_gears` TINYINT(1) NOT NULL AFTER `config_windowsmedia`, + ADD `config_silverlight` TINYINT(1) NOT NULL AFTER `config_gears`' => false, + ); + } + + // when restoring (possibly) previousy dropped columns, ignore mysql code error 1060: duplicate column + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.4.4.php b/www/analytics/core/Updates/0.4.4.php new file mode 100644 index 00000000..44cc1690 --- /dev/null +++ b/www/analytics/core/Updates/0.4.4.php @@ -0,0 +1,29 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + CHANGE `location_ip` `location_ip` BIGINT UNSIGNED NOT NULL' => false, + 'UPDATE `' . Common::prefixTable('logger_api_call') . '` + SET caller_ip=caller_ip+CAST(POW(2,32) AS UNSIGNED) WHERE caller_ip < 0' => false, + 'ALTER TABLE `' . Common::prefixTable('logger_api_call') . '` + CHANGE `caller_ip` `caller_ip` BIGINT UNSIGNED' => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.5.4.php b/www/analytics/core/Updates/0.5.4.php new file mode 100644 index 00000000..235391db --- /dev/null +++ b/www/analytics/core/Updates/0.5.4.php @@ -0,0 +1,65 @@ + false, + ); + } + + static function update() + { + $salt = Common::generateUniqId(); + $config = Config::getInstance(); + $superuser = $config->superuser; + if (!isset($superuser['salt'])) { + try { + if (is_writable(Config::getLocalConfigPath())) { + $superuser['salt'] = $salt; + $config->superuser = $superuser; + $config->forceSave(); + } else { + throw new \Exception('mandatory update failed'); + } + } catch (\Exception $e) { + throw new \Piwik\UpdaterErrorException("Please edit your config/config.ini.php file and add below [superuser] the following line:
    salt = $salt"); + } + } + + $plugins = $config->Plugins; + if (!in_array('MultiSites', $plugins)) { + try { + if (is_writable(Config::getLocalConfigPath())) { + $plugins[] = 'MultiSites'; + $config->Plugins = $plugins; + $config->forceSave(); + } else { + throw new \Exception('optional update failed'); + } + } catch (\Exception $e) { + throw new \Exception("You can now enable the new MultiSites plugin in the Plugins screen in the Piwik admin!"); + } + } + + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.5.5.php b/www/analytics/core/Updates/0.5.5.php new file mode 100644 index 00000000..aaacf0e9 --- /dev/null +++ b/www/analytics/core/Updates/0.5.5.php @@ -0,0 +1,46 @@ + '1091', + 'CREATE INDEX index_idsite_date_config ON ' . Common::prefixTable('log_visit') . ' (idsite, visit_server_date, config_md5config(8))' => '1061', + ); + + $tables = DbHelper::getTablesInstalled(); + foreach ($tables as $tableName) { + if (preg_match('/archive_/', $tableName) == 1) { + $sqlarray['DROP INDEX index_all ON ' . $tableName] = '1091'; + } + if (preg_match('/archive_numeric_/', $tableName) == 1) { + $sqlarray['CREATE INDEX index_idsite_dates_period ON ' . $tableName . ' (idsite, date1, date2, period)'] = '1061'; + } + } + + return $sqlarray; + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + + } +} diff --git a/www/analytics/core/Updates/0.5.php b/www/analytics/core/Updates/0.5.php new file mode 100644 index 00000000..f90f7f48 --- /dev/null +++ b/www/analytics/core/Updates/0.5.php @@ -0,0 +1,40 @@ + '1060', + 'ALTER TABLE ' . Common::prefixTable('log_visit') . ' CHANGE visit_exit_idaction visit_exit_idaction_url INTEGER(11) NOT NULL;' => '1054', + 'ALTER TABLE ' . Common::prefixTable('log_visit') . ' CHANGE visit_entry_idaction visit_entry_idaction_url INTEGER(11) NOT NULL;' => '1054', + 'ALTER TABLE ' . Common::prefixTable('log_link_visit_action') . ' CHANGE `idaction_ref` `idaction_url_ref` INTEGER(10) UNSIGNED NOT NULL;' => '1054', + 'ALTER TABLE ' . Common::prefixTable('log_link_visit_action') . ' CHANGE `idaction` `idaction_url` INTEGER(10) UNSIGNED NOT NULL;' => '1054', + 'ALTER TABLE ' . Common::prefixTable('log_link_visit_action') . ' ADD COLUMN `idaction_name` INTEGER(10) UNSIGNED AFTER `idaction_url_ref`;' => '1060', + 'ALTER TABLE ' . Common::prefixTable('log_conversion') . ' CHANGE `idaction` `idaction_url` INTEGER(11) UNSIGNED NOT NULL;' => '1054', + 'UPDATE ' . Common::prefixTable('log_action') . ' SET `hash` = CRC32(name);' => false, + 'CREATE INDEX index_type_hash ON ' . Common::prefixTable('log_action') . ' (type, hash);' => '1061', + 'DROP INDEX index_type_name ON ' . Common::prefixTable('log_action') . ';' => '1091', + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.6-rc1.php b/www/analytics/core/Updates/0.6-rc1.php new file mode 100644 index 00000000..43ab8e4e --- /dev/null +++ b/www/analytics/core/Updates/0.6-rc1.php @@ -0,0 +1,67 @@ + false, + 'ALTER TABLE ' . Common::prefixTable('site') . ' CHANGE ts_created ts_created TIMESTAMP NULL' => false, + 'ALTER TABLE ' . Common::prefixTable('site') . ' ADD `timezone` VARCHAR( 50 ) NOT NULL AFTER `ts_created` ;' => false, + 'UPDATE ' . Common::prefixTable('site') . ' SET `timezone` = "' . $defaultTimezone . '";' => false, + 'ALTER TABLE ' . Common::prefixTable('site') . ' ADD currency CHAR( 3 ) NOT NULL AFTER `timezone` ;' => false, + 'UPDATE ' . Common::prefixTable('site') . ' SET `currency` = "' . $defaultCurrency . '";' => false, + 'ALTER TABLE ' . Common::prefixTable('site') . ' ADD `excluded_ips` TEXT NOT NULL AFTER `currency` ;' => false, + 'ALTER TABLE ' . Common::prefixTable('site') . ' ADD excluded_parameters VARCHAR( 255 ) NOT NULL AFTER `excluded_ips` ;' => false, + 'ALTER TABLE ' . Common::prefixTable('log_visit') . ' ADD INDEX `index_idsite_datetime_config` ( `idsite` , `visit_last_action_time` , `config_md5config` ( 8 ) ) ;' => false, + 'ALTER TABLE ' . Common::prefixTable('log_visit') . ' ADD INDEX index_idsite_idvisit (idsite, idvisit) ;' => false, + 'ALTER TABLE ' . Common::prefixTable('log_conversion') . ' DROP INDEX index_idsite_date' => false, + 'ALTER TABLE ' . Common::prefixTable('log_conversion') . ' DROP visit_server_date;' => false, + 'ALTER TABLE ' . Common::prefixTable('log_conversion') . ' ADD INDEX index_idsite_datetime ( `idsite` , `server_time` )' => false, + ); + } + + static function update() + { + // first we disable the plugins and keep an array of warnings messages + $pluginsToDisableMessage = array( + 'SearchEnginePosition' => "SearchEnginePosition plugin was disabled, because it is not compatible with the new Piwik 0.6. \n You can download the latest version of the plugin, compatible with Piwik 0.6.\nClick here.", + 'GeoIP' => "GeoIP plugin was disabled, because it is not compatible with the new Piwik 0.6. \nYou can download the latest version of the plugin, compatible with Piwik 0.6.\nClick here." + ); + $disabledPlugins = array(); + foreach ($pluginsToDisableMessage as $pluginToDisable => $warningMessage) { + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated($pluginToDisable)) { + \Piwik\Plugin\Manager::getInstance()->deactivatePlugin($pluginToDisable); + $disabledPlugins[] = $warningMessage; + } + } + + // Run the SQL + Updater::updateDatabase(__FILE__, self::getSql()); + + // Outputs warning message, pointing users to the plugin download page + if (!empty($disabledPlugins)) { + throw new \Exception("The following plugins were disabled during the upgrade:" + . "
    • " . + implode('
    • ', $disabledPlugins) . + "
    "); + } + } +} diff --git a/www/analytics/core/Updates/0.6.2.php b/www/analytics/core/Updates/0.6.2.php new file mode 100644 index 00000000..15236ed0 --- /dev/null +++ b/www/analytics/core/Updates/0.6.2.php @@ -0,0 +1,47 @@ +getAllSitesId(); + Cache::regenerateCacheWebsiteAttributes($allSiteIds); + } +} diff --git a/www/analytics/core/Updates/0.6.3.php b/www/analytics/core/Updates/0.6.3.php new file mode 100644 index 00000000..87ef2d27 --- /dev/null +++ b/www/analytics/core/Updates/0.6.3.php @@ -0,0 +1,49 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('logger_api_call') . '` + CHANGE `caller_ip` `caller_ip` INT UNSIGNED' => false, + ); + } + + static function update() + { + $config = Config::getInstance(); + $dbInfos = $config->database; + if (!isset($dbInfos['schema'])) { + try { + if (is_writable(Config::getLocalConfigPath())) { + $config->database = $dbInfos; + $config->forceSave(); + } else { + throw new \Exception('mandatory update failed'); + } + } catch (\Exception $e) { + } + } + + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.7.php b/www/analytics/core/Updates/0.7.php new file mode 100644 index 00000000..3c6daad6 --- /dev/null +++ b/www/analytics/core/Updates/0.7.php @@ -0,0 +1,32 @@ + false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/0.9.1.php b/www/analytics/core/Updates/0.9.1.php new file mode 100644 index 00000000..5b11099b --- /dev/null +++ b/www/analytics/core/Updates/0.9.1.php @@ -0,0 +1,57 @@ + false, + + 'UPDATE `' . Common::prefixTable('option') . '` + SET option_value = "UTC" + WHERE option_name = "SitesManager_DefaultTimezone" + AND option_value IN (' . $timezoneList . ')' => false, + ); + } + + static function update() + { + if (SettingsServer::isTimezoneSupportEnabled()) { + Updater::updateDatabase(__FILE__, self::getSql()); + } + } +} diff --git a/www/analytics/core/Updates/1.1.php b/www/analytics/core/Updates/1.1.php new file mode 100644 index 00000000..df58645c --- /dev/null +++ b/www/analytics/core/Updates/1.1.php @@ -0,0 +1,44 @@ +superuser; + } catch (\Exception $e) { + return; + } + + if (empty($superuser['login'])) { + return; + } + + $rootLogin = $superuser['login']; + try { + // throws an exception if invalid + Piwik::checkValidLoginString($rootLogin); + } catch (\Exception $e) { + throw new \Exception('Superuser login name "' . $rootLogin . '" is no longer a valid format. ' + . $e->getMessage() + . ' Edit your config/config.ini.php to change it.'); + } + } +} diff --git a/www/analytics/core/Updates/1.10-b4.php b/www/analytics/core/Updates/1.10-b4.php new file mode 100755 index 00000000..5628487f --- /dev/null +++ b/www/analytics/core/Updates/1.10-b4.php @@ -0,0 +1,31 @@ +activatePlugin('MobileMessaging'); + } catch (\Exception $e) { + // pass + } + } +} diff --git a/www/analytics/core/Updates/1.10.1.php b/www/analytics/core/Updates/1.10.1.php new file mode 100755 index 00000000..e6051a7b --- /dev/null +++ b/www/analytics/core/Updates/1.10.1.php @@ -0,0 +1,31 @@ +activatePlugin('Overlay'); + } catch (\Exception $e) { + // pass + } + } +} diff --git a/www/analytics/core/Updates/1.10.2-b1.php b/www/analytics/core/Updates/1.10.2-b1.php new file mode 100755 index 00000000..bf58008b --- /dev/null +++ b/www/analytics/core/Updates/1.10.2-b1.php @@ -0,0 +1,33 @@ + 1060, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.10.2-b2.php b/www/analytics/core/Updates/1.10.2-b2.php new file mode 100644 index 00000000..de177432 --- /dev/null +++ b/www/analytics/core/Updates/1.10.2-b2.php @@ -0,0 +1,33 @@ + 1060, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.11-b1.php b/www/analytics/core/Updates/1.11-b1.php new file mode 100644 index 00000000..dc118714 --- /dev/null +++ b/www/analytics/core/Updates/1.11-b1.php @@ -0,0 +1,31 @@ +activatePlugin('UserCountryMap'); + } catch (\Exception $e) { + // pass + } + } +} diff --git a/www/analytics/core/Updates/1.12-b1.php b/www/analytics/core/Updates/1.12-b1.php new file mode 100644 index 00000000..5318a7cb --- /dev/null +++ b/www/analytics/core/Updates/1.12-b1.php @@ -0,0 +1,38 @@ + 1060 + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } + +} diff --git a/www/analytics/core/Updates/1.12-b15.php b/www/analytics/core/Updates/1.12-b15.php new file mode 100644 index 00000000..96a0696c --- /dev/null +++ b/www/analytics/core/Updates/1.12-b15.php @@ -0,0 +1,26 @@ +activatePlugin('SegmentEditor'); + } catch (\Exception $e) { + // pass + } + } +} diff --git a/www/analytics/core/Updates/1.12-b16.php b/www/analytics/core/Updates/1.12-b16.php new file mode 100644 index 00000000..d1090674 --- /dev/null +++ b/www/analytics/core/Updates/1.12-b16.php @@ -0,0 +1,33 @@ + 1060, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.2-rc1.php b/www/analytics/core/Updates/1.2-rc1.php new file mode 100644 index 00000000..cb46b767 --- /dev/null +++ b/www/analytics/core/Updates/1.2-rc1.php @@ -0,0 +1,150 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + ADD custom_var_k1 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v1 VARCHAR(100) DEFAULT NULL, + ADD custom_var_k2 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v2 VARCHAR(100) DEFAULT NULL, + ADD custom_var_k3 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v3 VARCHAR(100) DEFAULT NULL, + ADD custom_var_k4 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v4 VARCHAR(100) DEFAULT NULL, + ADD custom_var_k5 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v5 VARCHAR(100) DEFAULT NULL + ' => 1060, + 'ALTER TABLE `' . Common::prefixTable('log_link_visit_action') . '` + ADD `idsite` INT( 10 ) UNSIGNED NOT NULL AFTER `idlink_va` , + ADD `server_time` DATETIME AFTER `idsite`, + ADD `idvisitor` BINARY(8) NOT NULL AFTER `idsite`, + ADD `idaction_name_ref` INT UNSIGNED NOT NULL AFTER `idaction_name`, + ADD INDEX `index_idsite_servertime` ( `idsite` , `server_time` ) + ' => false, + + 'ALTER TABLE `' . Common::prefixTable('log_conversion') . '` + DROP `referer_idvisit`, + ADD `idvisitor` BINARY(8) NOT NULL AFTER `idsite`, + ADD visitor_count_visits SMALLINT(5) UNSIGNED NOT NULL, + ADD visitor_days_since_first SMALLINT(5) UNSIGNED NOT NULL + ' => false, + 'ALTER TABLE `' . Common::prefixTable('log_conversion') . '` + ADD custom_var_k1 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v1 VARCHAR(100) DEFAULT NULL, + ADD custom_var_k2 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v2 VARCHAR(100) DEFAULT NULL, + ADD custom_var_k3 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v3 VARCHAR(100) DEFAULT NULL, + ADD custom_var_k4 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v4 VARCHAR(100) DEFAULT NULL, + ADD custom_var_k5 VARCHAR(100) DEFAULT NULL, + ADD custom_var_v5 VARCHAR(100) DEFAULT NULL + ' => 1060, + + // Migrate 128bits IDs inefficiently stored as 8bytes (256 bits) into 64bits + 'UPDATE ' . Common::prefixTable('log_visit') . ' + SET idvisitor = binary(unhex(substring(visitor_idcookie,1,16))), + config_id = binary(unhex(substring(config_md5config,1,16))) + ' => false, + 'UPDATE ' . Common::prefixTable('log_conversion') . ' + SET idvisitor = binary(unhex(substring(visitor_idcookie,1,16))) + ' => false, + + // Drop migrated fields + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + DROP visitor_idcookie, + DROP config_md5config + ' => false, + 'ALTER TABLE `' . Common::prefixTable('log_conversion') . '` + DROP visitor_idcookie + ' => false, + + // Recreate INDEX on new field + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + ADD INDEX `index_idsite_datetime_config` (idsite, visit_last_action_time, config_id) + ' => false, + + // Backfill action logs as best as we can + 'UPDATE ' . Common::prefixTable('log_link_visit_action') . ' as action, + ' . Common::prefixTable('log_visit') . ' as visit + SET action.idsite = visit.idsite, + action.server_time = visit.visit_last_action_time, + action.idvisitor = visit.idvisitor + WHERE action.idvisit=visit.idvisit + ' => false, + + 'ALTER TABLE `' . Common::prefixTable('log_link_visit_action') . '` + CHANGE `server_time` `server_time` DATETIME NOT NULL + ' => false, + + // New index used max once per request, in case this table grows significantly in the future + 'ALTER TABLE `' . Common::prefixTable('option') . '` ADD INDEX ( `autoload` ) ' => false, + + // new field for websites + 'ALTER TABLE `' . Common::prefixTable('site') . '` ADD `group` VARCHAR( 250 ) NOT NULL' => false, + ); + } + + static function update() + { + // first we disable the plugins and keep an array of warnings messages + $pluginsToDisableMessage = array( + 'GeoIP' => "GeoIP plugin was disabled, because it is not compatible with the new Piwik 1.2. \nYou can download the latest version of the plugin, compatible with Piwik 1.2.\nClick here.", + 'EntryPage' => "EntryPage plugin is not compatible with this version of Piwik, it was disabled.", + ); + $disabledPlugins = array(); + foreach ($pluginsToDisableMessage as $pluginToDisable => $warningMessage) { + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated($pluginToDisable)) { + \Piwik\Plugin\Manager::getInstance()->deactivatePlugin($pluginToDisable); + $disabledPlugins[] = $warningMessage; + } + } + + // Run the SQL + Updater::updateDatabase(__FILE__, self::getSql()); + + // Outputs warning message, pointing users to the plugin download page + if (!empty($disabledPlugins)) { + throw new \Exception("The following plugins were disabled during the upgrade:" + . "
    • " . + implode('
    • ', $disabledPlugins) . + "
    "); + } + + } +} + diff --git a/www/analytics/core/Updates/1.2-rc2.php b/www/analytics/core/Updates/1.2-rc2.php new file mode 100644 index 00000000..02cb3208 --- /dev/null +++ b/www/analytics/core/Updates/1.2-rc2.php @@ -0,0 +1,26 @@ +activatePlugin('CustomVariables'); + } catch (\Exception $e) { + } + } +} + diff --git a/www/analytics/core/Updates/1.2.3.php b/www/analytics/core/Updates/1.2.3.php new file mode 100644 index 00000000..cf5c4559 --- /dev/null +++ b/www/analytics/core/Updates/1.2.3.php @@ -0,0 +1,41 @@ +database['dbname'] . '` DEFAULT CHARACTER SET utf8' => false, + + // Various performance improvements schema updates + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + DROP INDEX index_idsite_datetime_config, + DROP INDEX index_idsite_idvisit, + ADD INDEX index_idsite_config_datetime (idsite, config_id, visit_last_action_time), + ADD INDEX index_idsite_datetime (idsite, visit_last_action_time)' => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} + diff --git a/www/analytics/core/Updates/1.2.5-rc1.php b/www/analytics/core/Updates/1.2.5-rc1.php new file mode 100644 index 00000000..09c4dea6 --- /dev/null +++ b/www/analytics/core/Updates/1.2.5-rc1.php @@ -0,0 +1,37 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('log_conversion') . '` + ADD buster int unsigned NOT NULL AFTER revenue, + DROP PRIMARY KEY, + ADD PRIMARY KEY (idvisit, idgoal, buster)' => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} + diff --git a/www/analytics/core/Updates/1.2.5-rc7.php b/www/analytics/core/Updates/1.2.5-rc7.php new file mode 100644 index 00000000..c4a51afc --- /dev/null +++ b/www/analytics/core/Updates/1.2.5-rc7.php @@ -0,0 +1,34 @@ + false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} + + diff --git a/www/analytics/core/Updates/1.4-rc1.php b/www/analytics/core/Updates/1.4-rc1.php new file mode 100644 index 00000000..d8cc1397 --- /dev/null +++ b/www/analytics/core/Updates/1.4-rc1.php @@ -0,0 +1,37 @@ + '42S22', + 'ALTER TABLE `' . Common::prefixTable('pdf') . '` + ADD COLUMN `format` VARCHAR(10)' => '42S22', + ); + } + + static function update() + { + try { + Updater::updateDatabase(__FILE__, self::getSql()); + } catch (\Exception $e) { + } + } +} diff --git a/www/analytics/core/Updates/1.4-rc2.php b/www/analytics/core/Updates/1.4-rc2.php new file mode 100644 index 00000000..1f2ba56b --- /dev/null +++ b/www/analytics/core/Updates/1.4-rc2.php @@ -0,0 +1,44 @@ + false, + // this converts the 32-bit UNSIGNED INT column to a 16 byte VARBINARY; + // _but_ MySQL does string conversion! (e.g., integer 1 is converted to 49 -- the ASCII code for "1") + 'ALTER TABLE ' . Common::prefixTable('log_visit') . ' + MODIFY location_ip VARBINARY(16) NOT NULL' => false, + 'ALTER TABLE ' . Common::prefixTable('logger_api_call') . ' + MODIFY caller_ip VARBINARY(16) NOT NULL' => false, + + // fortunately, 2^32 is 10 digits long and fits in the VARBINARY(16) without truncation; + // to fix this, we cast to an integer, convert to hex, pad out leading zeros, and unhex it + 'UPDATE ' . Common::prefixTable('log_visit') . " + SET location_ip = UNHEX(LPAD(HEX(CONVERT(location_ip, UNSIGNED)), 8, '0'))" => false, + 'UPDATE ' . Common::prefixTable('logger_api_call') . " + SET caller_ip = UNHEX(LPAD(HEX(CONVERT(caller_ip, UNSIGNED)), 8, '0'))" => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.5-b1.php b/www/analytics/core/Updates/1.5-b1.php new file mode 100644 index 00000000..0ccd81e0 --- /dev/null +++ b/www/analytics/core/Updates/1.5-b1.php @@ -0,0 +1,63 @@ + false, + + 'ALTER IGNORE TABLE `' . Common::prefixTable('log_visit') . '` + ADD visitor_days_since_order SMALLINT(5) UNSIGNED NOT NULL AFTER visitor_days_since_last, + ADD visit_goal_buyer TINYINT(1) NOT NULL AFTER visit_goal_converted' => false, + + 'ALTER IGNORE TABLE `' . Common::prefixTable('log_conversion') . '` + ADD visitor_days_since_order SMALLINT(5) UNSIGNED NOT NULL AFTER visitor_days_since_first, + ADD idorder varchar(100) default NULL AFTER buster, + ADD items SMALLINT UNSIGNED DEFAULT NULL, + ADD revenue_subtotal float default NULL, + ADD revenue_tax float default NULL, + ADD revenue_shipping float default NULL, + ADD revenue_discount float default NULL, + ADD UNIQUE KEY unique_idsite_idorder (idsite, idorder), + MODIFY idgoal int(10) NOT NULL' => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.5-b2.php b/www/analytics/core/Updates/1.5-b2.php new file mode 100644 index 00000000..00c748f8 --- /dev/null +++ b/www/analytics/core/Updates/1.5-b2.php @@ -0,0 +1,41 @@ + 1060, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.5-b3.php b/www/analytics/core/Updates/1.5-b3.php new file mode 100644 index 00000000..781056db --- /dev/null +++ b/www/analytics/core/Updates/1.5-b3.php @@ -0,0 +1,63 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('log_conversion') . '` + CHANGE custom_var_k1 custom_var_k1 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v1 custom_var_v1 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_k2 custom_var_k2 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v2 custom_var_v2 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_k3 custom_var_k3 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v3 custom_var_v3 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_k4 custom_var_k4 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v4 custom_var_v4 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_k5 custom_var_k5 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v5 custom_var_v5 VARCHAR(100) DEFAULT NULL' => false, + 'ALTER TABLE `' . Common::prefixTable('log_link_visit_action') . '` + CHANGE custom_var_k1 custom_var_k1 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v1 custom_var_v1 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_k2 custom_var_k2 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v2 custom_var_v2 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_k3 custom_var_k3 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v3 custom_var_v3 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_k4 custom_var_k4 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v4 custom_var_v4 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_k5 custom_var_k5 VARCHAR(100) DEFAULT NULL, + CHANGE custom_var_v5 custom_var_v5 VARCHAR(100) DEFAULT NULL' => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.5-b4.php b/www/analytics/core/Updates/1.5-b4.php new file mode 100644 index 00000000..32c37bf2 --- /dev/null +++ b/www/analytics/core/Updates/1.5-b4.php @@ -0,0 +1,32 @@ + false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.5-b5.php b/www/analytics/core/Updates/1.5-b5.php new file mode 100644 index 00000000..5e4b10fa --- /dev/null +++ b/www/analytics/core/Updates/1.5-b5.php @@ -0,0 +1,37 @@ + false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.5-rc6.php b/www/analytics/core/Updates/1.5-rc6.php new file mode 100644 index 00000000..56e87e64 --- /dev/null +++ b/www/analytics/core/Updates/1.5-rc6.php @@ -0,0 +1,26 @@ +activatePlugin('PrivacyManager'); + } catch (\Exception $e) { + } + } +} + diff --git a/www/analytics/core/Updates/1.6-b1.php b/www/analytics/core/Updates/1.6-b1.php new file mode 100644 index 00000000..2b73ed95 --- /dev/null +++ b/www/analytics/core/Updates/1.6-b1.php @@ -0,0 +1,68 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + CHANGE custom_var_k1 custom_var_k1 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v1 custom_var_v1 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k2 custom_var_k2 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v2 custom_var_v2 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k3 custom_var_k3 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v3 custom_var_v3 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k4 custom_var_k4 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v4 custom_var_v4 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k5 custom_var_k5 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v5 custom_var_v5 VARCHAR(200) DEFAULT NULL' => 1060, + 'ALTER TABLE `' . Common::prefixTable('log_conversion') . '` + CHANGE custom_var_k1 custom_var_k1 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v1 custom_var_v1 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k2 custom_var_k2 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v2 custom_var_v2 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k3 custom_var_k3 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v3 custom_var_v3 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k4 custom_var_k4 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v4 custom_var_v4 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k5 custom_var_k5 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v5 custom_var_v5 VARCHAR(200) DEFAULT NULL' => 1060, + 'ALTER TABLE `' . Common::prefixTable('log_link_visit_action') . '` + CHANGE custom_var_k1 custom_var_k1 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v1 custom_var_v1 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k2 custom_var_k2 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v2 custom_var_v2 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k3 custom_var_k3 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v3 custom_var_v3 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k4 custom_var_k4 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v4 custom_var_v4 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_k5 custom_var_k5 VARCHAR(200) DEFAULT NULL, + CHANGE custom_var_v5 custom_var_v5 VARCHAR(200) DEFAULT NULL' => 1060, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/1.6-rc1.php b/www/analytics/core/Updates/1.6-rc1.php new file mode 100644 index 00000000..8255786c --- /dev/null +++ b/www/analytics/core/Updates/1.6-rc1.php @@ -0,0 +1,26 @@ +activatePlugin('ImageGraph'); + } catch (\Exception $e) { + } + } +} + diff --git a/www/analytics/core/Updates/1.7-b1.php b/www/analytics/core/Updates/1.7-b1.php new file mode 100644 index 00000000..04e45c9c --- /dev/null +++ b/www/analytics/core/Updates/1.7-b1.php @@ -0,0 +1,37 @@ + false, + 'UPDATE `' . Common::prefixTable('pdf') . '` + SET `aggregate_reports_format` = 1' => false, + ); + } + + static function update() + { + try { + Updater::updateDatabase(__FILE__, self::getSql()); + } catch (\Exception $e) { + } + } +} diff --git a/www/analytics/core/Updates/1.7.2-rc5.php b/www/analytics/core/Updates/1.7.2-rc5.php new file mode 100644 index 00000000..19388cc9 --- /dev/null +++ b/www/analytics/core/Updates/1.7.2-rc5.php @@ -0,0 +1,35 @@ + false + ); + } + + static function update() + { + try { + Updater::updateDatabase(__FILE__, self::getSql()); + } catch (\Exception $e) { + } + } +} diff --git a/www/analytics/core/Updates/1.7.2-rc7.php b/www/analytics/core/Updates/1.7.2-rc7.php new file mode 100755 index 00000000..ac1871de --- /dev/null +++ b/www/analytics/core/Updates/1.7.2-rc7.php @@ -0,0 +1,45 @@ + false, + ); + } + + static function update() + { + try { + $dashboards = Db::fetchAll('SELECT * FROM `' . Common::prefixTable('user_dashboard') . '`'); + foreach ($dashboards AS $dashboard) { + $idDashboard = $dashboard['iddashboard']; + $login = $dashboard['login']; + $layout = $dashboard['layout']; + $layout = html_entity_decode($layout); + $layout = str_replace("\\\"", "\"", $layout); + Db::query('UPDATE `' . Common::prefixTable('user_dashboard') . '` SET layout = ? WHERE iddashboard = ? AND login = ?', array($layout, $idDashboard, $login)); + } + Updater::updateDatabase(__FILE__, self::getSql()); + } catch (\Exception $e) { + } + } +} diff --git a/www/analytics/core/Updates/1.8.3-b1.php b/www/analytics/core/Updates/1.8.3-b1.php new file mode 100644 index 00000000..85f9f669 --- /dev/null +++ b/www/analytics/core/Updates/1.8.3-b1.php @@ -0,0 +1,115 @@ + false, + + 'CREATE TABLE `' . Common::prefixTable('report') . '` ( + `idreport` INT(11) NOT NULL AUTO_INCREMENT, + `idsite` INTEGER(11) NOT NULL, + `login` VARCHAR(100) NOT NULL, + `description` VARCHAR(255) NOT NULL, + `period` VARCHAR(10) NOT NULL, + `type` VARCHAR(10) NOT NULL, + `format` VARCHAR(10) NOT NULL, + `reports` TEXT NOT NULL, + `parameters` TEXT NULL, + `ts_created` TIMESTAMP NULL, + `ts_last_sent` TIMESTAMP NULL, + `deleted` tinyint(4) NOT NULL default 0, + PRIMARY KEY (`idreport`) + ) DEFAULT CHARSET=utf8' => 1050, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + if (!\Piwik\Plugin\Manager::getInstance()->isPluginLoaded('ScheduledReports')) { + return; + } + + try { + + // Common::prefixTable('pdf') has been heavily refactored to be more generic + // The following actions are taken in this update script : + // - create the new generic report table Common::prefixTable('report') + // - migrate previous reports, if any, from Common::prefixTable('pdf') to Common::prefixTable('report') + // - delete Common::prefixTable('pdf') + + $reports = Db::fetchAll('SELECT * FROM `' . Common::prefixTable('pdf') . '`'); + foreach ($reports AS $report) { + + $idreport = $report['idreport']; + $idsite = $report['idsite']; + $login = $report['login']; + $description = $report['description']; + $period = $report['period']; + $format = $report['format']; + $display_format = $report['display_format']; + $email_me = $report['email_me']; + $additional_emails = $report['additional_emails']; + $reports = $report['reports']; + $ts_created = $report['ts_created']; + $ts_last_sent = $report['ts_last_sent']; + $deleted = $report['deleted']; + + $parameters = array(); + + if (!is_null($additional_emails)) { + $parameters[ScheduledReports::ADDITIONAL_EMAILS_PARAMETER] = preg_split('/,/', $additional_emails); + } + + $parameters[ScheduledReports::EMAIL_ME_PARAMETER] = is_null($email_me) ? ScheduledReports::EMAIL_ME_PARAMETER_DEFAULT_VALUE : (bool)$email_me; + $parameters[ScheduledReports::DISPLAY_FORMAT_PARAMETER] = $display_format; + + Db::query( + 'INSERT INTO `' . Common::prefixTable('report') . '` SET + idreport = ?, idsite = ?, login = ?, description = ?, period = ?, + type = ?, format = ?, reports = ?, parameters = ?, ts_created = ?, + ts_last_sent = ?, deleted = ?', + array( + $idreport, + $idsite, + $login, + $description, + is_null($period) ? ScheduledReports::DEFAULT_PERIOD : $period, + ScheduledReports::EMAIL_TYPE, + is_null($format) ? ScheduledReports::DEFAULT_REPORT_FORMAT : $format, + Common::json_encode(preg_split('/,/', $reports)), + Common::json_encode($parameters), + $ts_created, + $ts_last_sent, + $deleted + ) + ); + } + + Db::query('DROP TABLE `' . Common::prefixTable('pdf') . '`'); + } catch (\Exception $e) { + } + + } +} diff --git a/www/analytics/core/Updates/1.8.4-b1.php b/www/analytics/core/Updates/1.8.4-b1.php new file mode 100644 index 00000000..97e70daa --- /dev/null +++ b/www/analytics/core/Updates/1.8.4-b1.php @@ -0,0 +1,190 @@ + 1060, // ignore error 1060 Duplicate column name 'url_prefix' + + // remove protocol and www and store information in url_prefix + " UPDATE `$action` + SET + url_prefix = IF ( + LEFT(name, 11) = 'http://www.', 1, IF ( + LEFT(name, 7) = 'http://', 0, IF ( + LEFT(name, 12) = 'https://www.', 3, IF ( + LEFT(name, 8) = 'https://', 2, NULL + ) + ) + ) + ), + name = IF ( + url_prefix = 0, SUBSTRING(name, 8), IF ( + url_prefix = 1, SUBSTRING(name, 12), IF ( + url_prefix = 2, SUBSTRING(name, 9), IF ( + url_prefix = 3, SUBSTRING(name, 13), name + ) + ) + ) + ), + hash = CRC32(name) + WHERE + type = 1 AND + url_prefix IS NULL; + " => false, + + // find duplicates + " DROP TABLE IF EXISTS `$duplicates`; + " => false, + " CREATE TABLE `$duplicates` ( + `before` int(10) unsigned NOT NULL, + `after` int(10) unsigned NOT NULL, + KEY `mainkey` (`before`) + ) ENGINE=InnoDB; + " => false, + + // grouping by name only would be case-insensitive, so we GROUP BY name,hash + // ON (action.type = 1 AND canonical.hash = action.hash) will use index (type, hash) + " INSERT INTO `$duplicates` ( + SELECT + action.idaction AS `before`, + canonical.idaction AS `after` + FROM + ( + SELECT + name, + hash, + MIN(idaction) AS idaction + FROM + `$action` AS action_canonical_base + WHERE + type = 1 AND + url_prefix IS NOT NULL + GROUP BY name, hash + HAVING COUNT(idaction) > 1 + ) + AS canonical + LEFT JOIN + `$action` AS action + ON (action.type = 1 AND canonical.hash = action.hash) + AND canonical.name = action.name + AND canonical.idaction != action.idaction + ); + " => false, + + // replace idaction in log_link_visit_action + " UPDATE + `$visitAction` AS link + LEFT JOIN + `$duplicates` AS duplicates_idaction_url + ON link.idaction_url = duplicates_idaction_url.before + SET + link.idaction_url = duplicates_idaction_url.after + WHERE + duplicates_idaction_url.after IS NOT NULL; + " => false, + " UPDATE + `$visitAction` AS link + LEFT JOIN + `$duplicates` AS duplicates_idaction_url_ref + ON link.idaction_url_ref = duplicates_idaction_url_ref.before + SET + link.idaction_url_ref = duplicates_idaction_url_ref.after + WHERE + duplicates_idaction_url_ref.after IS NOT NULL; + " => false, + + // replace idaction in log_conversion + " UPDATE + `$conversion` AS conversion + LEFT JOIN + `$duplicates` AS duplicates + ON conversion.idaction_url = duplicates.before + SET + conversion.idaction_url = duplicates.after + WHERE + duplicates.after IS NOT NULL; + " => false, + + // replace idaction in log_visit + " UPDATE + `$visit` AS visit + LEFT JOIN + `$duplicates` AS duplicates_entry + ON visit.visit_entry_idaction_url = duplicates_entry.before + SET + visit.visit_entry_idaction_url = duplicates_entry.after + WHERE + duplicates_entry.after IS NOT NULL; + " => false, + " UPDATE + `$visit` AS visit + LEFT JOIN + `$duplicates` AS duplicates_exit + ON visit.visit_exit_idaction_url = duplicates_exit.before + SET + visit.visit_exit_idaction_url = duplicates_exit.after + WHERE + duplicates_exit.after IS NOT NULL; + " => false, + + // remove duplicates from log_action + " DELETE action FROM + `$action` AS action + LEFT JOIN + `$duplicates` AS duplicates + ON action.idaction = duplicates.before + WHERE + duplicates.after IS NOT NULL; + " => false, + + // remove the duplicates table + " DROP TABLE `$duplicates`; + " => false + ); + } + + static function update() + { + try { + self::enableMaintenanceMode(); + Updater::updateDatabase(__FILE__, self::getSql()); + self::disableMaintenanceMode(); + } catch (\Exception $e) { + self::disableMaintenanceMode(); + throw $e; + } + } +} diff --git a/www/analytics/core/Updates/1.9-b16.php b/www/analytics/core/Updates/1.9-b16.php new file mode 100755 index 00000000..3c82828d --- /dev/null +++ b/www/analytics/core/Updates/1.9-b16.php @@ -0,0 +1,54 @@ + false, + + + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + ADD visit_total_searches SMALLINT(5) UNSIGNED NOT NULL AFTER `visit_total_actions`' + => 1060, + + 'ALTER TABLE `' . Common::prefixTable('site') . '` + ADD sitesearch TINYINT DEFAULT 1 AFTER `excluded_parameters`, + ADD sitesearch_keyword_parameters TEXT NOT NULL AFTER `sitesearch`, + ADD sitesearch_category_parameters TEXT NOT NULL AFTER `sitesearch_keyword_parameters`' + => 1060, + + // enable Site Search for all websites, users can manually disable the setting + 'UPDATE `' . Common::prefixTable('site') . '` + SET `sitesearch` = 1' => false, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} + diff --git a/www/analytics/core/Updates/1.9-b19.php b/www/analytics/core/Updates/1.9-b19.php new file mode 100755 index 00000000..d2496cbb --- /dev/null +++ b/www/analytics/core/Updates/1.9-b19.php @@ -0,0 +1,43 @@ + false, + 'ALTER TABLE `' . Common::prefixTable('log_visit') . '` + CHANGE `visit_exit_idaction_url` `visit_exit_idaction_url` INT( 10 ) UNSIGNED NULL DEFAULT 0' + => false + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + + + try { + \Piwik\Plugin\Manager::getInstance()->activatePlugin('Transitions'); + } catch (\Exception $e) { + } + } +} + diff --git a/www/analytics/core/Updates/1.9-b9.php b/www/analytics/core/Updates/1.9-b9.php new file mode 100755 index 00000000..dce8c9e2 --- /dev/null +++ b/www/analytics/core/Updates/1.9-b9.php @@ -0,0 +1,57 @@ + 1091, + + // add geoip columns to log_conversion + "ALTER TABLE `$logConversion` $addColumns" => 1091, + ); + } + + static function update() + { + try { + self::enableMaintenanceMode(); + Updater::updateDatabase(__FILE__, self::getSql()); + self::disableMaintenanceMode(); + } catch (\Exception $e) { + self::disableMaintenanceMode(); + throw $e; + } + } +} + diff --git a/www/analytics/core/Updates/1.9.1-b2.php b/www/analytics/core/Updates/1.9.1-b2.php new file mode 100644 index 00000000..d79dd992 --- /dev/null +++ b/www/analytics/core/Updates/1.9.1-b2.php @@ -0,0 +1,36 @@ + 1091 + ); + } + + static function update() + { + // manually remove ExampleFeedburner column + Updater::updateDatabase(__FILE__, self::getSql()); + + // remove ExampleFeedburner plugin + $pluginToDelete = 'ExampleFeedburner'; + self::deletePluginFromConfigFile($pluginToDelete); + } +} diff --git a/www/analytics/core/Updates/1.9.3-b10.php b/www/analytics/core/Updates/1.9.3-b10.php new file mode 100755 index 00000000..22583103 --- /dev/null +++ b/www/analytics/core/Updates/1.9.3-b10.php @@ -0,0 +1,31 @@ +activatePlugin('Annotations'); + } catch (\Exception $e) { + // pass + } + } +} diff --git a/www/analytics/core/Updates/1.9.3-b3.php b/www/analytics/core/Updates/1.9.3-b3.php new file mode 100644 index 00000000..0a511b14 --- /dev/null +++ b/www/analytics/core/Updates/1.9.3-b3.php @@ -0,0 +1,28 @@ +deletePluginFromFilesystem($pluginToDelete); + + // We also clean up 1.9.1 and delete Feedburner plugin + \Piwik\Plugin\Manager::getInstance()->deletePluginFromFilesystem('Feedburner'); + } +} diff --git a/www/analytics/core/Updates/1.9.3-b8.php b/www/analytics/core/Updates/1.9.3-b8.php new file mode 100755 index 00000000..03f75ce1 --- /dev/null +++ b/www/analytics/core/Updates/1.9.3-b8.php @@ -0,0 +1,34 @@ + 1060, + ); + } + + static function update() + { + // add excluded_user_agents column to site table + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/2.0-a12.php b/www/analytics/core/Updates/2.0-a12.php new file mode 100644 index 00000000..cc202d4e --- /dev/null +++ b/www/analytics/core/Updates/2.0-a12.php @@ -0,0 +1,48 @@ + false + ); + + $unneededLogTables = array('logger_exception', 'logger_error', 'logger_api_call'); + foreach ($unneededLogTables as $table) { + $tableName = Common::prefixTable($table); + + try { + $rows = Db::fetchOne("SELECT COUNT(*) FROM $tableName"); + if ($rows == 0) { + $result["DROP TABLE $tableName"] = false; + } + } catch (\Exception $ex) { + // ignore + } + } + + return $result; + } + + public static function update() + { + // change level column in logger_message table to string & remove other logging tables if empty + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/2.0-a13.php b/www/analytics/core/Updates/2.0-a13.php new file mode 100644 index 00000000..70ca0590 --- /dev/null +++ b/www/analytics/core/Updates/2.0-a13.php @@ -0,0 +1,66 @@ +activatePlugin('Referrers'); + } catch (\Exception $e) { + } + try { + \Piwik\Plugin\Manager::getInstance()->activatePlugin('ScheduledReports'); + } catch (\Exception $e) { + } + + } +} diff --git a/www/analytics/core/Updates/2.0-a17.php b/www/analytics/core/Updates/2.0-a17.php new file mode 100644 index 00000000..ec00c2ab --- /dev/null +++ b/www/analytics/core/Updates/2.0-a17.php @@ -0,0 +1,42 @@ +" . implode("
    ", $errors)); + } + } +} diff --git a/www/analytics/core/Updates/2.0-a7.php b/www/analytics/core/Updates/2.0-a7.php new file mode 100644 index 00000000..cdbbc7e2 --- /dev/null +++ b/www/analytics/core/Updates/2.0-a7.php @@ -0,0 +1,37 @@ + 1060, + + 'ALTER TABLE ' . Common::prefixTable('logger_message') + . " ADD COLUMN level TINYINT AFTER timestamp" => 1060, + ); + } + + static function update() + { + // add tag & level columns to logger_message table + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/2.0-b10.php b/www/analytics/core/Updates/2.0-b10.php new file mode 100644 index 00000000..273c4d08 --- /dev/null +++ b/www/analytics/core/Updates/2.0-b10.php @@ -0,0 +1,23 @@ +" . implode("
    ", $errors)); + } + } +} diff --git a/www/analytics/core/Updates/2.0-b3.php b/www/analytics/core/Updates/2.0-b3.php new file mode 100644 index 00000000..ae30df77 --- /dev/null +++ b/www/analytics/core/Updates/2.0-b3.php @@ -0,0 +1,46 @@ + 1060, + + 'ALTER TABLE ' . Common::prefixTable('log_link_visit_action') + . " ADD COLUMN idaction_event_category INTEGER(10) UNSIGNED AFTER idaction_name_ref, + ADD COLUMN idaction_event_action INTEGER(10) UNSIGNED AFTER idaction_event_category" => 1060, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + + try { + \Piwik\Plugin\Manager::getInstance()->activatePlugin('Events'); + } catch (\Exception $e) { + } + } +} diff --git a/www/analytics/core/Updates/2.0-b9.php b/www/analytics/core/Updates/2.0-b9.php new file mode 100644 index 00000000..612c79d1 --- /dev/null +++ b/www/analytics/core/Updates/2.0-b9.php @@ -0,0 +1,33 @@ + 1060, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } +} diff --git a/www/analytics/core/Updates/2.0-rc1.php b/www/analytics/core/Updates/2.0-rc1.php new file mode 100644 index 00000000..beda1cda --- /dev/null +++ b/www/analytics/core/Updates/2.0-rc1.php @@ -0,0 +1,24 @@ +activatePlugin('Morpheus'); + } catch(\Exception $e) { + } + } +} diff --git a/www/analytics/core/Updates/2.0.3-b7.php b/www/analytics/core/Updates/2.0.3-b7.php new file mode 100644 index 00000000..2e9b593a --- /dev/null +++ b/www/analytics/core/Updates/2.0.3-b7.php @@ -0,0 +1,67 @@ +isPluginActivated('DoNotTrack')) { + DoNotTrackHeaderChecker::activate(); + } + + // enable IP anonymization if AnonymizeIP plugin was enabled + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('AnonymizeIP')) { + IPAnonymizer::activate(); + } + } catch (\Exception $ex) { + // pass + } + + // disable & delete old plugins + $oldPlugins = array('DoNotTrack', 'AnonymizeIP'); + foreach ($oldPlugins as $plugin) { + try { + \Piwik\Plugin\Manager::getInstance()->deactivatePlugin($plugin); + } catch(\Exception $e) { + + } + + $dir = PIWIK_INCLUDE_PATH . "/plugins/$plugin"; + + if (file_exists($dir)) { + Filesystem::unlinkRecursive($dir, true); + } + + if (file_exists($dir)) { + $errors[] = "Please delete this directory manually (eg. using your FTP software): $dir \n"; + } + + } + if(!empty($errors)) { + throw new \Exception("Warnings during the update:
    " . implode("
    ", $errors)); + } + } +} diff --git a/www/analytics/core/Updates/2.0.4-b5.php b/www/analytics/core/Updates/2.0.4-b5.php new file mode 100644 index 00000000..78c8ab2b --- /dev/null +++ b/www/analytics/core/Updates/2.0.4-b5.php @@ -0,0 +1,92 @@ + 1060, + ); + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + + try { + self::migrateConfigSuperUserToDb(); + } catch (\Exception $e) { + throw new UpdaterErrorException($e->getMessage()); + } + } + + private static function migrateConfigSuperUserToDb() + { + $config = Config::getInstance(); + + if (!$config->existsLocalConfig()) { + return; + } + + try { + $superUser = $config->superuser; + } catch (\Exception $e) { + $superUser = null; + } + + if (!empty($superUser['bridge']) || empty($superUser)) { + // there is a super user which is not from the config but from the bridge, that means we already have + // a super user in the database + return; + } + + $userApi = UsersManagerApi::getInstance(); + + try { + Db::get()->insert(Common::prefixTable('user'), array( + 'login' => $superUser['login'], + 'password' => $superUser['password'], + 'alias' => $superUser['login'], + 'email' => $superUser['email'], + 'token_auth' => $userApi->getTokenAuth($superUser['login'], $superUser['password']), + 'date_registered' => Date::now()->getDatetime(), + 'superuser_access' => 1 + ) + ); + } catch(\Exception $e) { + echo "There was an issue, but we proceed: " . $e->getMessage(); + } + + if (array_key_exists('salt', $superUser)) { + $salt = $superUser['salt']; + } else { + $salt = Common::generateUniqId(); + } + + $config->General['salt'] = $salt; + $config->superuser = array(); + $config->forceSave(); + } +} diff --git a/www/analytics/core/Updates/2.0.4-b7.php b/www/analytics/core/Updates/2.0.4-b7.php new file mode 100644 index 00000000..83b19903 --- /dev/null +++ b/www/analytics/core/Updates/2.0.4-b7.php @@ -0,0 +1,73 @@ +getMessage()); + } + } + + private static function migrateExistingMobileMessagingOptions() + { + if (Option::get(MobileMessaging::DELEGATED_MANAGEMENT_OPTION) == 'true') { + return; + } + + // copy $superUserLogin_MobileMessagingSettings -> _MobileMessagingSettings as settings are managed globally + + $optionName = MobileMessaging::USER_SETTINGS_POSTFIX_OPTION; + $superUsers = UsersManagerApi::getInstance()->getUsersHavingSuperUserAccess(); + + if (empty($superUsers)) { + return; + } + + $firstSuperUser = array_shift($superUsers); + + if (empty($firstSuperUser)) { + return; + } + + $superUserLogin = $firstSuperUser['login']; + $optionPrefixed = $superUserLogin . $optionName; + + // $superUserLogin_MobileMessagingSettings + $value = Option::get($optionPrefixed); + + if (false !== $value) { + // _MobileMessagingSettings + Option::set($optionName, $value); + } + } +} diff --git a/www/analytics/core/Updates/2.0.4-b8.php b/www/analytics/core/Updates/2.0.4-b8.php new file mode 100644 index 00000000..354ae17f --- /dev/null +++ b/www/analytics/core/Updates/2.0.4-b8.php @@ -0,0 +1,78 @@ +forceSave(); + + } catch (\Exception $e) { + throw new UpdaterErrorException($e->getMessage()); + } + } + + private static function migrateBrandingConfig(Config $config) + { + $useCustomLogo = self::getValueAndDelete($config, 'branding', 'use_custom_logo'); + + $customLogo = new CustomLogo(); + $useCustomLogo ? $customLogo->enable() : $customLogo->disable(); + } + + private static function migratePrivacyManagerConfig(Config $oldConfig, PrivacyManagerConfig $newConfig) + { + $ipVisitEnrichment = self::getValueAndDelete($oldConfig, 'Tracker', 'use_anonymized_ip_for_visit_enrichment'); + $ipAddressMarkLength = self::getValueAndDelete($oldConfig, 'Tracker', 'ip_address_mask_length'); + + if (null !== $ipVisitEnrichment) { + $newConfig->useAnonymizedIpForVisitEnrichment = $ipVisitEnrichment; + } + if (null !== $ipAddressMarkLength) { + $newConfig->ipAddressMaskLength = $ipAddressMarkLength; + } + } + + private static function getValueAndDelete(Config $config, $section, $key) + { + if (!$config->$section || !array_key_exists($key, $config->$section)) { + return null; + } + + $values = $config->$section; + $value = $values[$key]; + unset($values[$key]); + + $config->$section = $values; + + return $value; + } +} diff --git a/www/analytics/core/Updates/2.1.1-b11.php b/www/analytics/core/Updates/2.1.1-b11.php new file mode 100644 index 00000000..4eb924d3 --- /dev/null +++ b/www/analytics/core/Updates/2.1.1-b11.php @@ -0,0 +1,133 @@ +getDatetime(); + + $archiveNumericTables = Db::get()->fetchCol("SHOW TABLES LIKE '%archive_numeric%'"); + + // for each numeric archive table, copy *_returning metrics to VisitsSummary metrics w/ the appropriate + // returning visit segment + foreach ($archiveNumericTables as $table) { + // get archives w/ *._returning + $sql = "SELECT idarchive, idsite, period, date1, date2 + FROM $table + WHERE name IN ('" . implode("','", $returningMetrics) . "') + GROUP BY idarchive"; + $idArchivesWithReturning = Db::fetchAll($sql); + + // get archives for visitssummary returning visitor segment + $sql = "SELECT idarchive, idsite, period, date1, date2 + FROM $table + WHERE name = ? + GROUP BY idarchive"; + $visitSummaryReturningSegmentDone = Rules::getDoneFlagArchiveContainsOnePlugin( + new Segment(VisitFrequencyApi::RETURNING_VISITOR_SEGMENT, $idSites = array()), 'VisitsSummary'); + $idArchivesWithVisitReturningSegment = Db::fetchAll($sql, array($visitSummaryReturningSegmentDone)); + + // collect info for new visitssummary archives have to be created to match archives w/ *._returning + // metrics + $missingIdArchives = array(); + $idArchiveMappings = array(); + foreach ($idArchivesWithReturning as $row) { + $withMetricsIdArchive = $row['idarchive']; + foreach ($idArchivesWithVisitReturningSegment as $segmentRow) { + if ($row['idsite'] == $segmentRow['idsite'] + && $row['period'] == $segmentRow['period'] + && $row['date1'] == $segmentRow['date1'] + && $row['date2'] == $segmentRow['date2'] + ) { + $idArchiveMappings[$withMetricsIdArchive] = $segmentRow['idarchive']; + } + } + + if (!isset($idArchiveMappings[$withMetricsIdArchive])) { + $missingIdArchives[$withMetricsIdArchive] = $row; + } + } + + // if there are missing idarchives, fill out new archive row values + if (!empty($missingIdArchives)) { + $newIdArchiveStart = Db::fetchOne("SELECT MAX(idarchive) FROM $table") + 1; + foreach ($missingIdArchives as $withMetricsIdArchive => &$rowToInsert) { + $idArchiveMappings[$withMetricsIdArchive] = $newIdArchiveStart; + + $rowToInsert['idarchive'] = $newIdArchiveStart; + $rowToInsert['ts_archived'] = $now; + $rowToInsert['name'] = $visitSummaryReturningSegmentDone; + $rowToInsert['value'] = ArchiveWriter::DONE_OK; + + ++$newIdArchiveStart; + } + + // add missing archives + try { + $params = array(); + foreach ($missingIdArchives as $missingIdArchive) { + $params[] = array_values($missingIdArchive); + } + BatchInsert::tableInsertBatch($table, array_keys(reset($missingIdArchives)), $params, $throwException = false); + } catch (\Exception $ex) { + Updater::handleQueryError($ex, "", false, __FILE__); + } + } + + // update idarchive & name columns in rows with *._returning metrics + $updateSqlPrefix = "UPDATE $table + SET idarchive = CASE idarchive "; + $updateSqlSuffix = " END, name = CASE name "; + foreach ($returningMetrics as $metric) { + $newMetricName = substr($metric, 0, strlen($metric) - strlen(VisitFrequencyApi::COLUMN_SUFFIX)); + $updateSqlSuffix .= "WHEN '$metric' THEN '" . $newMetricName . "' "; + } + $updateSqlSuffix .= " END WHERE idarchive IN (%s) + AND name IN ('" . implode("','", $returningMetrics) . "')"; + + // update only 1000 rows at a time so we don't send too large an SQL query to MySQL + foreach (array_chunk($missingIdArchives, 1000, $preserveKeys = true) as $chunk) { + $idArchives = array(); + + $updateSql = $updateSqlPrefix; + foreach ($chunk as $withMetricsIdArchive => $row) { + $updateSql .= "WHEN $withMetricsIdArchive THEN {$row['idarchive']} "; + + $idArchives[] = $withMetricsIdArchive; + } + $updateSql .= sprintf($updateSqlSuffix, implode(',', $idArchives)); + + Updater::executeMigrationQuery($updateSql, false, __FILE__); + } + } + } +} diff --git a/www/analytics/core/Updates/2.2.0-b15.php b/www/analytics/core/Updates/2.2.0-b15.php new file mode 100644 index 00000000..8437a83d --- /dev/null +++ b/www/analytics/core/Updates/2.2.0-b15.php @@ -0,0 +1,27 @@ + 'UserSettings', + * 'action' => 'index' + * )); + * Url::redirectToUrl($url); + * } + * + * **Link to a different controller action in a template** + * + * public function myControllerAction() + * { + * $url = Url::getCurrentQueryStringWithParametersModified(array( + * 'module' => 'UserCountryMap', + * 'action' => 'realtimeMap', + * 'changeVisitAlpha' => 0, + * 'removeOldVisits' => 0 + * )); + * $view = new View("@MyPlugin/myPopup"); + * $view->realtimeMapUrl = $url; + * return $view->render(); + * } + * + */ +class Url +{ + /** + * List of hosts that are never checked for validity. + */ + private static $alwaysTrustedHosts = array('localhost', '127.0.0.1', '::1', '[::1]'); + + /** + * Returns the current URL. + * + * @return string eg, `"http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2"` + * @api + */ + static public function getCurrentUrl() + { + return self::getCurrentScheme() . '://' + . self::getCurrentHost() + . self::getCurrentScriptName() + . self::getCurrentQueryString(); + } + + /** + * Returns the current URL without the query string. + * + * @param bool $checkTrustedHost Whether to do trusted host check. Should ALWAYS be true, + * except in {@link Piwik\Plugin\Controller}. + * @return string eg, `"http://example.org/dir1/dir2/index.php"` if the current URL is + * `"http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2"`. + * @api + */ + static public function getCurrentUrlWithoutQueryString($checkTrustedHost = true) + { + return self::getCurrentScheme() . '://' + . self::getCurrentHost($default = 'unknown', $checkTrustedHost) + . self::getCurrentScriptName(); + } + + /** + * Returns the current URL without the query string and without the name of the file + * being executed. + * + * @return string eg, `"http://example.org/dir1/dir2/"` if the current URL is + * `"http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2"`. + * @api + */ + static public function getCurrentUrlWithoutFileName() + { + return self::getCurrentScheme() . '://' + . self::getCurrentHost() + . self::getCurrentScriptPath(); + } + + /** + * Returns the path to the script being executed. The script file name is not included. + * + * @return string eg, `"/dir1/dir2/"` if the current URL is + * `"http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2"` + * @api + */ + static public function getCurrentScriptPath() + { + $queryString = self::getCurrentScriptName(); + + //add a fake letter case /test/test2/ returns /test which is not expected + $urlDir = dirname($queryString . 'x'); + $urlDir = str_replace('\\', '/', $urlDir); + // if we are in a subpath we add a trailing slash + if (strlen($urlDir) > 1) { + $urlDir .= '/'; + } + return $urlDir; + } + + /** + * Returns the path to the script being executed. Includes the script file name. + * + * @return string eg, `"/dir1/dir2/index.php"` if the current URL is + * `"http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2"` + * @api + */ + static public function getCurrentScriptName() + { + $url = ''; + + if (!empty($_SERVER['REQUEST_URI'])) { + $url = $_SERVER['REQUEST_URI']; + + // strip http://host (Apache+Rails anomaly) + if (preg_match('~^https?://[^/]+($|/.*)~D', $url, $matches)) { + $url = $matches[1]; + } + + // strip parameters + if (($pos = strpos($url, "?")) !== false) { + $url = substr($url, 0, $pos); + } + + // strip path_info + if (isset($_SERVER['PATH_INFO'])) { + $url = substr($url, 0, -strlen($_SERVER['PATH_INFO'])); + } + } + + /** + * SCRIPT_NAME is our fallback, though it may not be set correctly + * + * @see http://php.net/manual/en/reserved.variables.php + */ + if (empty($url)) { + if (isset($_SERVER['SCRIPT_NAME'])) { + $url = $_SERVER['SCRIPT_NAME']; + } elseif (isset($_SERVER['SCRIPT_FILENAME'])) { + $url = $_SERVER['SCRIPT_FILENAME']; + } elseif (isset($_SERVER['argv'])) { + $url = $_SERVER['argv'][0]; + } + } + + if (!isset($url[0]) || $url[0] !== '/') { + $url = '/' . $url; + } + return $url; + } + + /** + * Returns the current URL's protocol. + * + * @return string `'https'` or `'http'` + * @api + */ + static public function getCurrentScheme() + { + try { + $assume_secure_protocol = @Config::getInstance()->General['assume_secure_protocol']; + } catch (Exception $e) { + $assume_secure_protocol = false; + } + if ($assume_secure_protocol + || (isset($_SERVER['HTTPS']) + && ($_SERVER['HTTPS'] == 'on' || $_SERVER['HTTPS'] === true)) + || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') + ) { + return 'https'; + } + return 'http'; + } + + /** + * Validates the **Host** HTTP header (untrusted user input). Used to prevent Host header + * attacks. + * + * @param string|bool $host Contents of Host: header from the HTTP request. If `false`, gets the + * value from the request. + * @return bool `true` if valid; `false` otherwise. + */ + static public function isValidHost($host = false) + { + // only do trusted host check if it's enabled + if (isset(Config::getInstance()->General['enable_trusted_host_check']) + && Config::getInstance()->General['enable_trusted_host_check'] == 0 + ) { + return true; + } + + if ($host === false) { + $host = @$_SERVER['HTTP_HOST']; + if (empty($host)) // if no current host, assume valid + { + return true; + } + } + // if host is in hardcoded whitelist, assume it's valid + if (in_array($host, self::$alwaysTrustedHosts)) { + return true; + } + + $trustedHosts = self::getTrustedHosts(); + + // if no trusted hosts, just assume it's valid + if (empty($trustedHosts)) { + self::saveTrustedHostnameInConfig($host); + return true; + } + + // Only punctuation we allow is '[', ']', ':', '.' and '-' + $hostLength = strlen($host); + if ($hostLength !== strcspn($host, '`~!@#$%^&*()_+={}\\|;"\'<>,?/ ')) { + return false; + } + + foreach ($trustedHosts as &$trustedHost) { + $trustedHost = preg_quote($trustedHost); + } + $untrustedHost = Common::mb_strtolower($host); + $untrustedHost = rtrim($untrustedHost, '.'); + + $hostRegex = Common::mb_strtolower('/(^|.)' . implode('|', $trustedHosts) . '$/'); + + $result = preg_match($hostRegex, $untrustedHost); + return 0 !== $result; + } + + /** + * Records one host, or an array of hosts in the config file, + * if user is Super User + * + * @static + * @param $host string|array + * @return bool + */ + public static function saveTrustedHostnameInConfig($host) + { + if (Piwik::hasUserSuperUserAccess() + && file_exists(Config::getLocalConfigPath()) + ) { + $general = Config::getInstance()->General; + if (!is_array($host)) { + $host = array($host); + } + $host = array_filter($host); + if (empty($host)) { + return false; + } + $general['trusted_hosts'] = $host; + Config::getInstance()->General = $general; + Config::getInstance()->forceSave(); + return true; + } + return false; + } + + /** + * Returns the current host. + * + * @param bool $checkIfTrusted Whether to do trusted host check. Should ALWAYS be true, + * except in Controller. + * @return string|bool eg, `"demo.piwik.org"` or false if no host found. + */ + static public function getHost($checkIfTrusted = true) + { + // HTTP/1.1 request + if (isset($_SERVER['HTTP_HOST']) + && strlen($host = $_SERVER['HTTP_HOST']) + && (!$checkIfTrusted + || self::isValidHost($host)) + ) { + return $host; + } + + // HTTP/1.0 request doesn't include Host: header + if (isset($_SERVER['SERVER_ADDR'])) { + return $_SERVER['SERVER_ADDR']; + } + + return false; + } + + /** + * Sets the host. Useful for CLI scripts, eg. archive.php + * + * @param $host string + */ + static public function setHost($host) + { + $_SERVER['HTTP_HOST'] = $host; + } + + /** + * Returns the current host. + * + * @param string $default Default value to return if host unknown + * @param bool $checkTrustedHost Whether to do trusted host check. Should ALWAYS be true, + * except in Controller. + * @return string eg, `"example.org"` if the current URL is + * `"http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2"` + * @api + */ + static public function getCurrentHost($default = 'unknown', $checkTrustedHost = true) + { + $hostHeaders = array(); + + $config = Config::getInstance()->General; + if(isset($config['proxy_host_headers'])) { + $hostHeaders = $config['proxy_host_headers']; + } + + if (!is_array($hostHeaders)) { + $hostHeaders = array(); + } + + $host = self::getHost($checkTrustedHost); + $default = Common::sanitizeInputValue($host ? $host : $default); + + return IP::getNonProxyIpFromHeader($default, $hostHeaders); + } + + /** + * Returns the query string of the current URL. + * + * @return string eg, `"?param1=value1¶m2=value2"` if the current URL is + * `"http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2"` + * @api + */ + static public function getCurrentQueryString() + { + $url = ''; + if (isset($_SERVER['QUERY_STRING']) + && !empty($_SERVER['QUERY_STRING']) + ) { + $url .= "?" . $_SERVER['QUERY_STRING']; + } + return $url; + } + + /** + * Returns an array mapping query paramater names with query parameter values for + * the current URL. + * + * @return array If current URL is `"http://example.org/dir1/dir2/index.php?param1=value1¶m2=value2"` + * this will return: + * + * array( + * 'param1' => string 'value1', + * 'param2' => string 'value2' + * ) + * @api + */ + static public function getArrayFromCurrentQueryString() + { + $queryString = self::getCurrentQueryString(); + $urlValues = UrlHelper::getArrayFromQueryString($queryString); + return $urlValues; + } + + /** + * Modifies the current query string with the supplied parameters and returns + * the result. Parameters in the current URL will be overwritten with values + * in `$params` and parameters absent from the current URL but present in `$params` + * will be added to the result. + * + * @param array $params set of parameters to modify/add in the current URL + * eg, `array('param3' => 'value3')` + * @return string eg, `"?param2=value2¶m3=value3"` + * @api + */ + static function getCurrentQueryStringWithParametersModified($params) + { + $urlValues = self::getArrayFromCurrentQueryString(); + foreach ($params as $key => $value) { + $urlValues[$key] = $value; + } + $query = self::getQueryStringFromParameters($urlValues); + if (strlen($query) > 0) { + return '?' . $query; + } + return ''; + } + + /** + * Converts an array of parameters name => value mappings to a query + * string. + * + * @param array $parameters eg. `array('param1' => 10, 'param2' => array(1,2))` + * @return string eg. `"param1=10¶m2[]=1¶m2[]=2"` + * @api + */ + static public function getQueryStringFromParameters($parameters) + { + $query = ''; + foreach ($parameters as $name => $value) { + if (is_null($value) || $value === false) { + continue; + } + if (is_array($value)) { + foreach ($value as $theValue) { + $query .= $name . "[]=" . $theValue . "&"; + } + } else { + $query .= $name . "=" . $value . "&"; + } + } + $query = substr($query, 0, -1); + return $query; + } + + static public function getQueryStringFromUrl($url) + { + return parse_url($url, PHP_URL_QUERY); + } + + /** + * Redirects the user to the referrer. If no referrer exists, the user is redirected + * to the current URL without query string. + * + * @api + */ + static public function redirectToReferrer() + { + $referrer = self::getReferrer(); + if ($referrer !== false) { + self::redirectToUrl($referrer); + } + self::redirectToUrl(self::getCurrentUrlWithoutQueryString()); + } + + /** + * Redirects the user to the specified URL. + * + * @param string $url + * @api + */ + static public function redirectToUrl($url) + { + if (UrlHelper::isLookLikeUrl($url) + || strpos($url, 'index.php') === 0 + ) { + @header("Location: $url"); + } else { + echo "Invalid URL to redirect to."; + } + + if(Common::isPhpCliMode()) { + die("If you were using a browser, Piwik would redirect you to this URL: $url \n\n"); + } + exit; + } + + /** + * If the page is using HTTP, redirect to the same page over HTTPS + */ + static public function redirectToHttps() + { + if(ProxyHttp::isHttps()) { + return; + } + $url = self::getCurrentUrl(); + $url = str_replace("http://", "https://", $url); + self::redirectToUrl($url); + } + + /** + * Returns the **HTTP_REFERER** `$_SERVER` variable, or `false` if not found. + * + * @return string|false + * @api + */ + static public function getReferrer() + { + if (!empty($_SERVER['HTTP_REFERER'])) { + return $_SERVER['HTTP_REFERER']; + } + return false; + } + + /** + * Returns `true` if the URL points to something on the same host, `false` if otherwise. + * + * @param string $url + * @return bool True if local; false otherwise. + * @api + */ + static public function isLocalUrl($url) + { + if (empty($url)) { + return true; + } + + // handle host name mangling + $requestUri = isset($_SERVER['SCRIPT_URI']) ? $_SERVER['SCRIPT_URI'] : ''; + $parseRequest = @parse_url($requestUri); + $hosts = array(self::getHost(), self::getCurrentHost()); + if (!empty($parseRequest['host'])) { + $hosts[] = $parseRequest['host']; + } + + // drop port numbers from hostnames and IP addresses + $hosts = array_map(array('Piwik\IP', 'sanitizeIp'), $hosts); + + $disableHostCheck = Config::getInstance()->General['enable_trusted_host_check'] == 0; + // compare scheme and host + $parsedUrl = @parse_url($url); + $host = IP::sanitizeIp(@$parsedUrl['host']); + return !empty($host) + && ($disableHostCheck || in_array($host, $hosts)) + && !empty($parsedUrl['scheme']) + && in_array($parsedUrl['scheme'], array('http', 'https')); + } + + public static function getTrustedHostsFromConfig() + { + $trustedHosts = @Config::getInstance()->General['trusted_hosts']; + if (!is_array($trustedHosts)) { + return array(); + } + foreach ($trustedHosts as &$trustedHost) { + // Case user wrote in the config, http://example.com/test instead of example.com + if (UrlHelper::isLookLikeUrl($trustedHost)) { + $trustedHost = parse_url($trustedHost, PHP_URL_HOST); + } + } + return $trustedHosts; + } + + public static function getTrustedHosts() + { + $trustedHosts = self::getTrustedHostsFromConfig(); + + /* used by Piwik PRO */ + Piwik::postEvent('Url.filterTrustedHosts', array(&$trustedHosts)); + + return $trustedHosts; + } +} diff --git a/www/analytics/core/UrlHelper.php b/www/analytics/core/UrlHelper.php new file mode 100644 index 00000000..2ce26ba1 --- /dev/null +++ b/www/analytics/core/UrlHelper.php @@ -0,0 +1,496 @@ + '0', 'date' => '2012-01-01')`. + * @param $parametersToExclude Array of query parameter names that shouldn't be + * in the result query string, eg, `array('date', 'period')`. + * @return string A query string, eg, `"?site=0"`. + * @api + */ + public static function getQueryStringWithExcludedParameters($queryParameters, $parametersToExclude) + { + $validQuery = ''; + $separator = '&'; + foreach ($queryParameters as $name => $value) { + // decode encoded square brackets + $name = str_replace(array('%5B', '%5D'), array('[', ']'), $name); + + if (!in_array(strtolower($name), $parametersToExclude)) { + if (is_array($value)) { + foreach ($value as $param) { + if ($param === false) { + $validQuery .= $name . '[]' . $separator; + } else { + $validQuery .= $name . '[]=' . $param . $separator; + } + } + } else if ($value === false) { + $validQuery .= $name . $separator; + } else { + $validQuery .= $name . '=' . $value . $separator; + } + } + } + $validQuery = substr($validQuery, 0, -strlen($separator)); + return $validQuery; + } + + /** + * Reduce URL to more minimal form. 2 letter country codes are + * replaced by '{}', while other parts are simply removed. + * + * Examples: + * www.example.com -> example.com + * search.example.com -> example.com + * m.example.com -> example.com + * de.example.com -> {}.example.com + * example.de -> example.{} + * example.co.uk -> example.{} + * + * @param string $url + * @return string + */ + public static function getLossyUrl($url) + { + static $countries; + if (!isset($countries)) { + $countries = implode('|', array_keys(Common::getCountriesList(true))); + } + + return preg_replace( + array( + '/^(w+[0-9]*|search)\./', + '/(^|\.)m\./', + '/(\.(com|org|net|co|it|edu))?\.(' . $countries . ')(\/|$)/', + '/(^|\.)(' . $countries . ')\./', + ), + array( + '', + '$1', + '.{}$4', + '$1{}.', + ), + $url); + } + + /** + * Returns true if the string passed may be a URL ie. it starts with protocol://. + * We don't need a precise test here because the value comes from the website + * tracked source code and the URLs may look very strange. + * + * @param string $url + * @return bool + */ + public static function isLookLikeUrl($url) + { + return preg_match('~^(ftp|news|http|https)?://(.*)$~D', $url, $matches) !== 0 + && strlen($matches[2]) > 0; + } + + /** + * Returns a URL created from the result of the [parse_url](http://php.net/manual/en/function.parse-url.php) + * function. + * + * Copied from the PHP comments at [http://php.net/parse_url](http://php.net/parse_url). + * + * @param array $parsed Result of [parse_url](http://php.net/manual/en/function.parse-url.php). + * @return false|string The URL or `false` if `$parsed` isn't an array. + * @api + */ + public static function getParseUrlReverse($parsed) + { + if (!is_array($parsed)) { + return false; + } + + $uri = !empty($parsed['scheme']) ? $parsed['scheme'] . ':' . (!strcasecmp($parsed['scheme'], 'mailto') ? '' : '//') : ''; + $uri .= !empty($parsed['user']) ? $parsed['user'] . (!empty($parsed['pass']) ? ':' . $parsed['pass'] : '') . '@' : ''; + $uri .= !empty($parsed['host']) ? $parsed['host'] : ''; + $uri .= !empty($parsed['port']) ? ':' . $parsed['port'] : ''; + + if (!empty($parsed['path'])) { + $uri .= (!strncmp($parsed['path'], '/', 1)) + ? $parsed['path'] + : ((!empty($uri) ? '/' : '') . $parsed['path']); + } + + $uri .= !empty($parsed['query']) ? '?' . $parsed['query'] : ''; + $uri .= !empty($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; + return $uri; + } + + /** + * Returns a URL query string as an array. + * + * @param string $urlQuery The query string, eg, `'?param1=value1¶m2=value2'`. + * @return array eg, `array('param1' => 'value1', 'param2' => 'value2')` + * @api + */ + public static function getArrayFromQueryString($urlQuery) + { + if (strlen($urlQuery) == 0) { + return array(); + } + if ($urlQuery[0] == '?') { + $urlQuery = substr($urlQuery, 1); + } + $separator = '&'; + + $urlQuery = $separator . $urlQuery; + // $urlQuery = str_replace(array('%20'), ' ', $urlQuery); + $referrerQuery = trim($urlQuery); + + $values = explode($separator, $referrerQuery); + + $nameToValue = array(); + + foreach ($values as $value) { + $pos = strpos($value, '='); + if ($pos !== false) { + $name = substr($value, 0, $pos); + $value = substr($value, $pos + 1); + if ($value === false) { + $value = ''; + } + } else { + $name = $value; + $value = false; + } + if (!empty($name)) { + $name = Common::sanitizeInputValue($name); + } + if (!empty($value)) { + $value = Common::sanitizeInputValue($value); + } + + // if array without indexes + $count = 0; + $tmp = preg_replace('/(\[|%5b)(]|%5d)$/i', '', $name, -1, $count); + if (!empty($tmp) && $count) { + $name = $tmp; + if (isset($nameToValue[$name]) == false || is_array($nameToValue[$name]) == false) { + $nameToValue[$name] = array(); + } + array_push($nameToValue[$name], $value); + } else if (!empty($name)) { + $nameToValue[$name] = $value; + } + } + return $nameToValue; + } + + /** + * Returns the value of a single query parameter from the supplied query string. + * + * @param string $urlQuery The query string. + * @param string $parameter The query parameter name to return. + * @return string|null Parameter value if found (can be the empty string!), null if not found. + * @api + */ + public static function getParameterFromQueryString($urlQuery, $parameter) + { + $nameToValue = self::getArrayFromQueryString($urlQuery); + if (isset($nameToValue[$parameter])) { + return $nameToValue[$parameter]; + } + return null; + } + + /** + * Returns the path and query string of a URL. + * + * @param string $url The URL. + * @return string eg, `/test/index.php?module=CoreHome` if `$url` is `http://piwik.org/test/index.php?module=CoreHome`. + * @api + */ + public static function getPathAndQueryFromUrl($url) + { + $parsedUrl = parse_url($url); + $result = ''; + if (isset($parsedUrl['path'])) { + $result .= substr($parsedUrl['path'], 1); + } + if (isset($parsedUrl['query'])) { + $result .= '?' . $parsedUrl['query']; + } + return $result; + } + + + /** + * Extracts a keyword from a raw not encoded URL. + * Will only extract keyword if a known search engine has been detected. + * Returns the keyword: + * - in UTF8: automatically converted from other charsets when applicable + * - strtolowered: "QUErY test!" will return "query test!" + * - trimmed: extra spaces before and after are removed + * + * Lists of supported search engines can be found in /core/DataFiles/SearchEngines.php + * The function returns false when a keyword couldn't be found. + * eg. if the url is "http://www.google.com/partners.html" this will return false, + * as the google keyword parameter couldn't be found. + * + * @see unit tests in /tests/core/Common.test.php + * @param string $referrerUrl URL referrer URL, eg. $_SERVER['HTTP_REFERER'] + * @return array|bool false if a keyword couldn't be extracted, + * or array( + * 'name' => 'Google', + * 'keywords' => 'my searched keywords') + */ + public static function extractSearchEngineInformationFromUrl($referrerUrl) + { + $referrerParsed = @parse_url($referrerUrl); + $referrerHost = ''; + if (isset($referrerParsed['host'])) { + $referrerHost = $referrerParsed['host']; + } + if (empty($referrerHost)) { + return false; + } + // some search engines (eg. Bing Images) use the same domain + // as an existing search engine (eg. Bing), we must also use the url path + $referrerPath = ''; + if (isset($referrerParsed['path'])) { + $referrerPath = $referrerParsed['path']; + } + + // no search query + if (!isset($referrerParsed['query'])) { + $referrerParsed['query'] = ''; + } + $query = $referrerParsed['query']; + + // Google Referrers URLs sometimes have the fragment which contains the keyword + if (!empty($referrerParsed['fragment'])) { + $query .= '&' . $referrerParsed['fragment']; + } + + $searchEngines = Common::getSearchEngineUrls(); + + $hostPattern = self::getLossyUrl($referrerHost); + if (array_key_exists($referrerHost . $referrerPath, $searchEngines)) { + $referrerHost = $referrerHost . $referrerPath; + } elseif (array_key_exists($hostPattern . $referrerPath, $searchEngines)) { + $referrerHost = $hostPattern . $referrerPath; + } elseif (array_key_exists($hostPattern, $searchEngines)) { + $referrerHost = $hostPattern; + } elseif (!array_key_exists($referrerHost, $searchEngines)) { + if (!strncmp($query, 'cx=partner-pub-', 15)) { + // Google custom search engine + $referrerHost = 'google.com/cse'; + } elseif (!strncmp($referrerPath, '/pemonitorhosted/ws/results/', 28)) { + // private-label search powered by InfoSpace Metasearch + $referrerHost = 'wsdsold.infospace.com'; + } elseif (strpos($referrerHost, '.images.search.yahoo.com') != false) { + // Yahoo! Images + $referrerHost = 'images.search.yahoo.com'; + } elseif (strpos($referrerHost, '.search.yahoo.com') != false) { + // Yahoo! + $referrerHost = 'search.yahoo.com'; + } else { + return false; + } + } + $searchEngineName = $searchEngines[$referrerHost][0]; + $variableNames = null; + if (isset($searchEngines[$referrerHost][1])) { + $variableNames = $searchEngines[$referrerHost][1]; + } + if (!$variableNames) { + $searchEngineNames = Common::getSearchEngineNames(); + $url = $searchEngineNames[$searchEngineName]; + $variableNames = $searchEngines[$url][1]; + } + if (!is_array($variableNames)) { + $variableNames = array($variableNames); + } + + $key = null; + if ($searchEngineName === 'Google Images' + || ($searchEngineName === 'Google' && strpos($referrerUrl, '/imgres') !== false) + ) { + if (strpos($query, '&prev') !== false) { + $query = urldecode(trim(self::getParameterFromQueryString($query, 'prev'))); + $query = str_replace('&', '&', strstr($query, '?')); + } + $searchEngineName = 'Google Images'; + } else if ($searchEngineName === 'Google' + && (strpos($query, '&as_') !== false || strpos($query, 'as_') === 0) + ) { + $keys = array(); + $key = self::getParameterFromQueryString($query, 'as_q'); + if (!empty($key)) { + array_push($keys, $key); + } + $key = self::getParameterFromQueryString($query, 'as_oq'); + if (!empty($key)) { + array_push($keys, str_replace('+', ' OR ', $key)); + } + $key = self::getParameterFromQueryString($query, 'as_epq'); + if (!empty($key)) { + array_push($keys, "\"$key\""); + } + $key = self::getParameterFromQueryString($query, 'as_eq'); + if (!empty($key)) { + array_push($keys, "-$key"); + } + $key = trim(urldecode(implode(' ', $keys))); + } + + if ($searchEngineName === 'Google') { + // top bar menu + $tbm = self::getParameterFromQueryString($query, 'tbm'); + switch ($tbm) { + case 'isch': + $searchEngineName = 'Google Images'; + break; + case 'vid': + $searchEngineName = 'Google Video'; + break; + case 'shop': + $searchEngineName = 'Google Shopping'; + break; + } + } + + if (empty($key)) { + foreach ($variableNames as $variableName) { + if ($variableName[0] == '/') { + // regular expression match + if (preg_match($variableName, $referrerUrl, $matches)) { + $key = trim(urldecode($matches[1])); + break; + } + } else { + // search for keywords now &vname=keyword + $key = self::getParameterFromQueryString($query, $variableName); + $key = trim(urldecode($key)); + + // Special case: Google & empty q parameter + if (empty($key) + && $variableName == 'q' + + && ( + // Google search with no keyword + ($searchEngineName == 'Google' + && ( // First, they started putting an empty q= parameter + strpos($query, '&q=') !== false + || strpos($query, '?q=') !== false + // then they started sending the full host only (no path/query string) + || (empty($query) && (empty($referrerPath) || $referrerPath == '/') && empty($referrerParsed['fragment'])) + ) + ) + // search engines with no keyword + || $searchEngineName == 'Google Images' + || $searchEngineName == 'DuckDuckGo') + ) { + $key = false; + } + if (!empty($key) + || $key === false + ) { + break; + } + } + } + } + + // $key === false is the special case "No keyword provided" which is a Search engine match + if ($key === null + || $key === '' + ) { + return false; + } + + if (!empty($key)) { + if (function_exists('iconv') + && isset($searchEngines[$referrerHost][3]) + ) { + // accepts string, array, or comma-separated list string in preferred order + $charsets = $searchEngines[$referrerHost][3]; + if (!is_array($charsets)) { + $charsets = explode(',', $charsets); + } + + if (!empty($charsets)) { + $charset = $charsets[0]; + if (count($charsets) > 1 + && function_exists('mb_detect_encoding') + ) { + $charset = mb_detect_encoding($key, $charsets); + if ($charset === false) { + $charset = $charsets[0]; + } + } + + $newkey = @iconv($charset, 'UTF-8//IGNORE', $key); + if (!empty($newkey)) { + $key = $newkey; + } + } + } + + $key = Common::mb_strtolower($key); + } + + return array( + 'name' => $searchEngineName, + 'keywords' => $key, + ); + } + + /** + * Returns the query part from any valid url and adds additional parameters to the query part if needed. + * + * @param string $url Any url eg `"http://example.com/piwik/?foo=bar"` + * @param array $additionalParamsToAdd If not empty the given parameters will be added to the query. + * + * @return string eg. `"foo=bar&foo2=bar2"` + * @api + */ + public static function getQueryFromUrl($url, array $additionalParamsToAdd = array()) + { + $url = @parse_url($url); + $query = ''; + + if (!empty($url['query'])) { + $query .= $url['query']; + } + + if (!empty($additionalParamsToAdd)) { + if (!empty($query)) { + $query .= '&'; + } + + $query .= Url::getQueryStringFromParameters($additionalParamsToAdd); + } + + return $query; + } + + public static function getHostFromUrl($url) + { + if (!UrlHelper::isLookLikeUrl($url)) { + $url = "http://" . $url; + } + return parse_url($url, PHP_URL_HOST); + } +} diff --git a/www/analytics/core/Version.php b/www/analytics/core/Version.php new file mode 100644 index 00000000..d187b41b --- /dev/null +++ b/www/analytics/core/Version.php @@ -0,0 +1,25 @@ +property1 = "a view property"; + * $view->property2 = "another view property"; + * return $view->render(); + * } + * + * + * @api + */ +class View implements ViewInterface +{ + private $template = ''; + + /** + * Instance + * @var Twig_Environment + */ + private $twig; + protected $templateVars = array(); + private $contentType = 'text/html; charset=utf-8'; + private $xFrameOptions = null; + + /** + * Constructor. + * + * @param string $templateFile The template file to load. Must be in the following format: + * `"@MyPlugin/templateFileName"`. Note the absence of .twig + * from the end of the name. + */ + public function __construct($templateFile) + { + $templateExt = '.twig'; + if (substr($templateFile, -strlen($templateExt)) !== $templateExt) { + $templateFile .= $templateExt; + } + $this->template = $templateFile; + + $this->initializeTwig(); + + $this->piwik_version = Version::VERSION; + $this->userLogin = Piwik::getCurrentUserLogin(); + $this->isSuperUser = Access::getInstance()->hasSuperUserAccess(); + + try { + $this->piwikUrl = SettingsPiwik::getPiwikUrl(); + } catch (Exception $ex) { + // pass (occurs when DB cannot be connected to, perhaps piwik URL cache should be stored in config file...) + } + } + + /** + * Returns the template filename. + * + * @return string + */ + public function getTemplateFile() + { + return $this->template; + } + + /** + * Returns the variables to bind to the template when rendering. + * + * @param array $override Template variable override values. Mainly useful + * when including View templates in other templates. + * @return array + */ + public function getTemplateVars($override = array()) + { + return $override + $this->templateVars; + } + + /** + * Directly assigns a variable to the view script. + * Variable names may not be prefixed with '_'. + * + * @param string $key The variable name. + * @param mixed $val The variable value. + */ + public function __set($key, $val) + { + $this->templateVars[$key] = $val; + } + + /** + * Retrieves an assigned variable. + * Variable names may not be prefixed with '_'. + * + * @param string $key The variable name. + * @return mixed The variable value. + */ + public function &__get($key) + { + return $this->templateVars[$key]; + } + + private function initializeTwig() + { + $piwikTwig = new Twig(); + $this->twig = $piwikTwig->getTwigEnvironment(); + } + + /** + * Renders the current view. Also sends the stored 'Content-Type' HTML header. + * See {@link setContentType()}. + * + * @return string Generated template. + */ + public function render() + { + try { + $this->currentModule = Piwik::getModule(); + $this->currentAction = Piwik::getAction(); + + $this->url = Common::sanitizeInputValue(Url::getCurrentUrl()); + $this->token_auth = Piwik::getCurrentUserTokenAuth(); + $this->userHasSomeAdminAccess = Piwik::isUserHasSomeAdminAccess(); + $this->userIsSuperUser = Piwik::hasUserSuperUserAccess(); + $this->latest_version_available = UpdateCheck::isNewestVersionAvailable(); + $this->disableLink = Common::getRequestVar('disableLink', 0, 'int'); + $this->isWidget = Common::getRequestVar('widget', 0, 'int'); + $this->cacheBuster = UIAssetCacheBuster::getInstance()->piwikVersionBasedCacheBuster(); + + $this->loginModule = Piwik::getLoginPluginName(); + + $user = APIUsersManager::getInstance()->getUser($this->userLogin); + $this->userAlias = $user['alias']; + } catch (Exception $e) { + // can fail, for example at installation (no plugin loaded yet) + } + + try { + $this->totalTimeGeneration = Registry::get('timer')->getTime(); + $this->totalNumberOfQueries = Profiler::getQueryCount(); + } catch (Exception $e) { + $this->totalNumberOfQueries = 0; + } + + ProxyHttp::overrideCacheControlHeaders('no-store'); + + @header('Content-Type: ' . $this->contentType); + // always sending this header, sometimes empty, to ensure that Dashboard embed loads (which could call this header() multiple times, the last one will prevail) + @header('X-Frame-Options: ' . (string)$this->xFrameOptions); + + return $this->renderTwigTemplate(); + } + + protected function renderTwigTemplate() + { + $output = $this->twig->render($this->getTemplateFile(), $this->getTemplateVars()); + $output = $this->applyFilter_cacheBuster($output); + + $helper = new Theme; + $output = $helper->rewriteAssetsPathToTheme($output); + return $output; + } + + protected function applyFilter_cacheBuster($output) + { + $cacheBuster = UIAssetCacheBuster::getInstance()->piwikVersionBasedCacheBuster(); + $tag = 'cb=' . $cacheBuster; + + $pattern = array( + '~ + + +END_OF_TEMPLATE; + } +} diff --git a/www/analytics/core/View/RenderTokenParser.php b/www/analytics/core/View/RenderTokenParser.php new file mode 100644 index 00000000..ddb21b93 --- /dev/null +++ b/www/analytics/core/View/RenderTokenParser.php @@ -0,0 +1,83 @@ +parser; + $stream = $parser->getStream(); + + $view = $parser->getExpressionParser()->parseExpression(); + + $variablesOverride = new Twig_Node_Expression_Array(array(), $token->getLine()); + if ($stream->test(Twig_Token::NAME_TYPE, 'with')) { + $stream->next(); + + $variablesOverride->addElement($this->parser->getExpressionParser()->parseExpression()); + } + + $stream->expect(Twig_Token::BLOCK_END_TYPE); + + $viewTemplateExpr = new Twig_Node_Expression_MethodCall( + $view, + 'getTemplateFile', + new Twig_Node_Expression_Array(array(), $token->getLine()), + $token->getLine() + ); + + $variablesExpr = new Twig_Node_Expression_MethodCall( + $view, + 'getTemplateVars', + $variablesOverride, + $token->getLine() + ); + + return new Twig_Node_Include( + $viewTemplateExpr, + $variablesExpr, + $only = false, + $ignoreMissing = false, + $token->getLine() + ); + } + + /** + * Returns the tag identifier. + * + * @return string + */ + public function getTag() + { + return 'render'; + } +} \ No newline at end of file diff --git a/www/analytics/core/View/ReportsByDimension.php b/www/analytics/core/View/ReportsByDimension.php new file mode 100644 index 00000000..f75e240a --- /dev/null +++ b/www/analytics/core/View/ReportsByDimension.php @@ -0,0 +1,129 @@ +dimensionCategories = array(); + $this->id = $id; + } + + /** + * Adds a report to the list of reports to display. + * + * @param string $category The report's category. Can be a i18n token. + * @param string $title The report's title. Can be a i18n token. + * @param string $action The controller action used to load the report, ie, Referrers.getAll + * @param array $params The list of query parameters to use when loading the report. + * This list overrides query parameters currently in use. For example, + * array('idSite' => 2, 'viewDataTable' => 'goalsTable') + * would mean the goals report for site w/ ID=2 will always be loaded. + */ + public function addReport($category, $title, $action, $params = array()) + { + list($module, $action) = explode('.', $action); + $params = array('module' => $module, 'action' => $action) + $params; + + $categories = $this->dimensionCategories; + $categories[$category][] = array( + 'title' => $title, + 'params' => $params, + 'url' => Url::getCurrentQueryStringWithParametersModified($params) + ); + $this->dimensionCategories = $categories; + } + + /** + * Adds a set of reports to the list of reports to display. + * + * @param array $reports An array containing report information. The array requires + * the 'category', 'title', 'action' and 'params' elements. + * For information on what they should contain, @see addReport. + */ + public function addReports($reports) + { + foreach ($reports as $report) { + $this->addReport($report['category'], $report['title'], $report['action'], $report['params']); + } + } + + /** + * @return string The ID specified in the constructor, usually the plugin name + */ + public function getId() + { + return $this->id; + } + + /** + * Renders this view. + * + * @return string The rendered view. + */ + public function render() + { + /** + * Triggered before rendering {@link ReportsByDimension} views. + * + * Plugins can use this event to configure {@link ReportsByDimension} instances by + * adding or removing reports to display. + * + * @param ReportsByDimension $this The view instance. + */ + Piwik::postEvent('View.ReportsByDimension.render', array($this)); + + $this->firstReport = ""; + + // if there are reports & report categories added, render the first one so we can + // display it initially + $categories = $this->dimensionCategories; + if (!empty($categories)) { + $firstCategory = reset($categories); + $firstReportInfo = reset($firstCategory); + + $oldGet = $_GET; + $oldPost = $_POST; + + foreach ($firstReportInfo['params'] as $key => $value) { + $_GET[$key] = $value; + } + + $_POST = array(); + + $module = $firstReportInfo['params']['module']; + $action = $firstReportInfo['params']['action']; + $this->firstReport = FrontController::getInstance()->fetchDispatch($module, $action); + + $_GET = $oldGet; + $_POST = $oldPost; + } + + return parent::render(); + } +} diff --git a/www/analytics/core/View/UIControl.php b/www/analytics/core/View/UIControl.php new file mode 100644 index 00000000..467c5615 --- /dev/null +++ b/www/analytics/core/View/UIControl.php @@ -0,0 +1,172 @@ +innerView = new View(static::TEMPLATE); + + parent::__construct("@CoreHome\_uiControl"); + } + + /** + * Sets a variable. See {@link View::__set()}. + */ + public function __set($key, $val) + { + $this->innerView->__set($key, $val); + } + + /** + * Gets a view variable. See {@link View::__get()}. + */ + public function &__get($key) + { + return $this->innerView->__get($key); + } + + public function __isset($key) + { + return isset($this->innerView->templateVars[$key]); + } + + /** + * Renders the control view within a containing
    that is used by the UIControl JavaScript + * class. + * + * @return string + */ + public function render() + { + if ($this->cssIdentifier === null) { + throw new Exception("All UIControls must set a cssIdentifier property"); + } + + if ($this->jsClass === null) { + throw new Exception("All UIControls must set a jsClass property"); + } + + return parent::render(); + } + + /** + * See {@link View::getTemplateVars()}. + */ + public function getTemplateVars($override = array()) + { + $this->templateVars['implView'] = $this->innerView; + $this->templateVars['cssIdentifier'] = $this->cssIdentifier; + $this->templateVars['cssClass'] = $this->cssClass; + $this->templateVars['jsClass'] = $this->jsClass; + $this->templateVars['jsNamespace'] = $this->jsNamespace; + $this->templateVars['implOverride'] = $override; + + $innerTemplateVars = $this->innerView->getTemplateVars($override); + + $this->templateVars['clientSideProperties'] = array(); + foreach ($this->getClientSideProperties() as $name) { + $this->templateVars['clientSideProperties'][$name] = $innerTemplateVars[$name]; + } + + $this->templateVars['clientSideParameters'] = array(); + $clientSideParameters = $this->getClientSideParameters(); + foreach ($this->getClientSideParameters() as $name) { + $this->templateVars['clientSideParameters'][$name] = $innerTemplateVars[$name]; + } + + return parent::getTemplateVars($override); + } + + /** + * Returns the array of property names whose values are passed to the UIControl JavaScript class. + * + * Should be overriden by descendants. + * + * @return array + */ + public function getClientSideProperties() + { + return array(); + } + + /** + * Returns an array of property names whose values are passed to the UIControl JavaScript class. + * These values differ from those in {@link $clientSideProperties} in that they are meant to passed as + * request parameters when the JavaScript code makes an AJAX request. + * + * Should be overriden by descendants. + * + * @return array + */ + public function getClientSideParameters() + { + return array(); + } +} \ No newline at end of file diff --git a/www/analytics/core/View/ViewInterface.php b/www/analytics/core/View/ViewInterface.php new file mode 100644 index 00000000..b1e61339 --- /dev/null +++ b/www/analytics/core/View/ViewInterface.php @@ -0,0 +1,25 @@ + + * **Client Side Properties** + * + * Client side properties are properties that should be passed on to the browser so + * client side JavaScript can use them. Only affects ViewDataTables that output HTML. + * + * + * **Overridable Properties** + * + * Overridable properties are properties that can be set via the query string. + * If a request has a query parameter that matches an overridable property, the property + * will be set to the query parameter value. + * + * **Reusing base properties** + * + * Many of the properties in this class only have meaning for the {@link Piwik\Plugin\Visualization} + * class, but can be set for other visualizations that extend {@link Piwik\Plugin\ViewDataTable} + * directly. + * + * Visualizations that extend {@link Piwik\Plugin\ViewDataTable} directly and want to re-use these + * properties must make sure the properties are used in the exact same way they are used in + * {@link Piwik\Plugin\Visualization}. + * + * **Defining new display properties** + * + * If you are creating your own visualization and want to add new display properties for + * it, extend this class and add your properties as fields. + * + * Properties are marked as client side properties by calling the + * {@link addPropertiesThatShouldBeAvailableClientSide()} method. + * + * Properties are marked as overridable by calling the + * {@link addPropertiesThatCanBeOverwrittenByQueryParams()} method. + * + * ### Example + * + * **Defining new display properties** + * + * class MyCustomVizConfig extends Config + * { + * /** + * * My custom property. It is overridable. + * *\/ + * public $my_custom_property = false; + * + * /** + * * Another custom property. It is available client side. + * *\/ + * public $another_custom_property = true; + * + * public function __construct() + * { + * parent::__construct(); + * + * $this->addPropertiesThatShouldBeAvailableClientSide(array('another_custom_property')); + * $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('my_custom_property')); + * } + * } + * + * @api + */ +class Config +{ + /** + * The list of ViewDataTable properties that are 'Client Side Properties'. + */ + public $clientSideProperties = array( + 'show_limit_control' + ); + + /** + * The list of ViewDataTable properties that can be overriden by query parameters. + */ + public $overridableProperties = array( + 'show_goals', + 'show_exclude_low_population', + 'show_flatten_table', + 'show_table', + 'show_table_all_columns', + 'show_footer', + 'show_footer_icons', + 'show_all_views_icons', + 'show_active_view_icon', + 'show_related_reports', + 'show_limit_control', + 'show_search', + 'enable_sort', + 'show_bar_chart', + 'show_pie_chart', + 'show_tag_cloud', + 'show_export_as_rss_feed', + 'show_ecommerce', + 'search_recursive', + 'show_export_as_image_icon', + 'show_pagination_control', + 'show_offset_information', + 'hide_annotations_view', + 'export_limit' + ); + + /** + * Controls what footer icons are displayed on the bottom left of the DataTable view. + * The value of this property must be an array of footer icon groups. Footer icon groups + * have set of properties, including an array of arrays describing footer icons. For + * example: + * + * array( + * array( // footer icon group 1 + * 'class' => 'footerIconGroup1CssClass', + * 'buttons' => array( + * 'id' => 'myid', + * 'title' => 'My Tooltip', + * 'icon' => 'path/to/my/icon.png' + * ) + * ), + * array( // footer icon group 2 + * 'class' => 'footerIconGroup2CssClass', + * 'buttons' => array(...) + * ) + * ) + * + * By default, when a user clicks on a footer icon, Piwik will assume the 'id' is + * a viewDataTable ID and try to reload the DataTable w/ the new viewDataTable. You + * can provide your own footer icon behavior by adding an appropriate handler via + * DataTable.registerFooterIconHandler in your JavaScript code. + * + * The default value of this property is not set here and will show the 'Normal Table' + * icon, the 'All Columns' icon, the 'Goals Columns' icon and all jqPlot graph columns, + * unless other properties tell the view to exclude them. + */ + public $footer_icons = false; + + /** + * Controls whether the buttons and UI controls around the visualization or shown or + * if just the visualization alone is shown. + */ + public $show_visualization_only = false; + + /** + * Controls whether the goals footer icon is shown. + */ + public $show_goals = false; + + /** + * Array property mapping DataTable column names with their internationalized names. + * + * The default value for this property is set elsewhere. It will contain translations + * of common metrics. + */ + public $translations = array(); + + /** + * Controls whether the 'Exclude Low Population' option (visible in the popup that displays after + * clicking the 'cog' icon) is shown. + */ + public $show_exclude_low_population = true; + + /** + * Whether to show the 'Flatten' option (visible in the popup that displays after clicking the + * 'cog' icon). + */ + public $show_flatten_table = true; + + /** + * Controls whether the footer icon that allows users to switch to the 'normal' DataTable view + * is shown. + */ + public $show_table = true; + + /** + * Controls whether the 'All Columns' footer icon is shown. + */ + public $show_table_all_columns = true; + + /** + * Controls whether the entire view footer is shown. + */ + public $show_footer = true; + + /** + * Controls whether the row that contains all footer icons & the limit selector is shown. + */ + public $show_footer_icons = true; + + /** + * Array property that determines which columns will be shown. Columns not in this array + * should not appear in ViewDataTable visualizations. + * + * Example: `array('label', 'nb_visits', 'nb_uniq_visitors')` + * + * If this value is empty it will be defaulted to `array('label', 'nb_visits')` or + * `array('label', 'nb_uniq_visitors')` if the report contains a nb_uniq_visitors column + * after data is loaded. + */ + public $columns_to_display = array(); + + /** + * Controls whether graph and non core viewDataTable footer icons are shown or not. + */ + public $show_all_views_icons = true; + + /** + * Controls whether to display a tiny upside-down caret over the currently active view icon. + */ + public $show_active_view_icon = true; + + /** + * Related reports are listed below a datatable view. When clicked, the original report will + * change to the clicked report and the list will change so the original report can be + * navigated back to. + */ + public $related_reports = array(); + + /** + * "Related Reports" is displayed by default before listing the Related reports, + * The string can be changed. + */ + public $related_reports_title; + + /** + * The report title. Used with related reports so report headings can be changed when switching + * reports. + * + * This must be set if related reports are added. + */ + public $title = ''; + + /** + * Controls whether a report's related reports are listed with the view or not. + */ + public $show_related_reports = true; + + /** + * Contains the documentation for a report. + */ + public $documentation = false; + + /** + * Array property containing custom data to be saved in JSON in the data-params HTML attribute + * of a data table div. This data can be used by JavaScript DataTable classes. + * + * e.g. array('typeReferrer' => ...) + * + * It can then be accessed in the twig templates by clientSideParameters.typeReferrer + */ + public $custom_parameters = array(); + + /** + * Controls whether the limit dropdown (which allows users to change the number of data shown) + * is always shown or not. + * + * Normally shown only if pagination is enabled. + */ + public $show_limit_control = true; + + /** + * Controls whether the search box under the datatable is shown. + */ + public $show_search = true; + + /** + * Controls whether the user can sort DataTables by clicking on table column headings. + */ + public $enable_sort = true; + + /** + * Controls whether the footer icon that allows users to view data as a bar chart is shown. + */ + public $show_bar_chart = true; + + /** + * Controls whether the footer icon that allows users to view data as a pie chart is shown. + */ + public $show_pie_chart = true; + + /** + * Controls whether the footer icon that allows users to view data as a tag cloud is shown. + */ + public $show_tag_cloud = true; + + /** + * Controls whether the user is allowed to export data as an RSS feed or not. + */ + public $show_export_as_rss_feed = true; + + /** + * Controls whether the 'Ecoommerce Orders'/'Abandoned Cart' footer icons are shown or not. + */ + public $show_ecommerce = false; + + /** + * Stores an HTML message (if any) to display under the datatable view. + */ + public $show_footer_message = false; + + /** + * Array property that stores documentation for individual metrics. + * + * E.g. `array('nb_visits' => '...', ...)` + * + * By default this is set to values retrieved from report metadata (via API.getReportMetadata API method). + */ + public $metrics_documentation = array(); + + /** + * Row metadata name that contains the tooltip for the specific row. + */ + public $tooltip_metadata_name = false; + + /** + * The URL to the report the view is displaying. Modifying this means clicking back to this report + * from a Related Report will go to a different URL. Can be used to load an entire page instead + * of a single report when going back to the original report. + * + * The URL used to request the report without generic filters. + */ + public $self_url = ''; + + /** + * CSS class to use in the output HTML div. This is added in addition to the visualization CSS + * class. + */ + public $datatable_css_class = false; + + /** + * The JavaScript class to instantiate after the result HTML is obtained. This class handles all + * interactive behavior for the DataTable view. + */ + public $datatable_js_type = 'DataTable'; + + /** + * If true, searching through the DataTable will search through all subtables. + */ + public $search_recursive = false; + + /** + * The unit of the displayed column. Valid if only one non-label column is displayed. + */ + public $y_axis_unit = false; + + /** + * Controls whether to show the 'Export as Image' footer icon. + */ + public $show_export_as_image_icon = false; + + /** + * Array of DataTable filters that should be run before displaying a DataTable. Elements + * of this array can either be a closure or an array with at most three elements, including: + * - the filter name (or a closure) + * - an array of filter parameters + * - a boolean indicating if the filter is a priority filter or not + * + * Priority filters are run before queued filters. These filters should be filters that + * add/delete rows. + * + * If a closure is used, the view is appended as a parameter. + */ + public $filters = array(); + + /** + * Contains the controller action to call when requesting subtables of the current report. + * + * By default, this is set to the controller action used to request the report. + */ + public $subtable_controller_action = ''; + + /** + * Controls whether the 'prev'/'next' links are shown in the DataTable footer. These links + * change the 'filter_offset' query parameter, thus allowing pagination. + */ + public $show_pagination_control = true; + + /** + * Controls whether offset information (ie, '5-10 of 20') is shown under the datatable. + */ + public $show_offset_information = true; + + /** + * Controls whether annotations are shown or not. + */ + public $hide_annotations_view = true; + + /** + * The filter_limit query parameter value to use in export links. + * + * Defaulted to the value of the `[General] API_datatable_default_limit` INI config option. + */ + public $export_limit = ''; + + /** + * @ignore + */ + public $report_id = ''; + + /** + * @ignore + */ + public $controllerName; + + /** + * @ignore + */ + public $controllerAction; + + /** + * Constructor. + */ + public function __construct() + { + $this->export_limit = \Piwik\Config::getInstance()->General['API_datatable_default_limit']; + $this->translations = array_merge( + Metrics::getDefaultMetrics(), + Metrics::getDefaultProcessedMetrics() + ); + } + + /** + * @ignore + */ + public function setController($controllerName, $controllerAction) + { + $this->controllerName = $controllerName; + $this->controllerAction = $controllerAction; + $this->report_id = $controllerName . '.' . $controllerAction; + + $this->loadDocumentation(); + } + + /** Load documentation from the API */ + private function loadDocumentation() + { + $this->metrics_documentation = array(); + + $report = API::getInstance()->getMetadata(0, $this->controllerName, $this->controllerAction); + $report = $report[0]; + + if (isset($report['metricsDocumentation'])) { + $this->metrics_documentation = $report['metricsDocumentation']; + } + + if (isset($report['documentation'])) { + $this->documentation = $report['documentation']; + } + } + + /** + * Marks display properties as client side properties. [Read this](#client-side-properties-desc) + * to learn more. + * + * @param array $propertyNames List of property names, eg, `array('show_limit_control', 'show_goals')`. + */ + public function addPropertiesThatShouldBeAvailableClientSide(array $propertyNames) + { + foreach ($propertyNames as $propertyName) { + $this->clientSideProperties[] = $propertyName; + } + } + + /** + * Marks display properties as overridable. [Read this](#overridable-properties-desc) to + * learn more. + * + * @param array $propertyNames List of property names, eg, `array('show_limit_control', 'show_goals')`. + */ + public function addPropertiesThatCanBeOverwrittenByQueryParams(array $propertyNames) + { + foreach ($propertyNames as $propertyName) { + $this->overridableProperties[] = $propertyName; + } + } + + /** + * Returns array of all property values in this config object. Property values are mapped + * by name. + * + * @return array eg, `array('show_limit_control' => 0, 'show_goals' => 1, ...)` + */ + public function getProperties() + { + return get_object_vars($this); + } + + /** + * @ignore + */ + public function setDefaultColumnsToDisplay($columns, $hasNbVisits, $hasNbUniqVisitors) + { + if ($hasNbVisits || $hasNbUniqVisitors) { + $columnsToDisplay = array('label'); + + // if unique visitors data is available, show it, otherwise just visits + if ($hasNbUniqVisitors) { + $columnsToDisplay[] = 'nb_uniq_visitors'; + } else { + $columnsToDisplay[] = 'nb_visits'; + } + } else { + $columnsToDisplay = $columns; + } + + $this->columns_to_display = array_filter($columnsToDisplay); + } + + /** + * @ignore + */ + public function getFiltersToRun() + { + $priorityFilters = array(); + $presentationFilters = array(); + + foreach ($this->filters as $filterInfo) { + if ($filterInfo instanceof \Closure) { + $nameOrClosure = $filterInfo; + $parameters = array(); + $priority = false; + } else { + @list($nameOrClosure, $parameters, $priority) = $filterInfo; + } + + if ($priority) { + $priorityFilters[] = array($nameOrClosure, $parameters); + } else { + $presentationFilters[] = array($nameOrClosure, $parameters); + } + } + + return array($priorityFilters, $presentationFilters); + } + + /** + * Adds a related report to the {@link $related_reports} property. If the report + * references the one that is currently being displayed, it will not be added to the related + * report list. + * + * @param string $relatedReport The plugin and method of the report, eg, `'UserSettings.getBrowser'`. + * @param string $title The report's display name, eg, `'Browsers'`. + * @param array $queryParams Any extra query parameters to set in releated report's URL, eg, + * `array('idGoal' => 'ecommerceOrder')`. + */ + public function addRelatedReport($relatedReport, $title, $queryParams = array()) + { + list($module, $action) = explode('.', $relatedReport); + + // don't add the related report if it references this report + if ($this->controllerName == $module + && $this->controllerAction == $action) { + if(empty($queryParams)) { + return; + } + } + + $url = ApiRequest::getBaseReportUrl($module, $action, $queryParams); + + $this->related_reports[$url] = $title; + } + + /** + * Adds several related reports to the {@link $related_reports} property. If + * any of the reports references the report that is currently being displayed, it will not + * be added to the list. All other reports will still be added though. + * + * If you need to make sure the related report URL has some extra query parameters, + * use {@link addRelatedReport()}. + * + * @param array $relatedReports Array mapping report IDs with their internationalized display + * titles, eg, + * ``` + * array( + * 'UserSettings.getBrowser' => 'Browsers', + * 'UserSettings.getConfiguration' => 'Configurations' + * ) + * ``` + */ + public function addRelatedReports($relatedReports) + { + foreach ($relatedReports as $report => $title) { + $this->addRelatedReport($report, $title); + } + } + + /** + * Associates internationalized text with a metric. Overwrites existing mappings. + * + * See {@link $translations}. + * + * @param string $columnName The name of a column in the report data, eg, `'nb_visits'` or + * `'goal_1_nb_conversions'`. + * @param string $translation The internationalized text, eg, `'Visits'` or `"Conversions for 'My Goal'"`. + */ + public function addTranslation($columnName, $translation) + { + $this->translations[$columnName] = $translation; + } + + /** + * Associates multiple translations with metrics. + * + * See {@link $translations} and {@link addTranslation()}. + * + * @param array $translations An array of column name => text mappings, eg, + * ``` + * array( + * 'nb_visits' => 'Visits', + * 'goal_1_nb_conversions' => "Conversions for 'My Goal'" + * ) + * ``` + */ + public function addTranslations($translations) + { + foreach ($translations as $key => $translation) { + $this->addTranslation($key, $translation); + } + } +} diff --git a/www/analytics/core/ViewDataTable/Factory.php b/www/analytics/core/ViewDataTable/Factory.php new file mode 100644 index 00000000..1f618920 --- /dev/null +++ b/www/analytics/core/ViewDataTable/Factory.php @@ -0,0 +1,174 @@ +config->show_limit_control = true; + * $view->config->translations['myFancyMetric'] = "My Fancy Metric"; + * return $view->render(); + * } + * + * **Displaying a report in another way** + * + * // method in MyPlugin\Controller + * // use the same data that's used in myReport() above, but transform it in some way before + * // displaying. + * public function myReportShownDifferently() + * { + * $view = Factory::build('table', 'MyPlugin.myReport', 'MyPlugin.myReportShownDifferently'); + * $view->config->filters[] = array('MyMagicFilter', array('an arg', 'another arg')); + * return $view->render(); + * } + * + * **Force a report to be shown as a bar graph** + * + * // method in MyPlugin\Controller + * // force the myReport report to show as a bar graph if there is no viewDataTable query param, + * // even though it is configured to show as a table. + * public function myReportShownAsABarGraph() + * { + * $view = Factory::build('graphVerticalBar', 'MyPlugin.myReport', 'MyPlugin.myReportShownAsABarGraph', + * $forceDefault = true); + * return $view->render(); + * } + * + * + * @api + */ +class Factory +{ + /** + * Cache for getDefaultTypeViewDataTable result. + * + * @var array + */ + private static $defaultViewTypes = null; + + /** + * Creates a {@link Piwik\Plugin\ViewDataTable} instance by ID. If the **viewDataTable** query parameter is set, + * this parameter's value is used as the ID. + * + * See {@link Piwik\Plugin\ViewDataTable} to read about the visualizations that are packaged with Piwik. + * + * @param string|null $defaultType A ViewDataTable ID representing the default ViewDataTable type to use. If + * the **viewDataTable** query parameter is not found, this value is used as + * the ID of the ViewDataTable to create. + * + * If a visualization type is configured for the report being displayed, it + * is used instead of the default type. (See {@hook ViewDataTable.getDefaultType}). + * If nothing is configured for the report and `null` is supplied for this + * argument, **table** is used. + * @param string|false $apiAction The API method for the report that will be displayed, eg, + * `'UserSettings.getBrowser'`. + * @param string|false $controllerAction The controller name and action dedicated to displaying the report. This + * action is used when reloading reports or changing the report visualization. + * Defaulted to `$apiAction` if `false` is supplied. + * @param bool $forceDefault If true, then the visualization type that was configured for the report will be + * ignored and `$defaultType` will be used as the default. + * @throws \Exception + * @return \Piwik\Plugin\ViewDataTable + */ + public static function build($defaultType = null, $apiAction = false, $controllerAction = false, $forceDefault = false) + { + if (false === $controllerAction) { + $controllerAction = $apiAction; + } + + $defaultViewType = self::getDefaultViewTypeForReport($apiAction); + + if (!$forceDefault && !empty($defaultViewType)) { + $defaultType = $defaultViewType; + } + + $type = Common::getRequestVar('viewDataTable', false, 'string'); + // Common::getRequestVar removes backslashes from the defaultValue in case magic quotes are enabled. + // therefore do not pass this as a default value to getRequestVar() + if ('' === $type) { + $type = $defaultType ? : HtmlTable::ID; + } + + $visualizations = Manager::getAvailableViewDataTables(); + + if (array_key_exists($type, $visualizations)) { + return new $visualizations[$type]($controllerAction, $apiAction); + } + + if (class_exists($type)) { + return new $type($controllerAction, $apiAction); + } + + if (array_key_exists($defaultType, $visualizations)) { + return new $visualizations[$defaultType]($controllerAction, $apiAction); + } + + if (array_key_exists(HtmlTable::ID, $visualizations)) { + return new $visualizations[HtmlTable::ID]($controllerAction, $apiAction); + } + + throw new \Exception('No visualization found to render ViewDataTable'); + } + + /** + * Returns the default viewDataTable ID to use when determining which visualization to use. + */ + private static function getDefaultViewTypeForReport($apiAction) + { + $defaultViewTypes = self::getDefaultTypeViewDataTable(); + return isset($defaultViewTypes[$apiAction]) ? $defaultViewTypes[$apiAction] : false; + } + + /** + * Returns a list of default viewDataTables ID to use when determining which visualization to use for multiple + * reports. + */ + private static function getDefaultTypeViewDataTable() + { + if (null === self::$defaultViewTypes) { + self::$defaultViewTypes = array(); + /** + * Triggered when gathering the default view types for all available reports. + * + * If you define your own report, you may want to subscribe to this event to + * make sure the correct default Visualization is used (for example, a pie graph, + * bar graph, or something else). + * + * If there is no default type associated with a report, the **table** visualization + * used. + * + * **Example** + * + * public function getDefaultTypeViewDataTable(&$defaultViewTypes) + * { + * $defaultViewTypes['Referrers.getSocials'] = HtmlTable::ID; + * $defaultViewTypes['Referrers.getUrlsForSocial'] = Pie::ID; + * } + * + * @param array &$defaultViewTypes The array mapping report IDs with visualization IDs. + */ + Piwik::postEvent('ViewDataTable.getDefaultType', array(&self::$defaultViewTypes)); + } + + return self::$defaultViewTypes; + } +} diff --git a/www/analytics/core/ViewDataTable/Manager.php b/www/analytics/core/ViewDataTable/Manager.php new file mode 100644 index 00000000..8f05f8b1 --- /dev/null +++ b/www/analytics/core/ViewDataTable/Manager.php @@ -0,0 +1,273 @@ + $vizClass) { + if (false === strpos($vizClass, 'Piwik\\Plugins\\CoreVisualizations') + && false === strpos($vizClass, 'Piwik\\Plugins\\Goals\\Visualizations\\Goals')) { + $result[$vizId] = $vizClass; + } + } + + return $result; + } + + /** + * This method determines the default set of footer icons to display below a report. + * + * $result has the following format: + * + * ``` + * array( + * array( // footer icon group 1 + * 'class' => 'footerIconGroup1CssClass', + * 'buttons' => array( + * 'id' => 'myid', + * 'title' => 'My Tooltip', + * 'icon' => 'path/to/my/icon.png' + * ) + * ), + * array( // footer icon group 2 + * 'class' => 'footerIconGroup2CssClass', + * 'buttons' => array(...) + * ), + * ... + * ) + * ``` + */ + public static function configureFooterIcons(ViewDataTable $view) + { + $result = array(); + + // add normal view icons (eg, normal table, all columns, goals) + $normalViewIcons = array( + 'class' => 'tableAllColumnsSwitch', + 'buttons' => array(), + ); + + if ($view->config->show_table) { + $normalViewIcons['buttons'][] = static::getFooterIconFor(HtmlTable::ID); + } + + if ($view->config->show_table_all_columns) { + $normalViewIcons['buttons'][] = static::getFooterIconFor(HtmlTable\AllColumns::ID); + } + + if ($view->config->show_goals) { + $goalButton = static::getFooterIconFor(Goals::ID); + if (Common::getRequestVar('idGoal', false) == 'ecommerceOrder') { + $goalButton['icon'] = 'plugins/Zeitgeist/images/ecommerceOrder.gif'; + } + + $normalViewIcons['buttons'][] = $goalButton; + } + + if ($view->config->show_ecommerce) { + $normalViewIcons['buttons'][] = array( + 'id' => 'ecommerceOrder', + 'title' => Piwik::translate('General_EcommerceOrders'), + 'icon' => 'plugins/Zeitgeist/images/ecommerceOrder.gif', + 'text' => Piwik::translate('General_EcommerceOrders') + ); + + $normalViewIcons['buttons'][] = array( + 'id' => 'ecommerceAbandonedCart', + 'title' => Piwik::translate('General_AbandonedCarts'), + 'icon' => 'plugins/Zeitgeist/images/ecommerceAbandonedCart.gif', + 'text' => Piwik::translate('General_AbandonedCarts') + ); + } + + $normalViewIcons['buttons'] = array_filter($normalViewIcons['buttons']); + + if (!empty($normalViewIcons['buttons'])) { + $result[] = $normalViewIcons; + } + + // add insight views + $insightsViewIcons = array( + 'class' => 'tableInsightViews', + 'buttons' => array(), + ); + + // add graph views + $graphViewIcons = array( + 'class' => 'tableGraphViews tableGraphCollapsed', + 'buttons' => array(), + ); + + if ($view->config->show_all_views_icons) { + if ($view->config->show_bar_chart) { + $graphViewIcons['buttons'][] = static::getFooterIconFor(Bar::ID); + } + + if ($view->config->show_pie_chart) { + $graphViewIcons['buttons'][] = static::getFooterIconFor(Pie::ID); + } + + if ($view->config->show_tag_cloud) { + $graphViewIcons['buttons'][] = static::getFooterIconFor(Cloud::ID); + } + } + + $nonCoreVisualizations = static::getNonCoreViewDataTables(); + + foreach ($nonCoreVisualizations as $id => $klass) { + if ($klass::canDisplayViewDataTable($view)) { + $footerIcon = static::getFooterIconFor($id); + if (Insight::ID == $footerIcon['id']) { + $insightsViewIcons['buttons'][] = static::getFooterIconFor($id); + } else { + $graphViewIcons['buttons'][] = static::getFooterIconFor($id); + } + } + } + + $graphViewIcons['buttons'] = array_filter($graphViewIcons['buttons']); + + if (!empty($insightsViewIcons['buttons'])) { + $result[] = $insightsViewIcons; + } + + if (!empty($graphViewIcons['buttons'])) { + $result[] = $graphViewIcons; + } + + return $result; + } + + /** + * Returns an array with information necessary for adding the viewDataTable to the footer. + * + * @param string $viewDataTableId + * + * @return array + */ + private static function getFooterIconFor($viewDataTableId) + { + $tables = static::getAvailableViewDataTables(); + + if (!array_key_exists($viewDataTableId, $tables)) { + return; + } + + $klass = $tables[$viewDataTableId]; + + return array( + 'id' => $klass::getViewDataTableId(), + 'title' => Piwik::translate($klass::FOOTER_ICON_TITLE), + 'icon' => $klass::FOOTER_ICON, + ); + } +} diff --git a/www/analytics/core/ViewDataTable/Request.php b/www/analytics/core/ViewDataTable/Request.php new file mode 100644 index 00000000..8acfc9d6 --- /dev/null +++ b/www/analytics/core/ViewDataTable/Request.php @@ -0,0 +1,137 @@ +requestConfig = $requestConfig; + } + + /** + * Function called by the ViewDataTable objects in order to fetch data from the API. + * The function init() must have been called before, so that the object knows which API module and action to call. + * It builds the API request string and uses Request to call the API. + * The requested DataTable object is stored in $this->dataTable. + */ + public function loadDataTableFromAPI($fixedRequestParams = array()) + { + // we build the request (URL) to call the API + $requestArray = $this->getRequestArray(); + + foreach ($fixedRequestParams as $key => $value) { + $requestArray[$key] = $value; + } + + // we make the request to the API + $request = new ApiRequest($requestArray); + + // and get the DataTable structure + $dataTable = $request->process(); + + return $dataTable; + } + + /** + * @return array URL to call the API, eg. "method=Referrers.getKeywords&period=day&date=yesterday"... + */ + public function getRequestArray() + { + // we prepare the array to give to the API Request + // we setup the method and format variable + // - we request the method to call to get this specific DataTable + // - the format = original specifies that we want to get the original DataTable structure itself, not rendered + $requestArray = array( + 'method' => $this->requestConfig->apiMethodToRequestDataTable, + 'format' => 'original' + ); + + $toSetEventually = array( + 'filter_limit', + 'keep_summary_row', + 'filter_sort_column', + 'filter_sort_order', + 'filter_excludelowpop', + 'filter_excludelowpop_value', + 'filter_column', + 'filter_pattern', + ); + + foreach ($toSetEventually as $varToSet) { + $value = $this->getDefaultOrCurrent($varToSet); + if (false !== $value) { + $requestArray[$varToSet] = $value; + } + } + + $segment = ApiRequest::getRawSegmentFromRequest(); + if (!empty($segment)) { + $requestArray['segment'] = $segment; + } + + if (ApiRequest::shouldLoadExpanded()) { + $requestArray['expanded'] = 1; + } + + $requestArray = array_merge($requestArray, $this->requestConfig->request_parameters_to_modify); + + if (!empty($requestArray['filter_limit']) + && $requestArray['filter_limit'] === 0 + ) { + unset($requestArray['filter_limit']); + } + + return $requestArray; + } + + /** + * Returns, for a given parameter, the value of this parameter in the REQUEST array. + * If not set, returns the default value for this parameter @see getDefault() + * + * @param string $nameVar + * @return string|mixed Value of this parameter + */ + protected function getDefaultOrCurrent($nameVar) + { + if (isset($_GET[$nameVar])) { + return Common::sanitizeInputValue($_GET[$nameVar]); + } + $default = $this->getDefault($nameVar); + return $default; + } + + /** + * Returns the default value for a given parameter. + * For example, these default values can be set using the disable* methods. + * + * @param string $nameVar + * @return mixed + */ + protected function getDefault($nameVar) + { + if (isset($this->requestConfig->$nameVar)) { + return $this->requestConfig->$nameVar; + } + + return false; + } + +} diff --git a/www/analytics/core/ViewDataTable/RequestConfig.php b/www/analytics/core/ViewDataTable/RequestConfig.php new file mode 100644 index 00000000..ff4a6c06 --- /dev/null +++ b/www/analytics/core/ViewDataTable/RequestConfig.php @@ -0,0 +1,301 @@ + + * **Client Side Parameters** + * + * Client side parameters are request properties that should be passed on to the browser so + * client side JavaScript can use them. These properties will also be passed to the server with + * every AJAX request made. + * + * Only affects ViewDataTables that output HTML. + * + * + * **Overridable Properties** + * + * Overridable properties are properties that can be set via the query string. + * If a request has a query parameter that matches an overridable property, the property + * will be set to the query parameter value. + * + * **Reusing base properties** + * + * Many of the properties in this class only have meaning for the {@link Piwik\Plugin\Visualization} + * class, but can be set for other visualizations that extend {@link Piwik\Plugin\ViewDataTable} + * directly. + * + * Visualizations that extend {@link Piwik\Plugin\ViewDataTable} directly and want to re-use these + * properties must make sure the properties are used in the exact same way they are used in + * {@link Piwik\Plugin\Visualization}. + * + * **Defining new request properties** + * + * If you are creating your own visualization and want to add new request properties for + * it, extend this class and add your properties as fields. + * + * Properties are marked as client side parameters by calling the + * {@link addPropertiesThatShouldBeAvailableClientSide()} method. + * + * Properties are marked as overridable by calling the + * {@link addPropertiesThatCanBeOverwrittenByQueryParams()} method. + * + * ### Example + * + * **Defining new request properties** + * + * class MyCustomVizRequestConfig extends RequestConfig + * { + * /** + * * My custom property. It is overridable. + * *\/ + * public $my_custom_property = false; + * + * /** + * * Another custom property. It is available client side. + * *\/ + * public $another_custom_property = true; + * + * public function __construct() + * { + * parent::__construct(); + * + * $this->addPropertiesThatShouldBeAvailableClientSide(array('another_custom_property')); + * $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('my_custom_property')); + * } + * } + * + * @api + */ +class RequestConfig +{ + /** + * The list of request parameters that are 'Client Side Parameters'. + */ + public $clientSideParameters = array( + 'filter_excludelowpop', + 'filter_excludelowpop_value', + 'filter_pattern', + 'filter_column', + 'filter_offset' + ); + + /** + * The list of ViewDataTable properties that can be overriden by query parameters. + */ + public $overridableProperties = array( + 'filter_sort_column', + 'filter_sort_order', + 'filter_limit', + 'filter_offset', + 'filter_pattern', + 'filter_column', + 'filter_excludelowpop', + 'filter_excludelowpop_value', + 'disable_generic_filters', + 'disable_queued_filters' + ); + + /** + * Controls which column to sort the DataTable by before truncating and displaying. + * + * Default value: If the report contains nb_uniq_visitors and nb_uniq_visitors is a + * displayed column, then the default value is 'nb_uniq_visitors'. + * Otherwise, it is 'nb_visits'. + */ + public $filter_sort_column = false; + + /** + * Controls the sort order. Either 'asc' or 'desc'. + * + * Default value: 'desc' + */ + public $filter_sort_order = 'desc'; + + /** + * The number of items to truncate the data set to before rendering the DataTable view. + * + * Default value: false + */ + public $filter_limit = false; + + /** + * The number of items from the start of the data set that should be ignored. + * + * Default value: 0 + */ + public $filter_offset = 0; + + /** + * A regex pattern to use to filter the DataTable before it is shown. + * + * @see also self::FILTER_PATTERN_COLUMN + * + * Default value: false + */ + public $filter_pattern = false; + + /** + * The column to apply a filter pattern to. + * + * @see also self::FILTER_PATTERN + * + * Default value: false + */ + public $filter_column = false; + + /** + * Stores the column name to filter when filtering out rows with low values. + * + * Default value: false + */ + public $filter_excludelowpop = false; + + /** + * Stores the value considered 'low' when filtering out rows w/ low values. + * + * Default value: false + * @var \Closure|string + */ + public $filter_excludelowpop_value = false; + + /** + * An array property that contains query parameter name/value overrides for API requests made + * by ViewDataTable. + * + * E.g. array('idSite' => ..., 'period' => 'month') + * + * Default value: array() + */ + public $request_parameters_to_modify = array(); + + /** + * Whether to run generic filters on the DataTable before rendering or not. + * + * @see Piwik\API\DataTableGenericFilter + * + * Default value: false + */ + public $disable_generic_filters = false; + + /** + * Whether to run ViewDataTable's list of queued filters or not. + * + * _NOTE: Priority queued filters are always run._ + * + * Default value: false + */ + public $disable_queued_filters = false; + + /** + * returns 'Plugin.apiMethodName' used for this ViewDataTable, + * eg. 'Actions.getPageUrls' + * + * @var string + */ + public $apiMethodToRequestDataTable = ''; + + /** + * If the current dataTable refers to a subDataTable (eg. keywordsBySearchEngineId for id=X) this variable is set to the Id + * + * @var bool|int + */ + public $idSubtable = false; + + public function getProperties() + { + return get_object_vars($this); + } + + /** + * Marks request properties as client side properties. [Read this](#client-side-properties-desc) + * to learn more. + * + * @param array $propertyNames List of property names, eg, `array('disable_queued_filters', 'filter_column')`. + */ + public function addPropertiesThatShouldBeAvailableClientSide(array $propertyNames) + { + foreach ($propertyNames as $propertyName) { + $this->clientSideParameters[] = $propertyName; + } + } + + /** + * Marks display properties as overridable. [Read this](#overridable-properties-desc) to + * learn more. + * + * @param array $propertyNames List of property names, eg, `array('disable_queued_filters', 'filter_column')`. + */ + public function addPropertiesThatCanBeOverwrittenByQueryParams(array $propertyNames) + { + foreach ($propertyNames as $propertyName) { + $this->overridableProperties[] = $propertyName; + } + } + + public function setDefaultSort($columnsToDisplay, $hasNbUniqVisitors) + { + // default sort order to visits/visitors data + if ($hasNbUniqVisitors && in_array('nb_uniq_visitors', $columnsToDisplay)) { + $this->filter_sort_column = 'nb_uniq_visitors'; + } else { + $this->filter_sort_column = 'nb_visits'; + } + + $this->filter_sort_order = 'desc'; + } + + /** + * Returns `true` if queued filters have been disabled, `false` if otherwise. + * + * @return bool + */ + public function areQueuedFiltersDisabled() + { + return isset($this->disable_queued_filters) && $this->disable_queued_filters; + } + + /** + * Returns `true` if generic filters have been disabled, `false` if otherwise. + * + * @return bool + */ + public function areGenericFiltersDisabled() + { + // if disable_generic_filters query param is set to '1', generic filters are disabled + if (Common::getRequestVar('disable_generic_filters', '0', 'string') == 1) { + return true; + } + + if (isset($this->disable_generic_filters) && true === $this->disable_generic_filters) { + return true; + } + + return false; + } + + public function getApiModuleToRequest() + { + list($module, $method) = explode('.', $this->apiMethodToRequestDataTable); + + return $module; + } + + public function getApiMethodToRequest() + { + list($module, $method) = explode('.', $this->apiMethodToRequestDataTable); + + return $method; + } +} diff --git a/www/analytics/core/Visualization/Sparkline.php b/www/analytics/core/Visualization/Sparkline.php new file mode 100644 index 00000000..098124e5 --- /dev/null +++ b/www/analytics/core/Visualization/Sparkline.php @@ -0,0 +1,177 @@ +values = $data; + } + + /** + * Sets the height of the sparkline + * @param int $height + */ + public function setHeight($height) + { + + if (!is_numeric($height) || $height <= 0) { + return; + } + + $this->_height = (int)$height; + } + + /** + * Sets the width of the sparkline + * @param int $width + */ + public function setWidth($width) + { + + if (!is_numeric($width) || $width <= 0) { + return; + } + + $this->_width = (int)$width; + } + + /** + * Returns the width of the sparkline + * @return int + */ + public function getWidth() + { + return $this->_width; + } + + /** + * Returns the height of the sparkline + * @return int + */ + public function getHeight() + { + return $this->_height; + } + + public function main() + { + $width = $this->getWidth(); + $height = $this->getHeight(); + + $sparkline = new Sparkline_Line(); + $this->setSparklineColors($sparkline); + + $min = $max = $last = null; + $i = 0; + $toRemove = array('%', str_replace('%s', '', Piwik::translate('General_Seconds'))); + foreach ($this->values as $value) { + // 50% and 50s should be plotted as 50 + $value = str_replace($toRemove, '', $value); + // replace localized decimal separator + $value = str_replace(',', '.', $value); + if ($value == '') { + $value = 0; + } + + $sparkline->SetData($i, $value); + + if (null == $min || $value <= $min[1]) { + $min = array($i, $value); + } + if (null == $max || $value >= $max[1]) { + $max = array($i, $value); + } + $last = array($i, $value); + $i++; + } + $sparkline->SetYMin(0); + $sparkline->SetYMax($max[1]); + $sparkline->SetPadding(3, 0, 2, 0); // top, right, bottom, left + $sparkline->SetFeaturePoint($min[0], $min[1], 'minPointColor', 5); + $sparkline->SetFeaturePoint($max[0], $max[1], 'maxPointColor', 5); + $sparkline->SetFeaturePoint($last[0], $last[1], 'lastPointColor', 5); + $sparkline->SetLineSize(3); // for renderresampled, linesize is on virtual image + $ratio = 1; + $sparkline->RenderResampled($width * $ratio, $height * $ratio); + $this->sparkline = $sparkline; + } + + public function render() + { + if (self::$enableSparklineImages) { + $this->sparkline->Output(); + } + } + + /** + * Sets the sparkline colors + * + * @param Sparkline_Line $sparkline + */ + private function setSparklineColors($sparkline) + { + $colors = Common::getRequestVar('colors', false, 'json'); + if (empty($colors)) { // quick fix so row evolution sparklines will have color in widgetize's iframes + $colors = array( + 'backgroundColor' => '#ffffff', + 'lineColor' => '#162C4A', + 'minPointColor' => '#ff7f7f', + 'lastPointColor' => '#55AAFF', + 'maxPointColor' => '#75BF7C' + ); + } + + foreach (self::$colorNames as $name) { + if (!empty($colors[$name])) { + $sparkline->SetColorHtml($name, $colors[$name]); + } + } + } +} diff --git a/www/analytics/core/WidgetsList.php b/www/analytics/core/WidgetsList.php new file mode 100644 index 00000000..bb85a373 --- /dev/null +++ b/www/analytics/core/WidgetsList.php @@ -0,0 +1,215 @@ + array( + * array(...), // info about first widget in this category + * array(...) // info about second widget in this category, etc. + * ), + * 'Visits' => array( + * array(...), + * array(...) + * ), + * ) + * ``` + */ + static public function get() + { + self::addWidgets(); + + uksort(self::$widgets, array('Piwik\WidgetsList', '_sortWidgetCategories')); + + $widgets = array(); + foreach (self::$widgets as $key => $v) { + if (isset($widgets[Piwik::translate($key)])) { + $v = array_merge($widgets[Piwik::translate($key)], $v); + } + $widgets[Piwik::translate($key)] = $v; + } + return $widgets; + } + + private static function addWidgets() + { + if (!self::$hookCalled) { + self::$hookCalled = true; + + /** + * Used to collect all available dashboard widgets. + * + * Subscribe to this event to make your plugin's reports or other controller actions available + * as dashboard widgets. Event handlers should call the {@link WidgetsList::add()} method for each + * new dashboard widget. + * + * **Example** + * + * public function addWidgets() + * { + * WidgetsList::add('General_Actions', 'General_Pages', 'Actions', 'getPageUrls'); + * } + */ + Piwik::postEvent('WidgetsList.addWidgets'); + } + } + + /** + * Sorting method for widget categories + * + * @param string $a + * @param string $b + * @return bool + */ + protected static function _sortWidgetCategories($a, $b) + { + $order = array( + 'VisitsSummary_VisitsSummary', + 'Live!', + 'General_Visitors', + 'UserSettings_VisitorSettings', + 'DevicesDetection_DevicesDetection', + 'General_Actions', + 'Events_Events', + 'Actions_SubmenuSitesearch', + 'Referrers_Referrers', + 'Goals_Goals', + 'Goals_Ecommerce', + '_others_', + 'Example Widgets', + 'ExamplePlugin_exampleWidgets', + ); + + if (($oa = array_search($a, $order)) === false) { + $oa = array_search('_others_', $order); + } + if (($ob = array_search($b, $order)) === false) { + $ob = array_search('_others_', $order); + } + return $oa > $ob; + } + + /** + * Adds a report to the list of dashboard widgets. + * + * @param string $widgetCategory The widget category. This can be a translation token. + * @param string $widgetName The name of the widget. This can be a translation token. + * @param string $controllerName The report's controller name (same as the plugin name). + * @param string $controllerAction The report's controller action method name. + * @param array $customParameters Extra query parameters that should be sent while getting + * this report. + */ + static public function add($widgetCategory, $widgetName, $controllerName, $controllerAction, $customParameters = array()) + { + $widgetName = Piwik::translate($widgetName); + $widgetUniqueId = 'widget' . $controllerName . $controllerAction; + foreach ($customParameters as $name => $value) { + if (is_array($value)) { + // use 'Array' for backward compatibility; + // could we switch to using $value[0]? + $value = 'Array'; + } + $widgetUniqueId .= $name . $value; + } + self::$widgets[$widgetCategory][] = array( + 'name' => $widgetName, + 'uniqueId' => $widgetUniqueId, + 'parameters' => array('module' => $controllerName, + 'action' => $controllerAction + ) + $customParameters + ); + } + + /** + * Removes one or more widgets from the widget list. + * + * @param string $widgetCategory The widget category. Can be a translation token. + * @param string|false $widgetName The name of the widget to remove. Cannot be a + * translation token. If not supplied, the entire category + * will be removed. + */ + static public function remove($widgetCategory, $widgetName = false) + { + if (!isset(self::$widgets[$widgetCategory])) { + return; + } + + if (empty($widgetName)) { + unset(self::$widgets[$widgetCategory]); + return; + } + foreach (self::$widgets[$widgetCategory] as $id => $widget) { + if ($widget['name'] == $widgetName || $widget['name'] == Piwik::translate($widgetName)) { + unset(self::$widgets[$widgetCategory][$id]); + return; + } + } + } + + /** + * Returns `true` if a report exists in the widget list, `false` if otherwise. + * + * @param string $controllerName The controller name of the report. + * @param string $controllerAction The controller action of the report. + * @return bool + */ + static public function isDefined($controllerName, $controllerAction) + { + $widgetsList = self::get(); + foreach ($widgetsList as $widgetCategory => $widgets) { + foreach ($widgets as $widget) { + if ($widget['parameters']['module'] == $controllerName + && $widget['parameters']['action'] == $controllerAction + ) { + return true; + } + } + } + return false; + } + + /** + * Method to reset the widget list + * For testing only + * @ignore + */ + public static function _reset() + { + self::$widgets = null; + self::$hookCalled = false; + } +} diff --git a/www/analytics/core/dispatch.php b/www/analytics/core/dispatch.php new file mode 100644 index 00000000..c8a3769e --- /dev/null +++ b/www/analytics/core/dispatch.php @@ -0,0 +1,39 @@ +init(); + $response = $controller->dispatch(); + + if (!is_null($response)) { + echo $response; + } +} diff --git a/www/analytics/core/testMinimumPhpVersion.php b/www/analytics/core/testMinimumPhpVersion.php new file mode 100644 index 00000000..118c7204 --- /dev/null +++ b/www/analytics/core/testMinimumPhpVersion.php @@ -0,0 +1,139 @@ + 0; +if ($minimumPhpInvalid) { + $piwik_errorMessage .= "

    To run Piwik you need at least PHP version $piwik_minimumPHPVersion

    +

    Unfortunately it seems your webserver is using PHP version $piwik_currentPHPVersion.

    +

    Please try to update your PHP version, Piwik is really worth it! Nowadays most web hosts + support PHP $piwik_minimumPHPVersion.

    +

    Also see the FAQ: My Web host supports PHP4 by default. How can I enable PHP5?

    "; +} else { + if (!class_exists('ArrayObject')) { + $piwik_errorMessage .= "

    Piwik and Zend Framework require the SPL extension

    +

    It appears your PHP was compiled with

    --disable-spl
    . + To enjoy Piwik, you need PHP compiled without that configure option.

    "; + } + + if (!extension_loaded('session')) { + $piwik_errorMessage .= "

    Piwik and Zend_Session require the session extension

    +

    It appears your PHP was compiled with

    --disable-session
    . + To enjoy Piwik, you need PHP compiled without that configure option.

    "; + } + + if (!function_exists('ini_set')) { + $piwik_errorMessage .= "

    Piwik and Zend_Session require the ini_set() function

    +

    It appears your PHP has disabled this function. + To enjoy Piwik, you need remove

    ini_set
    from your
    disable_functions
    directive in php.ini, and restart your webserver.

    "; + } + + if (!function_exists('json_encode')) { + $piwik_errorMessage .= "

    Piwik requires the php5-json extension which provides the functions json_encode() and json_decode()

    +

    It appears your PHP has not yet installed the php5-json extension. + To use Piwik, please ask your web host to install php5-json or install it yourself, for example on debian system: sudo apt-get install php5-json.
    Then restart your webserver and refresh this page.

    "; + } + + if (!file_exists(PIWIK_INCLUDE_PATH . '/vendor/autoload.php') && !file_exists(PIWIK_INCLUDE_PATH . '/../../autoload.php')) { + $composerInstall = "In the piwik directory, run in the command line the following (eg. via ssh): \n\n" + . "
     curl -sS https://getcomposer.org/installer | php \n\n php composer.phar install\n\n
    "; + if (DIRECTORY_SEPARATOR === '\\' /* ::isWindows() */) { + $composerInstall = "Download and run Composer-Setup.exe, it will install the latest Composer version and set up your PATH so that you can just call composer from any directory in your command line. " + . "
    Then run this command in a terminal in the piwik directory:
    $ php composer.phar update "; + } + $piwik_errorMessage .= "

    It appears the composer tool is not yet installed. You can install Composer in a few easy steps:\n\n". + "
    " . $composerInstall. + " This will initialize composer for Piwik and download libraries we use in vendor/* directory.". + "\n\n

    Then reload this page to access your analytics reports." . + "\n\n

    Note: if for some reasons you cannot install composer, instead install the latest Piwik release from ". + "builds.piwik.org.

    "; + } +} + +if (!function_exists('Piwik_ExitWithMessage')) { + /** + * Returns true if Piwik should print the backtrace with error messages. + * + * To make sure the backtrace is printed, define PIWIK_PRINT_ERROR_BACKTRACE. + * + * @return bool + */ + function Piwik_ShouldPrintBackTraceWithMessage() + { + $bool = (defined('PIWIK_PRINT_ERROR_BACKTRACE') && PIWIK_PRINT_ERROR_BACKTRACE) + || !empty($GLOBALS['PIWIK_TRACKER_DEBUG']); + return $bool; + } + + /** + * Displays info/warning/error message in a friendly UI and exits. + * + * @param string $message Main message, must be html encoded before calling + * @param bool|string $optionalTrace Backtrace; will be displayed in lighter color + * @param bool $optionalLinks If true, will show links to the Piwik website for help + * @param bool $optionalLinkBack If true, displays a link to go back + */ + function Piwik_ExitWithMessage($message, $optionalTrace = false, $optionalLinks = false, $optionalLinkBack = false) + { + @header('Content-Type: text/html; charset=utf-8'); + if ($optionalTrace) { + $optionalTrace = 'Backtrace:
    ' . $optionalTrace . '
    '; + } + $isCli = PHP_SAPI == 'cli'; + if ($optionalLinks) { + $optionalLinks = ''; + } + if ($optionalLinkBack) { + $optionalLinkBack = 'Go Back
    '; + } + $headerPage = file_get_contents(PIWIK_INCLUDE_PATH . '/plugins/Zeitgeist/templates/simpleLayoutHeader.tpl'); + $footerPage = file_get_contents(PIWIK_INCLUDE_PATH . '/plugins/Zeitgeist/templates/simpleLayoutFooter.tpl'); + + $headerPage = str_replace('{$HTML_TITLE}', 'Piwik › Error', $headerPage); + + $content = '

    ' . $message . '

    +

    ' + . $optionalLinkBack + . 'Go to Piwik
    + Login' + . '

    ' + . ' ' . (Piwik_ShouldPrintBackTraceWithMessage() ? $optionalTrace : '') + . ' ' . $optionalLinks; + + if($isCli) { + $message = str_replace(array("
    ", "
    ", "
    ", "

    "), "\n", $message); + $message = str_replace("\t", "", $message); + echo strip_tags($message); + } else { + echo $headerPage . $content . $footerPage; + } + echo "\n"; + exit(1); + } +} + +if (!empty($piwik_errorMessage)) { + Piwik_ExitWithMessage($piwik_errorMessage, false, true); +} diff --git a/www/analytics/favicon.ico b/www/analytics/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/www/analytics/index.php b/www/analytics/index.php new file mode 100644 index 00000000..e7eaf1a9 --- /dev/null +++ b/www/analytics/index.php @@ -0,0 +1,47 @@ + + +Allow from all +Require all granted + + + +Allow from all +Require all granted + + + +Allow from all +Require all granted + +Satisfy any + diff --git a/www/analytics/js/LICENSE.txt b/www/analytics/js/LICENSE.txt new file mode 100644 index 00000000..76258a84 --- /dev/null +++ b/www/analytics/js/LICENSE.txt @@ -0,0 +1,32 @@ +Copyright 2013 Anthon Pang + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name of Anthon Pang nor the names of its contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +View online: http://piwik.org/free-software/bsd/ diff --git a/www/analytics/js/README.md b/www/analytics/js/README.md new file mode 100644 index 00000000..478fea68 --- /dev/null +++ b/www/analytics/js/README.md @@ -0,0 +1,58 @@ +## Introduction + +The js/ folder contains: + +* index.php - a servlet described below +* piwik.js - the uncompressed piwik.js source for you to study or reference +* README.md - this documentation file + +### Why Use "js/index.php"? + +* js/index.php (or implicitly as "js/") can be used to serve up the minified + piwik.js + + * it supports conditional-GET and Last-Modified, so piwik.js can be cached + by the browser + * it supports deflate/gzip compression if your web server (e.g., Apache + without mod_deflate or mod_gzip), shrinking the data transfer to 8K + +* js/index.php (or implicitly as "js/") can also act as a proxy to piwik.php + +* If you are concerned about the impact of browser-based privacy filters which + attempt to block tracking, you can change your tracking code to use "js/" + instead of "piwik.js" and "piwik.php", respectively. + +## Deployment + +* piwik.js is minified using YUICompressor 2.4.2. + To install YUICompressor run: + + ``` + $ cd /path/to/piwik/js/ + $ wget http://www.julienlecomte.net/yuicompressor/yuicompressor-2.4.2.zip + $ unzip yuicompressor-2.4.2.zip + ``` + + To compress the code containing the evil "eval", either apply the patch from + http://yuilibrary.com/projects/yuicompressor/ticket/2343811, + or run: + + ``` + $ cd /path/to/piwik/js/ + $ sed '//,/<\/DEBUG>/d' < piwik.js | sed 's/eval/replacedEvilString/' | java -jar yuicompressor-2.4.2/build/yuicompressor-2.4.2.jar --type js --line-break 1000 | sed 's/replacedEvilString/eval/' | sed 's/^[/][*]/\/*!/' > piwik-min.js && cp piwik-min.js ../piwik.js + ``` + + This will generate the minify /path/to/piwik/js/piwik-min.js and copy it to + /path/to/piwik/piwik.js + +* In a production environment, the tests/javascript folder is not used and can + be removed (if present). + + Note: if the file "js/tests/enable_sqlite" exists, additional unit tests + (requires the sqlite extension) are enabled. + +* We use /*! to include Piwik's license header in the minified source. Read + Stallman's "The JavaScript Trap" for more information. + +* We do not include the version number as a security best practice + (information disclosure). diff --git a/www/analytics/js/index.php b/www/analytics/js/index.php new file mode 100644 index 00000000..5cf84667 --- /dev/null +++ b/www/analytics/js/index.php @@ -0,0 +1,37 @@ + 1000 times + */ + function beforeUnloadHandler() { + var now; + + executePluginMethod('unload'); + + /* + * Delay/pause (blocks UI) + */ + if (expireDateTime) { + // the things we do for backwards compatibility... + // in ECMA-262 5th ed., we could simply use: + // while (Date.now() < expireDateTime) { } + do { + now = new Date(); + } while (now.getTimeAlias() < expireDateTime); + } + } + + /* + * Handler for onload event + */ + function loadHandler() { + var i; + + if (!hasLoaded) { + hasLoaded = true; + executePluginMethod('load'); + for (i = 0; i < registeredOnLoadHandlers.length; i++) { + registeredOnLoadHandlers[i](); + } + } + + return true; + } + + /* + * Add onload or DOM ready handler + */ + function addReadyListener() { + var _timer; + + if (documentAlias.addEventListener) { + addEventListener(documentAlias, 'DOMContentLoaded', function ready() { + documentAlias.removeEventListener('DOMContentLoaded', ready, false); + loadHandler(); + }); + } else if (documentAlias.attachEvent) { + documentAlias.attachEvent('onreadystatechange', function ready() { + if (documentAlias.readyState === 'complete') { + documentAlias.detachEvent('onreadystatechange', ready); + loadHandler(); + } + }); + + if (documentAlias.documentElement.doScroll && windowAlias === windowAlias.top) { + (function ready() { + if (!hasLoaded) { + try { + documentAlias.documentElement.doScroll('left'); + } catch (error) { + setTimeout(ready, 0); + + return; + } + loadHandler(); + } + }()); + } + } + + // sniff for older WebKit versions + if ((new RegExp('WebKit')).test(navigatorAlias.userAgent)) { + _timer = setInterval(function () { + if (hasLoaded || /loaded|complete/.test(documentAlias.readyState)) { + clearInterval(_timer); + loadHandler(); + } + }, 10); + } + + // fallback + addEventListener(windowAlias, 'load', loadHandler, false); + } + + /* + * Load JavaScript file (asynchronously) + */ + function loadScript(src, onLoad) { + var script = documentAlias.createElement('script'); + + script.type = 'text/javascript'; + script.src = src; + + if (script.readyState) { + script.onreadystatechange = function () { + var state = this.readyState; + + if (state === 'loaded' || state === 'complete') { + script.onreadystatechange = null; + onLoad(); + } + }; + } else { + script.onload = onLoad; + } + + documentAlias.getElementsByTagName('head')[0].appendChild(script); + } + + /* + * Get page referrer + */ + function getReferrer() { + var referrer = ''; + + try { + referrer = windowAlias.top.document.referrer; + } catch (e) { + if (windowAlias.parent) { + try { + referrer = windowAlias.parent.document.referrer; + } catch (e2) { + referrer = ''; + } + } + } + + if (referrer === '') { + referrer = documentAlias.referrer; + } + + return referrer; + } + + /* + * Extract scheme/protocol from URL + */ + function getProtocolScheme(url) { + var e = new RegExp('^([a-z]+):'), + matches = e.exec(url); + + return matches ? matches[1] : null; + } + + /* + * Extract hostname from URL + */ + function getHostName(url) { + // scheme : // [username [: password] @] hostame [: port] [/ [path] [? query] [# fragment]] + var e = new RegExp('^(?:(?:https?|ftp):)/*(?:[^@]+@)?([^:/#]+)'), + matches = e.exec(url); + + return matches ? matches[1] : url; + } + + /* + * Extract parameter from URL + */ + function getParameter(url, name) { + var regexSearch = "[\\?&#]" + name + "=([^&#]*)"; + var regex = new RegExp(regexSearch); + var results = regex.exec(url); + return results ? decodeWrapper(results[1]) : ''; + } + + /* + * UTF-8 encoding + */ + function utf8_encode(argString) { + return urldecode(encodeWrapper(argString)); + } + + /************************************************************ + * sha1 + * - based on sha1 from http://phpjs.org/functions/sha1:512 (MIT / GPL v2) + ************************************************************/ + + function sha1(str) { + // + original by: Webtoolkit.info (http://www.webtoolkit.info/) + // + namespaced by: Michael White (http://getsprink.com) + // + input by: Brett Zamir (http://brett-zamir.me) + // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // + jslinted by: Anthon Pang (http://piwik.org) + + var + rotate_left = function (n, s) { + return (n << s) | (n >>> (32 - s)); + }, + + cvt_hex = function (val) { + var strout = '', + i, + v; + + for (i = 7; i >= 0; i--) { + v = (val >>> (i * 4)) & 0x0f; + strout += v.toString(16); + } + + return strout; + }, + + blockstart, + i, + j, + W = [], + H0 = 0x67452301, + H1 = 0xEFCDAB89, + H2 = 0x98BADCFE, + H3 = 0x10325476, + H4 = 0xC3D2E1F0, + A, + B, + C, + D, + E, + temp, + str_len, + word_array = []; + + str = utf8_encode(str); + str_len = str.length; + + for (i = 0; i < str_len - 3; i += 4) { + j = str.charCodeAt(i) << 24 | str.charCodeAt(i + 1) << 16 | + str.charCodeAt(i + 2) << 8 | str.charCodeAt(i + 3); + word_array.push(j); + } + + switch (str_len & 3) { + case 0: + i = 0x080000000; + break; + case 1: + i = str.charCodeAt(str_len - 1) << 24 | 0x0800000; + break; + case 2: + i = str.charCodeAt(str_len - 2) << 24 | str.charCodeAt(str_len - 1) << 16 | 0x08000; + break; + case 3: + i = str.charCodeAt(str_len - 3) << 24 | str.charCodeAt(str_len - 2) << 16 | str.charCodeAt(str_len - 1) << 8 | 0x80; + break; + } + + word_array.push(i); + + while ((word_array.length & 15) !== 14) { + word_array.push(0); + } + + word_array.push(str_len >>> 29); + word_array.push((str_len << 3) & 0x0ffffffff); + + for (blockstart = 0; blockstart < word_array.length; blockstart += 16) { + for (i = 0; i < 16; i++) { + W[i] = word_array[blockstart + i]; + } + + for (i = 16; i <= 79; i++) { + W[i] = rotate_left(W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16], 1); + } + + A = H0; + B = H1; + C = H2; + D = H3; + E = H4; + + for (i = 0; i <= 19; i++) { + temp = (rotate_left(A, 5) + ((B & C) | (~B & D)) + E + W[i] + 0x5A827999) & 0x0ffffffff; + E = D; + D = C; + C = rotate_left(B, 30); + B = A; + A = temp; + } + + for (i = 20; i <= 39; i++) { + temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0x6ED9EBA1) & 0x0ffffffff; + E = D; + D = C; + C = rotate_left(B, 30); + B = A; + A = temp; + } + + for (i = 40; i <= 59; i++) { + temp = (rotate_left(A, 5) + ((B & C) | (B & D) | (C & D)) + E + W[i] + 0x8F1BBCDC) & 0x0ffffffff; + E = D; + D = C; + C = rotate_left(B, 30); + B = A; + A = temp; + } + + for (i = 60; i <= 79; i++) { + temp = (rotate_left(A, 5) + (B ^ C ^ D) + E + W[i] + 0xCA62C1D6) & 0x0ffffffff; + E = D; + D = C; + C = rotate_left(B, 30); + B = A; + A = temp; + } + + H0 = (H0 + A) & 0x0ffffffff; + H1 = (H1 + B) & 0x0ffffffff; + H2 = (H2 + C) & 0x0ffffffff; + H3 = (H3 + D) & 0x0ffffffff; + H4 = (H4 + E) & 0x0ffffffff; + } + + temp = cvt_hex(H0) + cvt_hex(H1) + cvt_hex(H2) + cvt_hex(H3) + cvt_hex(H4); + + return temp.toLowerCase(); + } + + /************************************************************ + * end sha1 + ************************************************************/ + + /* + * Fix-up URL when page rendered from search engine cache or translated page + */ + function urlFixup(hostName, href, referrer) { + if (hostName === 'translate.googleusercontent.com') { // Google + if (referrer === '') { + referrer = href; + } + + href = getParameter(href, 'u'); + hostName = getHostName(href); + } else if (hostName === 'cc.bingj.com' || // Bing + hostName === 'webcache.googleusercontent.com' || // Google + hostName.slice(0, 5) === '74.6.') { // Yahoo (via Inktomi 74.6.0.0/16) + href = documentAlias.links[0].href; + hostName = getHostName(href); + } + + return [hostName, href, referrer]; + } + + /* + * Fix-up domain + */ + function domainFixup(domain) { + var dl = domain.length; + + // remove trailing '.' + if (domain.charAt(--dl) === '.') { + domain = domain.slice(0, dl); + } + + // remove leading '*' + if (domain.slice(0, 2) === '*.') { + domain = domain.slice(1); + } + + return domain; + } + + /* + * Title fixup + */ + function titleFixup(title) { + title = title && title.text ? title.text : title; + + if (!isString(title)) { + var tmp = documentAlias.getElementsByTagName('title'); + + if (tmp && isDefined(tmp[0])) { + title = tmp[0].text; + } + } + + return title; + } + + /************************************************************ + * Page Overlay + ************************************************************/ + + function getPiwikUrlForOverlay(trackerUrl, apiUrl) { + if (apiUrl) { + return apiUrl; + } + + if (trackerUrl.slice(-9) === 'piwik.php') { + trackerUrl = trackerUrl.slice(0, trackerUrl.length - 9); + } + + return trackerUrl; + } + + /* + * Check whether this is a page overlay session + * + * @return boolean + * + * {@internal side-effect: modifies window.name }} + */ + function isOverlaySession(configTrackerSiteId) { + var windowName = 'Piwik_Overlay'; + + // check whether we were redirected from the piwik overlay plugin + var referrerRegExp = new RegExp('index\\.php\\?module=Overlay&action=startOverlaySession' + + '&idSite=([0-9]+)&period=([^&]+)&date=([^&]+)$'); + + var match = referrerRegExp.exec(documentAlias.referrer); + + if (match) { + // check idsite + var idsite = match[1]; + + if (idsite !== String(configTrackerSiteId)) { + return false; + } + + // store overlay session info in window name + var period = match[2], + date = match[3]; + + windowAlias.name = windowName + '###' + period + '###' + date; + } + + // retrieve and check data from window name + var windowNameParts = windowAlias.name.split('###'); + + return windowNameParts.length === 3 && windowNameParts[0] === windowName; + } + + /* + * Inject the script needed for page overlay + */ + function injectOverlayScripts(configTrackerUrl, configApiUrl, configTrackerSiteId) { + var windowNameParts = windowAlias.name.split('###'), + period = windowNameParts[1], + date = windowNameParts[2], + piwikUrl = getPiwikUrlForOverlay(configTrackerUrl, configApiUrl); + + loadScript( + piwikUrl + 'plugins/Overlay/client/client.js?v=1', + function () { + Piwik_Overlay_Client.initialize(piwikUrl, configTrackerSiteId, period, date); + } + ); + } + + /************************************************************ + * End Page Overlay + ************************************************************/ + + /* + * Piwik Tracker class + * + * trackerUrl and trackerSiteId are optional arguments to the constructor + * + * See: Tracker.setTrackerUrl() and Tracker.setSiteId() + */ + function Tracker(trackerUrl, siteId) { + + /************************************************************ + * Private members + ************************************************************/ + + var +/**/ + /* + * registered test hooks + */ + registeredHooks = {}, +/**/ + + // Current URL and Referrer URL + locationArray = urlFixup(documentAlias.domain, windowAlias.location.href, getReferrer()), + domainAlias = domainFixup(locationArray[0]), + locationHrefAlias = locationArray[1], + configReferrerUrl = locationArray[2], + + enableJSErrorTracking = false, + + // Request method (GET or POST) + configRequestMethod = 'GET', + + // Tracker URL + configTrackerUrl = trackerUrl || '', + + // API URL (only set if it differs from the Tracker URL) + configApiUrl = '', + + // This string is appended to the Tracker URL Request (eg. to send data that is not handled by the existin setters/getters) + configAppendToTrackingUrl = '', + + // Site ID + configTrackerSiteId = siteId || '', + + // Document URL + configCustomUrl, + + // Document title + configTitle = documentAlias.title, + + // Extensions to be treated as download links + configDownloadExtensions = '7z|aac|apk|ar[cj]|as[fx]|avi|azw3|bin|csv|deb|dmg|docx?|epub|exe|flv|gif|gz|gzip|hqx|jar|jpe?g|js|mobi|mp(2|3|4|e?g)|mov(ie)?|ms[ip]|od[bfgpst]|og[gv]|pdf|phps|png|pptx?|qtm?|ra[mr]?|rpm|sea|sit|tar|t?bz2?|tgz|torrent|txt|wav|wm[av]|wpd||xlsx?|xml|z|zip', + + // Hosts or alias(es) to not treat as outlinks + configHostsAlias = [domainAlias], + + // HTML anchor element classes to not track + configIgnoreClasses = [], + + // HTML anchor element classes to treat as downloads + configDownloadClasses = [], + + // HTML anchor element classes to treat at outlinks + configLinkClasses = [], + + // Maximum delay to wait for web bug image to be fetched (in milliseconds) + configTrackerPause = 500, + + // Minimum visit time after initial page view (in milliseconds) + configMinimumVisitTime, + + // Recurring heart beat after initial ping (in milliseconds) + configHeartBeatTimer, + + // Disallow hash tags in URL + configDiscardHashTag, + + // Custom data + configCustomData, + + // Campaign names + configCampaignNameParameters = [ 'pk_campaign', 'piwik_campaign', 'utm_campaign', 'utm_source', 'utm_medium' ], + + // Campaign keywords + configCampaignKeywordParameters = [ 'pk_kwd', 'piwik_kwd', 'utm_term' ], + + // First-party cookie name prefix + configCookieNamePrefix = '_pk_', + + // First-party cookie domain + // User agent defaults to origin hostname + configCookieDomain, + + // First-party cookie path + // Default is user agent defined. + configCookiePath, + + // Cookies are disabled + configCookiesDisabled = false, + + // Do Not Track + configDoNotTrack, + + // Count sites which are pre-rendered + configCountPreRendered, + + // Do we attribute the conversion to the first referrer or the most recent referrer? + configConversionAttributionFirstReferrer, + + // Life of the visitor cookie (in milliseconds) + configVisitorCookieTimeout = 63072000000, // 2 years + + // Life of the session cookie (in milliseconds) + configSessionCookieTimeout = 1800000, // 30 minutes + + // Life of the referral cookie (in milliseconds) + configReferralCookieTimeout = 15768000000, // 6 months + + // Is performance tracking enabled + configPerformanceTrackingEnabled = true, + + // Generation time set from the server + configPerformanceGenerationTime = 0, + + // Custom Variables read from cookie, scope "visit" + customVariables = false, + + // Custom Variables, scope "page" + customVariablesPage = {}, + + // Custom Variables, scope "event" + customVariablesEvent = {}, + + // Custom Variables names and values are each truncated before being sent in the request or recorded in the cookie + customVariableMaximumLength = 200, + + // Ecommerce items + ecommerceItems = {}, + + // Browser features via client-side data collection + browserFeatures = {}, + + // Guard against installing the link tracker more than once per Tracker instance + linkTrackingInstalled = false, + + // Guard against installing the activity tracker more than once per Tracker instance + activityTrackingInstalled = false, + + // Last activity timestamp + lastActivityTime, + + // Internal state of the pseudo click handler + lastButton, + lastTarget, + + // Hash function + hash = sha1, + + // Domain hash value + domainHash, + + // Visitor UUID + visitorUUID; + + + /* + * Set cookie value + */ + function setCookie(cookieName, value, msToExpire, path, domain, secure) { + if (configCookiesDisabled) { + return; + } + + var expiryDate; + + // relative time to expire in milliseconds + if (msToExpire) { + expiryDate = new Date(); + expiryDate.setTime(expiryDate.getTime() + msToExpire); + } + + documentAlias.cookie = cookieName + '=' + encodeWrapper(value) + + (msToExpire ? ';expires=' + expiryDate.toGMTString() : '') + + ';path=' + (path || '/') + + (domain ? ';domain=' + domain : '') + + (secure ? ';secure' : ''); + } + + /* + * Get cookie value + */ + function getCookie(cookieName) { + if (configCookiesDisabled) { + return 0; + } + + var cookiePattern = new RegExp('(^|;)[ ]*' + cookieName + '=([^;]*)'), + cookieMatch = cookiePattern.exec(documentAlias.cookie); + + return cookieMatch ? decodeWrapper(cookieMatch[2]) : 0; + } + + /* + * Removes hash tag from the URL + * + * URLs are purified before being recorded in the cookie, + * or before being sent as GET parameters + */ + function purify(url) { + var targetPattern; + + if (configDiscardHashTag) { + targetPattern = new RegExp('#.*'); + + return url.replace(targetPattern, ''); + } + + return url; + } + + /* + * Resolve relative reference + * + * Note: not as described in rfc3986 section 5.2 + */ + function resolveRelativeReference(baseUrl, url) { + var protocol = getProtocolScheme(url), + i; + + if (protocol) { + return url; + } + + if (url.slice(0, 1) === '/') { + return getProtocolScheme(baseUrl) + '://' + getHostName(baseUrl) + url; + } + + baseUrl = purify(baseUrl); + + i = baseUrl.indexOf('?'); + if (i >= 0) { + baseUrl = baseUrl.slice(0, i); + } + + i = baseUrl.lastIndexOf('/'); + if (i !== baseUrl.length - 1) { + baseUrl = baseUrl.slice(0, i + 1); + } + + return baseUrl + url; + } + + /* + * Is the host local? (i.e., not an outlink) + */ + function isSiteHostName(hostName) { + var i, + alias, + offset; + + for (i = 0; i < configHostsAlias.length; i++) { + alias = domainFixup(configHostsAlias[i].toLowerCase()); + + if (hostName === alias) { + return true; + } + + if (alias.slice(0, 1) === '.') { + if (hostName === alias.slice(1)) { + return true; + } + + offset = hostName.length - alias.length; + + if ((offset > 0) && (hostName.slice(offset) === alias)) { + return true; + } + } + } + + return false; + } + + /* + * Send image request to Piwik server using GET. + * The infamous web bug (or beacon) is a transparent, single pixel (1x1) image + */ + function getImage(request) { + var image = new Image(1, 1); + + image.onload = function () { + iterator = 0; // To avoid JSLint warning of empty block + }; + image.src = configTrackerUrl + (configTrackerUrl.indexOf('?') < 0 ? '?' : '&') + request; + } + + /* + * POST request to Piwik server using XMLHttpRequest. + */ + function sendXmlHttpRequest(request) { + try { + // we use the progid Microsoft.XMLHTTP because + // IE5.5 included MSXML 2.5; the progid MSXML2.XMLHTTP + // is pinned to MSXML2.XMLHTTP.3.0 + var xhr = windowAlias.XMLHttpRequest + ? new windowAlias.XMLHttpRequest() + : windowAlias.ActiveXObject + ? new ActiveXObject('Microsoft.XMLHTTP') + : null; + + xhr.open('POST', configTrackerUrl, true); + + // fallback on error + xhr.onreadystatechange = function () { + if (this.readyState === 4 && this.status !== 200) { + getImage(request); + } + }; + + // see XMLHttpRequest Level 2 spec, section 4.7.2 for invalid headers + // @link http://dvcs.w3.org/hg/xhr/raw-file/tip/Overview.html + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); + + xhr.send(request); + } catch (e) { + // fallback + getImage(request); + } + } + + /* + * Send request + */ + function sendRequest(request, delay) { + var now = new Date(); + + if (!configDoNotTrack) { + if (configRequestMethod === 'POST') { + sendXmlHttpRequest(request); + } else { + getImage(request); + } + + expireDateTime = now.getTime() + delay; + } + } + + /* + * Get cookie name with prefix and domain hash + */ + function getCookieName(baseName) { + // NOTE: If the cookie name is changed, we must also update the PiwikTracker.php which + // will attempt to discover first party cookies. eg. See the PHP Client method getVisitorId() + return configCookieNamePrefix + baseName + '.' + configTrackerSiteId + '.' + domainHash; + } + + /* + * Does browser have cookies enabled (for this site)? + */ + function hasCookies() { + if (configCookiesDisabled) { + return '0'; + } + + if (!isDefined(navigatorAlias.cookieEnabled)) { + var testCookieName = getCookieName('testcookie'); + setCookie(testCookieName, '1'); + + return getCookie(testCookieName) === '1' ? '1' : '0'; + } + + return navigatorAlias.cookieEnabled ? '1' : '0'; + } + + /* + * Update domain hash + */ + function updateDomainHash() { + domainHash = hash((configCookieDomain || domainAlias) + (configCookiePath || '/')).slice(0, 4); // 4 hexits = 16 bits + } + + /* + * Inits the custom variables object + */ + function getCustomVariablesFromCookie() { + var cookieName = getCookieName('cvar'), + cookie = getCookie(cookieName); + + if (cookie.length) { + cookie = JSON2.parse(cookie); + + if (isObject(cookie)) { + return cookie; + } + } + + return {}; + } + + /* + * Lazy loads the custom variables from the cookie, only once during this page view + */ + function loadCustomVariables() { + if (customVariables === false) { + customVariables = getCustomVariablesFromCookie(); + } + } + + /* + * Process all "activity" events. + * For performance, this function must have low overhead. + */ + function activityHandler() { + var now = new Date(); + + lastActivityTime = now.getTime(); + } + + /* + * Sets the Visitor ID cookie: either the first time loadVisitorIdCookie is called + * or when there is a new visit or a new page view + */ + function setVisitorIdCookie(uuid, createTs, visitCount, nowTs, lastVisitTs, lastEcommerceOrderTs) { + setCookie(getCookieName('id'), uuid + '.' + createTs + '.' + visitCount + '.' + nowTs + '.' + lastVisitTs + '.' + lastEcommerceOrderTs, configVisitorCookieTimeout, configCookiePath, configCookieDomain); + } + + /* + * Load visitor ID cookie + */ + function loadVisitorIdCookie() { + var now = new Date(), + nowTs = Math.round(now.getTime() / 1000), + id = getCookie(getCookieName('id')), + tmpContainer; + + if (id) { + tmpContainer = id.split('.'); + + // returning visitor flag + tmpContainer.unshift('0'); + } else { + // uuid - generate a pseudo-unique ID to fingerprint this user; + // note: this isn't a RFC4122-compliant UUID + if (!visitorUUID) { + visitorUUID = hash( + (navigatorAlias.userAgent || '') + + (navigatorAlias.platform || '') + + JSON2.stringify(browserFeatures) + + now.getTime() + + Math.random() + ).slice(0, 16); // 16 hexits = 64 bits + } + + tmpContainer = [ + // new visitor + '1', + + // uuid + visitorUUID, + + // creation timestamp - seconds since Unix epoch + nowTs, + + // visitCount - 0 = no previous visit + 0, + + // current visit timestamp + nowTs, + + // last visit timestamp - blank = no previous visit + '', + + // last ecommerce order timestamp + '' + ]; + } + + return tmpContainer; + } + + /* + * Loads the referrer attribution information + * + * @returns array + * 0: campaign name + * 1: campaign keyword + * 2: timestamp + * 3: raw URL + */ + function loadReferrerAttributionCookie() { + // NOTE: if the format of the cookie changes, + // we must also update JS tests, PHP tracker, Integration tests, + // and notify other tracking clients (eg. Java) of the changes + var cookie = getCookie(getCookieName('ref')); + + if (cookie.length) { + try { + cookie = JSON2.parse(cookie); + if (isObject(cookie)) { + return cookie; + } + } catch (ignore) { + // Pre 1.3, this cookie was not JSON encoded + } + } + + return [ + '', + '', + 0, + '' + ]; + } + + function deleteCookies() { + var savedConfigCookiesDisabled = configCookiesDisabled; + + // Temporarily allow cookies just to delete the existing ones + configCookiesDisabled = false; + setCookie(getCookieName('id'), '', -86400, configCookiePath, configCookieDomain); + setCookie(getCookieName('ses'), '', -86400, configCookiePath, configCookieDomain); + setCookie(getCookieName('cvar'), '', -86400, configCookiePath, configCookieDomain); + setCookie(getCookieName('ref'), '', -86400, configCookiePath, configCookieDomain); + + configCookiesDisabled = savedConfigCookiesDisabled; + } + + function sortObjectByKeys(value) { + if (!value || !isObject(value)) { + return; + } + + // Object.keys(value) is not supported by all browsers, we get the keys manually + var keys = []; + var key; + + for (key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + keys.push(key); + } + } + + var normalized = {}; + keys.sort(); + var len = keys.length; + var i; + + for (i = 0; i < len; i++) { + normalized[keys[i]] = value[keys[i]]; + } + + return normalized; + } + + /** + * Returns the URL to call piwik.php, + * with the standard parameters (plugins, resolution, url, referrer, etc.). + * Sends the pageview and browser settings with every request in case of race conditions. + */ + function getRequest(request, customData, pluginMethod, currentEcommerceOrderTs) { + var i, + now = new Date(), + nowTs = Math.round(now.getTime() / 1000), + newVisitor, + uuid, + visitCount, + createTs, + currentVisitTs, + lastVisitTs, + lastEcommerceOrderTs, + referralTs, + referralUrl, + referralUrlMaxLength = 1024, + currentReferrerHostName, + originalReferrerHostName, + customVariablesCopy = customVariables, + sesname = getCookieName('ses'), + refname = getCookieName('ref'), + cvarname = getCookieName('cvar'), + id = loadVisitorIdCookie(), + ses = getCookie(sesname), + attributionCookie = loadReferrerAttributionCookie(), + currentUrl = configCustomUrl || locationHrefAlias, + campaignNameDetected, + campaignKeywordDetected; + + if (configCookiesDisabled) { + deleteCookies(); + } + + if (configDoNotTrack) { + return ''; + } + + newVisitor = id[0]; + uuid = id[1]; + createTs = id[2]; + visitCount = id[3]; + currentVisitTs = id[4]; + lastVisitTs = id[5]; + // case migrating from pre-1.5 cookies + if (!isDefined(id[6])) { + id[6] = ""; + } + + lastEcommerceOrderTs = id[6]; + + if (!isDefined(currentEcommerceOrderTs)) { + currentEcommerceOrderTs = ""; + } + + // send charset if document charset is not utf-8. sometimes encoding + // of urls will be the same as this and not utf-8, which will cause problems + // do not send charset if it is utf8 since it's assumed by default in Piwik + var charSet = documentAlias.characterSet || documentAlias.charset; + + if (!charSet || charSet.toLowerCase() === 'utf-8') { + charSet = null; + } + + campaignNameDetected = attributionCookie[0]; + campaignKeywordDetected = attributionCookie[1]; + referralTs = attributionCookie[2]; + referralUrl = attributionCookie[3]; + + if (!ses) { + // cookie 'ses' was not found: we consider this the start of a 'session' + + // here we make sure that if 'ses' cookie is deleted few times within the visit + // and so this code path is triggered many times for one visit, + // we only increase visitCount once per Visit window (default 30min) + var visitDuration = configSessionCookieTimeout / 1000; + if (!lastVisitTs + || (nowTs - lastVisitTs) > visitDuration) { + visitCount++; + lastVisitTs = currentVisitTs; + } + + + // Detect the campaign information from the current URL + // Only if campaign wasn't previously set + // Or if it was set but we must attribute to the most recent one + // Note: we are working on the currentUrl before purify() since we can parse the campaign parameters in the hash tag + if (!configConversionAttributionFirstReferrer + || !campaignNameDetected.length) { + for (i in configCampaignNameParameters) { + if (Object.prototype.hasOwnProperty.call(configCampaignNameParameters, i)) { + campaignNameDetected = getParameter(currentUrl, configCampaignNameParameters[i]); + + if (campaignNameDetected.length) { + break; + } + } + } + + for (i in configCampaignKeywordParameters) { + if (Object.prototype.hasOwnProperty.call(configCampaignKeywordParameters, i)) { + campaignKeywordDetected = getParameter(currentUrl, configCampaignKeywordParameters[i]); + + if (campaignKeywordDetected.length) { + break; + } + } + } + } + + // Store the referrer URL and time in the cookie; + // referral URL depends on the first or last referrer attribution + currentReferrerHostName = getHostName(configReferrerUrl); + originalReferrerHostName = referralUrl.length ? getHostName(referralUrl) : ''; + + if (currentReferrerHostName.length && // there is a referrer + !isSiteHostName(currentReferrerHostName) && // domain is not the current domain + (!configConversionAttributionFirstReferrer || // attribute to last known referrer + !originalReferrerHostName.length || // previously empty + isSiteHostName(originalReferrerHostName))) { // previously set but in current domain + referralUrl = configReferrerUrl; + } + + // Set the referral cookie if we have either a Referrer URL, or detected a Campaign (or both) + if (referralUrl.length + || campaignNameDetected.length) { + referralTs = nowTs; + attributionCookie = [ + campaignNameDetected, + campaignKeywordDetected, + referralTs, + purify(referralUrl.slice(0, referralUrlMaxLength)) + ]; + + setCookie(refname, JSON2.stringify(attributionCookie), configReferralCookieTimeout, configCookiePath, configCookieDomain); + } + } + // build out the rest of the request + request += '&idsite=' + configTrackerSiteId + + '&rec=1' + + '&r=' + String(Math.random()).slice(2, 8) + // keep the string to a minimum + '&h=' + now.getHours() + '&m=' + now.getMinutes() + '&s=' + now.getSeconds() + + '&url=' + encodeWrapper(purify(currentUrl)) + + (configReferrerUrl.length ? '&urlref=' + encodeWrapper(purify(configReferrerUrl)) : '') + + '&_id=' + uuid + '&_idts=' + createTs + '&_idvc=' + visitCount + + '&_idn=' + newVisitor + // currently unused + (campaignNameDetected.length ? '&_rcn=' + encodeWrapper(campaignNameDetected) : '') + + (campaignKeywordDetected.length ? '&_rck=' + encodeWrapper(campaignKeywordDetected) : '') + + '&_refts=' + referralTs + + '&_viewts=' + lastVisitTs + + (String(lastEcommerceOrderTs).length ? '&_ects=' + lastEcommerceOrderTs : '') + + (String(referralUrl).length ? '&_ref=' + encodeWrapper(purify(referralUrl.slice(0, referralUrlMaxLength))) : '') + + (charSet ? '&cs=' + encodeWrapper(charSet) : ''); + + + // browser features + for (i in browserFeatures) { + if (Object.prototype.hasOwnProperty.call(browserFeatures, i)) { + request += '&' + i + '=' + browserFeatures[i]; + } + } + + // custom data + if (customData) { + request += '&data=' + encodeWrapper(JSON2.stringify(customData)); + } else if (configCustomData) { + request += '&data=' + encodeWrapper(JSON2.stringify(configCustomData)); + } + + // Custom Variables, scope "page" + function appendCustomVariablesToRequest(customVariables, parameterName) { + var customVariablesStringified = JSON2.stringify(customVariables); + if (customVariablesStringified.length > 2) { + return '&' + parameterName + '=' + encodeWrapper(customVariablesStringified); + } + return ''; + } + + var sortedCustomVarPage = sortObjectByKeys(customVariablesPage); + var sortedCustomVarEvent = sortObjectByKeys(customVariablesEvent); + + request += appendCustomVariablesToRequest(sortedCustomVarPage, 'cvar'); + request += appendCustomVariablesToRequest(sortedCustomVarEvent, 'e_cvar'); + + // Custom Variables, scope "visit" + if (customVariables) { + request += appendCustomVariablesToRequest(customVariables, '_cvar'); + + // Don't save deleted custom variables in the cookie + for (i in customVariablesCopy) { + if (Object.prototype.hasOwnProperty.call(customVariablesCopy, i)) { + if (customVariables[i][0] === '' || customVariables[i][1] === '') { + delete customVariables[i]; + } + } + } + + setCookie(cvarname, JSON2.stringify(customVariables), configSessionCookieTimeout, configCookiePath, configCookieDomain); + } + + // performance tracking + if (configPerformanceTrackingEnabled) { + if (configPerformanceGenerationTime) { + request += '>_ms=' + configPerformanceGenerationTime; + } else if (performanceAlias && performanceAlias.timing + && performanceAlias.timing.requestStart && performanceAlias.timing.responseEnd) { + request += '>_ms=' + (performanceAlias.timing.responseEnd - performanceAlias.timing.requestStart); + } + } + + // update cookies + setVisitorIdCookie(uuid, createTs, visitCount, nowTs, lastVisitTs, isDefined(currentEcommerceOrderTs) && String(currentEcommerceOrderTs).length ? currentEcommerceOrderTs : lastEcommerceOrderTs); + setCookie(sesname, '*', configSessionCookieTimeout, configCookiePath, configCookieDomain); + + // tracker plugin hook + request += executePluginMethod(pluginMethod); + + if (configAppendToTrackingUrl.length) { + request += '&' + configAppendToTrackingUrl; + } + return request; + } + + function logEcommerce(orderId, grandTotal, subTotal, tax, shipping, discount) { + var request = 'idgoal=0', + lastEcommerceOrderTs, + now = new Date(), + items = [], + sku; + + if (String(orderId).length) { + request += '&ec_id=' + encodeWrapper(orderId); + // Record date of order in the visitor cookie + lastEcommerceOrderTs = Math.round(now.getTime() / 1000); + } + + request += '&revenue=' + grandTotal; + + if (String(subTotal).length) { + request += '&ec_st=' + subTotal; + } + + if (String(tax).length) { + request += '&ec_tx=' + tax; + } + + if (String(shipping).length) { + request += '&ec_sh=' + shipping; + } + + if (String(discount).length) { + request += '&ec_dt=' + discount; + } + + if (ecommerceItems) { + // Removing the SKU index in the array before JSON encoding + for (sku in ecommerceItems) { + if (Object.prototype.hasOwnProperty.call(ecommerceItems, sku)) { + // Ensure name and category default to healthy value + if (!isDefined(ecommerceItems[sku][1])) { + ecommerceItems[sku][1] = ""; + } + + if (!isDefined(ecommerceItems[sku][2])) { + ecommerceItems[sku][2] = ""; + } + + // Set price to zero + if (!isDefined(ecommerceItems[sku][3]) + || String(ecommerceItems[sku][3]).length === 0) { + ecommerceItems[sku][3] = 0; + } + + // Set quantity to 1 + if (!isDefined(ecommerceItems[sku][4]) + || String(ecommerceItems[sku][4]).length === 0) { + ecommerceItems[sku][4] = 1; + } + + items.push(ecommerceItems[sku]); + } + } + request += '&ec_items=' + encodeWrapper(JSON2.stringify(items)); + } + request = getRequest(request, configCustomData, 'ecommerce', lastEcommerceOrderTs); + sendRequest(request, configTrackerPause); + } + + function logEcommerceOrder(orderId, grandTotal, subTotal, tax, shipping, discount) { + if (String(orderId).length + && isDefined(grandTotal)) { + logEcommerce(orderId, grandTotal, subTotal, tax, shipping, discount); + } + } + + function logEcommerceCartUpdate(grandTotal) { + if (isDefined(grandTotal)) { + logEcommerce("", grandTotal, "", "", "", ""); + } + } + + /* + * Log the page view / visit + */ + function logPageView(customTitle, customData) { + var now = new Date(), + request = getRequest('action_name=' + encodeWrapper(titleFixup(customTitle || configTitle)), customData, 'log'); + + sendRequest(request, configTrackerPause); + + // send ping + if (configMinimumVisitTime && configHeartBeatTimer && !activityTrackingInstalled) { + activityTrackingInstalled = true; + + // add event handlers; cross-browser compatibility here varies significantly + // @see http://quirksmode.org/dom/events + addEventListener(documentAlias, 'click', activityHandler); + addEventListener(documentAlias, 'mouseup', activityHandler); + addEventListener(documentAlias, 'mousedown', activityHandler); + addEventListener(documentAlias, 'mousemove', activityHandler); + addEventListener(documentAlias, 'mousewheel', activityHandler); + addEventListener(windowAlias, 'DOMMouseScroll', activityHandler); + addEventListener(windowAlias, 'scroll', activityHandler); + addEventListener(documentAlias, 'keypress', activityHandler); + addEventListener(documentAlias, 'keydown', activityHandler); + addEventListener(documentAlias, 'keyup', activityHandler); + addEventListener(windowAlias, 'resize', activityHandler); + addEventListener(windowAlias, 'focus', activityHandler); + addEventListener(windowAlias, 'blur', activityHandler); + + // periodic check for activity + lastActivityTime = now.getTime(); + setTimeout(function heartBeat() { + var requestPing; + now = new Date(); + + // there was activity during the heart beat period; + // on average, this is going to overstate the visitDuration by configHeartBeatTimer/2 + if ((lastActivityTime + configHeartBeatTimer) > now.getTime()) { + // send ping if minimum visit time has elapsed + if (configMinimumVisitTime < now.getTime()) { + requestPing = getRequest('ping=1', customData, 'ping'); + + sendRequest(requestPing, configTrackerPause); + } + + // resume heart beat + setTimeout(heartBeat, configHeartBeatTimer); + } + // else heart beat cancelled due to inactivity + }, configHeartBeatTimer); + } + } + + /* + * Log the event + */ + function logEvent(category, action, name, value, customData) { + // Category and Action are required parameters + if (String(category).length === 0 || String(action).length === 0) { + return false; + } + var request = getRequest( + 'e_c=' + encodeWrapper(category) + + '&e_a=' + encodeWrapper(action) + + (isDefined(name) ? '&e_n=' + encodeWrapper(name) : '') + + (isDefined(value) ? '&e_v=' + encodeWrapper(value) : ''), + customData, + 'event' + ); + + sendRequest(request, configTrackerPause); + } + + /* + * Log the site search request + */ + function logSiteSearch(keyword, category, resultsCount, customData) { + var request = getRequest('search=' + encodeWrapper(keyword) + + (category ? '&search_cat=' + encodeWrapper(category) : '') + + (isDefined(resultsCount) ? '&search_count=' + resultsCount : ''), customData, 'sitesearch'); + + sendRequest(request, configTrackerPause); + } + + /* + * Log the goal with the server + */ + function logGoal(idGoal, customRevenue, customData) { + var request = getRequest('idgoal=' + idGoal + (customRevenue ? '&revenue=' + customRevenue : ''), customData, 'goal'); + + sendRequest(request, configTrackerPause); + } + + /* + * Log the link or click with the server + */ + function logLink(url, linkType, customData) { + var request = getRequest(linkType + '=' + encodeWrapper(purify(url)), customData, 'link'); + + sendRequest(request, configTrackerPause); + } + + /* + * Browser prefix + */ + function prefixPropertyName(prefix, propertyName) { + if (prefix !== '') { + return prefix + propertyName.charAt(0).toUpperCase() + propertyName.slice(1); + } + + return propertyName; + } + + /* + * Check for pre-rendered web pages, and log the page view/link/goal + * according to the configuration and/or visibility + * + * @see http://dvcs.w3.org/hg/webperf/raw-file/tip/specs/PageVisibility/Overview.html + */ + function trackCallback(callback) { + var isPreRendered, + i, + // Chrome 13, IE10, FF10 + prefixes = ['', 'webkit', 'ms', 'moz'], + prefix; + + if (!configCountPreRendered) { + for (i = 0; i < prefixes.length; i++) { + prefix = prefixes[i]; + + // does this browser support the page visibility API? + if (Object.prototype.hasOwnProperty.call(documentAlias, prefixPropertyName(prefix, 'hidden'))) { + // if pre-rendered, then defer callback until page visibility changes + if (documentAlias[prefixPropertyName(prefix, 'visibilityState')] === 'prerender') { + isPreRendered = true; + } + break; + } + } + } + + if (isPreRendered) { + // note: the event name doesn't follow the same naming convention as vendor properties + addEventListener(documentAlias, prefix + 'visibilitychange', function ready() { + documentAlias.removeEventListener(prefix + 'visibilitychange', ready, false); + callback(); + }); + + return; + } + + // configCountPreRendered === true || isPreRendered === false + callback(); + } + + /* + * Construct regular expression of classes + */ + function getClassesRegExp(configClasses, defaultClass) { + var i, + classesRegExp = '(^| )(piwik[_-]' + defaultClass; + + if (configClasses) { + for (i = 0; i < configClasses.length; i++) { + classesRegExp += '|' + configClasses[i]; + } + } + + classesRegExp += ')( |$)'; + + return new RegExp(classesRegExp); + } + + /* + * Link or Download? + */ + function getLinkType(className, href, isInLink) { + // does class indicate whether it is an (explicit/forced) outlink or a download? + var downloadPattern = getClassesRegExp(configDownloadClasses, 'download'), + linkPattern = getClassesRegExp(configLinkClasses, 'link'), + + // does file extension indicate that it is a download? + downloadExtensionsPattern = new RegExp('\\.(' + configDownloadExtensions + ')([?&#]|$)', 'i'); + + // optimization of the if..elseif..else construct below + return linkPattern.test(className) ? 'link' : (downloadPattern.test(className) || downloadExtensionsPattern.test(href) ? 'download' : (isInLink ? 0 : 'link')); + +/* + var linkType = 0; + + if (linkPattern.test(className)) { + // class attribute contains 'piwik_link' (or user's override) + linkType = 'link'; + } else if (downloadPattern.test(className)) { + // class attribute contains 'piwik_download' (or user's override) + linkType = 'download'; + } else if (downloadExtensionsPattern.test(sourceHref)) { + // file extension matches a defined download extension + linkType = 'download'; + } else if (!isInLink) { + linkType = 'link'; + } + + return linkType; + */ + } + + /* + * Process clicks + */ + function processClick(sourceElement) { + var parentElement, + tag, + linkType; + + parentElement = sourceElement.parentNode; + while (parentElement !== null && + /* buggy IE5.5 */ + isDefined(parentElement)) { + tag = sourceElement.tagName.toUpperCase(); + if (tag === 'A' || tag === 'AREA') { + break; + } + sourceElement = parentElement; + parentElement = sourceElement.parentNode; + } + + if (isDefined(sourceElement.href)) { + // browsers, such as Safari, don't downcase hostname and href + var originalSourceHostName = sourceElement.hostname || getHostName(sourceElement.href), + sourceHostName = originalSourceHostName.toLowerCase(), + sourceHref = sourceElement.href.replace(originalSourceHostName, sourceHostName), + scriptProtocol = new RegExp('^(javascript|vbscript|jscript|mocha|livescript|ecmascript|mailto):', 'i'); + + // ignore script pseudo-protocol links + if (!scriptProtocol.test(sourceHref)) { + // track outlinks and all downloads + linkType = getLinkType(sourceElement.className, sourceHref, isSiteHostName(sourceHostName)); + + if (linkType) { + // urldecode %xx + sourceHref = urldecode(sourceHref); + logLink(sourceHref, linkType); + } + } + } + } + + /* + * Handle click event + */ + function clickHandler(evt) { + var button, + target; + + evt = evt || windowAlias.event; + button = evt.which || evt.button; + target = evt.target || evt.srcElement; + + // Using evt.type (added in IE4), we avoid defining separate handlers for mouseup and mousedown. + if (evt.type === 'click') { + if (target) { + processClick(target); + } + } else if (evt.type === 'mousedown') { + if ((button === 1 || button === 2) && target) { + lastButton = button; + lastTarget = target; + } else { + lastButton = lastTarget = null; + } + } else if (evt.type === 'mouseup') { + if (button === lastButton && target === lastTarget) { + processClick(target); + } + lastButton = lastTarget = null; + } + } + + /* + * Add click listener to a DOM element + */ + function addClickListener(element, enable) { + if (enable) { + // for simplicity and performance, we ignore drag events + addEventListener(element, 'mouseup', clickHandler, false); + addEventListener(element, 'mousedown', clickHandler, false); + } else { + addEventListener(element, 'click', clickHandler, false); + } + } + + /* + * Add click handlers to anchor and AREA elements, except those to be ignored + */ + function addClickListeners(enable) { + if (!linkTrackingInstalled) { + linkTrackingInstalled = true; + + // iterate through anchor elements with href and AREA elements + + var i, + ignorePattern = getClassesRegExp(configIgnoreClasses, 'ignore'), + linkElements = documentAlias.links; + + if (linkElements) { + for (i = 0; i < linkElements.length; i++) { + if (!ignorePattern.test(linkElements[i].className)) { + addClickListener(linkElements[i], enable); + } + } + } + } + } + + /* + * Browser features (plugins, resolution, cookies) + */ + function detectBrowserFeatures() { + var i, + mimeType, + pluginMap = { + // document types + pdf: 'application/pdf', + + // media players + qt: 'video/quicktime', + realp: 'audio/x-pn-realaudio-plugin', + wma: 'application/x-mplayer2', + + // interactive multimedia + dir: 'application/x-director', + fla: 'application/x-shockwave-flash', + + // RIA + java: 'application/x-java-vm', + gears: 'application/x-googlegears', + ag: 'application/x-silverlight' + }, + devicePixelRatio = (new RegExp('Mac OS X.*Safari/')).test(navigatorAlias.userAgent) ? windowAlias.devicePixelRatio || 1 : 1; + + if (!((new RegExp('MSIE')).test(navigatorAlias.userAgent))) { + // general plugin detection + if (navigatorAlias.mimeTypes && navigatorAlias.mimeTypes.length) { + for (i in pluginMap) { + if (Object.prototype.hasOwnProperty.call(pluginMap, i)) { + mimeType = navigatorAlias.mimeTypes[pluginMap[i]]; + browserFeatures[i] = (mimeType && mimeType.enabledPlugin) ? '1' : '0'; + } + } + } + + // Safari and Opera + // IE6/IE7 navigator.javaEnabled can't be aliased, so test directly + if (typeof navigator.javaEnabled !== 'unknown' && + isDefined(navigatorAlias.javaEnabled) && + navigatorAlias.javaEnabled()) { + browserFeatures.java = '1'; + } + + // Firefox + if (isFunction(windowAlias.GearsFactory)) { + browserFeatures.gears = '1'; + } + + // other browser features + browserFeatures.cookie = hasCookies(); + } + + // screen resolution + // - only Apple reports screen.* in device-independent-pixels (dips) + // - devicePixelRatio is always 2 on MacOSX+Retina regardless of resolution set in Display Preferences + browserFeatures.res = screenAlias.width * devicePixelRatio + 'x' + screenAlias.height * devicePixelRatio; + } + +/**/ + /* + * Register a test hook. Using eval() permits access to otherwise + * privileged members. + */ + function registerHook(hookName, userHook) { + var hookObj = null; + + if (isString(hookName) && !isDefined(registeredHooks[hookName]) && userHook) { + if (isObject(userHook)) { + hookObj = userHook; + } else if (isString(userHook)) { + try { + eval('hookObj =' + userHook); + } catch (ignore) { } + } + + registeredHooks[hookName] = hookObj; + } + + return hookObj; + } +/**/ + + /************************************************************ + * Constructor + ************************************************************/ + + /* + * initialize tracker + */ + detectBrowserFeatures(); + updateDomainHash(); + +/**/ + /* + * initialize test plugin + */ + executePluginMethod('run', registerHook); +/**/ + + /************************************************************ + * Public data and methods + ************************************************************/ + + return { +/**/ + /* + * Test hook accessors + */ + hook: registeredHooks, + getHook: function (hookName) { + return registeredHooks[hookName]; + }, +/**/ + + /** + * Get visitor ID (from first party cookie) + * + * @return string Visitor ID in hexits (or null, if not yet known) + */ + getVisitorId: function () { + return (loadVisitorIdCookie())[1]; + }, + + /** + * Get the visitor information (from first party cookie) + * + * @return array + */ + getVisitorInfo: function () { + return loadVisitorIdCookie(); + }, + + /** + * Get the Attribution information, which is an array that contains + * the Referrer used to reach the site as well as the campaign name and keyword + * It is useful only when used in conjunction with Tracker API function setAttributionInfo() + * To access specific data point, you should use the other functions getAttributionReferrer* and getAttributionCampaign* + * + * @return array Attribution array, Example use: + * 1) Call JSON2.stringify(piwikTracker.getAttributionInfo()) + * 2) Pass this json encoded string to the Tracking API (php or java client): setAttributionInfo() + */ + getAttributionInfo: function () { + return loadReferrerAttributionCookie(); + }, + + /** + * Get the Campaign name that was parsed from the landing page URL when the visitor + * landed on the site originally + * + * @return string + */ + getAttributionCampaignName: function () { + return loadReferrerAttributionCookie()[0]; + }, + + /** + * Get the Campaign keyword that was parsed from the landing page URL when the visitor + * landed on the site originally + * + * @return string + */ + getAttributionCampaignKeyword: function () { + return loadReferrerAttributionCookie()[1]; + }, + + /** + * Get the time at which the referrer (used for Goal Attribution) was detected + * + * @return int Timestamp or 0 if no referrer currently set + */ + getAttributionReferrerTimestamp: function () { + return loadReferrerAttributionCookie()[2]; + }, + + /** + * Get the full referrer URL that will be used for Goal Attribution + * + * @return string Raw URL, or empty string '' if no referrer currently set + */ + getAttributionReferrerUrl: function () { + return loadReferrerAttributionCookie()[3]; + }, + + /** + * Specify the Piwik server URL + * + * @param string trackerUrl + */ + setTrackerUrl: function (trackerUrl) { + configTrackerUrl = trackerUrl; + }, + + /** + * Specify the site ID + * + * @param int|string siteId + */ + setSiteId: function (siteId) { + configTrackerSiteId = siteId; + }, + + /** + * Pass custom data to the server + * + * Examples: + * tracker.setCustomData(object); + * tracker.setCustomData(key, value); + * + * @param mixed key_or_obj + * @param mixed opt_value + */ + setCustomData: function (key_or_obj, opt_value) { + if (isObject(key_or_obj)) { + configCustomData = key_or_obj; + } else { + if (!configCustomData) { + configCustomData = []; + } + configCustomData[key_or_obj] = opt_value; + } + }, + + /** + * Appends the specified query string to the piwik.php?... Tracking API URL + * + * @param string queryString eg. 'lat=140&long=100' + */ + appendToTrackingUrl: function (queryString) { + configAppendToTrackingUrl = queryString; + }, + + /** + * Get custom data + * + * @return mixed + */ + getCustomData: function () { + return configCustomData; + }, + + + /** + * Set custom variable within this visit + * + * @param int index + * @param string name + * @param string value + * @param string scope Scope of Custom Variable: + * - "visit" will store the name/value in the visit and will persist it in the cookie for the duration of the visit, + * - "page" will store the name/value in the next page view tracked. + * - "event" will store the name/value in the next event tracked. + */ + setCustomVariable: function (index, name, value, scope) { + var toRecord; + + if (!isDefined(scope)) { + scope = 'visit'; + } + if (!isDefined(name)) { + return; + } + if (!isDefined(value)) { + value = ""; + } + if (index > 0) { + name = !isString(name) ? String(name) : name; + value = !isString(value) ? String(value) : value; + toRecord = [name.slice(0, customVariableMaximumLength), value.slice(0, customVariableMaximumLength)]; + // numeric scope is there for GA compatibility + if (scope === 'visit' || scope === 2) { + loadCustomVariables(); + customVariables[index] = toRecord; + } else if (scope === 'page' || scope === 3) { + customVariablesPage[index] = toRecord; + } else if (scope === 'event') { /* GA does not have 'event' scope but we do */ + customVariablesEvent[index] = toRecord; + } + } + }, + + /** + * Get custom variable + * + * @param int index + * @param string scope Scope of Custom Variable: "visit" or "page" or "event" + */ + getCustomVariable: function (index, scope) { + var cvar; + + if (!isDefined(scope)) { + scope = "visit"; + } + + if (scope === "page" || scope === 3) { + cvar = customVariablesPage[index]; + } else if (scope === "event") { + cvar = customVariablesEvent[index]; + } else if (scope === "visit" || scope === 2) { + loadCustomVariables(); + cvar = customVariables[index]; + } + + if (!isDefined(cvar) + || (cvar && cvar[0] === '')) { + return false; + } + + return cvar; + }, + + /** + * Delete custom variable + * + * @param int index + */ + deleteCustomVariable: function (index, scope) { + // Only delete if it was there already + if (this.getCustomVariable(index, scope)) { + this.setCustomVariable(index, '', '', scope); + } + }, + + /** + * Set delay for link tracking (in milliseconds) + * + * @param int delay + */ + setLinkTrackingTimer: function (delay) { + configTrackerPause = delay; + }, + + /** + * Set list of file extensions to be recognized as downloads + * + * @param string extensions + */ + setDownloadExtensions: function (extensions) { + configDownloadExtensions = extensions; + }, + + /** + * Specify additional file extensions to be recognized as downloads + * + * @param string extensions + */ + addDownloadExtensions: function (extensions) { + configDownloadExtensions += '|' + extensions; + }, + + /** + * Set array of domains to be treated as local + * + * @param string|array hostsAlias + */ + setDomains: function (hostsAlias) { + configHostsAlias = isString(hostsAlias) ? [hostsAlias] : hostsAlias; + configHostsAlias.push(domainAlias); + }, + + /** + * Set array of classes to be ignored if present in link + * + * @param string|array ignoreClasses + */ + setIgnoreClasses: function (ignoreClasses) { + configIgnoreClasses = isString(ignoreClasses) ? [ignoreClasses] : ignoreClasses; + }, + + /** + * Set request method + * + * @param string method GET or POST; default is GET + */ + setRequestMethod: function (method) { + configRequestMethod = method || 'GET'; + }, + + /** + * Override referrer + * + * @param string url + */ + setReferrerUrl: function (url) { + configReferrerUrl = url; + }, + + /** + * Override url + * + * @param string url + */ + setCustomUrl: function (url) { + configCustomUrl = resolveRelativeReference(locationHrefAlias, url); + }, + + /** + * Override document.title + * + * @param string title + */ + setDocumentTitle: function (title) { + configTitle = title; + }, + + /** + * Set the URL of the Piwik API. It is used for Page Overlay. + * This method should only be called when the API URL differs from the tracker URL. + * + * @param string apiUrl + */ + setAPIUrl: function (apiUrl) { + configApiUrl = apiUrl; + }, + + /** + * Set array of classes to be treated as downloads + * + * @param string|array downloadClasses + */ + setDownloadClasses: function (downloadClasses) { + configDownloadClasses = isString(downloadClasses) ? [downloadClasses] : downloadClasses; + }, + + /** + * Set array of classes to be treated as outlinks + * + * @param string|array linkClasses + */ + setLinkClasses: function (linkClasses) { + configLinkClasses = isString(linkClasses) ? [linkClasses] : linkClasses; + }, + + /** + * Set array of campaign name parameters + * + * @see http://piwik.org/faq/how-to/#faq_120 + * @param string|array campaignNames + */ + setCampaignNameKey: function (campaignNames) { + configCampaignNameParameters = isString(campaignNames) ? [campaignNames] : campaignNames; + }, + + /** + * Set array of campaign keyword parameters + * + * @see http://piwik.org/faq/how-to/#faq_120 + * @param string|array campaignKeywords + */ + setCampaignKeywordKey: function (campaignKeywords) { + configCampaignKeywordParameters = isString(campaignKeywords) ? [campaignKeywords] : campaignKeywords; + }, + + /** + * Strip hash tag (or anchor) from URL + * Note: this can be done in the Piwik>Settings>Websites on a per-website basis + * + * @deprecated + * @param bool enableFilter + */ + discardHashTag: function (enableFilter) { + configDiscardHashTag = enableFilter; + }, + + /** + * Set first-party cookie name prefix + * + * @param string cookieNamePrefix + */ + setCookieNamePrefix: function (cookieNamePrefix) { + configCookieNamePrefix = cookieNamePrefix; + // Re-init the Custom Variables cookie + customVariables = getCustomVariablesFromCookie(); + }, + + /** + * Set first-party cookie domain + * + * @param string domain + */ + setCookieDomain: function (domain) { + configCookieDomain = domainFixup(domain); + updateDomainHash(); + }, + + /** + * Set first-party cookie path + * + * @param string domain + */ + setCookiePath: function (path) { + configCookiePath = path; + updateDomainHash(); + }, + + /** + * Set visitor cookie timeout (in seconds) + * Defaults to 2 years (timeout=63072000000) + * + * @param int timeout + */ + setVisitorCookieTimeout: function (timeout) { + configVisitorCookieTimeout = timeout * 1000; + }, + + /** + * Set session cookie timeout (in seconds). + * Defaults to 30 minutes (timeout=1800000) + * + * @param int timeout + */ + setSessionCookieTimeout: function (timeout) { + configSessionCookieTimeout = timeout * 1000; + }, + + /** + * Set referral cookie timeout (in seconds). + * Defaults to 6 months (15768000000) + * + * @param int timeout + */ + setReferralCookieTimeout: function (timeout) { + configReferralCookieTimeout = timeout * 1000; + }, + + /** + * Set conversion attribution to first referrer and campaign + * + * @param bool if true, use first referrer (and first campaign) + * if false, use the last referrer (or campaign) + */ + setConversionAttributionFirstReferrer: function (enable) { + configConversionAttributionFirstReferrer = enable; + }, + + /** + * Disables all cookies from being set + * + * Existing cookies will be deleted on the next call to track + */ + disableCookies: function () { + configCookiesDisabled = true; + browserFeatures.cookie = '0'; + }, + + /** + * One off cookies clearing. Useful to call this when you know for sure a new visitor is using the same browser, + * it maybe helps to "reset" tracking cookies to prevent data reuse for different users. + */ + deleteCookies: function () { + deleteCookies(); + }, + + /** + * Handle do-not-track requests + * + * @param bool enable If true, don't track if user agent sends 'do-not-track' header + */ + setDoNotTrack: function (enable) { + var dnt = navigatorAlias.doNotTrack || navigatorAlias.msDoNotTrack; + configDoNotTrack = enable && (dnt === 'yes' || dnt === '1'); + + // do not track also disables cookies and deletes existing cookies + if (configDoNotTrack) { + this.disableCookies(); + } + }, + + /** + * Add click listener to a specific link element. + * When clicked, Piwik will log the click automatically. + * + * @param DOMElement element + * @param bool enable If true, use pseudo click-handler (mousedown+mouseup) + */ + addListener: function (element, enable) { + addClickListener(element, enable); + }, + + /** + * Install link tracker + * + * The default behaviour is to use actual click events. However, some browsers + * (e.g., Firefox, Opera, and Konqueror) don't generate click events for the middle mouse button. + * + * To capture more "clicks", the pseudo click-handler uses mousedown + mouseup events. + * This is not industry standard and is vulnerable to false positives (e.g., drag events). + * + * There is a Safari/Chrome/Webkit bug that prevents tracking requests from being sent + * by either click handler. The workaround is to set a target attribute (which can't + * be "_self", "_top", or "_parent"). + * + * @see https://bugs.webkit.org/show_bug.cgi?id=54783 + * + * @param bool enable If true, use pseudo click-handler (mousedown+mouseup) + */ + enableLinkTracking: function (enable) { + if (hasLoaded) { + // the load event has already fired, add the click listeners now + addClickListeners(enable); + } else { + // defer until page has loaded + registeredOnLoadHandlers.push(function () { + addClickListeners(enable); + }); + } + }, + + /** + * Enable tracking of uncatched JavaScript errors + * + * If enabled, uncaught JavaScript Errors will be tracked as an event by defining a + * window.onerror handler. If a window.onerror handler is already defined we will make + * sure to call this previously registered error handler after tracking the error. + * + * By default we return false in the window.onerror handler to make sure the error still + * appears in the browser's console etc. Note: Some older browsers might behave differently + * so it could happen that an actual JavaScript error will be suppressed. + * If a window.onerror handler was registered we will return the result of this handler. + * + * Make sure not to overwrite the window.onerror handler after enabling the JS error + * tracking as the error tracking won't work otherwise. To capture all JS errors we + * recommend to include the Piwik JavaScript tracker in the HTML as early as possible. + * If possible directly in before loading any other JavaScript. + */ + enableJSErrorTracking: function () { + if (enableJSErrorTracking) { + return; + } + + enableJSErrorTracking = true; + var onError = windowAlias.onerror; + + windowAlias.onerror = function (message, url, linenumber, column, error) { + trackCallback(function () { + var category = 'JavaScript Errors'; + + var action = url + ':' + linenumber; + if (column) { + action += ':' + column; + } + + logEvent(category, action, message); + }); + + if (onError) { + return onError(message, url, linenumber, column, error); + } + + return false; + }; + }, + + /** + * Disable automatic performance tracking + */ + disablePerformanceTracking: function () { + configPerformanceTrackingEnabled = false; + }, + + /** + * Set the server generation time. + * If set, the browser's performance.timing API in not used anymore to determine the time. + * + * @param int generationTime + */ + setGenerationTimeMs: function (generationTime) { + configPerformanceGenerationTime = parseInt(generationTime, 10); + }, + + /** + * Set heartbeat (in seconds) + * + * @param int minimumVisitLength + * @param int heartBeatDelay + */ + setHeartBeatTimer: function (minimumVisitLength, heartBeatDelay) { + var now = new Date(); + + configMinimumVisitTime = now.getTime() + minimumVisitLength * 1000; + configHeartBeatTimer = heartBeatDelay * 1000; + }, + + /** + * Frame buster + */ + killFrame: function () { + if (windowAlias.location !== windowAlias.top.location) { + windowAlias.top.location = windowAlias.location; + } + }, + + /** + * Redirect if browsing offline (aka file: buster) + * + * @param string url Redirect to this URL + */ + redirectFile: function (url) { + if (windowAlias.location.protocol === 'file:') { + windowAlias.location = url; + } + }, + + /** + * Count sites in pre-rendered state + * + * @param bool enable If true, track when in pre-rendered state + */ + setCountPreRendered: function (enable) { + configCountPreRendered = enable; + }, + + /** + * Trigger a goal + * + * @param int|string idGoal + * @param int|float customRevenue + * @param mixed customData + */ + trackGoal: function (idGoal, customRevenue, customData) { + trackCallback(function () { + logGoal(idGoal, customRevenue, customData); + }); + }, + + /** + * Manually log a click from your own code + * + * @param string sourceUrl + * @param string linkType + * @param mixed customData + */ + trackLink: function (sourceUrl, linkType, customData) { + trackCallback(function () { + logLink(sourceUrl, linkType, customData); + }); + }, + + /** + * Log visit to this page + * + * @param string customTitle + * @param mixed customData + */ + trackPageView: function (customTitle, customData) { + if (isOverlaySession(configTrackerSiteId)) { + trackCallback(function () { + injectOverlayScripts(configTrackerUrl, configApiUrl, configTrackerSiteId); + }); + } else { + trackCallback(function () { + logPageView(customTitle, customData); + }); + } + }, + + /** + * Records an event + * + * @param string category The Event Category (Videos, Music, Games...) + * @param string action The Event's Action (Play, Pause, Duration, Add Playlist, Downloaded, Clicked...) + * @param string name (optional) The Event's object Name (a particular Movie name, or Song name, or File name...) + * @param float value (optional) The Event's value + */ + trackEvent: function (category, action, name, value) { + trackCallback(function () { + logEvent(category, action, name, value); + }); + }, + + /** + * Log special pageview: Internal search + * + * @param string customTitle + * @param mixed customData + */ + trackSiteSearch: function (keyword, category, resultsCount) { + trackCallback(function () { + logSiteSearch(keyword, category, resultsCount); + }); + }, + + + /** + * Used to record that the current page view is an item (product) page view, or a Ecommerce Category page view. + * This must be called before trackPageView() on the product/category page. + * It will set 3 custom variables of scope "page" with the SKU, Name and Category for this page view. + * Note: Custom Variables of scope "page" slots 3, 4 and 5 will be used. + * + * On a category page, you can set the parameter category, and set the other parameters to empty string or false + * + * Tracking Product/Category page views will allow Piwik to report on Product & Categories + * conversion rates (Conversion rate = Ecommerce orders containing this product or category / Visits to the product or category) + * + * @param string sku Item's SKU code being viewed + * @param string name Item's Name being viewed + * @param string category Category page being viewed. On an Item's page, this is the item's category + * @param float price Item's display price, not use in standard Piwik reports, but output in API product reports. + */ + setEcommerceView: function (sku, name, category, price) { + if (!isDefined(category) || !category.length) { + category = ""; + } else if (category instanceof Array) { + category = JSON2.stringify(category); + } + + customVariablesPage[5] = ['_pkc', category]; + + if (isDefined(price) && String(price).length) { + customVariablesPage[2] = ['_pkp', price]; + } + + // On a category page, do not track Product name not defined + if ((!isDefined(sku) || !sku.length) + && (!isDefined(name) || !name.length)) { + return; + } + + if (isDefined(sku) && sku.length) { + customVariablesPage[3] = ['_pks', sku]; + } + + if (!isDefined(name) || !name.length) { + name = ""; + } + + customVariablesPage[4] = ['_pkn', name]; + }, + + /** + * Adds an item (product) that is in the current Cart or in the Ecommerce order. + * This function is called for every item (product) in the Cart or the Order. + * The only required parameter is sku. + * + * @param string sku (required) Item's SKU Code. This is the unique identifier for the product. + * @param string name (optional) Item's name + * @param string name (optional) Item's category, or array of up to 5 categories + * @param float price (optional) Item's price. If not specified, will default to 0 + * @param float quantity (optional) Item's quantity. If not specified, will default to 1 + */ + addEcommerceItem: function (sku, name, category, price, quantity) { + if (sku.length) { + ecommerceItems[sku] = [ sku, name, category, price, quantity ]; + } + }, + + /** + * Tracks an Ecommerce order. + * If the Ecommerce order contains items (products), you must call first the addEcommerceItem() for each item in the order. + * All revenues (grandTotal, subTotal, tax, shipping, discount) will be individually summed and reported in Piwik reports. + * Parameters orderId and grandTotal are required. For others, you can set to false if you don't need to specify them. + * + * @param string|int orderId (required) Unique Order ID. + * This will be used to count this order only once in the event the order page is reloaded several times. + * orderId must be unique for each transaction, even on different days, or the transaction will not be recorded by Piwik. + * @param float grandTotal (required) Grand Total revenue of the transaction (including tax, shipping, etc.) + * @param float subTotal (optional) Sub total amount, typically the sum of items prices for all items in this order (before Tax and Shipping costs are applied) + * @param float tax (optional) Tax amount for this order + * @param float shipping (optional) Shipping amount for this order + * @param float discount (optional) Discounted amount in this order + */ + trackEcommerceOrder: function (orderId, grandTotal, subTotal, tax, shipping, discount) { + logEcommerceOrder(orderId, grandTotal, subTotal, tax, shipping, discount); + }, + + /** + * Tracks a Cart Update (add item, remove item, update item). + * On every Cart update, you must call addEcommerceItem() for each item (product) in the cart, including the items that haven't been updated since the last cart update. + * Then you can call this function with the Cart grandTotal (typically the sum of all items' prices) + * + * @param float grandTotal (required) Items (products) amount in the Cart + */ + trackEcommerceCartUpdate: function (grandTotal) { + logEcommerceCartUpdate(grandTotal); + } + + }; + } + + /************************************************************ + * Proxy object + * - this allows the caller to continue push()'ing to _paq + * after the Tracker has been initialized and loaded + ************************************************************/ + + function TrackerProxy() { + return { + push: apply + }; + } + + /************************************************************ + * Constructor + ************************************************************/ + + // initialize the Piwik singleton + addEventListener(windowAlias, 'beforeunload', beforeUnloadHandler, false); + addReadyListener(); + + Date.prototype.getTimeAlias = Date.prototype.getTime; + + asyncTracker = new Tracker(); + + // find the call to setTrackerUrl or setSiteid (if any) and call them first + for (iterator = 0; iterator < _paq.length; iterator++) { + if (_paq[iterator][0] === 'setTrackerUrl' + || _paq[iterator][0] === 'setAPIUrl' + || _paq[iterator][0] === 'setSiteId') { + apply(_paq[iterator]); + delete _paq[iterator]; + } + } + + // apply the queue of actions + for (iterator = 0; iterator < _paq.length; iterator++) { + if (_paq[iterator]) { + apply(_paq[iterator]); + } + } + + // replace initialization array with proxy object + _paq = new TrackerProxy(); + + /************************************************************ + * Public data and methods + ************************************************************/ + + Piwik = { + /** + * Add plugin + * + * @param string pluginName + * @param Object pluginObj + */ + addPlugin: function (pluginName, pluginObj) { + plugins[pluginName] = pluginObj; + }, + + /** + * Get Tracker (factory method) + * + * @param string piwikUrl + * @param int|string siteId + * @return Tracker + */ + getTracker: function (piwikUrl, siteId) { + return new Tracker(piwikUrl, siteId); + }, + + /** + * Get internal asynchronous tracker object + * + * @return Tracker + */ + getAsyncTracker: function () { + return asyncTracker; + } + }; + + // Expose Piwik as an AMD module + if (typeof define === 'function' && define.amd) { + define('piwik', [], function () { return Piwik; }); + } + + return Piwik; + }()); +} + +/************************************************************ + * Deprecated functionality below + * Legacy piwik.js compatibility ftw + ************************************************************/ + +/* + * Piwik globals + * + * var piwik_install_tracker, piwik_tracker_pause, piwik_download_extensions, piwik_hosts_alias, piwik_ignore_classes; + */ +/*global piwik_log:true */ +/*global piwik_track:true */ + +/** + * Track page visit + * + * @param string documentTitle + * @param int|string siteId + * @param string piwikUrl + * @param mixed customData + */ +if (typeof piwik_log !== 'function') { + piwik_log = function (documentTitle, siteId, piwikUrl, customData) { + 'use strict'; + + function getOption(optionName) { + try { + return eval('piwik_' + optionName); + } catch (ignore) { } + + return; // undefined + } + + // instantiate the tracker + var option, + piwikTracker = Piwik.getTracker(piwikUrl, siteId); + + // initialize tracker + piwikTracker.setDocumentTitle(documentTitle); + piwikTracker.setCustomData(customData); + + // handle Piwik globals + option = getOption('tracker_pause'); + + if (option) { + piwikTracker.setLinkTrackingTimer(option); + } + + option = getOption('download_extensions'); + + if (option) { + piwikTracker.setDownloadExtensions(option); + } + + option = getOption('hosts_alias'); + + if (option) { + piwikTracker.setDomains(option); + } + + option = getOption('ignore_classes'); + + if (option) { + piwikTracker.setIgnoreClasses(option); + } + + // track this page view + piwikTracker.trackPageView(); + + // default is to install the link tracker + if (getOption('install_tracker')) { + + /** + * Track click manually (function is defined below) + * + * @param string sourceUrl + * @param int|string siteId + * @param string piwikUrl + * @param string linkType + */ + piwik_track = function (sourceUrl, siteId, piwikUrl, linkType) { + piwikTracker.setSiteId(siteId); + piwikTracker.setTrackerUrl(piwikUrl); + piwikTracker.trackLink(sourceUrl, linkType); + }; + + // set-up link tracking + piwikTracker.enableLinkTracking(); + } + }; +} diff --git a/www/analytics/lang/.htaccess b/www/analytics/lang/.htaccess new file mode 100644 index 00000000..6cd2e134 --- /dev/null +++ b/www/analytics/lang/.htaccess @@ -0,0 +1,13 @@ + + +Deny from all + + + +Deny from all + + + +Deny from all + + diff --git a/www/analytics/lang/README.md b/www/analytics/lang/README.md new file mode 100644 index 00000000..7a1f3781 --- /dev/null +++ b/www/analytics/lang/README.md @@ -0,0 +1,5 @@ +## Contribute + +We cannot accept pull requests for translations on Github since we manage all translations in a separate system. Translations will automatically be merged from time to time! If you want to improve an existing Piwik translation, or contribute a new translation, please have a look at our [translation center](http://translations.piwik.org). + +If you have any questions feel free to contact the team at translations@piwik.org. diff --git a/www/analytics/lang/am.json b/www/analytics/lang/am.json new file mode 100644 index 00000000..a675ea5f --- /dev/null +++ b/www/analytics/lang/am.json @@ -0,0 +1,814 @@ +{ + "Actions": { + "ColumnClickedURL": "ጠቅ አድርግ ዩ አር ኤል", + "ColumnClicks": "ጠቃድርጎች", + "ColumnDownloadURL": "የማውረጃ ዩ አር ኤል", + "ColumnPageName": "የገፅ ስም", + "ColumnUniqueClicks": "ብቸኛ ጠቅአድርጎች", + "ColumnUniqueDownloads": "ብቸኛ የወረዱ" + }, + "API": { + "LoadedAPIs": "ማስጋባት ተሳክቷል %s ኤፒአይዎች" + }, + "CoreHome": { + "CategoryNoData": "በዚህ ፈርጅ ምንም ውሂብ የለም. \"ሁሉንም ምድብ\" ለማጠቃለል ይሞክሩ።", + "PageOf": "%1$s የ %2$s", + "PeriodDay": "ቀን", + "PeriodDays": "ቀናት", + "PeriodMonth": "ወር", + "PeriodMonths": "ወራት", + "PeriodWeek": "ሳምንት", + "PeriodWeeks": "ሳምንታት", + "PeriodYear": "ዓመት", + "PeriodYears": "ዓመታት", + "ShowJSCode": "ለማስገባት የጃቫ ስክሪፕትን ኮድ አሳይ" + }, + "CorePluginsAdmin": { + "Activate": "አግብር", + "Activated": "የተገበረ", + "Active": "ገባሪ", + "Deactivate": "አቦዝን", + "Inactive": "ያልተመረጠ", + "MainDescription": "ተሰኪዎች የፒዊክን አገልግሎት ያራዝማሉ ያሰፋሉ። ተሰኪዎች አንዴ ከተጫኑ እዚሁ ጋር መገበርም ማቦዘንም ይቻላል።", + "PluginHomepage": "የተሰኪ መነሻ ገፅ", + "PluginsManagement": "ተሰኪዎች መመነጅ", + "Status": "ሁኔታ", + "Version": "ስሪት" + }, + "CoreUpdater": { + "CreatingBackupOfConfigurationFile": "የውቅረት ፋይሉን መጠባበቂያ በመፍጠር ላይ %s", + "CriticalErrorDuringTheUpgradeProcess": "በማላቅ ሂደት ላይ ያጋጠመ ከባድ እንከን:", + "DatabaseUpgradeRequired": "የውሂብ ጎታ ማላቅ ያስፈልጋል", + "DownloadX": "አውርድ %s", + "ErrorDuringPluginsUpdates": "ተሰኪዎችን በማላቅ ሂደት ያጋጠሙ ስህተቶች:", + "HelpMessageContent": "የ %1$s ፒዊክን ኤፍ ኤ ኪው %2$s ይመልከቱ ምከነያቱም ባማላቀ ወቅት በጣም ተደጋጋሚ ለሆኑ ስህተቶች መልስ የሰጣልና %3$s የስርዓት አስተዳዳሪዎን ይጠይቁ - ከአገልጋይ ወይም ማይ ኤስ ኪው ኤል መዋቅር ጋር ተዛማጅነት ላላቸው ስህተቶች ርዳታ ሊያገኙ ይችላሉ", + "HelpMessageIntroductionWhenError": "ከላይ ያለው የስህተት ኮር መልእክት ነው። ምክንያቱን ለማስረዳት አጋዥ ሊሆን ይችላል ነገር ግን ተጨማሪ ርዳታ ከፈለጉ እባክዎ:", + "HelpMessageIntroductionWhenWarning": "ማላቁ በሚጋባ ተጠናቅቋል ቢሆንም በሂደቱ ላይ ችግሮች ነበሩ። ዝርዝር ከፈለጉ ከላይ የተቀመጡትን መግለጫዎች ያንቡ። ለተጨማሪ ርዳታ፡", + "InstallingTheLatestVersion": "አዲሱን ስሪት በመጫን ላይ", + "PiwikHasBeenSuccessfullyUpgraded": "ፒዊክን ማላቅ በተሳካ ሁኔታ ተጠናቋል!", + "PiwikUpdatedSuccessfully": "የፒዊክ ማላቅ ተሳክቷል!", + "PiwikWillBeUpgradedFromVersionXToVersionY": "የፒዊክ የውሂብ ጎታዎ ከ %1$sስሪት ወደ %2$sስሪት ይልቃል.", + "TheFollowingPluginsWillBeUpgradedX": "የሚከተሉት ተሰኪዎችም ይልቃሉ: %s.", + "ThereIsNewVersionAvailableForUpdate": "ለማላቅ ዝግጁ የሆነ የፒዊክ ሰሪት አለ", + "TheUpgradeProcessMayTakeAWhilePleaseBePatient": "የውሂብ ጎታ ማላቅ ሂደቱ ጥቂት ጊዜ ስለሚወስድ እባክዎ በትዕግስት ይጠብቁ", + "UpdateAutomatically": "ራስሰር አልቅ", + "UpdateHasBeenCancelledExplanation": "ፒዊክ አንድ ጠቅ አርግ ማላቅ ተሰርዟል። ከላይ ያለውን የስህተት መልዕክት መጠገን ካልቻልክ ፒዊክን በእጅ እንድታልቅትመከራለህ። %1$s ለመጀመር የ %2$sማላቂያ ስነዳ %3$sተመልከት!", + "UpdateTitle": "ፒዊክ › አልቅ", + "UpgradeComplete": "ማላቅ ተጠናቋል!", + "UpgradePiwik": "ፒዊክን አልቅ", + "VerifyingUnpackedFiles": "የተበተኑትን ፋይሎች በመበተን ላይ", + "WarningMessages": "የማስጠንቀቂያ መልዕክቶች:", + "WeAutomaticallyDeactivatedTheFollowingPlugins": "የሚከተሉትን ተሰኪዎች በራስሰር አቦዝነናል: %s", + "YouCanUpgradeAutomaticallyOrDownloadPackage": "ወደ ስሪት %s ራስሰር ማላቅ ወይም አካታቾቹን አውርዶ በጅ መጫን ይቻላል ይቻላል:", + "YourDatabaseIsOutOfDate": "የፒዊክ የውሂብ ጎታዎ ቀኑ ሰላለፈበት ከመቀጠልዎ በፊት ማላቅ ያስፈልገዋል" + }, + "Dashboard": { + "AddPreviewedWidget": "ቅድመ እይታ የተሰጠውን widget ወደ ዳሽቦርዱ ጨምር", + "Dashboard": "ዳሽቦርድ", + "DeleteWidgetConfirm": "እርግጠኛ ነህ ይህ widget ከዳሽቦርድ ይሰረዝ?", + "LoadingWidget": "widget በመጫን ላይ, እባክዎ ይጠበቁ...", + "SelectWidget": "ወደ ዳሽቦርድ የሚጨመረውን widget ምረጥ", + "WidgetNotFound": "Widget አልተገኘም", + "WidgetPreview": "የWidget ቅድመእይታ" + }, + "General": { + "Action": "ርምጃ", + "Actions": "ርምጃዎች", + "API": "ኤፒአይI", + "BackToPiwik": "ወደፒዊክ ተመለስ", + "Close": "ዝጋ", + "ColumnActionsPerVisit": "ርምጃ ከጉብኝት", + "ColumnAvgTimeOnSite": "በየድር ጣቢያ የተፈጀ አማካይ ሰዓት", + "ColumnBounceRate": "ተመላሽ", + "ColumnKeyword": "ቁልፍ ቃላት", + "ColumnLabel": "መሰየሚያ", + "ColumnMaxActions": "በአንድ ጉብኝት የተወሰዱ ከፍተኛ ርምጃዎች", + "ColumnNbActions": "ርምጃዎች", + "ColumnNbUniqVisitors": "ብቸኛ ጎብኚዎች", + "ColumnNbVisits": "ጉብኝቶች", + "ColumnPageviews": "ገፅ ትይታዎች", + "ColumnRevenue": "ገቢ", + "ColumnSumVisitLength": "በጎብኚዎች የተፈጀ ከፍተኛ ሰዓት (በሰከንዶች)", + "ColumnUniquePageviews": "ብቸኛ ገፅ ትይታዎች", + "ColumnValuePerVisit": "እሴት በጉብኝት", + "ColumnVisitsWithConversions": "ጉብኝት ከልወጣዎች ጋር", + "ContinueToPiwik": "ወደ ፒዊክ ቀጥል", + "DayFr": "አርብ", + "DayMo": "ሰኞ", + "DaySa": "ቅዳ", + "DaySu": "እሁ", + "DayTh": "ሐሙ,", + "DayTu": "ማክ", + "DayWe": "ረቡ", + "Delete": "ሰርዝ", + "Done": "አልቋል", + "Downloads": "የወረዱ", + "Edit": "አርትእ", + "EnglishLanguageName": "Amharic", + "Error": "ስህተት", + "ErrorRequest": "Oops… problem during the request, please try again.", + "EvolutionOverPeriod": "Evolution በፔሬዱ ላይ", + "Export": "ላክ", + "GiveUsYourFeedback": "እባክዎ መልሰህ መግብ ስጡን!", + "HelloUser": "ሃሎ, %s!", + "JsTrackingTag": "የጃቫ ስክሪፕት ዱካ መከተያ መለያ", + "LayoutDirection": "ltr", + "Loading": "በማስገባት ላይ...", + "LoadingData": "ውሂብ በማስገባት ላይ...", + "Locale": "am_ET.UTF-8", + "Logout": "ውጣ", + "LongDay_1": "ሰኞ", + "LongDay_2": "ማክሰኞ", + "LongDay_3": "ረቡዕ", + "LongDay_4": "ሐሙስ", + "LongDay_5": "አርብ", + "LongDay_6": "ቅዳሜ", + "LongDay_7": "እሁድ", + "LongMonth_1": "ጥር", + "LongMonth_10": "ጥቅምት", + "LongMonth_11": "ሀዳር", + "LongMonth_12": "ታህሳስ", + "LongMonth_2": "የካቲት", + "LongMonth_3": "መጋቢት", + "LongMonth_4": "ሚያዝያ", + "LongMonth_5": "ግንቦት", + "LongMonth_6": "ሰኔ", + "LongMonth_7": "ሐምሌ", + "LongMonth_8": "ነሐሴ", + "LongMonth_9": "መስከረም", + "Next": "ቀጥል", + "No": "የለም", + "NoDataForGraph": "ለዚህ ግራፍ ምንም ውሂብ የለም", + "NoDataForTagCloud": "ለዚህ መለያ ክላውድ ምንም ውሂብ የለም.", + "NVisits": "%s ጉበኝቶች", + "Ok": "ይሁን", + "OpenSourceWebAnalytics": "ክፍት የሆነ የድር ጣቢያ ማመዛዘኛ", + "OriginalLanguageName": "አማርኛ", + "Others": "ለሎች", + "Outlinks": "ወጪአገናኞች", + "Overview": "አጠቃላይ እይታ", + "Password": "የይለፍ ቃል", + "Piechart": "አምባሻ ገበታ", + "PiwikXIsAvailablePleaseUpdateNow": "ፒዊክን %1$s ማግኘት ይቻላል። %2$s እባክዎን ያልቁት!%3$s (ለውጦቹን%4$s ይመልከቱ%5$s).", + "Plugin": "ተሰኪ", + "Plugins": "ተሰኪዎች", + "Previous": "ቀደም ያለ", + "RefreshPage": "ገፁን አድስ", + "Required": "%s አስፈላጊ", + "Save": "አስቀምጥ", + "Search": "ፈልግ", + "Settings": "ቅንብሮች", + "ShortDay_1": "ሰኞ", + "ShortDay_2": "ማክ", + "ShortDay_3": "ረቡ", + "ShortDay_4": "ሐሙ", + "ShortDay_5": "አር", + "ShortDay_6": "ቅዳ", + "ShortDay_7": "እሁ", + "ShortMonth_1": "ጥር", + "ShortMonth_10": "ጥቅ", + "ShortMonth_11": "ሀዳ", + "ShortMonth_12": "ታህ", + "ShortMonth_2": "የካ", + "ShortMonth_3": "መጋ", + "ShortMonth_4": "ሚያ", + "ShortMonth_5": "ግን", + "ShortMonth_6": "ሰኔ", + "ShortMonth_7": "ሐም", + "ShortMonth_8": "ነሐሴ", + "ShortMonth_9": "መስከ", + "Table": "ሰንጠረዥ", + "TagCloud": "መለያ Cloud", + "Today": "ዛሬ", + "TranslatorEmail": "info@addismap.com", + "TranslatorName": "Alazar Tekle", + "Unknown": "ያልታወቀ", + "VBarGraph": "አቀባዊ አሞሌ ግራፍ", + "View": "ትእይታ", + "Visitors": "ጎብኝዎች", + "Warning": "ማስጠንቀቂያ", + "Website": "ድር ጣቢያ", + "Widgets": "Widgets", + "Yes": "አዎን", + "Yesterday": "ትናንት" + }, + "Goals": { + "ColumnConversions": "ልወጣዎች" + }, + "Installation": { + "CommunityNewsletter": "ከማህበረሰብ አልቅ ጋር ኢ-ሜይል አድርግልኝ(አዲስ ተሰኪዎች, አዲስ ባህርይዎች, ወዘተ.)", + "ConfirmDeleteExistingTables": "እርግጠኛ ነህ ይህንን ሰንጠረዥ ከውሂብ ጎታህ ውስጥ : %s መሰረዝ ትፈልጋለህ? ማስጠንቀቂያ: ከዚህ ሰንጠረዥ ውስጥ የጠፉ ውሂቦች ተመልሰው ሊገኙ አይችሉም!", + "Congratulations": "እንኳን ደስያለዎ", + "CongratulationsHelp": "

    እንኳን ደስያለዎ! የፒዊክ መጫንዎ ተሳክቷል።<\/p>

    የጃቫ ስክሪፕትዎ ቾድ በሁሉ ገፆች ላይ መግባቱን ያረጋግጡና የመጀመሪያ ጎበኚዎችዎን ይጠብቁ!<\/p>", + "Email": "ኢ-ሜይል", + "ErrorInvalidState": "ስህተት: የመጫኑን ሂደት የዘለልክ ይመስላል ወይም ኩኪዎቹ ቦዝነዋል ውይም የፒዊክ ውቅረት ፋይል አስቀድሞ ተፈጥሯል። %1$sኩኪዎቹ መንቃታቸውን አረጋግጥ%2$s እና ተመልሰህ %3$s ወደ መጫኛው የመጀመሪያ ገፅ ሂድ። %4$s.", + "GoBackAndDefinePrefix": "ወደኋላ ተመለስና ለፒዊክ ሰንጠረዦቹ ቅድም ቅጥያውን በይን", + "Installation": "መጫኛ", + "InstallationStatus": "የመጫኛ ሁኔታ", + "NoConfigFound": "የፒዊክ ውቅረት ፋይል ሊገኝ አልቻለም እና እርስዎ የፒዊክ ገፅ ለመድረስ እየሞከሩ ነው።
      » እርስዎ ፒዊክን አሁን መጫን ይችላሉ<\/a><\/b>
    ፒዊክን ከዚህ ቀደም ጭነው ከነበረ እና በዲቢዎ ላይ የተወሰኑ ሰንጠረዦች ካለዎ የነበሩትን ሰንጠረዦች ደግመው መጠቀምና የነበረዎትን ውሂብ ማስቀመጥ ይችላሉ!<\/small>", + "Password": "የይለፍ ቃል", + "PasswordDoNotMatch": "የይለፍ ቃሉ አቻ አይደለም", + "PasswordRepeat": "የይለፍ ቃል (ድገም)", + "PercentDone": "%s %% አልቋል", + "SecurityNewsletter": "ከዋነኛ የፒዊክ ማላቂያዎች እና የጥበቃ ማስጠንቀቂያዎች ጋር ኢ-ሜይል አድርግልኝ", + "SetupWebsite": "ድር ጣቢያ ጫን", + "SetupWebsiteError": "ድር ጣቢያውን በመጫን ጊዜ ስህተት አጋጥሟል", + "SuperUserLogin": "ሊቀ ተገልጋይ ግባ", + "SystemCheck": "ስርዓት አረጋግጥ", + "SystemCheckError": "ስህተት ተከስቷል - ከመቀጠልዎ በፊት መጠገን አለበት", + "SystemCheckGDHelp": "የብልጭታ መስመሮቹ (ትናንሽ ግራፎች)አይሰሩም", + "SystemCheckMemoryLimit": "የማህደረ ትውስታ ገደብ", + "SystemCheckMemoryLimitHelp": "ከፍተኛ የጎብኚ መጨናነቅ ባለባችው ድር ጣቢያዎች ላይ የምዝገባ ሂድት በወቅቱ ከተፈቀደው ብላይ ብዙ ማህደረ ትውስታ ሊያስፈልገው ይችላል።
    የማህደረ ትውስታውን ገደብ መመሪያ በ php.ini ፋይል ውስጥ አስፈላጊ ከሆነ መመልከት ትችላለህ።", + "SystemCheckPhp": "የፒ ኤች ፒ ስሪት", + "SystemCheckTimeLimitHelp": "ከፍተኛ የትራፊክ መጨናነቅ ያለባቸው የድር ጣቢያዎች ላይ የምዝገባ ሂደቱን ማስፈፀም ከተፈቀደው ሰዓት በላይ ሊፈልግ ይችላል።
    ከፍተኛ_የማስፈፀሚያ_ጊዜ መመሪያውን በ php.ini ፋይል ውስጥ አስፈላጊ ከሆነ መመልከት ትችላለህ።", + "SystemCheckWarning": "ፒዊክ በመደበኛነት ሊሰራ ይችላል ነገርግን አንዳንድ ባህርይዎች ጠፍተው ሊሆን ይችላል", + "SystemCheckWriteDirs": "ማውጫ ከፃፍ መድረሻ ጋር", + "SystemCheckWriteDirsHelp": "ይህንን ስህተት በሊኑክስ ስርዓት ላይ ለማስተካከል የሚከተሉትን ትእዛዝ(ዞች) ይተይቡ", + "Tables": "ሠንጠረዦቹን በመፍጠር ላይ", + "TablesCreatedSuccess": "ሰንጠረዦች በሚገባ ተፈጥረዋል!", + "TablesDelete": "የተገኙትን ሰንጠረዦች ሰርዝ", + "TablesDeletedSuccess": "ቀደም ሲል የነበሩት የፒዊክ ሰንጠረዦች በሚገባ ተሰርዘዋል", + "TablesFound": "የሚከተሉት ሰንጠረዦች በውሂብ ጎታዎች ውስጥ ተገኝተዋል", + "TablesReuse": "ቀደም ሲል የነበሩትን ሰንጠረዦች መልሰህ ተጠቀም", + "TablesWarningHelp": "ቀደም ሲል የነበሩትን የውሂብ ጎታዎች ስንጠረዥመልሰህ ለመጠቀም ምረጥ ወይም በውሂብ ጎታዎቹ ውስጥ ያሉትን ውሂቦች ለማጥፋት ንፁህ ጫን ምረጥ", + "TablesWithSameNamesFound": "አንዳንድ %1$s በውሂብ ጎታ ውስጥ ያሉ ሰንጠረዦች%2$s ፒዊክ ለመፍጠር እየሞከረ ካሉት ሰንጠረዦች ጋር ተመሳሳይ ስም አላቸው", + "Welcome": "እንኳን ደህና መጡ!", + "WelcomeHelp": "

    ፒዊክ ክፍት የሆነ የድር ማመዛዘኛ ሶፍትዌር ሲሆን ከጎብኚዎችዎ የሚፈልጉትን መረጃ ለማግኘት ቀላል ያደርገዋል።<\/p>

    ይህ ሂደት ወደ ቀላል ደረጃዎች %s የተከፋፈለ ሲሆን ወደ 5 ደቂቃ አካባቢ ይፈጃል።<\/p>" + }, + "Login": { + "ContactAdmin": "ተገቢው ምክንያት: አስተናጋጅዎ የ ሜይል() ተግባርን አቦዝኖት ሊሆን ይችላል።
    እባክዎ የፒዊክ መናጅዎን ያነጋግሩ", + "InvalidUsernameEmail": "የተሳሳት የተጠቃሚ ስም እና\/ወይም ኢ-ሜይል አድራሻ", + "LogIn": "ግባ", + "LoginOrEmail": "ግባ ወይም ኢ-ሜይል", + "LoginPasswordNotCorrect": "የተጠቃሚ ስም እና የይለፍ ቃል ትክክል አይደለም", + "LostYourPassword": "የይለፍ ቃል ጠፋብህ?", + "PasswordRepeat": "የይለፍ ቃል (ድገም)" + }, + "Provider": { + "ColumnProvider": "አቅራቢ", + "SubmenuLocationsProvider": "መገኛ እና አቅራቢ", + "WidgetProviders": "አቅራቢዎች" + }, + "Referrers": { + "ColumnSearchEngine": "የመፈለጊያ ሞተሮች", + "ColumnWebsite": "ድር ጣቢያዎች", + "ColumnWebsitePage": "የድር ጣቢያዎች ገፅ", + "DetailsByReferrerType": "ዝርዝር በአመላካች ዓይነት", + "DirectEntry": "ቀጥተኛ ምዝግብ", + "Distinct": "የተለዩ ምልከታዎች በአመላካች ዓይነት", + "DistinctCampaigns": "የተለዩ campaigns", + "DistinctKeywords": "የተለዩ ቁልፍ ቃላት", + "DistinctSearchEngines": "የተለዩ የመፈለጊያ ሞተሮች", + "DistinctWebsites": "የተለዩ ድር ጣቢያዎች", + "Keywords": "ቁልፍ ቃላት", + "Referrers": "አመላካቾች", + "SearchEngines": "የመፈለጊያ ሞተሮች", + "SubmenuSearchEngines": "የመፈለጊያ ሞተሮች እና ቁልፍ ቃላት", + "SubmenuWebsites": "ድር ጣቢያዎች", + "Type": "የምልከታ ዓይነት", + "TypeCampaigns": "%s ከ campaigns", + "TypeDirectEntries": "%s ቀጥተኛ ምዝግቦች", + "TypeSearchEngines": "%s ከመፈለጊያ ሞተሮች", + "TypeWebsites": "%s ከድር ጣቢያዎች", + "Websites": "ድር ጣቢያዎች", + "WidgetExternalWebsites": "የውጫዊ ድር ጣቢያዎች ዝርዝር", + "WidgetKeywords": "የቁልፍ ቃላት ዝርዝር" + }, + "SitesManager": { + "AddSite": "አዲስ ድር ጣቢያ ጨምር", + "Currency": "ገንዘብ", + "DeleteConfirm": "እርግጠኛ ነህ ድር ጣቢያውን ለመሰረዝ %s?", + "ExceptionDeleteSite": "የተመዘገበ ብቸኛው ድር ጣቢያ ስለሆነ ለመዘረዝ አይቻልም። በመጀመሪያ አዲስ ድር ጣቢያ ጨምርና ቀጠሎ ይህንን ሰርዝ።", + "ExceptionEmptyName": "የድር ጣቢያው ስም ባዶ መሆን አይችልም።", + "ExceptionNoUrl": "ለዚህ ድር ጣቢያ ቢያንስ አንድ ዩ አር ኤል ማምር አለብህ።", + "JsTrackingTagHelp": "በሁሉም ገፆች ለማካተት እንዲያስችልህ የጃቫ ስክሪፕት ዱካ መከተያ መለያ እዚህ አለ", + "MainDescription": "የድር ማመዛዘኛ ሪፖርት ድር ጣቢያ ያስፈልጋቸዋል! ጨምር፣ አልቅ፣ ድር ጣቢያ ሰርዝ እና በገፅህ ላይ ለማስገባት የፈለግከውን የጃቫ ስክሪፕት አሳይ", + "NoWebsites": "ለታስተዳድረው የምትችለው ምንም አይነት ድር ጣቢያ የለም", + "ShowTrackingTag": "የዱካ መከተያ መለያ አሳይ", + "Sites": "ድር ጣቢያ", + "Timezone": "የሰዓት ሰቅ", + "Urls": "ዩ አር ኤሎች", + "WebsitesManagement": "የድር ጣቢያ ምነጃ" + }, + "UserCountry": { + "Continent": "አህጉር", + "continent_afr": "Africa", + "continent_amn": "North America", + "continent_ams": "South and Central America", + "continent_asi": "Asia", + "continent_eur": "Europe", + "continent_oce": "Oceania", + "Country": "ሀገር", + "country_ac": "Ascension Islands", + "country_ad": "Andorra", + "country_ae": "United Arab Emirates", + "country_af": "Afghanistan", + "country_ag": "Antigua and Barbuda", + "country_ai": "Anguilla", + "country_al": "Albania", + "country_am": "Armenia", + "country_an": "Netherlands Antilles", + "country_ao": "Angola", + "country_aq": "Antarctica", + "country_ar": "Argentina", + "country_as": "American Samoa", + "country_at": "Austria", + "country_au": "Australia", + "country_aw": "Aruba", + "country_ax": "Aland Islands", + "country_az": "Azerbaijan", + "country_ba": "Bosnia and Herzegovina", + "country_bb": "Barbados", + "country_bd": "Bangladesh", + "country_be": "Belgium", + "country_bf": "Burkina Faso", + "country_bg": "Bulgaria", + "country_bh": "Bahrain", + "country_bi": "Burundi", + "country_bj": "Benin", + "country_bl": "Saint Barthelemy", + "country_bm": "Bermuda", + "country_bn": "Bruneo", + "country_bo": "Bolivia", + "country_bq": "የካሪቢያን ኔዘርላንድስ", + "country_br": "Brazil", + "country_bs": "Bahamas", + "country_bt": "Bhutan", + "country_bu": "Burma", + "country_bv": "Bouvet Island", + "country_bw": "Botswana", + "country_by": "Belarus", + "country_bz": "Belize", + "country_ca": "Canada", + "country_cc": "Cocos (Keeling) Islands", + "country_cd": "Congo, The Democratic Republic of the", + "country_cf": "Central African Republic", + "country_cg": "Congo", + "country_ch": "Switzerland", + "country_ci": "Cote D'Ivoire", + "country_ck": "Cook Islands", + "country_cl": "Chile", + "country_cm": "Cameroon", + "country_cn": "China", + "country_co": "Colombia", + "country_cp": "Clipperton Island", + "country_cr": "Costa Rica", + "country_cs": "Serbia Montenegro", + "country_cu": "Cuba", + "country_cv": "Cape Verde", + "country_cw": "ኩራሳዎ", + "country_cx": "Christmas Island", + "country_cy": "Cyprus", + "country_cz": "Czech Republic", + "country_de": "Germany", + "country_dg": "Diego Garcia", + "country_dj": "Djibouti", + "country_dk": "Denmark", + "country_dm": "Dominica", + "country_do": "Dominican Republic", + "country_dz": "Algeria", + "country_ea": "Ceuta, Melilla", + "country_ec": "Ecuador", + "country_ee": "Estonia", + "country_eg": "Egypt", + "country_eh": "Western Sahara", + "country_er": "Eritrea", + "country_es": "Spain", + "country_et": "Ethiopia", + "country_eu": "European Union", + "country_fi": "Finland", + "country_fj": "Fiji", + "country_fk": "Falkland Islands (Malvinas)", + "country_fm": "Micronesia, Federated States of", + "country_fo": "Faroe Islands", + "country_fr": "France", + "country_fx": "France, Metropolitan", + "country_ga": "Gabon", + "country_gb": "Great Britain", + "country_gd": "Grenada", + "country_ge": "Georgia", + "country_gf": "French Guyana", + "country_gg": "Guernsey", + "country_gh": "Ghana", + "country_gi": "Gibraltar", + "country_gl": "Greenland", + "country_gm": "Gambia", + "country_gn": "Guinea", + "country_gp": "Guadeloupe", + "country_gq": "Equatorial Guinea", + "country_gr": "Greece", + "country_gs": "South Georgia and the South Sandwich Islands", + "country_gt": "Guatemala", + "country_gu": "Guam", + "country_gw": "Guinea-Bissau", + "country_gy": "Guyana", + "country_hk": "Hong Kong", + "country_hm": "Heard Island and McDonald Islands", + "country_hn": "Honduras", + "country_hr": "Croatia", + "country_ht": "Haiti", + "country_hu": "Hungary", + "country_ic": "Canary Islands", + "country_id": "Indonesia", + "country_ie": "Ireland", + "country_il": "Israel", + "country_im": "Man Island", + "country_in": "India", + "country_io": "British Indian Ocean Territory", + "country_iq": "Iraq", + "country_ir": "Iran, Islamic Republic of", + "country_is": "Iceland", + "country_it": "Italy", + "country_je": "Jersey", + "country_jm": "Jamaica", + "country_jo": "Jordan", + "country_jp": "Japan", + "country_ke": "Kenya", + "country_kg": "Kyrgyzstan", + "country_kh": "Cambodia", + "country_ki": "Kiribati", + "country_km": "Comoros", + "country_kn": "Saint Kitts and Nevis", + "country_kp": "Korea, Democratic People's Republic of", + "country_kr": "Korea, Republic of", + "country_kw": "Kuwait", + "country_ky": "Cayman Islands", + "country_kz": "Kazakhstan", + "country_la": "Laos", + "country_lb": "Lebanon", + "country_lc": "Saint Lucia", + "country_li": "Liechtenstein", + "country_lk": "Sri Lanka", + "country_lr": "Liberia", + "country_ls": "Lesotho", + "country_lt": "Lithuania", + "country_lu": "Luxembourg", + "country_lv": "Latvia", + "country_ly": "Libya", + "country_ma": "Morocco", + "country_mc": "Monaco", + "country_md": "Moldova, Republic of", + "country_me": "Montenegro", + "country_mf": "Saint Martin", + "country_mg": "Madagascar", + "country_mh": "Marshall Islands", + "country_mk": "Macedonia", + "country_ml": "Mali", + "country_mm": "Myanmar", + "country_mn": "Mongolia", + "country_mo": "Macau", + "country_mp": "Northern Mariana Islands", + "country_mq": "Martinique", + "country_mr": "Mauritania", + "country_ms": "Montserrat", + "country_mt": "Malta", + "country_mu": "Mauritius", + "country_mv": "Maldives", + "country_mw": "Malawi", + "country_mx": "Mexico", + "country_my": "Malaysia", + "country_mz": "Mozambique", + "country_na": "Namibia", + "country_nc": "New Caledonia", + "country_ne": "Niger", + "country_nf": "Norfolk Island", + "country_ng": "Nigeria", + "country_ni": "Nicaragua", + "country_nl": "Netherlands", + "country_no": "Norway", + "country_np": "Nepal", + "country_nr": "Nauru", + "country_nt": "Neutral Zone", + "country_nu": "Niue", + "country_nz": "New Zealand", + "country_om": "Oman", + "country_pa": "Panama", + "country_pe": "Peru", + "country_pf": "French Polynesia", + "country_pg": "Papua New Guinea", + "country_ph": "Philippines", + "country_pk": "Pakistan", + "country_pl": "Poland", + "country_pm": "Saint Pierre and Miquelon", + "country_pn": "Pitcairn", + "country_pr": "Puerto Rico", + "country_ps": "Palestinian Territory", + "country_pt": "Portugal", + "country_pw": "Palau", + "country_py": "Paraguay", + "country_qa": "Qatar", + "country_re": "Reunion Island", + "country_ro": "Romania", + "country_rs": "Serbia", + "country_ru": "Russia", + "country_rw": "Rwanda", + "country_sa": "Saudi Arabia", + "country_sb": "Solomon Islands", + "country_sc": "Seychelles", + "country_sd": "Sudan", + "country_se": "Sweden", + "country_sf": "Finland", + "country_sg": "Singapore", + "country_sh": "Saint Helena", + "country_si": "Slovenia", + "country_sj": "Svalbard", + "country_sk": "Slovakia", + "country_sl": "Sierra Leone", + "country_sm": "San Marino", + "country_sn": "Senegal", + "country_so": "Somalia", + "country_sr": "Suriname", + "country_ss": "ደቡብ ሱዳን", + "country_st": "Sao Tome and Principe", + "country_su": "Old U.S.S.R", + "country_sv": "El Salvador", + "country_sx": "ሲንት ማርተን", + "country_sy": "Syrian Arab Republic", + "country_sz": "Swaziland", + "country_ta": "Tristan da Cunha", + "country_tc": "Turks and Caicos Islands", + "country_td": "Chad", + "country_tf": "French Southern Territories", + "country_tg": "Togo", + "country_th": "Thailand", + "country_tj": "Tajikistan", + "country_tk": "Tokelau", + "country_tl": "East Timor", + "country_tm": "Turkmenistan", + "country_tn": "Tunisia", + "country_to": "Tonga", + "country_tp": "East Timor", + "country_tr": "Turkey", + "country_tt": "Trinidad and Tobago", + "country_tv": "Tuvalu", + "country_tw": "Taiwan", + "country_tz": "Tanzania, United Republic of", + "country_ua": "Ukraine", + "country_ug": "Uganda", + "country_uk": "United Kingdom", + "country_um": "United States Minor Outlying Islands", + "country_us": "United States", + "country_uy": "Uruguay", + "country_uz": "Uzbekistan", + "country_va": "Vatican City", + "country_vc": "Saint Vincent and the Grenadines", + "country_ve": "Venezuela", + "country_vg": "Virgin Islands, British", + "country_vi": "Virgin Islands, U.S.", + "country_vn": "Vietnam", + "country_vu": "Vanuatu", + "country_wf": "Wallis and Futuna", + "country_ws": "Samoa", + "country_ye": "Yemen", + "country_yt": "Mayotte", + "country_yu": "Yugoslavia", + "country_za": "South Africa", + "country_zm": "Zambia", + "country_zr": "Zaire", + "country_zw": "Zimbabwe", + "DistinctCountries": "%s የተለዩ ሀገራት", + "SubmenuLocations": "አቀማመጦች" + }, + "UserSettings": { + "BrowserFamilies": "የማሰሺያ ቤተሰቦች", + "Browsers": "ማሰሻዎች", + "ColumnBrowser": "ማሰሺያ", + "ColumnBrowserFamily": "የማሰሻ ቤተሰብ", + "ColumnConfiguration": "ውቅረት", + "ColumnResolution": "ጥራት", + "ColumnTypeOfScreen": "የማያ ዓይነት", + "Configurations": "ውቅረቶች", + "Language_aa": "አፋርኛ", + "Language_ab": "አብሐዚኛ", + "Language_af": "አፍሪካንስኛ", + "Language_ak": "አካንኛ", + "Language_am": "አማርኛ", + "Language_ar": "ዐርቢኛ", + "Language_as": "አሳሜዛዊ", + "Language_ay": "አያማርኛ", + "Language_az": "አዜርባይጃንኛ", + "Language_ba": "ባስኪርኛ", + "Language_be": "ቤላራሻኛ", + "Language_bg": "ቡልጋሪኛ", + "Language_bh": "ቢሃሪ", + "Language_bi": "ቢስላምኛ", + "Language_bn": "በንጋሊኛ", + "Language_bo": "ትበትንኛ", + "Language_br": "ብሬቶንኛ", + "Language_bs": "ቦስኒያንኛ", + "Language_ca": "ካታላንኛ", + "Language_co": "ኮርሲካኛ", + "Language_cs": "ቼክኛ", + "Language_cy": "ወልሽ", + "Language_da": "ዴኒሽ", + "Language_de": "ጀርመን", + "Language_dv": "ዲቬህ", + "Language_dz": "ድዞንግኻኛ", + "Language_ee": "ኢዊ", + "Language_el": "ግሪክኛ", + "Language_en": "እንግሊዝኛ", + "Language_eo": "ኤስፐራንቶ", + "Language_es": "ስፓኒሽ", + "Language_et": "ኤስቶኒአን", + "Language_eu": "ባስክኛ", + "Language_fa": "ፐርሲያኛ", + "Language_fi": "ፊኒሽ", + "Language_fj": "ፊጂኛ", + "Language_fo": "ፋሮኛ", + "Language_fr": "ፈረንሳይኛ", + "Language_fy": "ፍሪስኛ", + "Language_ga": "አይሪሽ", + "Language_gd": "እስኮትስ ጌልክኛ", + "Language_gl": "ጋለጋኛ", + "Language_gn": "ጓራኒኛ", + "Language_gu": "ጉጃርቲኛ", + "Language_ha": "ሃውሳኛ", + "Language_he": "ዕብራስጥ", + "Language_hi": "ሐንድኛ", + "Language_hr": "ክሮሽያንኛ", + "Language_ht": "ሃይትኛ", + "Language_hu": "ሀንጋሪኛ", + "Language_hy": "አርመናዊ", + "Language_ia": "ኢንቴርሊንጓ", + "Language_id": "እንዶኒሲኛ", + "Language_ie": "እንተርሊንግወ", + "Language_ig": "ኢግቦኛ", + "Language_ik": "እኑፒያቅኛ", + "Language_is": "አይስላንድኛ", + "Language_it": "ጣሊያንኛ", + "Language_iu": "እኑክቲቱትኛ", + "Language_ja": "ጃፓንኛ", + "Language_jv": "ጃቫንኛ", + "Language_ka": "ጊዮርጊያን", + "Language_kg": "ኮንጎኛ", + "Language_kk": "ካዛክኛ", + "Language_kl": "ካላሊሱትኛ", + "Language_km": "ክመርኛ", + "Language_kn": "ካናዳኛ", + "Language_ko": "ኮሪያኛ", + "Language_ks": "ካሽሚርኛ", + "Language_ku": "ኩርድሽኛ", + "Language_ky": "ኪርጊዝኛ", + "Language_la": "ላቲንኛ", + "Language_lb": "ሉክዘምበርገርኛ", + "Language_lg": "ጋንዳኛ", + "Language_ln": "ሊንጋላኛ", + "Language_lo": "ላውስኛ", + "Language_lt": "ሊቱአኒያን", + "Language_lv": "ላትቪያን", + "Language_mg": "ማላጋስኛ", + "Language_mi": "ማዮሪኛ", + "Language_mk": "ማከዶኒኛ", + "Language_ml": "ማላያላምኛ", + "Language_mn": "ሞንጎላዊኛ", + "Language_mr": "ማራዚኛ", + "Language_ms": "ማላይኛ", + "Language_mt": "ማልቲስኛ", + "Language_my": "ቡርማኛ", + "Language_na": "ናኡሩ", + "Language_nb": "የኖርዌይ ቦክማል", + "Language_nd": "ሰሜን ንዴብሌ", + "Language_ne": "ኔፓሊኛ", + "Language_nl": "ደች", + "Language_nn": "የኖርዌ አዲሱ ኖርዌጅያንኛ", + "Language_no": "ኖርዌጂያን", + "Language_ny": "ንያንጃ", + "Language_oc": "ኦኪታንኛ", + "Language_om": "ኦሮምኛ", + "Language_or": "ኦሪያኛ", + "Language_os": "ኦሴቲክ", + "Language_pa": "ፓንጃቢኛ", + "Language_pl": "ፖሊሽ", + "Language_ps": "ፑሽቶኛ", + "Language_pt": "ፖርቱጋሊኛ", + "Language_qu": "ኵቿኛ", + "Language_rm": "ሮማንስ", + "Language_rn": "ሩንዲኛ", + "Language_ro": "ሮማኒያን", + "Language_ru": "ራሽኛ", + "Language_rw": "ኪንያርዋንድኛ", + "Language_sa": "ሳንስክሪትኛ", + "Language_sd": "ሲንድሂኛ", + "Language_se": "ሰሜናዊ ሳሚ", + "Language_sg": "ሳንጎኛ", + "Language_si": "ስንሃልኛ", + "Language_sk": "ስሎቫክኛ", + "Language_sl": "ስሎቪኛ", + "Language_sm": "ሳሞአኛ", + "Language_sn": "ሾናኛ", + "Language_so": "ሱማልኛ", + "Language_sq": "ልቤኒኛ", + "Language_sr": "ሰርቢኛ", + "Language_ss": "ስዋቲኛ", + "Language_st": "ሶዞኛ", + "Language_su": "ሱዳንኛ", + "Language_sv": "ስዊድንኛ", + "Language_sw": "ስዋሂሊኛ", + "Language_ta": "ታሚልኛ", + "Language_te": "ተሉጉኛ", + "Language_tg": "ታጂኪኛ", + "Language_th": "ታይኛ", + "Language_ti": "ትግርኛ", + "Language_tk": "ቱርክመንኛ", + "Language_tl": "ታጋሎገኛ", + "Language_tn": "ጽዋናዊኛ", + "Language_to": "ቶንጋ", + "Language_tr": "ቱርክኛ", + "Language_ts": "ጾንጋኛ", + "Language_tt": "ታታርኛ", + "Language_tw": "ትዊኛ", + "Language_ty": "ታሂታንኛ", + "Language_ug": "ኡዊግሁርኛ", + "Language_uk": "ዩክረኒኛ", + "Language_ur": "ኡርዱኛ", + "Language_uz": "ኡዝበክኛ", + "Language_ve": "ቬንዳ", + "Language_vi": "ቪትናምኛ", + "Language_vo": "ቮላፑክኛ", + "Language_wo": "ዎሎፍኛ", + "Language_xh": "ዞሳኛ", + "Language_yi": "ይዲሻዊኛ", + "Language_yo": "ዮሩባዊኛ", + "Language_za": "ዡዋንግኛ", + "Language_zh": "ቻይንኛ", + "Language_zu": "ዙሉኛ", + "LanguageCode": "የቋንቋ ኮድ", + "Resolutions": "ጥራቶች", + "VisitorSettings": "የጎበኚዎች ቅንብሮች", + "WideScreen": "ሰፊ ማያ", + "WidgetBrowserFamilies": "ማሰሺያ በቤተሰብ", + "WidgetBrowsers": "የጎብኚ ማሰሻዎች", + "WidgetGlobalVisitors": "የሁሉም ጎብኚዎች ውቅረት", + "WidgetOperatingSystems": "ስርዓተ ክወናዎች", + "WidgetPlugins": "የተሰኪዎች ዝርዝር", + "WidgetResolutions": "የማያ ጥራቶች", + "WidgetWidescreen": "የተለመደ \/ ሰፊ ማያ" + }, + "UsersManager": { + "AddUser": "አዲስ ተጤቃሚ ጨምር", + "Alias": "ተለዋጭ ስም", + "AllWebsites": "ሁሉንም ድር ጣቢያዎች", + "ApplyToAllWebsites": "በሁሉም ድር ጣቢያዎች ላይ ተግብር", + "ChangeAllConfirm": "እርግጠኛ ነህ '%s' የሁሉንም ድር ጣቢያዎች ፈቃድ መቀየር ትፈልጋለህ?", + "DeleteConfirm": "እርግጠኛ ነህ ተጠቃሚዎቹን መሰረዝ ትፈልጋለህ %s?", + "Email": "ኢ-ሜይል", + "ExceptionAccessValues": "የግቤት መድረሻ ከሚከተሉት አንዱ እሴት ሊኖረው ይገባል: [ %s ]", + "ExceptionAdminAnonymous": "የአስተዳዳሪን ፈቃድ ላልታወቀ ሰው መስጠት አይቻልም", + "ExceptionDeleteDoesNotExist": "ተጠቃሚው '%s' የለም ስለዚህ ሊሰረዝ አይችልም", + "ExceptionEditAnonymous": "ያልታወቀው ተጠቃሚን መሰረዝ ወይም አርትእ አይቻልም። በፒዊክ ያልገባን ተጤቃሚ ለመግለፅ ይጠቅማል። ለምሳሌ የርስዎን ስታቲስቲክ 'የትይታ' ፈቃድ 'ላልታወቀ' ተጠቃሚ በመስጠት ግልፅ ማድረግ ትችላለህ።", + "ExceptionEmailExists": "በዚህ ኢ-ሜይል ተጠቃሚ '%s'አለ.", + "ExceptionInvalidEmail": "ኢ-ሜይሉ ትክክለኛ ቅርፀት የለውም", + "ExceptionInvalidLoginFormat": "ገባ በ %1$s እና %2$s በ ቁምፊ መካከል መሆን አለበት እንዲሁም ፊደላት፣ቁጥሮች ወይም ቁምፊ ብቻ መያዝ አለበት '_' ወይም '-' ወይም '.'", + "ExceptionLoginExists": "ግባ '%s' አስቀድሞ ማለት.", + "MainDescription": "የትኞቹ ተጠቃሚዎች የትኛው በድር ጣቢያህ ላይ የፒዊክ መድረሻ እንዳላቸው ወስን። በሁሉም ድር ጣቢያዎች ላይ ፈቃዳቸውን በአንድ ጊዜ ማዘጋጀት ይቻላል።", + "ManageAccess": "መድረሻ መንጅ", + "MenuUsers": "ተጠቃሚዎች", + "PrivAdmin": "አስተዳዳሪ", + "PrivNone": "መድረሻ የለም", + "PrivView": "ትእይታ", + "User": "ተጠቃሚ", + "UsersManagement": "የተጠቃሚዎች ምነጃ", + "UsersManagementMainDescription": "አዳዲስ ተጤቃሚዎችን ይፍጠሩ ወይም ያሉትን ያልቁ።ፈቃዳቸውን ከላይ ማዘጋጀት ይቻላል." + }, + "VisitFrequency": { + "ColumnActionsByReturningVisits": "በመልስ ጉብኝቶች የተወሰዱ ርምጃዎች", + "ColumnBounceRateForReturningVisits": "የመልስ ጉብኝቶች ተመላሽ ፍጥነት", + "ColumnReturningVisits": "መልስ ጉበኝቶች", + "ReturnActions": "%s በመልስ ጉብኝቶች የተወሰዱ ርምጃዎች", + "ReturnBounceRate": "%s መልስ ጉብኝቶች ተመላሽ ሆነዋል (ከአንድ ድር ጣቢያ በኋላ ገፁን ትተዋል)", + "ReturnVisits": "%s መልስ ጉብኝቶች", + "SubmenuFrequency": "ድግግሞሽ", + "WidgetGraphReturning": "የመልስ ጉብኝቶች ግራፍ", + "WidgetOverview": "የድግግሞሽ አጠቃላይ እይታ" + }, + "VisitorInterest": { + "ColumnPagesPerVisit": "ጉብኝት ከገፆች ጋር", + "ColumnVisitDuration": "የጉብኝት ቆይታዎች", + "NPages": "%s ገፆች", + "OnePage": "1 ገፅ", + "PlusXMin": "%s ደቂቃ", + "VisitsPerDuration": "ጉብኝት ከጉብኝት ቆይታ ጋር", + "VisitsPerNbOfPages": "ጉብኝት ከገፅ ቁጥር ጋር", + "WidgetLengths": "የጉብኝት ርዝመት", + "WidgetPages": "ጉብኝት ከገፆች ጋር" + }, + "VisitsSummary": { + "GenerateQueries": "%s ጥያቄዎች ተሰርተዋል", + "GenerateTime": "%s ሰከንዶች ገፁን ለማመንጨት", + "MaxNbActions": "%s በአንድ ጉብኝት የተወሰዱ ከፍተኛ ርምጃዎች", + "NbUniqueVisitors": "%s የተለዩ ጎብኚዎች", + "NbVisitsBounced": "%s የተመለሱ ጉብኝቶች (ከአንድ ገፅ በኋላ ገፁን የተዉ)", + "WidgetLastVisits": "የመጨረሻው ጉብኝቶች ግራፍ", + "WidgetOverviewGraph": "አጠቃላይ እይታ ከግራፍ ጋር", + "WidgetVisits": "የጉብኝቶች አጠቃላይ እይታ" + }, + "VisitTime": { + "ColumnLocalTime": "አካባቢያዊ ሰዓት", + "ColumnServerTime": "የአገልጋይ ሰዓት", + "LocalTime": "ጉብኝቶች ከአካባቢያዊ ሰዓት", + "ServerTime": "ጉብኝቶች ከአገልጋይ ሰዓት", + "SubmenuTimes": "ሰዓታት", + "WidgetLocalTime": "ጉብኝቶች በአካባቢያዊ ሰዓት", + "WidgetServerTime": "ጉብኝቶች በአገልጋይ ሰዓት" + } +} \ No newline at end of file diff --git a/www/analytics/lang/ar.json b/www/analytics/lang/ar.json new file mode 100644 index 00000000..67e7b828 --- /dev/null +++ b/www/analytics/lang/ar.json @@ -0,0 +1,1497 @@ +{ + "Actions": { + "AvgGenerationTimeTooltip": "متوسط على أساس %s كبسة %s ما بين %s و %s", + "ColumnClickedURL": "الرابط المتبوع", + "ColumnClicks": "النقرات", + "ColumnClicksDocumentation": "عدد مرات النقر على هذا الرابط.", + "ColumnDownloadURL": "رابط التحميل", + "ColumnEntryPageTitle": "عنوان صفحة الوصول", + "ColumnEntryPageURL": "رابط صفحة الوصول", + "ColumnExitPageTitle": "عنوان صفحة المغادرة", + "ColumnExitPageURL": "رابط صفحة المغادرة", + "ColumnNoResultKeyword": "الكلمات الدلالية بدون نتائج بحث", + "ColumnPageName": "اسم الصفحة", + "ColumnPagesPerSearch": "صفحات نتائج البحث", + "ColumnPagesPerSearchDocumentation": "سيقوم الزوار بالبحث على صفحات موقعك، وفي بعض الأحيان سينقرون \"التالي\" لمشاهدة المزيد من النتائج. هذا هو متوسط عدد صفحات نتائج البحث المعروضة لتلك الكلمة الدلالية.", + "ColumnPageURL": "رابط الصفحة", + "ColumnSearchCategory": "فئة البحث", + "ColumnSearches": "البجث", + "ColumnSearchesDocumentation": "عدد الزيارات التي تم فيها البحث عن هذه الكلمة باستخدام محرك بحث موقعك.", + "ColumnSearchExits": "% البحث موجود", + "ColumnSearchExitsDocumentation": "نسبة الزيارات التي غادرت موقعك بعد البحث عن هذه الكلمة باستخدام محرك بحث موقعك.", + "ColumnSearchResultsCount": "عدد نتائج البحث", + "ColumnSiteSearchKeywords": "الكلمات الدلالية الفريدة", + "ColumnUniqueClicks": "النقرات الفريدة", + "ColumnUniqueClicksDocumentation": "عدد الزيارات التي تم النقر فيها على هذا الرابط. إذا تم النقر عدة مرات على الرابط نفسه في زيارة واحدة، ستحتسب جميعها واحدة فقط.", + "ColumnUniqueDownloads": "التحميلات الفريدة", + "ColumnUniqueOutlinks": "الروابط الصادرة الفريدة", + "DownloadsReportDocumentation": "في هذا التقرير، يمكنك مشاهدة أي الملفات قام زوارك بتحميلها. %s ما يحتسبه بايويك كعملية تحميل هو النقر على رابط التحميل. لا يستطيع أن يعلم بايويك ما إذا كان التحميل قد اكتمل أم لا.", + "EntryPagesReportDocumentation": "يتضمن هذا التقرير معلومات حول صفحات الوصول التي تم استخدامها خلال فترة معينة. صفحة الوصول هي الصفحة الأولى التي يشاهدها المستخدم خلال زيارته. %s روابط الوصول معروضة في شكل شجرة مجلدات.", + "EntryPageTitles": "عناوين صفحات الوصول", + "EntryPageTitlesReportDocumentation": "يتضمن هذا التقرير معلومات عن عناوين صفحات الوصول التي تم استخدامها خلال فترة زمنية معينة.", + "ExitPagesReportDocumentation": "يتضمن هذا التقرير معلومات حول صفحات الخروج التي حدثت خلال الفترة المحددة. صفحة الخروج هي آخر صفحة شاهدها المستخدم خلال زيارته. %s روابط الخروج معروضة في شكل شجرة مجلدات.", + "ExitPageTitles": "عناوين صفحات الخروج", + "ExitPageTitlesReportDocumentation": "يتضمن هذا التقرير معلومات عن عناوين صفحات الخروج التي حدثت خلال الفترة المحددة.", + "LearnMoreAboutSiteSearchLink": "تعرف على المزيد عن تتبع كيف يستخدم زوارك محرك بحثك.", + "OutlinkDocumentation": "الرابط الصادر هو رابط يقود الزائر بعيداً عن موقعك (إلى اسم نطاق آخر).", + "OutlinksReportDocumentation": "يعرض هذا التقرير قائمة هيكلية بالروابط الصادرة والتي تم النقر عليها بواسطة زوارك.", + "PagesReportDocumentation": "يتضمن هذا التقرير معلومات عن روابط الصفحات التي تم زيارتها. %s الجدول منظم هيكلياً، والروابط معروضة في شكل شجرة مجلدات.", + "PageTitlesReportDocumentation": "يتضمن هذا التقرير معلومات عن عناوين الصفحات التي تم زيارتها. %s عنوان الصفحة هو وسم لغة %s HTML الذي تعرضه أغلب متصفحات ويب كعناون النافذة.", + "PageUrls": "روابط الصفحة", + "PluginDescription": "التقارير حول مشاهدات الصفحة، الروابط الصادرة، والتحميلات. تتبع الروابط الصادرة والتحميلات يتم آلياً.", + "SiteSearchCategories1": "يعرض هذا التقرير قائمة الفئات التي اختارها زوارك عندما قاموا بالبحث في موقعك.", + "SiteSearchCategories2": "على سبيل المثال، المواقع التجارية عادة ما تحتوي طريقة لاختيار \"فئة\" بحيث يمكن للزوار حصر البحث في فئة منتجات معينة.", + "SiteSearchFollowingPagesDoc": "عندما يقوم زوارك بالبحث في موقعك، فإنهم يبحثون عن صفحة بعينها، مقال ما، منتج أو خدمة. هذا التقرير يعرض أكثر الصفحات التي قاموا بالنقر عليها عند استخدام بحث الموقع. بكلمات أخرى، هذه قائمة بالصفحات التي يبحث عنها زوارك دائماً.", + "SiteSearchIntro": "تتبع عمليات البحث التي يقوم بها الزوار على موقعك هي أكثر الطرق فعالية لمعرفة ما يبحث عنه جمهورك، هذا قد يساعد على البحث عن أفكار للمحتوى الجديد، سلع جديد للتسوق الألكتروني التي بحث عنها المستهلكين، و عموما تحسين تجربة الزوار على موقعك.", + "SiteSearchKeyword": "كلمة (بحث موقع(", + "SiteSearchKeywordsDocumentation": "يعرض هذا التقرير قوائم كلمات البحث الدلالية التي بحث عنها زوارك باستخدام محرك البحث الداخلي لديك.", + "SiteSearchKeywordsNoResultDocumentation": "يعرض هذا التقرير قوائك كلمات البحث الدلالية التي لم تأتي بأي نتائج: ربما تحتاج خوارزمية محرك البحث إلى التحسين، أو ربما بحث زوار موقعك عن محتوى غير موجود (بعد)؟", + "SubmenuPagesEntry": "صفحات الدخول", + "SubmenuPagesExit": "صفحات الخروج", + "SubmenuPageTitles": "عناوين الصفحات", + "SubmenuSitesearch": "بحث الموقع", + "WidgetEntryPageTitles": "عناوين صفحات الدخول", + "WidgetExitPageTitles": "عناوين صفحات الخروج", + "WidgetPagesEntry": "صفحات الوصول", + "WidgetPagesExit": "صفحات الخروج", + "WidgetPageTitles": "عناوين الصفحة", + "WidgetPageTitlesFollowingSearch": "عناوين الصفحة بعد بحث بالموقع", + "WidgetPageUrlsFollowingSearch": "الصفحات بعد بحث بالموقع", + "WidgetSearchCategories": "فئات البحث", + "WidgetSearchKeywords": "كلمات البحث الدلالية", + "WidgetSearchNoResultKeywords": "كلمات البحث الدلالية بلا نتائج" + }, + "Annotations": { + "AddAnnotationsFor": "أضف توضيحات عن %s...", + "AnnotationOnDate": "توضيحات عن %1$s: %2$s", + "Annotations": "التوضيحات", + "ClickToDelete": "انقر لحذف هذا التوضيح.", + "ClickToEdit": "انقر لتحرير هذا التوضيح.", + "ClickToEditOrAdd": "انقر لتحرير أو إضافة توضيح جديد.", + "ClickToStarOrUnstar": "انقر لتمييز أو أزالة تمييز هذا التوضيح.", + "CreateNewAnnotation": "إنشاء توضيح جديد...", + "EnterAnnotationText": "أكتب ملاحظتك...", + "HideAnnotationsFor": "إخفاء توضيحات %s...", + "IconDesc": "شاهد ملاحظات نطاق التاريخ هذا.", + "IconDescHideNotes": "إخفاء ملاحظات نطاق التاريخ هذا.", + "InlineQuickHelp": "يمكنك إنشاء توضيحات لإضافة أحداث مميزة (كمقال جديد بالمدونة، أو تصميم جديد للموقع)، لحفظ تحليل بياناتك أو لحفظ أي شيء آخر تراه مهماً.", + "LoginToAnnotate": "سجل الدخول لإنشاء توضيح.", + "NoAnnotations": "لا توجد توضيحات في هذا النطاق التاريخي.", + "PluginDescription": "يمكنك ارفاق ملاحظات لأيام مختلفة لتحديد تغييرات أجريتها على موقعك، حفظ تحليلك الذي أجريته فيما يتعلق بالبيانات ومشاركة أفكارك مع زملائك. بوضع توضيحات على بياناتك، ستتمكن من تذكر لماذا كانت بياناتك تبدو بهذه الطريقة.", + "ViewAndAddAnnotations": "مشاهدة وإضافة توضيحات حول %s...", + "YouCannotModifyThisNote": "لا يمكنك تحرير هذا التوضيح لأنك لم تنشأها، ولا تملك صلاحيات وصول مشرف في هذا الموقع." + }, + "API": { + "GenerateVisits": "إذا كنت لا تملك بيانات لليوم الحالي، فيمكنك أن تنشئ بعض البيانات باستخدام تطبيق %s. يمكنك تفعيل تطبيق %s، ثم النقر على قائمة \"مولد الزوا\" في لوحة إدارة Piwik.", + "KeepTokenSecret": "مفتاح المصادقة هذا سري كما هو الحال في اسم المستخدم ولكلمة المرور، %s لا تعطه لأحد قط%s!", + "LoadedAPIs": "تم تحميل %s واجهة تطبيقات.", + "MoreInformation": "لمزيد من المعلومات حول واجهة التطبيقات لبرنامج Piwik، الرجاء مراجعة %s مقدمة إلى واجهة تطبيقات Piwik %s وكذلك %sدليل واجهة تطبيقات Piwik %s.", + "PluginDescription": "كافة البيانات في Piwik متوافرة من خلال واجهة برمجة تطبيقات API بسيطة. هذه الإضافة هي خدمة ترتكز إلى ويب كنقطة دخول، يمكنك استخدامها في الوصول إلى بيانات تحليلات ويب في شكل xml, json, php, csv, وغيرها.", + "QuickDocumentationTitle": "مستندات دعم API السريعة", + "TopLinkTooltip": "الوصول إلى تحليلات ويب الخاصة بك برمجياً عبر واجهة تطبيقات بسيطة API على شكل json, xml وغيرها.", + "UserAuthentication": "مصادقة المستخدم", + "UsingTokenAuth": "إذا كنت ترغب في %s طلب بيانات من خلال نص برمجي أو Crontab، أو غيرها %s فستحتاج إلى إضافة باراميتر %s في روابط طلبات API والتي تتطلب المصادقة." + }, + "CoreAdminHome": { + "Administration": "الإدارة", + "BrandingSettings": "إعدادات العلامة التجارية", + "CheckReleaseGetVersion": "عند التحقق من الاصدار الجديد من البيويك، دائما احصل على", + "ClickHereToOptIn": "انقر هناك للاشتراك.", + "ClickHereToOptOut": "انقر هنا لإلغاء الاشتراك.", + "CustomLogoFeedbackInfo": "إذا قمت بتخصيص شعار بايويك، فقد ترغب أيضاً في إخفاء الرابط %s في القائمة العليا. لإجراء هذا، يمكنك تعطيل الملحق البرمجي \"التغذية الراجعة\" في صفحة %sإدارة الملحقات%s.", + "CustomLogoHelpText": "يمكنك تخصيص شعار بايويك والذي يتم عرضه في صفحة المستخدم والتقارير البريدية.", + "EmailServerSettings": "إعدادات ملقم البريد", + "ForBetaTestersOnly": "لمجربي نسخة بيتا فقط", + "ImageTracking": "صورة التتيع", + "ImageTrackingIntro1": "عندما يقوم أحد الزوار بتعطيل JaveScript، أو عندما لا يمكن استخدامها، فيمكنك أن تستخدم رابط التتبع بالصورة لمتابعة زوارك.", + "ImageTrackingIntro3": "للقائمة الكاملة بالخيارات التي يمكنك استخدامها بواسطة متتبع الصورة، انظر %1$sمستندات واجهة تطبيقات التتبع%2$s.", + "ImageTrackingLink": "رابط صورة التتبع", + "ImportingServerLogs": "جاري استيراد سجلات الملقم", + "JavaScriptTracking": "التتبع بجافاسكريبت", + "JSTracking_CampaignNameParam": "باراميتر اسم الحملة", + "JSTracking_CustomCampaignQueryParam": "استخدم أسماء باراميترات استعلام مخصصة لاسم الحملة وكلماتها الدلالية", + "JSTracking_EnableDoNotTrack": "تفعيل اكتشاف إعدادات عدم التتبع لدى العميل", + "JSTracking_EnableDoNotTrackDesc": "بحيث لن يتم إرسال طلبات التتبع إذا لم يرغب الزوار في ذلك.", + "JSTracking_GroupPageTitlesByDomainDesc1": "بحيث إذا زار أحدهم صفحة 'عنا' على المدونة %1$s سيتم تسجيلها في شكل 'المدونة \/ عنا'. هذه هي أسهل طريقة للحصول على نظرة عامة حول حركة الزوار في نطاق فرعي.", + "JSTracking_MergeAliasesDesc": "بحيث تكون النقرات على الروابط إلى روابط مماثلة Alias URL (مثل. %s) لن تعتبر كـ'روابط صادرة'.", + "JSTracking_MergeSubdomainsDesc": "بحيث إذا زار أحدهم %1$s و %2$s، سيتم احتسابه كزائر فريد وحيد.", + "JSTracking_PageCustomVarsDesc": "على سبيل المثال، مع اسم المتغير \"فئة\" وقيمته \"الصفحات البيضاء\".", + "JSTracking_VisitorCustomVarsDesc": "على سبيل المثال، باسم متغير \"النوع\" وقيمته \"العميل\".", + "JSTrackingIntro2": "ما أن تحصل على كود التتبع لموقعك، انسخه وألصقه في كافة الصفحات التي ترغب من بايويك أن يتتبعها.", + "JSTrackingIntro4": "إذا لم تكن ترغب في استخدام جافاسكريبت لتتبع زوارك، قم %1$sبتوليد رابط تتبع بالصورة أدناه%2$s.", + "LogoUpload": "اختر شعاراً لرفعه", + "MenuDiagnostic": "التشخيص", + "MenuGeneralSettings": "الإعدادات العامة", + "MenuManage": "الإدارة", + "OptOutComplete": "اكتمل إلغاء الاشتراك: لن يتم احتساب زياراتك لهذا الموقع بواسطة أدوات تحليلات ويب الخاصة بنا.", + "OptOutCompleteBis": "لاحظ أنك في حالة مسح الكوكيز Coockies، فإنك بذلك تحذف الكوكيز الخاصة بإلغاء الاشتراك، أو في حالة تغيير جهاز الكمبيوتر أو المتصفح، فستحتاج لإعادة هذا الإجراء مرة أخرى.", + "OptOutExplanation": "Piwik ملتزم بالخصوصية على الإنترنت. لمنح زوارك اختيار إلغاء الاشتراك في تحليلات ويب من Piwik، يمكنك إضافة كود HTML التالي على أحد صفحات موقعك. على سبيل المثال في صفحة سياسة الخصوصية.", + "OptOutExplanationBis": "سيقوم هذا الكود بعرض iFrame يحتوي رابطاً لزوارك لإلغاء اشتراكهم في Piwik من خلال ضبط كوكيز في متصفحهم. %s انقر هنا %s لمشاهدة المحتويات التي سيتم عرضها في النافذة الفرعية iFrame.", + "OptOutForYourVisitors": "إلغاء الاشتراك في Piwik لزوارك", + "PiwikIsInstalledAt": "بايويك مثبت في المسار", + "PluginDescription": "إدارة Piwik", + "TrackAGoal": "تتبع هدف", + "TrustedHostConfirm": "هل ترغب حقاً في تغيير اسم المُضيف الموثوق لدى بايويك؟", + "TrustedHostSettings": "مُضيف بايويك الموثوق", + "UseCustomLogo": "استخدم شعاراً مخصصاً", + "ValidPiwikHostname": "مُضيف بايويك صالح", + "WithOptionalRevenue": "مع أرباح اختيارية", + "YouAreOptedIn": "أنت مشترك حالياً.", + "YouAreOptedOut": "أنت غير مشترك حالياً.", + "YouMayOptOut": "يمكنك أن تختار ألا تحصل على كوكيز ذات معرف فريد يتم تعيينه لجهازك لتجنب شمول وتحليل البيانات التي يتم جمعها على هذا الموقع.", + "YouMayOptOutBis": "لإجراء هذا الاختيار، الرجاء النقر أدناه للحصول على كوكيز إلغاء الاشتراك." + }, + "CoreHome": { + "CategoryNoData": "لا توجد بيانات في هذه الفئة. حاول أن \"تضمن كافة الكثافات\".", + "CheckForUpdates": "بحث عن تحديثات", + "CheckPiwikOut": "تفحص بايويك!", + "DataForThisReportHasBeenPurged": "بيانات هذا التقرير أقدم من %s شهور وتم التخلص منها.", + "DataTableExcludeAggregateRows": "صفوف الإجمالي معروضة، %s اخفها", + "DateFormat": "%longDay% %day% %longMonth% %longYear%", + "Default": "الافتراضي", + "DonateCall1": "لن يكلفك بايويك قرشاً في استخدامه أبداً، ولكن ذلك لا يعني أنه لا يكلفنا شيئاً في تطويره.", + "DonateCall2": "يحتاج بايويك إلى دعمك المستمر لينمو ويزدهر.", + "DonateCall3": "إذا شعرت أن بايويك قد أضاف قيمة كبيرة إلى أعمالك، %1$sنرجو منك التبرع!%2$s", + "DonateFormInstructions": "انقر على الشريط لاختيار مبلغ، ثم انقر على اشتراك للتبرع.", + "ExcludeRowsWithLowPopulation": "كافة الصفوف معروضة %s استثني الكثافات المنخفضة", + "FlattenDataTable": "التقرير هيكلي %s اجعله بمستوى واحد", + "IncludeRowsWithLowPopulation": "الصفوف ذات الأعداد المنخفضة مخفية %s عرض كافة الصفوف", + "InjectedHostEmailSubject": "تم الوصول إلى بايويك باستخدام مضيف غير معروف: %s", + "InjectedHostSuperUserWarning": "قد تكون إعدادات بايويك غير مضبوطة بشكل صحيح (مثلاً، أن يكون قد تم نقله إلى ملقم جديد مؤخراً). يمكنك إما %1$sالنقر هنا وإضافة مضيف%2$s صالح جديد (إذا كنت تثق به)%3$s، أو %4$sانقر هنا لزيارة %5$s بايويك بأمان%6$s.", + "JavascriptDisabled": "يجب تفعيل برمجيات جافا في سبيل استخدام Piwik في الوضع القياسي. ومع ذلك، يبدو أن برمجيات جافا إما معطلة أو غير مدعمة في متصفح ويب الخاص بك.لاستخدام العرض القياسي، قم بتفعيل JavaScript من خلال تغيير إعدادات متصفحك، ثم %1$sحاول مرة أخرى%2$s.", + "LongMonthFormat": "%longYear%, %longMonth%", + "MakeADifference": "اصنع فرقاً: %1$sتبرع الآن%2$s لتمويل بايويك 2.0!", + "NoPrivilegesAskPiwikAdmin": "لقد سجلت الدخول بصفتك '%s' ولكن يبدو أنه لا توجد أي صلاحيات لك في Piwik. %s اسأل مدير Piwik (انقر لمراسلته)%s gلمنحك صلاحيات \"المشاهدة\" لموقع ما.", + "PageOf": "%1$s من %2$s", + "PeriodDay": "يوم", + "PeriodDays": "أيام", + "PeriodMonth": "شهر", + "PeriodMonths": "شهور", + "PeriodRange": "نطاق", + "PeriodWeek": "أسبوع", + "PeriodWeeks": "أسابيع", + "PeriodYear": "سنة", + "PeriodYears": "سنوات", + "PluginDescription": "بنية تقارير تحليلات ويب.", + "ReportGeneratedOn": "تم إنشاء التقرير في %s", + "SharePiwikLong": "مرحباً.. وجدت تطبيقاً رائعاً مفتوح المصدر: Piwik!\n\nسيمكنك بايويك من تتبع زوار موقعك مجاناً، ويتوجب عليك حقاً أن تجربه الآن!", + "ShareThis": "شارك هذا", + "ShortDateFormatWithYear": "%day% %shortMonth% %shortYear%", + "ShortWeekFormat": "%dayFrom% %shortMonthFrom% - %dayTo% %shortMonthTo% %shortYearTo%", + "ShowJSCode": "عرض كود JaveScript الذي سيتم إدخاله.", + "SupportPiwik": "ادعم بايويك!", + "ThereIsNoDataForThisReport": "لا توجد بيانات لهذا التقرير.", + "ViewAllPiwikVideoTutorials": "مشاهدة كافة دروس بايويك الفيديوية", + "WebAnalyticsReports": "تقارير تحليلات ويب" + }, + "CorePluginsAdmin": { + "Activate": "تفعيل", + "Activated": "تم تفعيله", + "Active": "مفعل", + "AuthorHomepage": "صفحة الناشر", + "Deactivate": "تعطيل", + "Inactive": "معطل", + "LicenseHomepage": "صفحة الرخصة", + "MainDescription": "التطبيقات تزيد من إمكانيات Piwik. ما أن يتم تنصيب تطبيق، يمكنك تفعيل أو تعطيل التطبيق من هنا.", + "PluginDescription": "واجهة إدارة التطبيقات", + "PluginHomepage": "صفحة التطبيقات الرئيسية", + "PluginsManagement": "إدارة التطبيقات", + "Status": "الحالة", + "Version": "الإصدار" + }, + "CoreUpdater": { + "ClickHereToViewSqlQueries": "انقر هنا لمشاهدة ونسخ قائمة استعلامات SQL والتي سيتم تنفيذها.", + "CreatingBackupOfConfigurationFile": "جاري حفظ ملف الإعدادات احتياطياً في %s", + "CriticalErrorDuringTheUpgradeProcess": "خطأ حرج أثناء عملية التحديث:", + "DatabaseUpgradeRequired": "تحديث قاعدة البيانات مطلوب", + "DownloadingUpdateFromX": "جاري تحميل التحديث من %s", + "DownloadX": "تحميل %s", + "EmptyDatabaseError": "قاعدة البيانات %s فارغة. يتوجب عليك تحرير أو إزالة ملف إعدادات Piwik.", + "ErrorDIYHelp": "إذا كنت مستخدماً محترفاً وواجهت خطأ في عملية الترقية:", + "ErrorDIYHelp_1": "قم بتحديد مصدر المشكلة وصححه (مثلاً: memory_limit أو max_execution_time)", + "ErrorDIYHelp_2": "نفذ باقي الاستعلامات في التحديث الذي فشل", + "ErrorDIYHelp_3": "قم بالتحديث اليدوي لجدول \"option\" في قاعدة بيانات Piwik معدلاً قيمة version_core إلى رقم الإصدار للتحديث الذي فشل تنفيذه", + "ErrorDIYHelp_4": "أعد تشغيل برنامج التحديث (من خلال المتصفح أو المحث) لمتابعة بقية التحديثات", + "ErrorDIYHelp_5": "أبلغ عن المشكلة (والحل) بحيث يمكن تحسين Piwik", + "ErrorDuringPluginsUpdates": "خطأ أثناء ترقية الإضافات:", + "ExceptionAlreadyLatestVersion": "إصدار Piwik المثبت لديك %s مُحدث.", + "ExceptionArchiveEmpty": "الملف المضغوط فارغ.", + "ExceptionArchiveIncompatible": "الملف المضغوط غير صالح: %s", + "ExceptionArchiveIncomplete": "الملف المضغوط ناقص: بعض الملفات مفقودة (مثلاً %s).", + "HelpMessageContent": "انظر %1$s الأسئلة الشائعة %2$s والتي تشرح أغلب الأخطاء الشائعة أثناء التحديث. %3$s اسأل مدير النظام لديك - قد يكونوا قادرين على المساعدة في الخطأ والذي قد يكون ناجماً عن إعدادات الملقم أو MySQL.", + "HelpMessageIntroductionWhenError": "الموضح أعلاه هو رسالة الخطأ الأساسية. يجب أن تكون قادرة على توضيح السبب، ولكنك إذا كنت في حاجة إلى مساعدة أكثر:", + "HelpMessageIntroductionWhenWarning": "تمت عملية التحديث بنجاح. على الرغم من ذلك، فهناك عدة أمور أثناء العملية. الرجاء قراءة الوصف أعلاه لمزيد من التفاصيل. لمزيد من المساعدة:", + "InstallingTheLatestVersion": "جاري تثبيت آخر إصدار", + "MajorUpdateWarning1": "هذه ترقية رئيسية وكبيرة! ستستغرق أطول من المعتاد.", + "NoteForLargePiwikInstances": "تنبيه هام لمواقع Piwik الكبير", + "NoteItIsExpectedThatQueriesFail": "ملاحظة: إذا قمت بإجراء هذه الاستعلامات يدوياً، فمن المتوقع أن بعضها سيفضل. في هذه الحالة، تجاهل الأخطاء وتابع تنفيذ الاستعلام التالي في القائمة.", + "PiwikHasBeenSuccessfullyUpgraded": "تم تحديث Piwik بنجاح!", + "PiwikUpdatedSuccessfully": "تم تحديث Piwik بنجاح!", + "PiwikWillBeUpgradedFromVersionXToVersionY": "ستتم ترقية قاعدة بيانات Piwik من الإصدار %1$s إلى الإصدار الجديد %2$s.", + "PluginDescription": "آلية تحديث Piwik", + "ReadyToGo": "هل أنت مستعد؟", + "TheFollowingPluginsWillBeUpgradedX": "سيتم تحديث الإضافات التالية: %s.", + "ThereIsNewVersionAvailableForUpdate": "يوجد إصدار أحدث من Piwik", + "TheUpgradeProcessMayFailExecuteCommand": "إذا كانت قاعدة بيانات Piwik كبيرة جداً، قد تستغرق عملية التحديث وقتاً طويلاً من خلال المتصفح. في هذه الحالة، يمكنك إجراء عملية التحديث من خلال أمر المحث التالي: %s", + "TheUpgradeProcessMayTakeAWhilePleaseBePatient": "قد تستغرق عملية التحديث بعض الوقت، لذ كن صبوراً.", + "UnpackingTheUpdate": "جاري فك ضغط ملفات التحديث", + "UpdateAutomatically": "تحديث آلي", + "UpdateHasBeenCancelledExplanation": "تم إلغاء تحديث Piwik الآلي. إذا لم يكن في استطاعتك حل رسالة الخطأ الواردة أعلاه، فنوصي بالتحديث اليدوي لنظام Piwik. %1$s يرجى مراجعة %2$s مساعدة التحديث %3$s لتبدأ!", + "UpdateTitle": "تحديث", + "UpgradeComplete": "اكتملت عملية الترقية!", + "UpgradePiwik": "ترقية Piwik", + "VerifyingUnpackedFiles": "جاري تعريف الملفات", + "WarningMessages": "رسائل التحذير:", + "WeAutomaticallyDeactivatedTheFollowingPlugins": "لقد قمنا آلياً بتعطيل الإضافات التالية: %s", + "YouCanUpgradeAutomaticallyOrDownloadPackage": "يمكنك التحديث آلياً إلى الإصدار %s أو تحميلها على جهازك وتثبيتها يدوياً:", + "YouCouldManuallyExecuteSqlQueries": "إذا كنت غير قادر على استخدام محث الترقية، وإذا فشل Piwik في التحديث (بسبب انتهاء المهلة الزمنية المحددة، انتهاء مهلة المتصفح أو لأي سبب آخر)، يمكنك إجراء استعلام SQL يدوياً لتحديث Piwik.", + "YouMustDownloadPackageOrFixPermissions": "Piwik غير قادر على الكتابة فوق التثبيت الحالي. يمكنك معالجة صلاحيات المجلدات \/ الملفات، أو تحميل حزمة التثبيت وتثبيت الإصدار %s يدوياً:", + "YourDatabaseIsOutOfDate": "قاعدة بيانات Piwik غير مُحدَّثَة، ويجب ترقيتها قبل أن تتمكن من المتابعة." + }, + "CustomVariables": { + "ColumnCustomVariableName": "اسم المتغير المخصص", + "ColumnCustomVariableValue": "قيمة المتغير المخصص", + "CustomVariables": "المتغيرات المخصصة", + "PluginDescription": "المتغيرات المخصصة هي أزواج اسم،قيمة يمكنك تعيينها لزيارة باستخدام واجهة تطبيقات كافاسكريبت باستخدام الدالة setVisitCustomVariables(). سيقوم Piwik بعدها عدد الزيارات، الصفحات ومعدلات التحويل لكل من هذه الأسماء والقيم." + }, + "Dashboard": { + "AddPreviewedWidget": "أضف لوحة الأداة التي تم معاينتها إلى لوحة التحكم", + "ChangeDashboardLayout": "تغيير تخطيط اللوحة الرئيسية", + "CreateNewDashboard": "إنشاء لوحة جديدة", + "Dashboard": "لوحة التحكم", + "DashboardEmptyNotification": "اللوحة الرئيسية فارغة تماماً. ابدأ بإضافة بعد التطبيقات أو قم بتنضيد اللوحة الرئيسية إلى الإعدادات الافتراضية.", + "DashboardName": "اسم اللوحة:", + "DefaultDashboard": "اللوحة الافتراضية - تستخدم المجموعة الافتراضية من التطبيقات وتخطيط الأعمدة.", + "DeleteWidgetConfirm": "هل ترغب حقاً في حذف لوحة الأداة هذه من لوحة التحكم.", + "LoadingWidget": "جاري تحميل الأداة، الرجاء الانتظار...", + "ManageDashboard": "إدارة اللوحة", + "Maximise": "تكبير", + "NotUndo": "لن يمكنك التراجع عن هذه العملية.", + "PluginDescription": "لوحتك لإدارة تحليلات ويب. يمكنك تخصيص لوحة التحكم، إضافة لوحات أدوات، وتغيير ترتيبها. يمكن لكل مستخدم الوصول للوحة الإدارة الخاصة به.", + "RemoveDashboard": "إزالة اللوحة", + "RenameDashboard": "إعادة تسمية اللوحة", + "ResetDashboardConfirm": "هل ترغب حقاً في تنضيد تخطيط اللوحة الرئيسية ومجموعة التطبيقات المعروضة فيها إلى الافتراضية؟", + "SelectWidget": "اختر لوحة الأداة لإضافتها للوحة التحكم", + "SetAsDefaultWidgets": "اضبط كمجموعة التطبيقات الافتراضية", + "SetAsDefaultWidgetsConfirmHelp": "سيتم استخدام مجموعة التطبيقات هذه ومخطط أعمدة اللوحة عند إنشاء لوحات جديدة أو عند استخدام خاصية \"%s\".", + "WidgetNotFound": "لم يتم العثور على الأداة", + "WidgetPreview": "معاينة لوحة الأداة", + "WidgetsAndDashboard": "اللوحة والتطبيقات" + }, + "Feedback": { + "ContactThePiwikTeam": "اتصل بفريق Piwik!", + "DoYouHaveBugReportOrFeatureRequest": "هل لديك خطأ أو ميزة تود إضافتها وتريد إخبارنا عنها؟", + "IWantTo": "أرغب في:", + "LearnWaysToParticipate": "تعرف على كافة الطرق التي يمكنك %s المساهمة %s بها.", + "ManuallySendEmailTo": "الرجاء إرسال رسالتك يدوياً إلى", + "PluginDescription": "أرسل رأيك وانطباعك إلى فريق Piwik. شاركنا بأفكارك واقتراحاتك!", + "SendFeedback": "أرسل التغذية الراجعة", + "SpecialRequest": "هل لديك طلب خاص من فريق Piwik؟", + "ThankYou": "شكراً لمساعدتك في جعل Piwik أفضل!", + "VisitTheForums": "قم بزيارة %sالمنتديات%s" + }, + "General": { + "AbandonedCarts": "تخلى عربات", + "AboutPiwikX": "حول Piwik %s", + "Action": "الأوامر", + "Actions": "السلوكيات", + "Add": "اضف", + "AfterEntry": "بعد دخول هنا", + "AllowPiwikArchivingToTriggerBrowser": "السماح لبرنامج Piwik ببدأ الأرشفة عند تصفح التقارير من المتصفح.", + "AllWebsitesDashboard": "لوحة التحكم لكافة المواقع", + "API": "واجهة تحكم التطبيقات", + "ApplyDateRange": "تطبيق مدى التاريخ", + "ArchivingInlineHelp": "للمواقع المتوسطة وعالية الزيارات، من المفضل تعطيل إطلاق الأرشفة من المتصفح. بدلاً من ذلك، فنحن نفضل ضبط Cron job لمعالجة Piwik كل ساعة.", + "ArchivingTriggerDescription": "مفضل في حالة مواقع Piwik الكبيرة أن تقوم %sبضبط وظيفة %s لمعالجة التقارير آلياً.", + "AuthenticationMethodSmtp": "أسلوب المصادقة لمزود SMTP", + "AverageOrderValue": "متوسط ​​قيمة الطلب", + "AveragePrice": "متوسط السعر", + "AverageQuantity": "الکمیة المتوسطہ", + "BackToPiwik": "العودة إلى Piwik", + "Broken": "تالف", + "Cancel": "إلغاء", + "ChangePassword": "تغيير كلمة المرور", + "ChooseDate": "اختر التاريخ", + "ChooseLanguage": "اختر اللغة", + "ChoosePeriod": "اختر الفترة", + "ChooseWebsite": "اختر الموقع", + "Close": "إغلاق", + "ColumnActionsPerVisit": "السلوكيات لكل زيارة", + "ColumnAverageTimeOnPage": "متوسط الزمن على الصفحة", + "ColumnAvgTimeOnSite": "متوسط الزمن على الموقع", + "ColumnBounceRate": "معدل الارتداد", + "ColumnBounces": "الارتدادات", + "ColumnConversionRate": "معدل الفاعلية", + "ColumnEntrances": "الدخول", + "ColumnExitRate": "معدل الخروج", + "ColumnExits": "الخروج", + "ColumnKeyword": "كلمة دلالية", + "ColumnLabel": "عنوان", + "ColumnMaxActions": "أقصى عدد من السلوكيات في زيارة واحدة", + "ColumnNbActions": "السلوكيات", + "ColumnNbUniqVisitors": "زوار فريدين", + "ColumnNbVisits": "الزيارات", + "ColumnPageviews": "المشاهدات", + "ColumnPercentageVisits": "% زيارة", + "ColumnRevenue": "الأرباح", + "ColumnSumVisitLength": "إجمالي الوقت الذي استغرقه الزائر (بالثواني)", + "ColumnUniquePageviews": "المشاهدات الفريدة", + "ColumnValuePerVisit": "القيمة لكل زيارة", + "ColumnVisitDuration": "مدة الزيارة (بالثواني)", + "ColumnVisitsWithConversions": "زيارات بفائدة", + "ConfigFileIsNotWritable": "ملف إعدادات Piwik %s غير قابل للكتابة، بعض التغييرات التي قمت بها قد لا تكون محفوظة. %s الرجاء تغيير صلاحيات ملف الإعدادات بحيث تكون قابلة للكتابة.", + "ContinueToPiwik": "المتابعة إلى Piwik", + "CurrentMonth": "الشهر الحالي", + "CurrentWeek": "الأسبوع الحالي", + "CurrentYear": "السنة الحالية", + "Daily": "يومي", + "DashboardForASpecificWebsite": "اللوحة الرئيسية لموقع محدد", + "Date": "التاريخ", + "DateRange": "فترة معينة:", + "DateRangeFrom": "من", + "DateRangeInPeriodList": "مدى التاريخ", + "DateRangeTo": "إلى", + "DayFr": "جم", + "DayMo": "ثن", + "DaySa": "سب", + "DaysHours": "%1$s أيام %2$s ساعات", + "DaysSinceFirstVisit": "أيام منذ أول زيارة", + "DaysSinceLastEcommerceOrder": "منذ أيام النظام التجارة الإلكترونية مشاركة", + "DaysSinceLastVisit": "أيام منذ آخر زيارة", + "DaySu": "حد", + "DayTh": "خم", + "DayTu": "ثل", + "DayWe": "رب", + "Default": "الافتراضي", + "Delete": "حذف", + "Description": "الوصف", + "Details": "التفاصيل", + "DisplaySimpleTable": "عرض جدول مبسط", + "DisplayTableWithGoalMetrics": "عرض الجدول مع متغيرات الأهداف", + "DisplayTableWithMoreMetrics": "عرض جدول مع المزيد من المتغيرات", + "Donate": "تبرع", + "Done": "تم", + "Download": "تنزيل", + "DownloadFullVersion": "%1$s قم بتحميل %2$s الإصدار الكامل! انظر %3$s", + "Downloads": "التحميلات", + "EcommerceOrders": "طلبات التجارة الإلكترونية", + "Edit": "تحرير", + "EncryptedSmtpTransport": "أدخل نوع التشفير المطلوب بواسطة خادم SMTP", + "EnglishLanguageName": "Arabic", + "Error": "خطأ", + "ErrorRequest": "أوه... حدث خطأ أثناء معالجة طلبك. الرجاء المحاولة مرة أخرى.", + "EvolutionOverPeriod": "النمو خلال فترة معينة", + "ExceptionConfigurationFileNotFound": "لم يمكن العثور على ملف الإعدادات (%s).", + "ExceptionDatabaseVersion": "%1$s إصدارك %2$s ولكن Piwik يتطلب على الأقل %3$s.", + "ExceptionFileIntegrity": "فشل فحص السلامة: %s", + "ExceptionFilesizeMismatch": "خطأ في حجم الملف: %1$s (الحجم المتوقع: %2$s، الحجم الفعلي: %3$s).", + "ExceptionIncompatibleClientServerVersions": "%1$s إصدار عميلك %2$s والذي هو غير متوافق مع إصدار الخادم %3$s.", + "ExceptionInvalidArchiveTimeToLive": "زمن الأرشفة لليوم يجب أن يكون رقماً أكبر من الصفر حيث يمثل الثواني.", + "ExceptionInvalidDateFormat": "يجب أن تكون صيغة التاريخ: %s أو أي كلمة استدلالية مدعومة بواسطة الدالة %s (انظر %s لمزيد من المعلومات).", + "ExceptionInvalidDateRange": "التاريخ \"%s\" ليس بفترة صالحة في التقويم. يجب أن تكون على الصيغة التالية: %s.", + "ExceptionInvalidPeriod": "الفترة \"%s\" غير مدعومة. حاول أياً من التالي بدلاً منها: %s.", + "ExceptionInvalidRendererFormat": "الصيغة \"%s\" غير صحيحة. حاول أياً من التالي بدلاً منها: %s.", + "ExceptionInvalidReportRendererFormat": "تهيئة التقرير '%s' غير صالحة. الرجاء تجربة أياً مما يلي كبديل: %s.", + "ExceptionInvalidToken": "المفتاح غير صالح.", + "ExceptionLanguageFileNotFound": "ملف اللغة \"%s\" غير موجود.", + "ExceptionMethodNotFound": "النظام \"%s\" غير موجود أو غير متوافر في الموديول \"%s\".", + "ExceptionMissingFile": "ملف مفقود: %s", + "ExceptionNonceMismatch": "لم يمكن تعريف مفتاح الأمان في هذا النموذج.", + "ExceptionPrivilege": "لا يمكنك الوصول لهذا المورد، فهو يتطلب صلاحيات وصول %s.", + "ExceptionPrivilegeAccessWebsite": "لا يمكنك مشاهدة هذا المورد، فهو يتطلب صلاحيات وصول %s لموقع id=%d.", + "ExceptionPrivilegeAtLeastOneWebsite": "لا يمكنك الوصول لهذا المورد، فهو يتطلب صلاحيات وصول %s على الأقل لموقع واحد.", + "ExceptionUndeletableFile": "لم يمكن حذف %s", + "ExceptionUnreadableFileDisabledMethod": "لم يمكن قراءة ملف الإعدادات (%s). قد يكون المستضيف قد عطل %s.", + "Export": "تصدير", + "ExportAsImage": "تصدير كصورة", + "ExportThisReport": "تصدير البيانات في هيئة ملفات أخرى", + "FileIntegrityWarningExplanation": "فشل فحص سلامة الملفات وتم تسجيل بعض الأخطاء. يحدث هذا غالباً نتيجة الرفع الناقص أو الخاطئ لبعض ملفات Piwik. يتوجب عليك إعادة رفع ملفات Piwik في وضع BINARY ثم إعادة تحديث هذه الصفحة حتى تنتهي هذه الأخطاء.", + "ForExampleShort": "مثال:", + "FromReferrer": "من", + "GeneralSettings": "الإعدادات العامة", + "GiveUsYourFeedback": "أخبرنا عن رأيك!", + "GoTo": "اذهب إلى %s", + "GraphHelp": "لمزيد من المعلومات حول عرض الرسومات البيانية في Piwik.", + "HelloUser": "مرحباً بك %s", + "HoursMinutes": "%1$s ساعة%2$s دقيقة", + "Id": "معرف", + "IfArchivingIsFastYouCanSetupCronRunMoreOften": "بفرض أن الأرشفة سريعة بالنسبة لإعداداتك، فيمكنك ضبط إعدادات Crontab ليتم تفعيلها بمعدل أكبر.", + "InvalidDateRange": "مدى التاريخ غير صالح، حاول مرة أخرى", + "InvalidResponse": "البيانات المستقبلة غير صالحة.", + "JsTrackingTag": "وسم التتبع بلغة جافا", + "Language": "اللغة", + "LastDays": "آخر %s أيام (تشمل اليوم)", + "LayoutDirection": "rtl", + "Loading": "جاري التحميل...", + "LoadingData": "جاري تحميل البيانات...", + "Locale": "ar_EG.UTF-8", + "Logout": "تسحيل خروج", + "LongDay_1": "الاثنين", + "LongDay_2": "الثلاثاء", + "LongDay_3": "الأربعاء", + "LongDay_4": "الخميس", + "LongDay_5": "الجمعة", + "LongDay_6": "السبت", + "LongDay_7": "الأحد", + "LongMonth_1": "يناير", + "LongMonth_10": "أكتوبر", + "LongMonth_11": "نوفمبر", + "LongMonth_12": "ديسمبر", + "LongMonth_2": "فبراير", + "LongMonth_3": "مارس", + "LongMonth_4": "أبريل", + "LongMonth_5": "مايو", + "LongMonth_6": "يونيو", + "LongMonth_7": "يوليو", + "LongMonth_8": "أغسطس", + "LongMonth_9": "سبتمبر", + "MainMetrics": "أهم مقاييس", + "MediumToHighTrafficItIsRecommendedTo": "للمواقع متوسطة وعالية الزيارات، نفضل معالجة تقارير اليوم الحالي على الأكثر كل نصف ساعة (%s ثانية) أو كل ساعة (%s ثانية).", + "Metadata": "الفوقية", + "MetricsToPlot": "مقاييس لمؤامرة", + "MetricToPlot": "متري لرسم", + "MinutesSeconds": "%1$s دقيقة%2$s ثانية", + "Monthly": "شهري", + "MultiSitesSummary": "كافة المواقع", + "Name": "الاسم", + "NbActions": "عدد السلوكيات", + "Never": "أبداً", + "NewReportsWillBeProcessedByCron": "عندما لا تم إطلاق أرشفة Piwik من خلال المتصفح، يتم معالجة التقارير بواسطة Cronjob.", + "NewUpdatePiwikX": "تحديث جديد: Piwik %s", + "NewVisitor": "زائر جديد", + "Next": "التالي", + "No": "لا", + "NoDataForGraph": "لا توجد بيانات لهذا الرسم البياني.", + "NoDataForTagCloud": "لا توجد بيانات لسحابة الوسوم هذه.", + "NotDefined": "ليست محددة %s", + "NotValid": "%s غير صالح", + "NSeconds": "%s ثانية", + "NumberOfVisits": "عدد الزيارات", + "NVisits": "%s الزيارات", + "Ok": "موافق", + "OneDay": "يوم", + "OneVisit": "1 زيارة", + "OnlyEnterIfRequired": "أدخل اسم مسخدم فقط في حالة ما إذا كان مزود SMTP يطلب ذلك.", + "OnlyEnterIfRequiredPassword": "أدخل كلمة مرور فقط في حالة ما إذا كان مزود SMTP يطلب ذلك.", + "OnlyUsedIfUserPwdIsSet": "تستخدم فقط في حالة تحديد اسم مستخدم \/ كلمة مرور، اطلب من موفر الخدمة لديك إذا كنت غير متأكد أي الوسائل ستستخدم.", + "OpenSourceWebAnalytics": "تحليلات ويب مفتوحة المصدر", + "OptionalSmtpPort": "اختياري. الافتراضي 25 لاتصالات TLS SMTP غير المشفرة، و465 لاتصالات SSL SMTP المشفرة.", + "OrCancel": "أو %s إلغاء %s", + "OriginalLanguageName": "العربية", + "Others": "أخرى", + "Outlinks": "الروابط الصادرة", + "Overview": "نظرة عامة", + "Pages": "الصفحات", + "Password": "كلمة المرور", + "Period": "الفترة", + "Piechart": "رسم بياني", + "PiwikXIsAvailablePleaseUpdateNow": "Piwik %1$s متوفر الآن. %2$s الرجاء التحديث الآن! %3$s (انظر %4$s التغييرات %5$s).", + "PleaseSpecifyValue": "الرجاء تحديد قيمة للحقل \"%s\".", + "PleaseUpdatePiwik": "الرجاء تحديث Piwik", + "Plugin": "التطبيق", + "Plugins": "التطبيقات", + "PoweredBy": "مدعوم من", + "Previous": "السابق", + "PreviousDays": "%s يوم سابق (لا يشمل اليوم الحالي)", + "Price": "السعر", + "PurchasedProducts": "شراء المنتجات", + "RecordsToPlot": "السجلات لرسم", + "RefreshPage": "تحديث الصفحة", + "Report": "تقرير", + "Reports": "تقارير", + "ReportsContainingTodayWillBeProcessedAtMostEvery": "تقارير اليوم (أو أي مدى تاريخ آخر شاملة اليوم) سيتم معالجتها بحد أقصى مرة كل", + "ReportsWillBeProcessedAtMostEveryHour": "وبذلك سيتم معالجة التقارير كل ساعة بحد أقصى.", + "RequestTimedOut": "انتهت صلاحية طلب البيانات المرسل إلى %s. يرجى المحاولة مرة أخرى.", + "Required": "%s مطلوب", + "ReturningVisitor": "زائر متكرر", + "RowsToDisplay": "الصفوف التي تريد عرضها", + "Save": "حفظ", + "SaveImageOnYourComputer": "لحفظ الصورة على جهازك، انقر بز الفأرة الأيمن واختر \"حفظ الصورة باسم\"...", + "Search": "بحث", + "Seconds": "%s ثانية", + "SeeTheOfficialDocumentationForMoreInformation": "انظر %sمستندات المساعدة الرسمية%s لمزيد من المعلومات.", + "SelectYesIfYouWantToSendEmailsViaServer": "اختر \"نعم\" إذا كنت ترغب في أو يتوجب عليك إرسال البريد الإلكتروني من خلال مزود معين بدلاً من دالة البريد الإلكتروني المحلية.", + "Settings": "الإعدادات", + "ShortDay_1": "اثنين", + "ShortDay_2": "ثلاثاء", + "ShortDay_3": "أربعاء", + "ShortDay_4": "خميس", + "ShortDay_5": "جمعة", + "ShortDay_6": "سبت", + "ShortDay_7": "أحد", + "ShortMonth_1": "ينا", + "ShortMonth_10": "أكت", + "ShortMonth_11": "نوف", + "ShortMonth_12": "ديس", + "ShortMonth_2": "فبر", + "ShortMonth_3": "مار", + "ShortMonth_4": "أبر", + "ShortMonth_5": "ماي", + "ShortMonth_6": "يون", + "ShortMonth_7": "يول", + "ShortMonth_8": "أغس", + "ShortMonth_9": "سبت", + "SmallTrafficYouCanLeaveDefault": "للمواقع قليلة الزيارات، يمكنك ترك القيمة الافتراضية %s ثانية، ومراجعة التقارير في الوقت الحقيقي.", + "SmtpEncryption": "تشفير SMTP", + "SmtpPassword": "كلمة مرور SMTP", + "SmtpPort": "منفذ SMTP", + "SmtpServerAddress": "عنوان مزود SMTP", + "SmtpUsername": "اسم مستخدم SMTP", + "Subtotal": "المجموع الفرعي", + "Table": "جدول", + "TagCloud": "سحابة وسوم", + "Today": "اليوم", + "Total": "مجموع", + "TotalRevenue": "إجمالي الإيرادات", + "TranslatorEmail": "mustafa@i-translate.info, benkheil.abdelouali@gmail.com", + "TranslatorName": "Mustafa Rawi, Benkheil Abdelouali", + "Unknown": "غير معروف", + "Upload": "رفع", + "Username": "اسم المستخدم", + "UseSMTPServerForEmail": "استخدم مزود بريد SMTP", + "Value": "القيمة", + "VBarGraph": "رسم بياني رأسي", + "View": "مشاهدة", + "Visit": "زيارة", + "VisitConvertedGoal": "الزيارات التي حققت هدفاً واحداً على الأقل", + "VisitConvertedGoalId": "زيارة تحويل معرف هدف محدد", + "VisitConvertedNGoals": "الزيارة حولت %s أهداف", + "VisitDuration": "متوسط زمن الزيارة (بالثواني)", + "VisitorID": "معرف الزائر", + "VisitorIP": "عنوان IP للزائر", + "Visitors": "الزوار", + "VisitType": "نوع الزائر", + "Warning": "تنبيه", + "WarningFileIntegrityNoManifest": "لم يمكن إجراء فحص سلامة الملفات بسبب فقد ملف manifest.inc.php.", + "WarningFileIntegrityNoMd5file": "لم يمكن إتمام فحص سلامة الملفات بسبب فقد دالة md5_file().", + "WarningPasswordStored": "%sتنبيه:%s سيتم حفظ كلمة المرور هذه في ملف الإعدادات وظاهرة لأياً كان ممن يمكنه الوصول إليه.", + "Website": "الموقع", + "Weekly": "أسبوعي", + "Widgets": "الإضافات", + "YearsDays": "%1$s سنة %2$s أيام", + "Yes": "نعم", + "Yesterday": "الأمس", + "YouAreViewingDemoShortMessage": "أنت تشاهد حالياً نسخة عرض من Piwik", + "YouMustBeLoggedIn": "يجب عليك تسجيل الدخول للوصول إلى هذه الخاصية.", + "YourChangesHaveBeenSaved": "تم حفظ التغييرات." + }, + "Goals": { + "AddGoal": "أضف هدفاً", + "AddNewGoal": "أضف هدفاً جديداً", + "AddNewGoalOrEditExistingGoal": "%s أضف هدفاً جديداً %s أو %s حرر %s أهدافاً قائمة", + "AllowGoalConvertedMoreThanOncePerVisit": "السماح للهدف بالتحويل أكثر من مرة لكل زيارة", + "AllowMultipleConversionsPerVisit": "السماح بعدة تحويلات لكل زيارة", + "BestCountries": "أفضل الدول من حيث التحويل هي:", + "BestKeywords": "أفضل الكلمات الدلالية من حيث التحويل:", + "BestReferrers": "أفضل المواقع التي جلبت زيارات ربحية هي:", + "CaseSensitive": "يطابق حالة الأحرف", + "ClickOutlink": "ينقر رابط لموقع خارجي", + "ColumnConversions": "التحويلات", + "Contains": "يتضمن %s", + "ConversionRate": "%s معدل التحويل", + "Conversions": "%s تحويلات", + "ConversionsOverview": "نظرة عامة على التحويلات", + "ConversionsOverviewBy": "التحويل العام بواسطة نوع الزيارة", + "CreateNewGOal": "إنشاء هدف جديد", + "DefaultGoalConvertedOncePerVisit": "(الافتراضي) الهدف يمكن تحويله مرة واحدة لكل زيارة", + "DefaultRevenue": "قيمة الربح الافتراضية للهدف", + "DefaultRevenueHelp": "على سبيل المثال، نموذج الاتصال الذي يرسله الزائر قد يساوي 10 جنيه في المتوسط. سيساعدك Piwik في فهم كيف تؤدي القطاعات المختلفة من زياراتك.", + "DeleteGoalConfirm": "هل ترغب حقاً في حذف الهدف %s؟", + "Download": "تحميل ملف", + "ExceptionInvalidMatchingString": "إذا اخترت \"مطابق تماماً\"، فإن العبارة المطابقة يجب أن تكون رابط ويب يبدأ بالتالي %s. على سبيل المثال \"%s\".", + "ExternalWebsiteUrl": "رابط موقع خارجي", + "Filename": "اسم الملف", + "GoalConversion": "تحويل الهدف", + "GoalConversionsBy": "تحويل الهدف %s وفقاً لنوع الزيارة", + "GoalIsTriggered": "تم تفعيل الهدف", + "GoalIsTriggeredWhen": "سيتم تفعيل الهدف عندما", + "GoalName": "اسم الهدف", + "Goals": "الأهداف", + "GoalsManagement": "إدارة الأهداف", + "GoalsOverview": "نظرة عامة على الأهداف", + "GoalX": "الهدف %s", + "HelpOneConversionPerVisit": "إذا كانت صفحة تضاهي الهدف يتم تحديثها أو مشاهدتها أكثر من مرة في الزيارة، سيتم احتساب الهدف في أول مرة يتم تحميل الصفحة فيها أثناء هذه الزيارة.", + "IsExactly": "يساوي بالضبط %s", + "LearnMoreAboutGoalTrackingDocumentation": "تعرف على المزيد حول %s التتبع في Piwik %s في دليل المستخدم.", + "Manually": "يدوياً", + "ManuallyTriggeredUsingJavascriptFunction": "يتم تفعيل الهدف يدوياً باستخدام كود جافا سكريبت trackGoal()", + "MatchesExpression": "يطابق التعبير %s", + "NewVisitorsConversionRateIs": "معدل التحويل للزيارات الجديدة هو %s", + "Optional": "(اختياري)", + "OverallConversionRate": "%s إجمالي معدل التحويل (الزيارات التي حققت هدفاً)", + "OverallRevenue": "%s ربح إجمالي", + "PageTitle": "عنوان الصفحة", + "Pattern": "النمط", + "PluginDescription": "أنشأ الأهداف وشاهدة التقارير حول معدلات تحويل الأهداف: التطور مع الوقت، الأرباح لكل زيارة، الأرباح لكل مصدر زيارات، لكل كلمة دلالية، وغيرها.", + "ReturningVisitorsConversionRateIs": "معدل التحويل للزيارات المتكررة هو %s", + "UpdateGoal": "تحديث هدف", + "URL": "الرابط", + "ViewAndEditGoals": "مشاهدة وتحرير الأهداف", + "ViewGoalsBy": "مشاهدة الأهداف وفقاً إلى %s", + "VisitPageTitle": "يزور صفحة بعنوان معين", + "VisitUrl": "زيارة رابط معين (صفحة أو مجموعة من الصفحات)", + "WhenVisitors": "عندما يقوم الزوار", + "WhereThe": "حيث يكون", + "WhereVisitedPageManuallyCallsJavascriptTrackerLearnMore": "حيث تكون الصفحة تحتوي على استدعاء لجافاسكريبت piwikTracker.trackGoal() (%sتعرف على المزيد%s)" + }, + "Installation": { + "CommunityNewsletter": "راسلني عند وجود تحديثات (إضافات جديدة، خصائص جديدة، وغيرها)", + "ConfigurationHelp": "يبدو أن ملف الإعدادات الخاص بك به خطأ. يمكنك إما حذف config\/config.ini.php ومتابعة التثبيت أو تصحيح إعدادات الاتصال بقاعدة البيانات.", + "ConfirmDeleteExistingTables": "هل ترغب حقاً في حذف الجداول: %s من قاعدة بياناتك؟ تنبيه: لن يمكن إستعادة البيانات من هذه الجداول!", + "Congratulations": "مبروك", + "CongratulationsHelp": "

    مبروك! تم تثبيت Piwik بنجاح.<\/p>

    تأكد من إضافة كود JavaScript لكافة الصفحات التي تود تتبعها، ثم انتظر أول زوارك!<\/p>", + "DatabaseCheck": "فحص قاعدة البيانات", + "DatabaseClientVersion": "إصدار عميل قاعدة البيانات", + "DatabaseCreation": "إنشاء قاعدة البيانات", + "DatabaseErrorConnect": "خطأ أثناء الاتصال بملقم قاعدة البيانات", + "DatabaseServerVersion": "إصدار ملقم قاعدة البيانات", + "DatabaseSetup": "تثبيت قاعدة البيانات", + "DatabaseSetupAdapter": "المحول", + "DatabaseSetupDatabaseName": "اسم قاعدة البيانات", + "DatabaseSetupLogin": "اسم المستخدم", + "DatabaseSetupServer": "ملقم قاعدة البيانات", + "DatabaseSetupTablePrefix": "بادئة الجداول", + "Email": "البريد الإلكتروني", + "ErrorInvalidState": "خطأ: يبدو أنك حاولت تجاوز خطوة من عملية التثبيت أو تم تعطيل الكوكيز، أو تم إنشاء إعدادات Piwik بالفعل. %1$s تأكد من تفعيل الكوكيز لديك %2$s ثم انقر تراجع %3$s إلى الصفحة الأولى من التثبيت %4$s.", + "Extension": "الإضافات", + "GoBackAndDefinePrefix": "رجوع وحدد بادئة جداول لجداول Piwik", + "Installation": "التثبيت", + "InstallationStatus": "حالة التثبيت", + "LargePiwikInstances": "المساعدة في مواقع Piwik العملاقة", + "Legend": "دليل", + "NoConfigFound": "لم يمكن العثور على ملف إعدادات Piwik، وأنت تحاول الوصول لأحد صفحات Piwik.
    يمكنك
    تثبيت Piwik الآن<\/a><\/b>
    إذا كنت قد ثبته مسبقاً ولديك بعض الجداول في قاعدة البيانات. ولا داعي للقلق، فيمكنك إعادة استخدام الجداول القديمة والاحتفاظ ببياناتك القديمة!<\/small>", + "Optional": "اختياري", + "Password": "كلمة المرور", + "PasswordDoNotMatch": "كلمة المرور غير متطابقة", + "PasswordRepeat": "كلمة المرور (تأكيد)", + "PercentDone": "تم تنفيذ %s %%", + "PleaseFixTheFollowingErrors": "الرجاء إصلاح الأخطاء التالية", + "PluginDescription": "عملية تثبيت Piwik. عملية التثبيت تتم مرة واحدة عادة. إذا تم حذف ملف الإعدادات config\/config.inc.php فستبدأ عملية التثبيت مرة أخرى.", + "Requirements": "متطلبات Piwik", + "SecurityNewsletter": "راسلني عند وجود ترقيات وتنبيهات أمنية في Piwik", + "SetupWebsite": "إعداد موقع ويب", + "SetupWebsiteError": "حدث خطأ ما أثناء إضافة الموقع", + "SetupWebSiteName": "اسم موقع ويب", + "SetupWebsiteSetupSuccess": "تم إنشاء الموقع %s بنجاح!", + "SetupWebSiteURL": "رابط موقع ويب", + "SuperUser": "المدير العام", + "SuperUserLogin": "اسم مستخدم المدير العام", + "SuperUserSetupSuccess": "تم إنشاء المدير العام بنجاح!", + "SystemCheck": "فحص النظام", + "SystemCheckAutoUpdateHelp": "ملاحظة: يتطلب تحديث Piwik الآلي صلاحية الكتابة في مجلدات Piwik ومحتوياتها.", + "SystemCheckCreateFunctionHelp": "يستخدم Piwik دوال anonymous للاستدعاءات.", + "SystemCheckDatabaseHelp": "يحتاج Piwik إما الإضافةmysqli أو كلاً من PDO و pdo_mysql.", + "SystemCheckDebugBacktraceHelp": "لن يتمكن View::factory من إنشاء عرض الموديول.", + "SystemCheckError": "حدث خطأ ما - يجب إصلاحه قبل أن تتمكن من المتابعة", + "SystemCheckEvalHelp": "يتطلبها HTML QuickForm ونظام قولبة Smarty.", + "SystemCheckExtensions": "الإضافات الأخرى المطلوبة", + "SystemCheckFileIntegrity": "سلامة الملفات", + "SystemCheckFunctions": "الدوال المطلوبة", + "SystemCheckGDHelp": "لن تعمل خاصية sparklines (الرسومات المصغرة).", + "SystemCheckGlobHelp": "تم تعطيل هذه الدالة المبنية ضمنياً، وسيحاول Piwik تقليدها ولكنه قد يواجه اعتبارات أمنية أخرى. قد يتأثر عدد من الوظائف أيضاً.", + "SystemCheckGzcompressHelp": "تحتاج إلى تفعيل إضافة zlib ودالة gzcompress.", + "SystemCheckGzuncompressHelp": "تحتاج إلى تفعيل إضافة zlib ودالة gzcompress.", + "SystemCheckIconvHelp": "تحتاج إلى إعداد وإعادة بناء PHP مع تفعيل دعم iconv: --with-iconv.", + "SystemCheckMailHelp": "لن يتم إرسال رسائل التغذية الراجعة وكلمات المرور المفقودة بدون mail().", + "SystemCheckMbstring": "mbstring", + "SystemCheckMbstringExtensionHelp": "إضافة mbstring مطلوبة للرموز متعددة البايتات في استجابات واجهة برمجة التطبيقات باستخدام CSV و TSV.", + "SystemCheckMbstringFuncOverloadHelp": "يتوجب عليك ضبط mbstring.func_overload إلى صفر.", + "SystemCheckMemoryLimit": "حد الذاكرة", + "SystemCheckMemoryLimitHelp": "في المواقع ذات الزيارات الكبيرة، فإن عملية الأرشفة تتطلب ذاكرة أعلى مما هو مسموح به حالياً. إذا تطلب الأمر، قم بتغيير memory_limit في ملف php.ini الخاص بك.", + "SystemCheckOpenURL": "Open URL", + "SystemCheckOpenURLHelp": "الاشتراك في النشرة الدورية، إعلامات التحديثات والتحديث السريع يتطلب إضافة 'cURL' وتفعيل 'allow_url_fopen=On' أو 'fsockopen()'.", + "SystemCheckOtherExtensions": "إضافات أخرى", + "SystemCheckOtherFunctions": "دوال أخرى", + "SystemCheckPackHelp": "الدالة pack() مطلوبة ليتمكن Piwik من تتبع الزوار.", + "SystemCheckParseIniFileHelp": "تم تعطيل الدالة المبنية ضمناً من طرف مزود الخدمة لديك. Piwik سيحاول أن يقلد هذه الدالة ذاتياً ولكنه قد يواجه عدداً من الاعتبارات الأمنية الأخرى. سيتأثر أيضاً أداء عملية التتبع.", + "SystemCheckPdoAndMysqliHelp": "على ملقم لينوكس يمكنك ضبط PHP بالإعدادات التالية: %1$s في ملف php.ini أضف السطور التالية: %2$s", + "SystemCheckPhp": "إصدار PHP", + "SystemCheckPhpPdoAndMysqli": "لمزيد من المعلومات عن: %1$sPHP PDO%2$s و %3$sMYSQLI%4$s.", + "SystemCheckSecureProtocol": "بروتوكول الأمان", + "SystemCheckSecureProtocolHelp": "يبدو أنك تستخدم https في ملقمك. هذه السطور سيتم إضافة إلى ملف config\/config.ini.php:", + "SystemCheckSplHelp": "ستحتاج لضبط وإعادة بناء PHP وتكون مكتبة PHP القياسية SPL فيها مفعلة (افتراضياً).", + "SystemCheckTimeLimitHelp": "في المواقع ذات حجم الزيارات الكبيرة، فإن تنفيذ عملية الأرشفة قد تتطلب وقتاً أطول مما هو مسموح به. إذا تطلب الأمر، قم بتغيير max_execution_time في ملف php.ini الخاص بك.", + "SystemCheckTracker": "حالة المتتبع", + "SystemCheckTrackerHelp": "فشل طلب GET لملف piwik.php. حاول أن تضيف هذا الرابط إلى mod_security و HTTP Authentication.", + "SystemCheckWarnDomHelp": "يفترض أن تفعل إضافة 'dom' (مثلاً: تثبيت حزمة 'php-dom' و\/أو حزمة 'php-xml').", + "SystemCheckWarning": "سيعمل Piwik بشكل طبيعي ولكن بعض الخصائص ستصبح مفقودة.", + "SystemCheckWarnJsonHelp": "يفترض أن تفعل الإضافة 'json' (مثلاً ثبت حزمة 'php-json') لأداء أفضل.", + "SystemCheckWarnLibXmlHelp": "يفترض أن تفعل الإضافة 'libxml' (مثلاً: تثبيت حزمة 'php=libxml' (حيث أنها مطلوبة بواسطة حزم إضافات PHP أخرى.", + "SystemCheckWarnSimpleXMLHelp": "يتوجب عليك تفعيل إضافة 'SimpleXML' (مثلاً: تثبيت حزمة 'php-simplexml' و\/أو 'php-xml').", + "SystemCheckWinPdoAndMysqliHelp": "على ملقم ويندوز، يمكنك إضافة الصطور التالية لملف php.ini الخاص بك: %s", + "SystemCheckWriteDirs": "المجلدات بصلاحية الكتابة", + "SystemCheckWriteDirsHelp": "لإصلاح هذا الخطأ على نظام لينوكس، حوال كتابة الأمر\/الأوامر التالية", + "SystemCheckZlibHelp": "تحتاج إلى إعداد وإعادة بناء PHP وتفعيل دعم مكتبة zlib: --with-zlib.", + "Tables": "جاري إنشاء الجداول", + "TablesCreatedSuccess": "تم إنشاء الجداول بنجاح!", + "TablesDelete": "حذف الجداول المكتشفة", + "TablesDeletedSuccess": "تم حذف جداول Piwik القائمة بنجاح", + "TablesFound": "تم العثور على الجداول التالية في قاعدة البيانات", + "TablesReuse": "إعادة استخدام الجداول الموجودة", + "TablesWarningHelp": "اختر إما إعادة استخدام الجداول الموجودة أو اختر تثبيت جديد ومسح كافة البيانات من قاعدة البيانات.", + "TablesWithSameNamesFound": "بعض %1$s الجداول في قاعدة بياناتك %2$s تحمل نفس الأسماء التي يحاول Piwik إنشائها.", + "Timezone": "المنطقة الزمنية للموقع", + "Welcome": "مرحباً بك!", + "WelcomeHelp": "

    Piwik عبارة عن برمجيات تحليلات ويب مفتوحة المصدر تجعل من السهل أن تحصل على المعلومات التي ترغب فيها عن زوارك.<\/p>

    هذه العملية تنقسم إلى %s خطوات سهلة وستستغرق حوالي 5 دقائق فقط.<\/p>" + }, + "LanguagesManager": { + "AboutPiwikTranslations": "حول ترجمة Piwik", + "PluginDescription": "هذا التطبيق يعرض قائمة باللغات المتوفر لواجهة Piwik. سيتم حفظ اللغة المحددة في تفضيلات كل مستخدم." + }, + "Live": { + "GoalType": "النوع", + "KeywordRankedOnSearchResultForThisVisitor": "الكلمة الدلالية %1$s كان ترتيبها %2$s في %3$s في صفحة نتائج البحث لهذا الزائر.", + "LastHours": "آخر %s ساعة", + "LastMinutes": "آخر %s دقيقة", + "LinkVisitorLog": "مشاهدة سجل تفصيلي للزائر", + "PluginDescription": "تابع وتجسس على زوارك مباشر وفي الوقت الحقيقي!", + "Referrer_URL": "جاء من", + "VisitorLog": "سجل الزائر", + "VisitorsInRealTime": "الزوار في الوقت الحقيقي" + }, + "Login": { + "ContactAdmin": "الأسباب المحتملة: قد يكون مستضيف الموقع قد عطل خاصية mail(). الرجاء الاتصال بمدير Piwik.", + "ExceptionPasswordMD5HashExpected": "باراميتر كلمة المرور كان يتوقع أن يكون MD5 hash لكلمة المرور.", + "InvalidNonceOrHeadersOrReferrer": "فشل نموذج الأمن. الرجاء إعادة تحديث النموذج وفحص ما إذا كانت الكوكيز مفعلة لديك. إذا كنت تستخدم ملقم بروكسي، يتوجب عليك %s ضبط Piwik ليقبل ترويسات البروكسي %s والتي ترسل ترويسة المضيف. أيضاً افحص ما إذا كان يتم إرسال ترويسة المرسل بشكل صحيح.", + "InvalidOrExpiredToken": "الشفرة غير صالحة أو انتهت صلاحيتها", + "InvalidUsernameEmail": "اسم المستخدم\/البريد الإلكتروني غير صالح", + "LogIn": "تسجيل الدخول", + "LoginOrEmail": "اسم المستخدم أو البريد", + "LoginPasswordNotCorrect": "اسم المستخدم أو كلمة المرور غير صحيحة", + "LostYourPassword": "فقدت كلمة المرور؟", + "PasswordRepeat": "كلمة المرور (تأكيد)", + "PasswordsDoNotMatch": "كلمتي المرور غير متطابقتين.", + "RememberMe": "تذكرني" + }, + "MultiSites": { + "Evolution": "التطور", + "PluginDescription": "يعرض إحصائيات مواقع متعددة، يتم تطويره حالياً كإضافة رئيسية في Piwik." + }, + "PrivacyManager": { + "AnonymizeIpInlineHelp": "قم بتشفير آخر بايت من عناوين IP للزوار للإلتزام بقوانين\/إرشادات الخصوصية المحلية." + }, + "Provider": { + "ColumnProvider": "مزود الخدمة", + "PluginDescription": "يعرض تقارير عن مزود الخدمة للزوار.", + "SubmenuLocationsProvider": "المكان ومزود الخدمة", + "WidgetProviders": "مزودو الخدمة" + }, + "Referrers": { + "Campaigns": "الحملات", + "ColumnCampaign": "الحملة", + "ColumnSearchEngine": "محرك بحث", + "ColumnWebsite": "موقع ويب", + "ColumnWebsitePage": "صفحة موقع", + "DetailsByReferrerType": "التفاصيل حسب نوع المرسل", + "DirectEntry": "الوصول المباشر", + "Distinct": "أبرز المرسلون حسب النوع", + "DistinctCampaigns": "أبرز الحملات", + "DistinctKeywords": "أبرز الكلمات الدلالية", + "DistinctSearchEngines": "أبرز محركات البحث", + "DistinctWebsites": "أبرز مواقع ويب", + "Keywords": "الكلمات الدلالية", + "PluginDescription": "تقارير عن مرسلي الزوار: محركات البحث، الكلمات الدلالية، المواقع، تتبع الحملات، والوصول المباشر.", + "ReferrerName": "اسم المرسل", + "Referrers": "مرسلو الزوار", + "SearchEngines": "محركات البحث", + "SubmenuSearchEngines": "محركات البحث والكلمات الدلالية", + "SubmenuWebsites": "مواقع ويب", + "Type": "نوع المرسل", + "TypeDirectEntries": "%s وصول مباشر", + "TypeSearchEngines": "%s من محركات البحث", + "TypeWebsites": "%s من مواقع ويب", + "UsingNDistinctUrls": "(باستخدام %s روابط بارزة)", + "Websites": "المواقع", + "WidgetExternalWebsites": "قائمة المواقع الخارجية", + "WidgetKeywords": "قائمة الكلمات الدلالية" + }, + "ScheduledReports": { + "AlsoSendReportToTheseEmails": "أرسل التقرير إلى هذه العناوين أيضاً (عنوان لكل سطر):", + "AreYouSureDeleteReport": "هل ترغب حقاً في حذف هذا التقرير وجدولته الزمنية؟", + "CancelAndReturnToReports": "إلغاء و%s رجوع إلى قائمة التقارير%s", + "CreateAndScheduleReport": "أنشئ وأعد جدولاً لتقرير", + "CreateReport": "إنشاء تقرير", + "DescriptionOnFirstPage": "وصف التقرير سيتم عرضه في الصفحة الأولى من التقرير.", + "EmailHello": "مرحباً", + "EmailReports": "التقارير البريدية", + "EmailSchedule": "جدولة البريد", + "FrontPage": "صفحة المقدمة", + "ManageEmailReports": "إدارة تقارير البريد", + "MonthlyScheduleHelp": "الجدولة الشهرية: سيتم إرسال التقرير في الأول من كل شهر.", + "MustBeLoggedIn": "يجب أن تسجل الدخول لإنشاء وجدولة التقارير.", + "PiwikReports": "تقارير Piwik", + "PleaseFindAttachedFile": "انظر المرفقات رجاء لتحميل %1$s التقارير حول %2$s.", + "PleaseFindBelow": "ستجد %1$s تقريرك حول %2$s أدناه.", + "PluginDescription": "أنشأ وحمل تقاريرك المخصصة، واجعلها تُرسل إليك يومياً، أسبوعياً أو شهرياً.", + "ReportFormat": "تهيئة التقرير", + "ReportsIncluded": "الإحصائيات المُضمنة", + "SendReportNow": "أرسل التقرير الآن", + "SendReportTo": "أرسل التقرير إلى", + "SentToMe": "أرسلها لي", + "TableOfContent": "قائمة التقارير", + "ThereIsNoReportToManage": "لا يوجد تقرير لإدارته للموقع %s.", + "TopOfReport": "إلى أعلى", + "UpdateReport": "تحديث تقرير", + "WeeklyScheduleHelp": "جدولة أسبوعية: سيتم إرسال التقرير يوم الاثنين من كل أسبوع." + }, + "SEO": { + "AlexaRank": "رتبة أليكسا", + "DomainAge": "عمر اسن النطاق", + "Rank": "الرتبة", + "SeoRankings": "رتب ملائمة محركات البحث", + "SEORankingsFor": "رتبة ملائمة محركات البحث في %s" + }, + "SitesManager": { + "AddSite": "أضف موقعاً جديداً", + "AdvancedTimezoneSupportNotFound": "الدعم المتقدم لإعدادات المناطق الزمنية غير موجود في نظام PHP لديك (مدعم في الإصدار 5.2). لا يزال في إمكانك اختيار الفارق الزمني UTC يدوياً.", + "AliasUrlHelp": "من المفضل - ولكن ليس إجبارياً - تحديد روابط ويب المتعددة - واحد لكل سطر - والتي سيستخدمها زوارك للوصول لموقعك. العناوين المرادفة لموقع ما لن تظهر في تقارير الإحالة. لاحظ أنه ليس من الضروري أن تضيف الروابط مع بادئة www وبدونها حيث يقوم Piwik بوضع الاثنين في اعتباره.", + "ChangingYourTimezoneWillOnlyAffectDataForward": "تغيير المنطقة الزمنية سيؤثر فقط على البيانات التي ستحدث في المستقبل، ولن يكون لها أثر رجعي.", + "ChooseCityInSameTimezoneAsYou": "اختر مدينة في نفس منطقتك الزمنية", + "Currency": "العملة", + "CurrencySymbolWillBeUsedForGoals": "رمز العملة الذي سيتم عرضه بجوار عائد الأهداف.", + "DefaultCurrencyForNewWebsites": "العملة الافتراضية للمواقع الجديدة", + "DefaultTimezoneForNewWebsites": "المنطقة الزمنية الافتراضية للمواقع الجديدة", + "DeleteConfirm": "هل ترغب حقاً في حذف هذا الموقع %s؟", + "ExceptionDeleteSite": "لا يمكنك حذف هذا الموقع، فهو الوحيد المسجل لديك. أضف موقعاً جديداً ثم قم بحذف هذا الموقع.", + "ExceptionEmptyName": "لا يمكن ترك حقل اسم الموقع فارغاً.", + "ExceptionInvalidCurrency": "العملة \"%s\" غير صالحة. الرجاء إدخال رمز عملة صالح (مثل %s).", + "ExceptionInvalidIPFormat": "عنوان IP المستثنى \"%s\" لا يوافق صيغة عنوان IP صحيحة (مثل %s).", + "ExceptionInvalidTimezone": "المنقطة الزمنية \"%s\" غير صالحة. الرجاء إدخال منطقة زمنية صالحة.", + "ExceptionInvalidUrl": "الرابط \"%s\" غير صالح.", + "ExceptionNoUrl": "يجب عليك أن تحدد رابط ويب وحيد على الأقل للموقع.", + "ExcludedIps": "عناوين IP المستثناة", + "ExcludedParameters": "الباراميترات المستثناة", + "GlobalListExcludedIps": "القائمة العامة لعناوين IP المستثناة", + "GlobalListExcludedQueryParameters": "القائمة العامة لباراميترات روابط الاستعلام المستثناة", + "GlobalWebsitesSettings": "إعدادات المواقع العامة", + "HelpExcludedIps": "أدخل قائمة عناوين IP، واحداً لكل سطر، والتي ترغب في استثنائها من عملية التتبع بواسطة Piwik. يمكنك استخدام العلامات العشوائية مثل %1$s أو %2$s", + "JsTrackingTagHelp": "إليك وسم التتبع بلغة جافا لتضمنه في كافة صفحات موقعك.", + "ListOfIpsToBeExcludedOnAllWebsites": "عناوين IP الموضحة بالأسفل سيتم استثناؤها من عمليات التتبع على كافة المواقع.", + "ListOfQueryParametersToBeExcludedOnAllWebsites": "سيتم استثناء باراميترات روابط الاستعلام التالية من كافة روابط جميع المواقع.", + "ListOfQueryParametersToExclude": "أدخل قائمة باراميترات روابط الاستعلام، واحداً لكل سطر، ليتم استثنءه من تقارير روابط الصفحات.", + "MainDescription": "تحليلات ويب الخاصة بك تحتاج لمواقع! أضف، حدِّث أو احذف مواقع واعرض أكواد جافا لتدخلها في صفحاتك.", + "NotFound": "لم يتم العثور على مواقع تخص", + "NoWebsites": "أنت لا تملك أي مواقع لتديرها.", + "OnlyOneSiteAtTime": "لا يمكنك تحرير أكثر من موقع في الوقت ذاته. يرجى حفظ أو إلغاء التغييرات الحالية على الموقع %s.", + "PiwikWillAutomaticallyExcludeCommonSessionParameters": "سيقوم Piwik آلياً باستثناء كافة باراميترات الجلسات الشائعة (%s).", + "PluginDescription": "إدارة المواقع في Piwik: أضف موقعاً جديداً، أو قم بتحرير واحداً موجوداً، اعرض كود برمجيات جافا ليتم تضمينه في صفحاتك. كافة السلوكيات متوفرة أيضاً من خلال واجهة برمجة التطبيقات.", + "SelectACity": "اختر مدينة", + "SelectDefaultCurrency": "يمكنك اختيار العملة الافتراضية للمواقع الجديدة.", + "SelectDefaultTimezone": "يمكنك تحديد منطقة زمنية افتراضية للمواقع الجديدة", + "ShowTrackingTag": "عرض وسوم التتبع", + "Sites": "المواقع", + "Timezone": "المنطقة الزمنية", + "TrackingTags": "وسم التتبع لموقع %s", + "Urls": "الروابط", + "UTCTimeIs": "توقيت UTC هو %s.", + "WebsitesManagement": "إدارة المواقع", + "YouCurrentlyHaveAccessToNWebsites": "يمكنك حالياً الوصول إلى مواقع %s.", + "YourCurrentIpAddressIs": "عنوان IP الحالي لديك هو %s" + }, + "UserCountry": { + "Continent": "القارة", + "continent_afr": "أفريقيا", + "continent_amc": "أمريكا الوسطى", + "continent_amn": "أمريكا الشمالية", + "continent_ams": "أمريكا الجنوبية", + "continent_ant": "أنتاركتيكا - القارة القطبية الجنوبية", + "continent_asi": "آسيا", + "continent_eur": "أوروبا", + "continent_oce": "أوقيانوسيا", + "Country": "الدولة", + "country_a1": "بروكسي مجهول", + "country_a2": "مزود خدمة عبر الأقمار الصناعية", + "country_ac": "جزر آسينشن", + "country_ad": "آندورا", + "country_ae": "الإمارات العربية المتحدة", + "country_af": "أفغانستان", + "country_ag": "آنتيجوا وباربودا", + "country_ai": "أنجيلا", + "country_al": "آلبانيا", + "country_am": "آرمينيا", + "country_an": "جزر الأنتيل الهولندية", + "country_ao": "أنجولا", + "country_ap": "المنطقة الآسيوية\/الباسيفيكية", + "country_aq": "القارة القطبية الجنوبية", + "country_ar": "الأرجنتين", + "country_as": "ساموا الأمريكية", + "country_at": "النمسا", + "country_au": "أستراليا", + "country_aw": "آروبا", + "country_ax": "جزر آلاند", + "country_az": "أذربيجان", + "country_ba": "البوسنة والهرسك", + "country_bb": "باربادوس", + "country_bd": "بنجلاديش", + "country_be": "بلجيكا", + "country_bf": "بوركينا فاسو", + "country_bg": "بلغاريا", + "country_bh": "البحرين", + "country_bi": "بوروندي", + "country_bj": "بنين", + "country_bl": "سانت بارتيليمي", + "country_bm": "برمودا", + "country_bn": "برونيو", + "country_bo": "بوليفيا متعددة القوميات", + "country_bq": "بونير، وسانت اوستاتيوس سابا", + "country_br": "البرازيل", + "country_bs": "جزء الباهاماس", + "country_bt": "بوتان", + "country_bu": "بورما", + "country_bv": "جزيرة بوفيت", + "country_bw": "بوتسوانا", + "country_by": "بيلاروس", + "country_bz": "بيليز", + "country_ca": "كندا", + "country_cat": "المجتمعات الناطقة بالكاتالانية", + "country_cc": "جزر كوكوس - كيلينج", + "country_cd": "جمهورية الكونجو الديمقراطية", + "country_cf": "جمهورية أفريقيا الوسطى", + "country_cg": "الكونجو", + "country_ch": "سويسرا", + "country_ci": "ساحل العاج", + "country_ck": "جزر كوك", + "country_cl": "تشيلي", + "country_cm": "كاميرون", + "country_cn": "الصين", + "country_co": "كولومبيا", + "country_cp": "جزيرة كليبرتون", + "country_cr": "كوستا ريكا", + "country_cs": "صربيا والجبل الأسود", + "country_cu": "كوبا", + "country_cv": "الرأس الأخضر", + "country_cw": "كوراساو", + "country_cx": "جزيرة كريسماس", + "country_cy": "قبرص", + "country_cz": "جمهورية التشيك", + "country_de": "ألمانيا", + "country_dg": "دييغو جارسيا", + "country_dj": "جيبوتي", + "country_dk": "الدنمارك", + "country_dm": "دومينيكا", + "country_do": "جمهورية الدومينيكان", + "country_dz": "الجزائر", + "country_ea": "سبتا مليلة", + "country_ec": "الإكوادور", + "country_ee": "إستونيا", + "country_eg": "مصر", + "country_eh": "الصحراء الغربية", + "country_er": "إريتريا", + "country_es": "أسبانيا", + "country_et": "أثيوبيا", + "country_eu": "الاتحاد الأوروبي", + "country_fi": "فنلندا", + "country_fj": "فيجي", + "country_fk": "جزر فوكلاند - مالفيناس", + "country_fm": "ولايات ميكرونيزيا الاتحادية", + "country_fo": "جزر فارو", + "country_fr": "فرنسا", + "country_fx": "فرنسا - متروبوليتان", + "country_ga": "الجابون", + "country_gb": "المملكة المتحدة - بريطانيا", + "country_gd": "جرينادا", + "country_ge": "جيورجيا", + "country_gf": "جيانا الفرنسية", + "country_gg": "غيرنسي", + "country_gh": "غانا", + "country_gi": "جبل طارق", + "country_gl": "جرين لاند", + "country_gm": "غامبيا", + "country_gn": "غينيا", + "country_gp": "غواديلوب", + "country_gq": "غينيا الاستوائية", + "country_gr": "اليونان", + "country_gs": "جورجيا الجنوبية وجزء ساندويتش الجنوبية", + "country_gt": "جواتيمالا", + "country_gu": "غوام", + "country_gw": "غينيا بيساو", + "country_gy": "غيانا", + "country_hk": "هونج كونج", + "country_hm": "جزيرة هيرد وجزر ماكدونالد", + "country_hn": "هندوراس", + "country_hr": "كرواتيا", + "country_ht": "هاييتي", + "country_hu": "هنغاريا", + "country_ic": "جزر الكناري", + "country_id": "إندونيسيا", + "country_ie": "أيرلندا", + "country_il": "إسرائيل", + "country_im": "جزيرة مان", + "country_in": "الهند", + "country_io": "إقليم المحيط الهندي البريطاني", + "country_iq": "العراق", + "country_ir": "جمهورية إيران الإسلامية", + "country_is": "آيسلندا", + "country_it": "إيطاليا", + "country_je": "جيرسي", + "country_jm": "جامايكا", + "country_jo": "الأردن", + "country_jp": "اليابان", + "country_ke": "كينيا", + "country_kg": "قيرغيزستان", + "country_kh": "كمبوديا", + "country_ki": "كيريباتي", + "country_km": "جزر القمر", + "country_kn": "سانت كيتس ونيفيس", + "country_kp": "جمهورية كوريا الشعبية الديمقراطية", + "country_kr": "جمهورية كوريا", + "country_kw": "الكويت", + "country_ky": "جزر كايمن", + "country_kz": "كازاخستان", + "country_la": "لاوس", + "country_lb": "لبنان", + "country_lc": "ساينت لوسيا", + "country_li": "ليختنشتاين", + "country_lk": "سريلانكا", + "country_lr": "ليبريا", + "country_ls": "ليسوتو", + "country_lt": "ليتوانيا", + "country_lu": "لوكسمبورغ", + "country_lv": "لاتفيا", + "country_ly": "الجماهيرية العربية الليبية", + "country_ma": "المغرب", + "country_mc": "موناكو", + "country_md": "مولدوفا", + "country_me": "الجبل الأسود", + "country_mf": "ساينت مارتين", + "country_mg": "مدغشقر", + "country_mh": "جزر مارشال", + "country_mk": "مقدونيا", + "country_ml": "مالي", + "country_mm": "ميانمار", + "country_mn": "مونغوليا", + "country_mo": "ماكاو", + "country_mp": "جزر ماريانا الشمالية", + "country_mq": "مارتينيك", + "country_mr": "موريتانيا", + "country_ms": "مونتسيرات", + "country_mt": "مالطا", + "country_mu": "موريشيوس", + "country_mv": "مالاديف", + "country_mw": "مالاوي", + "country_mx": "المسكيك", + "country_my": "ماليزيا", + "country_mz": "موزمبيق", + "country_na": "ناميبيا", + "country_nc": "كاليدونيا الجديدة", + "country_ne": "النيجر", + "country_nf": "جزيرة نورفولك", + "country_ng": "نيجيريا", + "country_ni": "نيكاراجوا", + "country_nl": "هولندا", + "country_no": "النرويج", + "country_np": "نيبال", + "country_nr": "ناورو", + "country_nt": "منطقة محايدة", + "country_nu": "نيوي", + "country_nz": "نيوزيلندا", + "country_o1": "دولة أخرى", + "country_om": "عمان", + "country_pa": "بنما", + "country_pe": "بيرو", + "country_pf": "بولينيزيا الفرنسية", + "country_pg": "بابوا غينيا الجديدة", + "country_ph": "الفلبين", + "country_pk": "باكستان", + "country_pl": "بولندا", + "country_pm": "سان بيار وميكلون", + "country_pn": "بيتكيرن", + "country_pr": "بورت ريكو", + "country_ps": "الأراضي الفلسطينية المحتلة", + "country_pt": "البرتغال", + "country_pw": "بالاو", + "country_py": "البارجواي", + "country_qa": "قطر", + "country_re": "جزيرة ريونيون", + "country_ro": "رومانيا", + "country_rs": "الصرب", + "country_ru": "الاتحاد الروسي", + "country_rw": "رواندا", + "country_sa": "المملكة العربية السعودية", + "country_sb": "جزر سليمان", + "country_sc": "سيشيل", + "country_sd": "السودان", + "country_se": "السويد", + "country_sf": "فنلندا", + "country_sg": "سنغافورة", + "country_sh": "ساينت هيلانة وتريستان دا كونيا", + "country_si": "سلوفينيا", + "country_sj": "سفالبارد", + "country_sk": "سلوفاكيا", + "country_sl": "سيراليون", + "country_sm": "سان مارينو", + "country_sn": "السنغال", + "country_so": "الصومال", + "country_sr": "سورينام", + "country_ss": "جنوب السودان", + "country_st": "ساو تومي وبرينسيبي", + "country_su": "الاتحاد السوفييتي القديم", + "country_sv": "السلفادور", + "country_sx": "ساينت مارتن", + "country_sy": "الجمهورية العربية السورية", + "country_sz": "سوازيلاند", + "country_ta": "تريستان دا كونها", + "country_tc": "جزر كايكوس وتركس", + "country_td": "تشاد", + "country_tf": "الأقاليم الفرنسية الجنوبية", + "country_tg": "توجو", + "country_th": "تايلاند", + "country_tj": "طاجكستان", + "country_tk": "توكيلاو", + "country_tl": "تيمور الشرقية", + "country_tm": "تركمنستان", + "country_tn": "تونس", + "country_to": "تونجا", + "country_tp": "تيمور الشرقية", + "country_tr": "تركيا", + "country_tt": "ترينيداد وتوباغو", + "country_tv": "توفالو", + "country_tw": "تايوان", + "country_tz": "جمهورية تنزانيا الاتحادية", + "country_ua": "أوكرانيا", + "country_ug": "أوغندا", + "country_uk": "المملكة المتحدة", + "country_um": "جزر الولايات المتحدة الصغيرة النائية", + "country_us": "الولايات المتحدة الأمريكية", + "country_uy": "يوروغواي", + "country_uz": "أوزباكستان", + "country_va": "مدينة الفاتيكان", + "country_vc": "سانت فنسنت وغرينادين", + "country_ve": "جمهورية فنزويلا البوليفارية", + "country_vg": "جزر فيرجين البريطانية", + "country_vi": "جزر فيرجين الأمريكية", + "country_vn": "فييتنام", + "country_vu": "فانواتو", + "country_wf": "واليس وفوتونا", + "country_ws": "ساموا", + "country_ye": "اليمن", + "country_yt": "مايوت", + "country_yu": "يوغوسلافيا", + "country_za": "أفريقيا الجنوبية", + "country_zm": "زامبيا", + "country_zr": "زائير", + "country_zw": "زيمبابوي", + "DistinctCountries": "%s دولة بارزة", + "Location": "المكان", + "PluginDescription": "تقارير دول الزوار", + "SubmenuLocations": "الأماكن" + }, + "UserCountryMap": { + "map": "خريطة" + }, + "UserSettings": { + "BrowserFamilies": "عائلات المتصفحات", + "Browsers": "المتصفحات", + "ColumnBrowser": "المتصفح", + "ColumnBrowserFamily": "عائلة المتصفح", + "ColumnBrowserVersion": "إصدار المتصفح", + "ColumnConfiguration": "الإعداد", + "ColumnOperatingSystem": "نظام التشغيل", + "ColumnResolution": "الكثافة النقطية", + "ColumnTypeOfScreen": "نوع الشاشة", + "Configurations": "الإعدادات", + "Language_aa": "الأفارية", + "Language_ab": "الأبخازية", + "Language_ae": "الأفستية", + "Language_af": "الأفريقية", + "Language_ak": "الأكانية", + "Language_am": "الأمهرية", + "Language_an": "الأراجونية", + "Language_ar": "العربية", + "Language_as": "الأسامية", + "Language_av": "الأفاريكية", + "Language_ay": "الأيمارا", + "Language_az": "الأذرية", + "Language_ba": "الباشكيرية", + "Language_be": "البيلوروسية", + "Language_bg": "البلغارية", + "Language_bh": "البيهارية", + "Language_bi": "البيسلامية", + "Language_bm": "البامبارا", + "Language_bn": "البنغالية", + "Language_bo": "التبتية", + "Language_br": "البريتونية", + "Language_bs": "البوسنية", + "Language_ca": "الكاتالوينية", + "Language_ce": "الشيشانية", + "Language_ch": "التشامورو", + "Language_co": "الكورسيكية", + "Language_cr": "الكرى", + "Language_cs": "التشيكية", + "Language_cu": "سلافية كنسية", + "Language_cv": "التشفاش", + "Language_cy": "الولزية", + "Language_da": "الدانماركية", + "Language_de": "الألمانية", + "Language_dv": "المالديفية", + "Language_dz": "الزونخاية", + "Language_ee": "الايوي", + "Language_el": "اليونانية", + "Language_en": "الانجليزية", + "Language_eo": "اسبرانتو", + "Language_es": "الأسبانية", + "Language_et": "الأستونية", + "Language_eu": "لغة الباسك", + "Language_fa": "الفارسية", + "Language_ff": "الفلة", + "Language_fi": "الفنلندية", + "Language_fj": "الفيجية", + "Language_fo": "الفارويز", + "Language_fr": "الفرنسية", + "Language_fy": "الفريزيان", + "Language_ga": "الأيرلندية", + "Language_gd": "الغيلية الأسكتلندية", + "Language_gl": "الجاليكية", + "Language_gn": "الجوارانى", + "Language_gu": "الغوجاراتية", + "Language_gv": "المنكية", + "Language_ha": "الهوسا", + "Language_he": "العبرية", + "Language_hi": "الهندية", + "Language_ho": "الهيرى موتو", + "Language_hr": "الكرواتية", + "Language_ht": "الهايتية", + "Language_hu": "الهنغارية", + "Language_hy": "الأرمينية", + "Language_hz": "الهيريرو", + "Language_ia": "اللّغة الوسيطة", + "Language_id": "الأندونيسية", + "Language_ie": "الانترلينج", + "Language_ig": "الايجبو", + "Language_ii": "السيتشيون يى", + "Language_ik": "الاينبياك", + "Language_io": "الايدو", + "Language_is": "الأيسلاندية", + "Language_it": "الايطالية", + "Language_iu": "الاينكتيتت", + "Language_ja": "اليابانية", + "Language_jv": "الجاوية", + "Language_ka": "الجورجية", + "Language_kg": "الكونغو", + "Language_ki": "الكيكيو", + "Language_kj": "الكيونياما", + "Language_kk": "الكازاخستانية", + "Language_kl": "الكالاليست", + "Language_km": "الخميرية", + "Language_kn": "الكانادا", + "Language_ko": "الكورية", + "Language_kr": "الكانيوري", + "Language_ks": "الكاشميرية", + "Language_ku": "الكردية", + "Language_kv": "الكومي", + "Language_kw": "الكورنية", + "Language_ky": "القيرغستانية", + "Language_la": "اللاتينية", + "Language_lb": "اللوكسمبرجية", + "Language_lg": "الجاندا", + "Language_li": "الليمبرجيشية", + "Language_ln": "اللينجالا", + "Language_lo": "اللاوية", + "Language_lt": "اللتوانية", + "Language_lu": "اللبا-كاتانجا", + "Language_lv": "اللاتفية", + "Language_mg": "المالاجاشية", + "Language_mh": "المارشالية", + "Language_mi": "الماورية", + "Language_mk": "المقدونية", + "Language_ml": "الماليالام", + "Language_mn": "المنغولية", + "Language_mr": "الماراثى", + "Language_ms": "لغة الملايو", + "Language_mt": "المالطية", + "Language_my": "البورمية", + "Language_na": "النورو", + "Language_nb": "البوكمالية النرويجية", + "Language_nd": "النديبيل الشمالى", + "Language_ne": "النيبالية", + "Language_ng": "الندونجا", + "Language_nl": "الهولندية", + "Language_nn": "النينورسك النرويجي", + "Language_no": "النرويجية", + "Language_nr": "النديبيل الجنوبى", + "Language_nv": "النافاجو", + "Language_ny": "النيانجا، التشيتشوا، التشوا", + "Language_oc": "الأوكيتانية", + "Language_oj": "الأوجيبوا", + "Language_om": "الأورومو", + "Language_or": "الأورييا", + "Language_os": "الأوسيتيك", + "Language_pa": "البنجابية", + "Language_pi": "البالية", + "Language_pl": "البولندية", + "Language_ps": "البشتونية", + "Language_pt": "البرتغالية", + "Language_qu": "الكويتشوا", + "Language_rm": "الرهايتو-رومانس", + "Language_rn": "الرندى", + "Language_ro": "الرومانية", + "Language_ru": "الروسية", + "Language_rw": "الكينيارواندا", + "Language_sa": "السنسكريتية", + "Language_sc": "السردينية", + "Language_sd": "السيندى", + "Language_se": "السامي الشمالى", + "Language_sg": "السانجو", + "Language_si": "السريلانكية", + "Language_sk": "السلوفاكية", + "Language_sl": "السلوفانية", + "Language_sm": "الساموائية", + "Language_sn": "الشونا", + "Language_so": "الصومالية", + "Language_sq": "الألبانية", + "Language_sr": "الصربية", + "Language_ss": "السواتى", + "Language_st": "السوتو الجنوبية", + "Language_su": "السودانية", + "Language_sv": "السويدية", + "Language_sw": "السواحلية", + "Language_ta": "التاميلية", + "Language_te": "التيلجو", + "Language_tg": "الطاجيكية", + "Language_th": "التايلاندية", + "Language_ti": "التيجرينيا", + "Language_tk": "التركمانية", + "Language_tl": "التاغالوغية", + "Language_tn": "التسوانية", + "Language_to": "تونجا - جزر تونجا", + "Language_tr": "التركية", + "Language_ts": "السونجا", + "Language_tt": "التتارية", + "Language_tw": "التوي", + "Language_ty": "التاهيتية", + "Language_ug": "الأغورية", + "Language_uk": "الأوكرانية", + "Language_ur": "الأردية", + "Language_uz": "الاوزباكية", + "Language_ve": "الفيندا", + "Language_vi": "الفيتنامية", + "Language_wa": "الولونية", + "Language_wo": "الولوف", + "Language_xh": "الخوسا", + "Language_yi": "اليديشية", + "Language_yo": "اليوروبية", + "Language_za": "الزهيونج", + "Language_zh": "الصينية", + "Language_zu": "الزولو", + "LanguageCode": "كود اللغة", + "OperatingSystems": "أنظمة التشغيل", + "PluginDescription": "تقارير عن إعدادات المستخدمين المختلفة: المتصفح، عائلة المتصفح، نظام التشغيل، الإضافات البرمجية، كثافة الشاشة النقطية، الإعدادات العامة.", + "PluginDetectionDoesNotWorkInIE": "ملاحظة: اكتشاف الإضافات البرمجية لا تعمل في متصفح إنترنت إكسبلورر. هذه الخاصية ترتكز للمتصفحات من العائلات الأخرى غير إنترنت إكسبلورر.", + "Resolutions": "الكثافات النقطية", + "VisitorSettings": "إعدادات الزوار", + "WideScreen": "شاشة عريضة", + "WidgetBrowserFamilies": "المتصفحات حسب العائلة", + "WidgetBrowsers": "متصفحات الزوار", + "WidgetGlobalVisitors": "الإعدادات العامة للزوار", + "WidgetOperatingSystems": "أنظمة التشغيل", + "WidgetPlugins": "قائمة الإضافات", + "WidgetResolutions": "كثافات الشاشة النقطية", + "WidgetWidescreen": "عادية \/ عريضة" + }, + "UsersManager": { + "AddUser": "أضف مستخدم جديد", + "Alias": "اللقب", + "AllWebsites": "كافة المواقع", + "ApplyToAllWebsites": "تطبيق على كافة المواقع", + "ChangeAllConfirm": "هل ترغب حقاً في تعديل صلاحيات '%s' في كافة المواقع؟", + "ClickHereToDeleteTheCookie": "انقر هنا لحذف الكوكيز وليتتبع Piwik زياراتك", + "DeleteConfirm": "هل ترغب حقاً في حذف المستخدم %s؟", + "Email": "البريد الإلكتروني", + "ExceptionAccessValues": "بارامتيرات الوصول يجب أن تتضمن أحد القيم التالية: [%s]", + "ExceptionAdminAnonymous": "لا يمكنك منح \"المدير\" وصولاً للمستخدم \"مجهول\"", + "ExceptionDeleteDoesNotExist": "المستخدم '%s' غير موجود ولذلك لا يمكن حذفه.", + "ExceptionEditAnonymous": "لا يمكن تحرير أو حذف المستخدم \"مجهول\"، فهو يستخدم من قبل Piwik لتعريف المستخدم الذي لم يقم بتسجيل الدخول بعد. على سبيل المثال، يمكنك جعل صفحة الإحصائيات عامة من خلال منح المستخدم \"مجهول\" صلاحيات \"المشاهدة\".", + "ExceptionEmailExists": "المستخدم بعنوان البريد '%s' موجود مسبقاً.", + "ExceptionInvalidEmail": "صيغة البريد الإلكتروني غير صحيحة", + "ExceptionInvalidLoginFormat": "اسم المستخدم يجب أن يكون بين %1$s و %2$s رمزاً ويتضمن الحروف، الأرقام والرموز \"_\" أو \"-\" أو \".\" أو \"@\" أو \"+\" فقط.", + "ExceptionInvalidPassword": "يجب أن يكون طول كلمة المرور بين %1$s و %2$s رمزاً.", + "ExceptionLoginExists": "اسم المستخدم '%s' موجود مسبقاً.", + "ExceptionPasswordMD5HashExpected": "يتوقع UsersManager.getTokenAuth كلمة مرور مشفرة باستخدام MD5 (طولها 32 رمزاً). الرجاء استدعاء الدالة md5() لكلمة المرور قبل استدعاء هذه الدالة.", + "ExceptionUserDoesNotExist": "المستخدم '%s' غير موجود.", + "ExcludeVisitsViaCookie": "استثني زياراتك باستخدام Cookies", + "ForAnonymousUsersReportDateToLoadByDefault": "للمستخدمين المجهولين، حدد تاريخ التقرير", + "IfYouWouldLikeToChangeThePasswordTypeANewOne": "إذا كنت ترغب في تغيير كلمة المرور، اكتب واحدة جديدة، أو اتركها فارغة لعدم تغييرها.", + "MainDescription": "قرر أي المستخدمين لديه أي صلاحيات في Piwik. يمكنك أيضاً إعداد الصلاحيات لكل موقع على حدة أو لكل المواقع دفعة واحدة.", + "ManageAccess": "إدارة صلاحيات الوصول", + "MenuAnonymousUserSettings": "إعدادات المستخدمين المجهولين", + "MenuUsers": "المستخدمون", + "MenuUserSettings": "إعدادات المستخدم", + "PluginDescription": "إدارة المستخدمين في Piwik: أضف مستخدماً جديداً أو حرر مستخدم موجود، عدل الصلاحيات لمستخدم ما. كافة الصلاحيات متوفرة أيضاً من خلال API واجهة استخدام التطبيقات.", + "PrivAdmin": "إشراف", + "PrivNone": "بدون وصول", + "PrivView": "مشاهدة", + "ReportDateToLoadByDefault": "تاريخ التقرير ليتم تحميله", + "ReportToLoadByDefault": "التقرير الافتراضي", + "TheLoginScreen": "شاشة تسجيل الدخول", + "ThereAreCurrentlyNRegisteredUsers": "يوجد حالياً %s مستخدم مسجل.", + "TypeYourPasswordAgain": "اكتب كلمة المرور الجديدة مرة أخرى.", + "User": "المستخدم", + "UsersManagement": "إدارة المستخدمين", + "UsersManagementMainDescription": "أنشئ متسخدم جديد أو قم بتحديث مستخدم قائم. يمكنك أن تضبط صلاحياتهم بالأعلى.", + "WhenUsersAreNotLoggedInAndVisitPiwikTheyShouldAccess": "عندما لا يسجل المستخدمون دخولهم ويزورون Piwik،فسيمكنهم الوصول إلى", + "YourUsernameCannotBeChanged": "لا يمكن تغيير اسم المستخدم الخاص بك.", + "YourVisitsAreIgnoredOnDomain": "%s زياراتك يتم تجاهلها في Piwik%s %s(تم العثور على كوكيز التجاهل في متصفحك).", + "YourVisitsAreNotIgnored": "%s لا يتم تجاهل زياراتك في Piwik%s (كوكيز التجاهل غير موجودة في متصفحك)." + }, + "VisitFrequency": { + "ColumnActionsByReturningVisits": "السلوكيات بواسطة الزيارات العائدة", + "ColumnAverageVisitDurationForReturningVisitors": "متوسط طول الزيارة للزوار العائدين (بالثواني)", + "ColumnAvgActionsPerReturningVisit": "متوسط السلوكيات لكل زيارة عائدة", + "ColumnBounceRateForReturningVisits": "معدل الارتداد للزوار العائدين", + "ColumnReturningVisits": "الزيارات العائدة", + "PluginDescription": "تقارير حول إحصائيات متعددة عن الزوار العائدين في مقابل الزوار الجدد.", + "ReturnActions": "%s سلوك بواسطة الزيارات المتكررة", + "ReturnAverageVisitDuration": "%s متوسط طول الزيارة للزائر المتكرر", + "ReturnAvgActions": "%s سلوك لكل زيارة متكررة", + "ReturnBounceRate": "%s زيارة متكررة مرتدة (غادر الموقع من أول صفحة)", + "ReturnVisits": "%s زيارة عائدة", + "SubmenuFrequency": "التكرار", + "WidgetGraphReturning": "رسم بياني للزيارات المتكررة", + "WidgetOverview": "نظرة عامة على التكرار" + }, + "VisitorInterest": { + "BetweenXYMinutes": "%1$s - %2$s دقيقة", + "BetweenXYSeconds": "%1$s - %2$s ثانية", + "ColumnPagesPerVisit": "الصفحات لكل زيارة", + "ColumnVisitDuration": "زمن الزيارة", + "Engagement": "التفاعل", + "NPages": "%s صفحات", + "OnePage": "صفحة واحدة", + "PluginDescription": "تقارير حول اهتمامات الزوار: عدد الصفحات المشاهدة، الوقت المستغرق على الموقع.", + "PlusXMin": "%s دقيقة", + "VisitsPerDuration": "الزيارات لزمن الزيارة", + "VisitsPerNbOfPages": "الزيارات لعدد الصفحات", + "WidgetLengths": "طول الزيارة", + "WidgetPages": "الصفحات لكل زيارة" + }, + "VisitsSummary": { + "AverageVisitDuration": "%s متوسط زمن الزيارة", + "GenerateQueries": "%s استعلام تم تنفيذهم", + "GenerateTime": "%s ثانية لإنشاء هذه الصفحة", + "MaxNbActions": "%s أقصى عدد للسلوكيات في زيارة واحدة", + "NbActionsDescription": "%s سلوك (عدد مشاهدات الصفحة، التحميلات، والروابط الصادرة)", + "NbActionsPerVisit": "%s سلوك لكل زيارة", + "NbUniqueVisitors": "%s زيارة فريدة", + "NbVisitsBounced": "%s زيارات مرتدة (غادر الموقع بعد مشاهدة أول صفحة)", + "PluginDescription": "يعد تقاريراً حول أرقام التحليلات العامة الخاصة بالزيارات، الزيارات الفريدة، عدد السلوكيات، معدل الارتداد، وغيرها.", + "VisitsSummary": "ملخص الزيارات", + "WidgetLastVisits": "الرسم البياني لآخر الزيارات", + "WidgetOverviewGraph": "نظرة عامة مع الرسم البياني", + "WidgetVisits": "نظرة عامة على الزيارات" + }, + "VisitTime": { + "ColumnLocalTime": "التوقيت المحلي", + "ColumnServerTime": "توقيت الملقم", + "LocalTime": "الزيارات حسب التوقيت المحلي", + "NHour": "%s س", + "PluginDescription": "يعد تقارير حول الأوقات المحلية وعلى الملقم: توقيت المقلم قد يكون مفيداً في جدولة مواعيد الصيانة للموقع.", + "ServerTime": "الزيارات حسب توقيت الملقم", + "SubmenuTimes": "التوقيت", + "WidgetLocalTime": "الزيارات حسب التوقيت المحلي", + "WidgetServerTime": "الزيارات حسب توقيت الملقم" + }, + "Widgetize": { + "OpenInNewWindow": "فتح في نافذة جديدة", + "PluginDescription": "الإضافات تجعل من السهل جداً تصدير أي لوحة إعدادات في Piwik إلى مدونتك، موقعك أو صفحتك الخاصة على iGoogle أو Netvibes." + } +} \ No newline at end of file diff --git a/www/analytics/lang/be.json b/www/analytics/lang/be.json new file mode 100644 index 00000000..b7f38c53 --- /dev/null +++ b/www/analytics/lang/be.json @@ -0,0 +1,1423 @@ +{ + "Actions": { + "ColumnClickedURL": "націснуты URL", + "ColumnClicks": "Націскі", + "ColumnClicksDocumentation": "Колькасць націскаў па гэтай спасылцы.", + "ColumnDownloadURL": "URL запамповак", + "ColumnEntryPageTitle": "Назва старонкі ўваходу", + "ColumnEntryPageURL": "URL старонкі ўваходу", + "ColumnExitPageTitle": "Назва старонак выхаду", + "ColumnExitPageURL": "URL старонкі выхаду", + "ColumnPageName": "Імя старонкі", + "ColumnPageURL": "URL старонкі", + "ColumnUniqueClicks": "Унікальныя націскі", + "ColumnUniqueClicksDocumentation": "Колькасць наведвалнікаў, якія націснулі на гэтую спасылку. Калі спасылка была націснутая некалькі разоў на працягу аднаго наведвання, то яна лічыцца адзін раз.", + "ColumnUniqueDownloads": "Унікальныя запампоўкі", + "DownloadsReportDocumentation": "На гэтай справаздачы, можна ўбачыць, якія файлы былі спампаваны вашымя наведвальнікамі. %s Piwik лічыць за спампаўку націск на адпаведную спасылку. Была запампоўка завершана ці не, гэта не вядома Piwik.", + "EntryPagesReportDocumentation": "Гэтая справаздача змяшчае інфармацыю аб старонках ўваходу, якія былі выкарыстаныя на працягу дадзенага перыяду. Старонка ўваходу з'яўляецца першай старонкай, якую праглядае карыстач пад час свайго наведвання. %s URL-адрасы старонак ўваходу адлюстроўваюцца ў выглядзе тэчакавай структуры.", + "ExitPagesReportDocumentation": "Гэтая справаздача змяшчае інфармацыю аб старонках выхаду, якія былі выкарыстаныя на працягу дадзенага перыяду. Старонка выхаду - гэта апошняя старонка, якую карыстач праглядае пад час свайго наведвання. %s URL-адрасы старонак выхаду адлюстроўваюцца ў выглядзе тэчакавай структуры.", + "OutlinkDocumentation": "Знешняя спасылка - гэта такая спасылка, якая вядзе наведвальніка прэч з вашага сайта (да іншага дамену).", + "OutlinksReportDocumentation": "Гэтая справаздача паказвае іерархічны спіс URL-адрасоў знешніх спасылак, якія былі націснуты вашымі наведвальнікамі.", + "PagesReportDocumentation": "Гэтая справаздача змяшчае інфармацыю аб URL-адрасах старонак, якія былі наведаны. %s Табліца арганізавана іерархічна, URL-адрасы адлюстроўваюцца ў выглядзе тэчакавай структуры.", + "PageTitlesReportDocumentation": "Гэтая справаздача змяшчае інфармацыю аб назвах старонак, якія былі наведаны. %s Назва старонкі гэта HTML %s тэг, які большасць брасзэраў паказваюць у загалоўке акна.", + "PluginDescription": "Справаздачы аб праглядах старонак, аутлінках і запампоўках. Аутлінкі і запампоўкі адсочваюцца аўтаматычна!", + "SubmenuPagesEntry": "Старонкі ўваходу", + "SubmenuPagesExit": "Старонкі выхаду", + "SubmenuPageTitles": "Назвы старонак" + }, + "API": { + "GenerateVisits": "Калі ў вас няма дадзеных на сённяшні дзень, вы можаце стварыць некаторыя дадзеныя, выкарыстоўваючы даданы модуль %s. Уключыце даданы модуль %s, затым націсніце на \"Генератар наведванняў\" у адміністрацыйнай частцы Piwik.", + "KeepTokenSecret": "Гэта ідэнтыфікацыйны токэн, ён такі жа сакрэтны, як ваш лагін і пароль, %s не дзеліцеся ім ня з кім%s!", + "LoadedAPIs": "%s API паспяхова загружаны", + "MoreInformation": "Дадатковыя звесткі аб Piwik API, калі ласка, звярніце ўвагу на %s Уводзіны ў Piwik API %s і %s Piwik API спасылкі %s.", + "PluginDescription": "Усе дадзеныя ў Piwik даступныя праз простый API. Гэта убудаваныя вэб-службы, праз якія вы можаце атрымаць дадзеныя вэб-аналітыкі ў xml, json, php, csv і г.д. фармаце.", + "QuickDocumentationTitle": "Хуткая дакументацыя па API-функцыях", + "UserAuthentication": "Аўтэнтыфікацыя карыстальніка", + "UsingTokenAuth": "Калі вы жадаеце %s запытаць дадзеныя ў рамках скрыпта, кронтаба і г.д. %s. Вам патрабуецца дадаць параметр %s да API каб выклікаць URL-адрасоў, якія патрабуюць праверкі сапраўднасці." + }, + "CoreAdminHome": { + "Administration": "Адміністрацыя", + "BrandingSettings": "Брэндынг наладкі", + "ClickHereToOptIn": "Націсніце тут, каб адмяніць адмову.", + "ClickHereToOptOut": "Націсніце тут, каб адмовіцца.", + "CustomLogoFeedbackInfo": "Калі Вы наладзілі свой лагатып, магчыма вы будзіце зацікаўлены ў схаванні %s спасылкі у верхнім меню. Для гэтага, вы можаце адключыць плагін зваротнай сувязі на старонцы %sУпраўлення плагінамі%s.", + "CustomLogoHelpText": "Вы можаце наладзіць свой лагатып, які будзе адлюстроўвацца ў карыстацкім інтэрфейсе і ў справаздачах, якія атрымліваюць па электроннай пошце.", + "EmailServerSettings": "Наладкі сервера электроннай пошты", + "LogoUpload": "Выбраць лагатып для загрузкі", + "MenuGeneralSettings": "Агульныя наладкі", + "OptOutComplete": "Адмова завершана; вашы наведванні гэтага вэб-сайта не будуць запісаны інструментам Вэб-Аналітыкі.", + "OptOutCompleteBis": "Заўважце, што калі вы выдаляеце cookies, выдаляеце спецыяльны cookie для адмовы, змяняяце кампутары або вэб-браўзэры, вам трэба выканаць працэдуру адмовы зноў.", + "OptOutExplanation": "Piwik прысвечаны забеспячэнню канфідэнцыяльнасці ў Інтэрнэце. Каб прапанаваць сваім наведвальнікам адмовіцца ад Piwik Вэб-Аналітыкі, вы можаце дадаць наступны код на адной з старонак вашага сайта, напрыклад, на старонцы Палітыка прыватнасці.", + "OptOutExplanationBis": "Гэты код будзе адлюстроўвацца як Айфрэйм, які будзе змяшчаць спасылку для вашых наведвальнікаў, каб адмовіцца ад Piwik з дапамогай усталявання спецыяльных cookie у браўзэрах. %s Націсніце тут %s, каб прагледзець змесціва, якое будзе адлюстроўвацца ў Айфрэйме.", + "OptOutForYourVisitors": "Адмова ад Piwik для вашых наведвальнікаў", + "PluginDescription": "Адміністрацыная частка Piwik.", + "UseCustomLogo": "Выкарыстаць ўласны лагатып", + "YouAreOptedIn": "У дадзены момант Вы не адмоўлены.", + "YouAreOptedOut": "У дадзены момант Вы адмоўлены.", + "YouMayOptOut": "Вы можаце выбраць, і атрымаць унікальны ідэнтыфікацыйны cookie з нумарам, прысвоеным вашаму кампутару, каб пазбегнуць агрэгацыі і аналізу дадзеных, сабраных на гэтым вэб-сайце.", + "YouMayOptOutBis": "Каб зрабіць гэты выбар, калі ласка, націсніце ніжэй, каб атрымаць спецыяльны cookie для адмовы." + }, + "CoreHome": { + "CategoryNoData": "Няма дадзеных у гэтай катэгорыі. Паспрабуйце \"Ўключыць усе паказчыкі\".", + "JavascriptDisabled": "Java-скрыпт павінен быць уключаны, каб выкарыстоўваць Piwik ў звычайны рэжыме прагляду.
    Аднак, здаецца, што Java-скрыпт адключаны або не падтрымліваецца браўзэрам.
    Каб выкарыстоўваць стандартны выгляд, уключыце JavaScript, змяніўшы наладкі браўзэра , затым %1$sпаспрабуйце зноў%2$s.
    ", + "NoPrivilegesAskPiwikAdmin": "Вы ўвайшлі як '%s', але здаецца, што у вас няма патрэбнага дазволу у Piwik. %s Спытайце свайго Piwik адміністратара (націснуць, каб паслаць паведамленне)%s, каб падаць вам дазвол для прагляду вэб-сайту.", + "PageOf": "%1$s з %2$s", + "PeriodDay": "Дзень", + "PeriodDays": "дзён", + "PeriodMonth": "Месяц", + "PeriodMonths": "месяцаў", + "PeriodWeek": "Тыдзень", + "PeriodWeeks": "тыдняў", + "PeriodYear": "Год", + "PeriodYears": "гадоў", + "PluginDescription": "Структура справаздач Вэб-Аналітыкі.", + "ShowJSCode": "Паказаць Java-код для ўстаўкі", + "ThereIsNoDataForThisReport": "Няма дадзеных для гэтай справаздачы.", + "WebAnalyticsReports": "Справаздачы Вэб-Аналітыкі" + }, + "CorePluginsAdmin": { + "Activate": "Актываваць", + "Activated": "Актывавана", + "Active": "Актыўныя", + "AuthorHomepage": "Хатняя старонка аўтара", + "Deactivate": "Дэактываваць", + "Inactive": "Неактыўныя", + "LicenseHomepage": "Хатняя старонка ліцэнзіі", + "MainDescription": "Плагіны пашыраюць функцыянальнасць Piwik. Пасля ўсталёўкі плагіна Вы можаце актываваць ці дэактываваць яго тут.", + "PluginDescription": "Інтэрфейс адміністравання плагінаў", + "PluginHomepage": "Хатняя старонка плагіна", + "PluginsManagement": "Кіраванне плагінамі", + "Status": "Статус", + "Version": "Версія" + }, + "CoreUpdater": { + "ClickHereToViewSqlQueries": "Націсніце тут для прагляду і капіравання спісу SQL запытаў, якія будуць выкананы", + "CreatingBackupOfConfigurationFile": "Содаю рэзервовую копію файла канфігурацыі ў %s", + "CriticalErrorDuringTheUpgradeProcess": "Крытычная памылка падчас абнаўлення:", + "DatabaseUpgradeRequired": "Патрабуецца абнаўленне базы дадзеных", + "DownloadingUpdateFromX": "Запампоўка абнаўленняў %s", + "DownloadX": "Запампаваць %s", + "EmptyDatabaseError": "База дадзеных %s пустая. Вам неабходна кіраваць уручную ці выдаліць файл канфігурацыі Piwik.", + "ErrorDIYHelp": "Калі вы дасведчаны карыстач і сутыкнуліся з памылкай у абнаўленні базы дадзеных:", + "ErrorDIYHelp_1": "выявіце і выправіце крыніцу праблемы (e.g., memory_limit or max_execution_time)", + "ErrorDIYHelp_2": "выканаце астатнія запыты ў абнаўленне, якое не атрымалася", + "ErrorDIYHelp_3": "ўручную абнавіце `option` табліцу ў базе дадзеных Piwik, усталёўваючы значэнне version_core на версію няўдалага абнаўлення", + "ErrorDIYHelp_4": "паўторна запусціце абнаўленне (праз браўзэр або камандны радок), каб працягнуць астатнія абнаўленні", + "ErrorDIYHelp_5": "паведаміце аб гэтай праблеме (і рашэнні), каб палепшыць Piwik", + "ErrorDuringPluginsUpdates": "Памылка падчас абнаўлення плагіна:", + "ExceptionAlreadyLatestVersion": "Ваш версія Piwik %s ў актуальным стане.", + "ExceptionArchiveEmpty": "Пусты архіў.", + "ExceptionArchiveIncompatible": "Несумяшчальны архіў: %s", + "ExceptionArchiveIncomplete": "Архіў з'яўляецца няпоўным: некаторыя файлы адсутнічаюць (напр.,%s).", + "HelpMessageContent": "Праверце %1$s Piwik FAQ %2$s, у якім тлумачыцца большасць вядомых памылак, якія могуць здарыцца падчас абнаўлення. %3$s Звернецеся да сістэмнага адміністратара - ён можа дапамагчы Вам з рашэннем праблемы на серверы ці з наладамі MySQL.", + "HelpMessageIntroductionWhenError": "Вышэй падаецца код памылкі ядра сістэмы. Ён дапаможа Вам растлумачыць чыннік памылкі, але калі ж Вам неабходна дадатковая дапамога, калі ласка:", + "HelpMessageIntroductionWhenWarning": "Абнаўленне завершана паспяхова, аднак падчас паўстала некалькі папярэджанняў. Калі ласка, прачытайце нататкі ніжэй. Для дадатковай дапамогі:", + "InstallingTheLatestVersion": "Усталёўка апошняй версіі", + "NoteForLargePiwikInstances": "Важныя заўвагі для буйных усталёвак Piwik", + "NoteItIsExpectedThatQueriesFail": "Заўвага: калі вы ўручную выконваеце гэтыя запыты, часам адбываецца, што некаторыя з іх церпяць няўдачу. У гэтым выпадку, проста ігнаруйце памылкі, і запускайце наступныя у спісе.", + "PiwikHasBeenSuccessfullyUpgraded": "Piwik паспяхова абноўлены!", + "PiwikUpdatedSuccessfully": "Piwik абноўлены паспяхова!", + "PiwikWillBeUpgradedFromVersionXToVersionY": "Piwik база будзе абноўлена з версіі %1$s да версіі %2$s.", + "PluginDescription": "Механізм абнаўлення Piwik", + "ReadyToGo": "Гатовы пачаць?", + "TheFollowingPluginsWillBeUpgradedX": "Наступныя плагіны будуць абноўлены: %s.", + "ThereIsNewVersionAvailableForUpdate": "Даступная новая версія Piwik", + "TheUpgradeProcessMayFailExecuteCommand": "Калі ў вас вялікая база дадзеных Piwik, абнаўленне можа заняць шмат часу для працы ў браўзэры. У гэтай сітуацыі, вы можаце выконваць абнаўлення з каманднага радка: %s", + "TheUpgradeProcessMayTakeAWhilePleaseBePatient": "Абнаўленне базы дадзеных можа заняць некаторы час, пачакайце трохі.", + "UnpackingTheUpdate": "Распакаванне абнаўленняў", + "UpdateAutomatically": "Абнавіць аўтаматычна", + "UpdateHasBeenCancelledExplanation": "Piwik One Click Update адменены. Калі Вы не можаце выправіць вышэйапісаныя памылкі, рэкамендуецца абнавіць Piwik уручную. %1$s Калі ласка, праверце %2$sдокументацію абнаўленні%3$s для тона, каб пачаць!", + "UpdateTitle": "Абнаўленне", + "UpgradeComplete": "Абнаўленне завершана!", + "UpgradePiwik": "Абнавіць Piwik", + "VerifyingUnpackedFiles": "Правяраю распакаваныя файлы", + "WarningMessages": "Папярэджанні:", + "WeAutomaticallyDeactivatedTheFollowingPlugins": "Наступныя плагіны аўтаматычна дэактываваны: %s", + "YouCanUpgradeAutomaticallyOrDownloadPackage": "Вы можаце абнавіцца да версіі %s аўтаматычна ці запампаваць усталявальны пакет і ўручную ўсталяваць яго:", + "YouCouldManuallyExecuteSqlQueries": "Калі вы не можаце выкарыстоўваць камандны радок для абнаўлення, і калі Piwik не ўдаецца абнавіць (з-за тайм-аўта у базе дадзеных, тайм-аўта ў браўзэр, або па любой іншай падставе), вы можаце ўручную запусціць SQL запыты для абнаўлення Piwik.", + "YouMustDownloadPackageOrFixPermissions": "Piwik не можа перазапісаць бягучыя ўсталёўкі. Вы можаце выправіць правы доступу да файлаў\/каталогаў або загрузіць і ўсталяваць пакет %s ўручную:", + "YourDatabaseIsOutOfDate": "Тэрмін дзеяння Вашай Piwik базы мінуў, Вам трэба яе абнавіць для працягу." + }, + "CustomVariables": { + "ColumnCustomVariableName": "Назва карыстацкай зменнай", + "ColumnCustomVariableValue": "Значэнне карыстацкай зменнай", + "CustomVariables": "Карыстацкія зменныя", + "CustomVariablesReportDocumentation": "Справаздача змяшчае інфармацыю аб вашых Карыстацкіх зменных. Націсніце на імя зменнай, каб убачыць размеркаванне каштоўнасцяў. %s Дадатковыя звесткі аб карыстацкіх зменных чытайце ў %sДакументацыя аб карыстацкіх зменных на piwik.org%s.", + "PluginDescription": "Карыстацкія зменныя - гэта імя, значэнне пары, якія можна ўсталяваць наведванню з выкарыстаннем функціі Java-скрыпт API setVisitCustomVariables()." + }, + "Dashboard": { + "AddPreviewedWidget": "Дадаць предпросмотренный віджэт на інформпанель", + "Dashboard": "Інфармацыйная панэль", + "DeleteWidgetConfirm": "Вы ўпэўнены, што жадаеце выдаліць гэты віджэт з інфармацыйнай панэлі?", + "LoadingWidget": "Загрузка віджэта, калі ласка пачакайце...", + "Maximise": "Максімалізаваць", + "PluginDescription": "Ваша галоўная панель Вэб-Аналітыкі. Вы можаце наладжваць панэлі інструментаў: дадаваць новыя віджэты, змяніць парадак вашых віджэтаў. Кожны карыстальнік можа атрымаць доступ да яго ўласнай галоўнай панелі.", + "SelectWidget": "Абярыце віджэт для дадання на інформпанель", + "WidgetNotFound": "Віджэт не знойдзены", + "WidgetPreview": "Прадпрагляд віджэта" + }, + "Feedback": { + "ContactThePiwikTeam": "Звяжыцеся з камандай Piwik!", + "DoYouHaveBugReportOrFeatureRequest": "Ці ёсць у вас памылка аб якой неабходна певедаміць?", + "IWantTo": "Я хачу, каб:", + "LearnWaysToParticipate": "Даведайцеся пра ўсе спосабы %s ўдзельнічыства %s", + "ManuallySendEmailTo": "Калі ласка, уручную адпраўце паведамленне да", + "PluginDescription": "Водгук для каманды Piwik. Падзяліцеся сваімі ідэямі і прапановамі з намі!", + "SendFeedback": "Адправіць водгук", + "SpecialRequest": "У вас ёсць спецыяльны запыт для каманды Piwik?", + "ThankYou": "Дзякуй за дапамогу нам зрабіць Piwik лепш!", + "VisitTheForums": "Наведайце %s форумы %s" + }, + "General": { + "AbandonedCarts": "Адмовілася ад замоў", + "AboutPiwikX": "Аб Piwik %s", + "Action": "Дзеянне", + "Actions": "Дзеянні", + "AfterEntry": "пасля ўваходны тут", + "AllowPiwikArchivingToTriggerBrowser": "Дазволіць Piwik запускаць архіваванне, калі справаздачы праглядваюцца браўзэрам.", + "AllWebsitesDashboard": "Галоўная панэль ўсіх вэб-сайтаў", + "API": "API-функцыі", + "ApplyDateRange": "Прымяніце дыяпазон дат", + "ArchivingInlineHelp": "Для сайтаў з сярэднім і высокім трафікам, рэкамендуецца адключыць функцыю архівавання Piwik для запуску з браўзэра.", + "ArchivingTriggerDescription": "Рэкамендуецца для Piwik на сайтах з высокім трафікам, патрэбна %sўсталяваць заданне для крону%s для аўтаматычнай апрацоўкі справаздач.", + "AuthenticationMethodSmtp": "Метад праверкі сапраўднасці для SMTP", + "AverageOrderValue": "Сярэдні кошт замоў", + "AveragePrice": "Сярэднш кошт", + "AverageQuantity": "Сярэдняя колькасць", + "BackToPiwik": "Вярнуцца да Piwik", + "BrokenDownReportDocumentation": "Яна разбіта на некалькі справаздач, якія адлюстроўваюцца ў спарклайны ў ніжняй частцы старонкі. Вы можаце павялічыць графікі, націснуўшы на справаздачу, якую вы хацелі б бачыць.", + "ChangePassword": "Змяніць пароль", + "ChangeTagCloudView": "Калі ласка, звярніце ўвагу на то, што вы можаце прагледзець справаздачу не толькі як воблака тэгаў. Каб зрабіць гэта, выкарыстоўвайце элементы кіравання ў ніжняй частцы справаздачы.", + "ChooseDate": "Выберыце дату", + "ChooseLanguage": "Абярыце мову", + "ChoosePeriod": "Абярыце перыяд", + "ChooseWebsite": "Абярыце вэб-сайт", + "ClickHere": "Націсніце тут для атрымання дадатковай інфармацыі.", + "Close": "Зачыніць", + "ColumnActionsPerVisit": "Дзеянні за наведванне", + "ColumnActionsPerVisitDocumentation": "Сярэдні лік дзеянняў (прагляд старонак, запампоўка або аутлінкі), якія праводзіліся падчас візітаў.", + "ColumnAverageTimeOnPage": "Сяр. час на старонцы", + "ColumnAverageTimeOnPageDocumentation": "Сярэдняе колькасць часу, праведзенага наведвальнікамі на гэтай старонцы (толькі старонкі, а не ўвесь вэб-сайт).", + "ColumnAvgTimeOnSite": "Сяр. час на вэб-сайце", + "ColumnAvgTimeOnSiteDocumentation": "Сярэдняя працягласць наведвання.", + "ColumnBounceRate": "Узровень адмоў", + "ColumnBounceRateDocumentation": "Працэнт наведванняў, якія выказалі толькі азін прагляд старонкі. Гэта азначае, што наведвальнік пакінуў сайт прама са старонкі ўваходу.", + "ColumnBounceRateForPageDocumentation": "Працэнт наведванняў, якія пачаліся і скончыліся на гэтай старонцы.", + "ColumnBounces": "Адмовы", + "ColumnBouncesDocumentation": "Колькасць наведванняў, якая пачалася і скончылася на гэтай старонцы. Гэта азначае, што наведвальнік пакінуў сайт пасля прагляду толькі гэтай старонкі.", + "ColumnConversionRate": "Узровень канверсій", + "ColumnConversionRateDocumentation": "Працэнт наведванняў, якія выклікалі мэты канверсіі.", + "ColumnEntrances": "Уваходы", + "ColumnEntrancesDocumentation": "Колькасць наведванняў, якія пачаліся на гэтай старонцы.", + "ColumnExitRate": "Узровень выхадаў", + "ColumnExitRateDocumentation": "Працэнт наведванняў, якія пакінулі вэб-сайт пасля прагляду гэтай старонкі (унікальныя прагляды старонак падзелены на выхады)", + "ColumnExits": "Выхады", + "ColumnExitsDocumentation": "Колькасць наведванняў, якія скончыліся на гэтай старонцы.", + "ColumnKeyword": "Ключавое слова", + "ColumnLabel": "Пазнака", + "ColumnMaxActions": "Максімальная колькасць дзеянняў ў адно наведванне", + "ColumnNbActions": "Дзеянні", + "ColumnNbActionsDocumentation": "Колькасць дзеянняў, якія выконваюцца вашымі наведвальнікамі. Дзеянні могуць быць прагляды старонак, запампоўка або аутлінкі.", + "ColumnNbUniqVisitors": "Унікальных наведвальнікаў", + "ColumnNbUniqVisitorsDocumentation": "Колькасць неповторяющихся наведвальнікаў, якія прыйшлі на ваш сайт. Кожны карыстальнік лічыцца толькі адзін раз, нават калі ён наведвае вэб-сайт некалькі разоў у дзень.", + "ColumnNbVisits": "Наведванні", + "ColumnNbVisitsDocumentation": "Калі наведвальнік прыходзіць на ваш сайт у першы раз або калі ён наведвае старонку больш чым за 30 хвілін пасля яго апошняга прагляду, гэта будзе ўлічвацца ў як новае наведванне.", + "ColumnPageBounceRateDocumentation": "Працэнт наведванняў, якія пачаліся на гэтай старонцы і адразу пакінулі вэб-сайт.", + "ColumnPageviews": "Прагляды старонак", + "ColumnPageviewsDocumentation": "Колькі раз гэтая старонка была праглядзена.", + "ColumnPercentageVisits": "% Наведванні", + "ColumnRevenue": "Прыбытак", + "ColumnSumVisitLength": "Агульны час, праведзены наведвальнікамі (у секундах)", + "ColumnUniqueEntrances": "Унікальныя ўваходы", + "ColumnUniqueExits": "Унікальныя выхады", + "ColumnUniquePageviews": "Унікальныя прагляды старонак", + "ColumnUniquePageviewsDocumentation": "Колькасць наведванняў, якія ўключалі гэтую старонку. Калі старонка была праглядзена некалькіх разоў на працягу аднаго наведвання, то яна лічыцца толькі адзін раз.", + "ColumnValuePerVisit": "Прыбытак за кожнае наведванне", + "ColumnVisitDuration": "Працягласць наведвання (у секундах)", + "ColumnVisitsWithConversions": "Наведванні з канверсіяй", + "ConfigFileIsNotWritable": "Файл канфігурацыі Piwik %s недаступны для запісу, некаторыя з Вашых змяненняў могуць быць не захованы. %s Калі ласка, змяніце дазвол канфігурацыйнага файла, каб зрабіць яго даступным для запісу.", + "ContinueToPiwik": "Перайсці да Piwik", + "CurrentMonth": "Бягучы месяц", + "CurrentWeek": "Бягучы тыдзень", + "CurrentYear": "Бягучы год", + "Daily": "Штодзень", + "DailySum": "штодзённая сума", + "DashboardForASpecificWebsite": "Галоўная панэль пэўнага вэб-сайта", + "Date": "Дата", + "DateRange": "Дыяпазон дат:", + "DateRangeFrom": "Ад", + "DateRangeFromTo": "Ад %s Да %s", + "DateRangeInPeriodList": "Дыяпазон дат", + "DateRangeTo": "Да", + "DayFr": "Пт", + "DayMo": "Пн", + "DaySa": "Сб", + "DaysHours": "%1$s дзён %2$s гадзін", + "DaysSinceFirstVisit": "Лік дзён з даты першага наведвання", + "DaysSinceLastEcommerceOrder": "Лік дзён з даты апошняй замовы электроннай камерцыі", + "DaysSinceLastVisit": "Лік дзён з даты апошняга наведвання", + "DaySu": "Нд", + "DayTh": "Чт", + "DayTu": "Ат", + "DayWe": "Ср", + "Default": "Пазмаўчанні", + "Delete": "Выдаліць", + "Description": "Апісанне", + "Details": "Дэталі", + "Discount": "Зніжка", + "DisplaySimpleTable": "Паказаць простую табліцу", + "DisplayTableWithGoalMetrics": "Паказаць табліцу з Мэтамі метрыкі", + "DisplayTableWithMoreMetrics": "Паказаць табліцу з больш метрыкамі", + "Done": "Завершана", + "Download": "Спампаваць", + "DownloadFullVersion": "%1$sСпампуйце%2$s поўную версію! праверце %3$s", + "Downloads": "Запампоўкі", + "EcommerceOrders": "Замовы электроннай камерціі", + "EcommerceVisitStatusDesc": "Наведаць статус электроннай камерцыі у канцы", + "EcommerceVisitStatusEg": "Напрыклад, каб выбраць усе наведванні, якія зрабілі электронна-камерцыйнаю замову, API-запыт будзе змяшчаць %s", + "Edit": "Рэдагаваць", + "EncryptedSmtpTransport": "Калі ласка, увядзіце тып шыфравання які патрабуе ваш SMTP-сервер.", + "EnglishLanguageName": "Belarusian", + "Error": "Памылка", + "ErrorRequest": "На жаль… праблема падчас запыту, калі ласка, паспрабуйце яшчэ раз.", + "EvolutionOverPeriod": "Змена за перыяд", + "ExceptionConfigurationFileNotFound": "Файл канфігурацыі {%s} не знойдзены.", + "ExceptionDatabaseVersion": "Ваша %1$s версія %2$s, але Piwik патрабуе па меншай меры версію %3$s.", + "ExceptionFileIntegrity": "Праверка цэласнасці не ўдалася: %s", + "ExceptionFilesizeMismatch": "Неадпаведнасць памеру файла: %1$s (чаканы памер: %2$s, атрыманы памер: %3$s)", + "ExceptionIncompatibleClientServerVersions": "Ваш кліент %1$s версіі %2$s несумяшчальнs з версіяй сервера %3$s.", + "ExceptionInvalidAggregateReportsFormat": "Сукупны фармат справаздач '%s' несапраўдныя. Паспрабуйце любы з наступных замест: %s.", + "ExceptionInvalidArchiveTimeToLive": "Сённяшни архіўны час павинен быць колькасцю секунд больш за нуль", + "ExceptionInvalidDateFormat": "Фармат даты павінен быць: %s, або на любое ключавое слова, падтрымліваемае функцыяй %s (глядзі %s для больш падрабязнай інфармацыі)", + "ExceptionInvalidDateRange": "Дата - '%s' - няправільны дыяпазон дат. Ён павінен мець наступны фармат: %s.", + "ExceptionInvalidPeriod": "Перыяд '%s' не падтрымліваецца. Паспрабуйце любы з наступных замест перыяда: %s", + "ExceptionInvalidRendererFormat": "Фармат рэндара '%s' не падтрымліваецца. Паспрабуйце любы з наступных замест рэндара: %s", + "ExceptionInvalidReportRendererFormat": "Фармат справаздачы '%s' не падтрымліваецца. Паспрабуйце любы з наступных замест справаздачы: %s", + "ExceptionInvalidToken": "Маркер з'яўляецца няправільным.", + "ExceptionLanguageFileNotFound": "Моўны файл '%s' не знойдзены.", + "ExceptionMethodNotFound": "Метад '%s' не існуе ці не даступны ў модулі '%s'.", + "ExceptionMissingFile": "Адсутнічае файл: %s", + "ExceptionNonceMismatch": "Не атрымалася праверыць токэн бяспекі ў гэтай форме.", + "ExceptionPrivilege": "Вы не можаце атрымаць доступ да гэтага рэсурсу, таму што ён патрабуе %s доступ.", + "ExceptionPrivilegeAccessWebsite": "Вы не можаце атрымаць доступ да гэтага рэсурсу, таму што ён патрабуе %s доступ для вэб-сайта id = %d.", + "ExceptionPrivilegeAtLeastOneWebsite": "Вы не можаце атрымаць доступ да гэтага рэсурсу, таму што ён патрабуе %s доступ, па меншай меры аднаго вэб-сайта.", + "ExceptionUnableToStartSession": "Немагчыма запусціць сесію.", + "ExceptionUndeletableFile": "Немагчыма выдаліць %s", + "ExceptionUnreadableFileDisabledMethod": "Файл канфігурацыі {%s} не можа быць прачытаны. Магчыма ваш хост адключыў %s.", + "Export": "Экспартаваць", + "ExportAsImage": "Экспартаваць у малюнак", + "ExportThisReport": "Экспартаваць гэты набор дадзеных у іншыя фарматы", + "FileIntegrityWarningExplanation": "Праверка цэласнасці файлаў не атрымалася і паведаміла аб некаторых памылках. Хутчэй за ўсё, гэта атрымалася з-за таго, што файлы Piwik не былі загружаныя належным чынам. Вам варта перазагрузіць ўсе файлы Piwik у бінарным рэжыме і абнавіць гэтую старонку, пакуль вы не ўбачыце, што паведамленні пра памылкі не паказваюцца.", + "First": "Першы", + "ForExampleShort": "напр.", + "FromReferrer": "ад", + "GeneralSettings": "Агульныя наладкі", + "GiveUsYourFeedback": "Дайце нам водгук!", + "Goal": "Мэта", + "GoTo": "Перайсці да %s", + "GraphHelp": "Больш інфармаціі аб адлюстраванні графікаў у Piwik.", + "HelloUser": "Прывітанне, %s!", + "Help": "Дапамога", + "HoursMinutes": "%1$s гадзін %2$s хв", + "Id": "Id", + "IfArchivingIsFastYouCanSetupCronRunMoreOften": "Здаецца што архіваванне выконваецца хутка для вашай ўстаноўкі, вы можаце наладзіць кронтаб запускацца часцей.", + "InvalidDateRange": "Няправільны дыяпазон дат, паспрабуйце яшчэ раз", + "InvalidResponse": "Атрыманыя дадзеныя несапраўдныя.", + "JsTrackingTag": "Код JavaScript", + "Language": "Мова", + "LastDays": "Апошніх %s дзён (уключаючы сёння)", + "LayoutDirection": "ltr", + "Loading": "Загрузка…", + "LoadingData": "Загрузка дадзеных…", + "Locale": "be_BY.UTF-8", + "Logout": "Выйсці", + "LongDay_1": "Панядзелак", + "LongDay_2": "Аўторак", + "LongDay_3": "Серада", + "LongDay_4": "Чацвер", + "LongDay_5": "Пятніца", + "LongDay_6": "Субота", + "LongDay_7": "Нядзеля", + "LongMonth_1": "Студзень", + "LongMonth_10": "Кастрычнік", + "LongMonth_11": "Лістапад", + "LongMonth_12": "Снежань", + "LongMonth_2": "Люты", + "LongMonth_3": "Сакавік", + "LongMonth_4": "Красавік", + "LongMonth_5": "Травень", + "LongMonth_6": "Чэрвень", + "LongMonth_7": "Ліпень", + "LongMonth_8": "Жнівень", + "LongMonth_9": "Верасень", + "MainMetrics": "Галоўная метрыкі", + "MediumToHighTrafficItIsRecommendedTo": "Для вэб-сайтаў з сярэднім і высокім трафікам, мы рэкамендуем апрацоўваць справаздачы на сённяшні дзень не больш чым праз кожныя паўгадзіны (%s секунд) або кожную гадзіну (%s секунд).", + "Metadata": "Метададзеныя", + "MetricsToPlot": "Метрыкі для пабудовы", + "MetricToPlot": "Метрычная пабудаваць", + "MinutesSeconds": "%1$s хв %2$sс", + "Monthly": "Штомесяц", + "MultiSitesSummary": "Усе вэб-сайты", + "Name": "Імя", + "NbActions": "Колькасць дзеянняў", + "NDays": "%s дзён", + "Never": "Ніколі", + "NewReportsWillBeProcessedByCron": "Калі архіваванне Piwik не запускаецца ў браўзэры, новыя справаздачы будуць апрацаваныя кронтабам.", + "NewUpdatePiwikX": "Новае абнаўленне: Piwik %s", + "NewVisitor": "Новы наведвальнік", + "NewVisits": "Новыя наведвання", + "Next": "Далей", + "No": "Не", + "NoDataForGraph": "Няма дадзеных для пабудовы графіка", + "NoDataForTagCloud": "Няма дадзеных для гэтага аблока тэгаў.", + "NotDefined": "%s не вызначана", + "NotValid": "%s не сапраўдны", + "NSeconds": "%s секунд", + "NumberOfVisits": "Колькасць наведванняў", + "NVisits": "%s наведванняў", + "Ok": "Ок", + "OneDay": "1 дзень", + "OneVisit": "1 наведванне", + "OnlyEnterIfRequired": "Увядзіце імя карыстальніка толькі, калі ваш SMTP-сервер патрабуе гэтага.", + "OnlyEnterIfRequiredPassword": "Увядзіце пароль толькі, калі ваш SMTP-сервер патрабуе гэтага.", + "OnlyUsedIfUserPwdIsSet": "Выкарыстоўваецца толькі калі імя карыстальніка \/ пароль ўстаноўлены. Калі вы не ўпэўненыя, які метад выкарыстаць, папытаеце вашага правайдэра.", + "OpenSourceWebAnalytics": "Вэб аналітыка з адчыненым кодам", + "OptionalSmtpPort": "Неабавязкова. Па змаўчанні - 25 - для незашыфраваных злучэнняў і TLS SMTP. 465 - для зашыфраваных і SSL SMTP.", + "OrCancel": "або %s Адмена %s", + "OriginalLanguageName": "Беларуская", + "Others": "Іншыя", + "Outlink": "Аутлінк", + "Outlinks": "Знешнія спасылкі", + "Overview": "Агляд", + "Pages": "Старонкі", + "ParameterMustIntegerBetween": "Параметр %s павінна быць цэлае лік у дыяпазоне ад %s і %s", + "Password": "Пароль", + "Period": "Перыяд", + "Piechart": "Кругавая дыяграма", + "PiwikXIsAvailablePleaseUpdateNow": "Piwik %1$s даступны для спампоўкі. %2$s Калі ласка, абновіцеся!%3$s (глядзі %4$sзмены%5$s).", + "PleaseSpecifyValue": "Калі ласка, увядзіце значэнне для '%s'.", + "PleaseUpdatePiwik": "Ваш Piwik патрабуе абнаўлення", + "Plugin": "Плагін", + "Plugins": "Плагіны", + "PoweredBy": "Распрацавана на", + "Previous": "Папярэдняе", + "PreviousDays": "Папярэдніх %s дзён (не ўключаючы сёння)", + "Price": "Кошт", + "ProductConversionRate": "Узровень канверсіі прадукту", + "ProductRevenue": "Прыбытак ад прадукту", + "PurchasedProducts": "Набытыя тавары", + "Quantity": "Колькасць", + "Recommended": "(Рэкамендуецца)", + "RecordsToPlot": "Справаздачы для пабудовы", + "RefreshPage": "Абнавіць старонку", + "Report": "Справаздача", + "Reports": "Справаздачы", + "ReportsContainingTodayWillBeProcessedAtMostEvery": "Справаздачы на сёння (або любы іншы дыяпазон дат, у тым ліку і сёння) будзе вырабляцца не больш чым", + "ReportsWillBeProcessedAtMostEveryHour": "Справаздачы будуць апрацоўвацца не больш чым кожную гадзіну.", + "RequestTimedOut": "Запыт дадзеных к %s выклікаў тайм-аўт. Калі ласка, паспрабуйце яшчэ раз.", + "Required": "%s патрабуецца", + "ReturningVisitor": "Наведвальнік, які вярнуўся", + "Save": "Захаваць", + "SaveImageOnYourComputer": "Каб захаваць малюнак на ваш кампутар, націсніце правай кнопкай мышы на малюнак і абярыце \"Захаваць малюнак як…\"", + "Search": "Пошук", + "Seconds": "%sс", + "SeeTheOfficialDocumentationForMoreInformation": "Глядзіце %sафіцыйную дакументацыю%s для атрымання падрабязнай інфармацыі.", + "SelectYesIfYouWantToSendEmailsViaServer": "Адзначце \"Так\" калі патрэбна адправіць электронную пошту праз імянны сервер, замест выкарысання лакальнай паштовай функціі", + "Settings": "Наладкі", + "Shipping": "Дастаўка", + "ShortDay_1": "Пн", + "ShortDay_2": "Ат", + "ShortDay_3": "Ср", + "ShortDay_4": "Чц", + "ShortDay_5": "Пт", + "ShortDay_6": "Сб", + "ShortDay_7": "Нд", + "ShortMonth_1": "Студ", + "ShortMonth_10": "Кас", + "ShortMonth_11": "Ліс", + "ShortMonth_12": "Снеж", + "ShortMonth_2": "Лют", + "ShortMonth_3": "Сак", + "ShortMonth_4": "Крас", + "ShortMonth_5": "Трав", + "ShortMonth_6": "Чэр", + "ShortMonth_7": "Ліп", + "ShortMonth_8": "Жнів", + "ShortMonth_9": "Вер", + "SmallTrafficYouCanLeaveDefault": "Для сайтаў з невялікім трафікам, вы можаце пакінуць значэнне секунд па змаўчанні %s, таксама апрацоўваць ўсе справаздачы ў рэальным часе.", + "SmtpEncryption": "SMTP шыфраванне", + "SmtpPassword": "SMTP пароль", + "SmtpPort": "SMTP порт", + "SmtpServerAddress": "SMTP адрас сервера", + "SmtpUsername": "SMTP імя карыстальніка", + "Subtotal": "Разам", + "Table": "Табліца", + "TagCloud": "Воблака тэгаў", + "Tax": "Падатак", + "Today": "Сёння", + "Total": "Агульна", + "TotalRevenue": "Агульны прыбытак", + "TranslatorEmail": "by.marcis@gmail.com, albanardua@gmail.com, iflexion.1@gmail.com", + "TranslatorName": "Marcis G,
    Alban 'r4bble' Ardua<\/a>, Iflexion design", + "UniquePurchases": "Унікальныя пакупкі", + "Unknown": "Невядома", + "Upload": "Загрузіць", + "UsePlusMinusIconsDocumentation": "Выкарыстоўвайце значкі плюса і мінуса, якія знаходзяцца злева, для навігацыі.", + "Username": "Імя карыстальніка", + "UseSMTPServerForEmail": "Выкарыстоўвайце SMTP сервер для электроннай пошты", + "Value": "Значэнне", + "VBarGraph": "Вертыкальны графік", + "View": "Наведвання", + "Visit": "Наведванне", + "VisitConvertedGoal": "Наведванне пераўтвораннае хаця б у адну Мэту", + "VisitConvertedGoalId": "Наведаць пераўтвораны вызначаным ідэнтыфікатарам мэты", + "VisitConvertedNGoals": "Наведванне, якое канвертавалася %s мэту", + "VisitDuration": "Сяр. працягласць наведвання (у секундах)", + "Visitor": "Наведвальнік", + "VisitorID": "ID наведвальніка", + "VisitorIP": "IP наведвальніка", + "Visitors": "Наведвальнікі", + "VisitsWith": "Наведванняў з %s", + "VisitType": "Тып наведвальніка", + "VisitTypeExample": "Напрыклад, каб выбраць усіх наведвальнікаў, якія вяртаюцца на вэб-сайт, у тым ліку і тых, хто купіў нешта ў сваіх папярэдніх візітах, API-запыт будзе змяшчаць %s", + "Warning": "Увага", + "WarningFileIntegrityNoManifest": "Праверка цэласнасці файлаў не можа быць выканана з-за адсутнасці файла manifest.inc.php.", + "WarningFileIntegrityNoMd5file": "Праверка цэласнасці файлаў не можа быць завершана з-за адсутнасці md5_file () функцыі.", + "WarningPasswordStored": "%sWarning:%s Гэты пароль будзе захаваны ў файле канфігурацыі, ен будзе бачным ўсім, хто мае да яго доступ.", + "Website": "Сайт", + "Weekly": "Штотыдзень", + "Widgets": "Віджэты", + "YearsDays": "%1$s гадоў %2$s дзён", + "Yes": "Так", + "Yesterday": "Учора", + "YouAreViewingDemoShortMessage": "Вы праглядаеце дэма версію Piwik", + "YouMustBeLoggedIn": "Каб атрымаць доступ да гэтай функцыянальнасці, Вы павінны аўтарызавацца.", + "YourChangesHaveBeenSaved": "Вашы змены былі захаваныя." + }, + "Goals": { + "AbandonedCart": "Адмененых Кошыкаў", + "AddGoal": "Дадаць Мэту", + "AddNewGoal": "Дадаць новую Мэту", + "AddNewGoalOrEditExistingGoal": "%sДадаць новую Мэту%s або %sРэдагаваць%s існуючыя Мэты", + "AllowGoalConvertedMoreThanOncePerVisit": "Дазволіць канвертаванне Мэты больш чым адзін раз за наведванне", + "AllowMultipleConversionsPerVisit": "Дазволіць некалькі канверсій за адно наведванне", + "BestCountries": "Краіны з самай лепшай канверсіяй:", + "BestKeywords": "Ключавые словы з самай лепшай канверсіяй:", + "BestReferrers": "Спасыльнікі з самай лепшай канверсіяй:", + "CaseSensitive": "З улікам рэгістра", + "ClickOutlink": "Націсніце на спасылку на вонкавы сайт", + "ColumnAverageOrderRevenueDocumentation": "Сярэдні кошт замовы (СКЗ) - гэта агульная сума прыбытку ад усіх камерцыйных замоў падзяленная на колькасць замоў.", + "ColumnAveragePriceDocumentation": "Сярэдні прыбытак для гэтага %s.", + "ColumnAverageQuantityDocumentation": "Сярэдняя колькасць гэтага %s прададзена праз замовы электроннай камерцыі.", + "ColumnConversionRateDocumentation": "Працэнт наведванняў, за якія адбыліся мэты %s.", + "ColumnConversionRateProductDocumentation": "%s Узровень канверсіі - колькасць замоў, якія змяшчаюць гэты прадукт, падзяленная на колькасць наведванняў на старонку прадукта.", + "ColumnConversions": "Канверсіі", + "ColumnConversionsDocumentation": "Колькасць канверсий для %s.", + "ColumnOrdersDocumentation": "Агульная колькасць электронна-камерцыйных замоў у якіх змяшчаецца гэтая %s па меншай меры адзін раз.", + "ColumnPurchasedProductsDocumentation": "Колькасць набытых прадуктаў з'яўляецца сумай прадуктаў прададзеных ва ўсіх парадках электроннай камерцыі.", + "ColumnQuantityDocumentation": "Агульная колькасць рэалізаванай прадукцыі для кожнага %s.", + "ColumnRevenueDocumentation": "Агульны прыбытак ад %s.", + "ColumnRevenuePerVisitDocumentation": "Агульны прыбытак ад %s падзяленны на колькасць наведванняў.", + "ColumnVisits": "Агульная колькасць наведванняў, незалежна ад таго, выканалася мэта ці не.", + "ColumnVisitsProductDocumentation": "Колькасць наведванняў на старонках Прадукт\/Катэгорыя. Гэта выкарыстоўваецца, каб выканаць %s узровень канверсіі.", + "Contains": "змяшчае %s", + "ConversionRate": "%s ўзровень канверсіі", + "Conversions": "%s канверсіі", + "ConversionsOverview": "Агляд Канверсій", + "ConversionsOverviewBy": "Агляд канверсій па тыпу наведвання", + "CreateNewGOal": "Стварыць новую Мэту", + "DefaultGoalConvertedOncePerVisit": "(па змаўчанні) Мэта можа быць канвертавана толькі адзін раз за наведванне", + "DefaultRevenue": "Прыбытак Мэты па змаўчанню", + "DefaultRevenueHelp": "Напрыклад, Кантактная фарма запоўненая наведвальнікам можа каштаваць у сярэднім $10. Piwik дапаможа вам зразумець, наколькі добра выконваюцца вашы сегменты наведвальнікаў.", + "DeleteGoalConfirm": "Вы сапраўды хочаце выдаліць Мэту %s?", + "DocumentationRevenueGeneratedByProductSales": "Рэалізаваныя прадукты. Выключаючы падатак, дастаўку і скідкі", + "Download": "Спампаваць файл", + "Ecommerce": "Электронная камерцыя", + "EcommerceAndGoalsMenu": "Электронная камерцыя і Мэты", + "EcommerceLog": "Электронна-камерцыйны запіс", + "EcommerceOrder": "Электронна-камерцыйная замова", + "EcommerceOverview": "Агляд Электроннай камерцыі", + "EcommerceReports": "Справаздачы электроннай камерцыі", + "ExceptionInvalidMatchingString": "Калі вы вылучыце 'дакладнае супадзенне', адпавядаючы радок павінен быць URL-адрасам, пачынаючы %s. Напрыклад, '%s'.", + "ExternalWebsiteUrl": "знешні URL-адрас сайта", + "Filename": "імя файла", + "GoalConversion": "Канверсія Мэты", + "GoalConversions": "Канверсіі Мэты", + "GoalConversionsBy": "Мэта %s канверсіі па тыпу наведвання", + "GoalIsTriggered": "Мэта адбылася", + "GoalIsTriggeredWhen": "Мэта адбылася калі", + "GoalName": "Імя Мэты", + "Goals": "Мэты", + "GoalsManagement": "Кіраванне мэтамі", + "GoalsOverview": "Аглят Мэт", + "GoalsOverviewDocumentation": "Гэта агляд вашых дасягнутых мэт канверсій. Першапачаткова, графік паказвае суму ўсіх канверсій. %s Ніжэй графік, на якім можна ўбачыць справаздачы канверсій для кожнай з вашых мэтаў. Спарклайны можна павялічыць, націснуўшы на іх.", + "GoalX": "Мэта %s", + "HelpOneConversionPerVisit": "Калі старонка, якая адпавядае гэтай Мэце абнаўляецца ці наведваецца больш чым адзін раз, Мэта будзе адсочвацца толькі ў ходзе першага наведвання.", + "IsExactly": "дакладна %s", + "LearnMoreAboutGoalTrackingDocumentation": "Даведайцеся больш пра %s Адсочванне Мэт у Piwik%s у карыстацкай дакументацыі.", + "Manually": "уручную", + "ManuallyTriggeredUsingJavascriptFunction": "Мэта адбылася ўручную праз Java-скрыпт API trackGoal()", + "MatchesExpression": "супадае з выразам %s", + "NewVisitorsConversionRateIs": "Канверсія новых наведвальнікаў - %s", + "Optional": "(неабавязкова)", + "OverallConversionRate": "%s агульны ўзровень канверсіі (наведванні з завершанай мэтай)", + "OverallRevenue": "%s агульны прыбытак", + "PageTitle": "Назва старонкі", + "Pattern": "Шаблон", + "PluginDescription": "Стварэнне мэтаў і прагляд справаздач аб дасягнутых канверсіях мэт: змены ў часе, прыбытак за наведванне, канверсія за спасыльніка, за ключаве слова, г.д.", + "ProductCategory": "Катэгорыя прадукта", + "ProductName": "Імя прадукта", + "Products": "Прадукты", + "ProductSKU": "Прадукт SKU", + "ReturningVisitorsConversionRateIs": "Канверсія вяртаючыхя наведвальнікаў - %s", + "SingleGoalOverviewDocumentation": "Гэта агляд канверсій для адной мэты. %s Спарклайны можна павялічыць, націснуўшы на іх.", + "UpdateGoal": "Абнаўленне Мэты", + "URL": "URL", + "ViewAndEditGoals": "Прагляд і Рэдагаванне Мэтаў", + "ViewGoalsBy": "Паглядзець мэты, як %s", + "VisitPageTitle": "Наведайце назву старонкі", + "VisitUrl": "Наведайце URL-адрас (старонкі, або групу старонак)", + "WhenVisitors": "калі наведвальнікі", + "WhereThe": "дзе", + "WhereVisitedPageManuallyCallsJavascriptTrackerLearnMore": "дзе старонка павінна змяшчаць выклік JavaScript piwikTracker.trackGoal() метада (%sдаведацца больш%s)", + "YouCanEnableEcommerceReports": "Вы можаце ўключыць %s для гэтага вэб-сайта ў %s старонкі." + }, + "Installation": { + "CommunityNewsletter": "паведамляць мне пра абнаўленні супольнасці (новыя ўбудовы, новыя функцыі і г.д.) па email", + "ConfigurationHelp": "Ваш Piwik канфігурацыйны файл, няправільна зканфігураваны. Вы можаце альбо выдаліць config\/config.ini.php і працягнуць усталёўку або правільна наладзіць злучэнне з базай дадзеных.", + "ConfirmDeleteExistingTables": "Вы сапраўды жадаеце выдаліць табліцы: %s з базы дадзеных? УВАГА: ВЫДАЛЕНЫЯ ДАДЗЕНЫЯ НЕМАГЧЫМА АДНАВІЦЬ!", + "Congratulations": "Віншуем", + "CongratulationsHelp": "

    Віншуем! Усталёўка Piwik скончана.<\/p>

    Пераканаецеся, што JavaScript-код змесцаваны на ўсіх старонках, і, чакайце першых наведвальнікаў!", + "DatabaseCheck": "Праверка базы дадзеных", + "DatabaseClientVersion": "Кліенцкая версія баз дадзеных", + "DatabaseCreation": "Стварэнне базы дадзеных", + "DatabaseErrorConnect": "Адбылася памылка пры спробе падключэння да сервера базы дадзеных", + "DatabaseServerVersion": "Серверная версія баз дадзеных", + "DatabaseSetup": "Наладкі базы дадзеных", + "DatabaseSetupAdapter": "адаптар", + "DatabaseSetupDatabaseName": "імя базы дадзеных", + "DatabaseSetupLogin": "лагін", + "DatabaseSetupServer": "сервер базы дадзеных", + "DatabaseSetupTablePrefix": "прэфікс табліцы", + "Email": "email", + "ErrorInvalidState": "Памылка: Падобна, Вы спрабуеце прапусціць крок усталявальнай праграмы, ці cookie забаронены ў брауезере, ці файл канфігурацыі Piwik ужо існуе. %1$sУбедітесь, што cookies дазволены%2$s і вярніцеся назад %3$s да першага кроку ўсталёўкі %4$s.", + "Extension": "пашырэнне", + "GoBackAndDefinePrefix": "Вярніцеся назад і ўвядзіце прэфікс для табліц Piwik", + "Installation": "Усталёўка", + "InstallationStatus": "Статут усталёўкі", + "LargePiwikInstances": "Дапамога для буйных усталёвак Piwik", + "Legend": "Легенда", + "NoConfigFound": "Канфігурацыйны файл Piwik не можа быць знойдзены, аднак вы спрабуеце зайсці ў сістэму.
      » Вы можаце
    усталяваць Piwik цяпер<\/a><\/b>
    . Калі Piwik ужо быў усталяваны і вы маеце дадзеныя ў БД, вы можаце выкарыстоўваць іх.<\/small>", + "Optional": "Апцыянальна", + "Password": "пароль", + "PasswordDoNotMatch": "пароль не супадае", + "PasswordRepeat": "пароль (яшчэ раз)", + "PercentDone": "%s %% Завершана", + "PleaseFixTheFollowingErrors": "Калі ласка, выпраўце наступныя памылкі", + "PluginDescription": "Працэс усталёўкі Piwik. Усталёўка звычайна адбывацца толькі адзін раз. Калі канфігурацыйны файл config\/config.inc.php выдалены, ўстаноўка пачнецца зноў.", + "Requirements": "Патрабаванні Piwik", + "SecurityNewsletter": "паведамляць мяне пра значныя абнаўленні Piwik і памылках бяспекі па email", + "SetupWebsite": "Дадаць сайт", + "SetupWebsiteError": "Паўстала памылка пры даданні сайта", + "SetupWebSiteName": "імя вэб-сайта", + "SetupWebsiteSetupSuccess": "Вэб-сайт %s створаны з поспехам!", + "SetupWebSiteURL": "URL-адрас вэб-сайта", + "SuperUser": "Супер Карыстач", + "SuperUserLogin": "лагін супер карыстача", + "SuperUserSetupSuccess": "Супер Карыстач створаны з поспехам!", + "SystemCheck": "Праверка сістэмы", + "SystemCheckAutoUpdateHelp": "Заўважце: абнаўленне Piwik у рэжыме \"аднаго націска\" п атрабуе дазвол запісу да тэчцы Piwik і яё змесціва.", + "SystemCheckCreateFunctionHelp": "Piwik выкарыстоўвае ананімныя функцыі зваротнага выкліку.", + "SystemCheckDatabaseHelp": "Piwik патрабуе пашырэнне mysqli альбо абодва пашырэння PDO і pdo_mysql.", + "SystemCheckDebugBacktraceHelp": "Прагляд::сістэма не ў стане стварыць прадстаўленне для выкліку модуля.", + "SystemCheckError": "Паўстала памылка - павінна быць выпраўлена перад працягам", + "SystemCheckEvalHelp": "Патрабуецца для HTML QuickForm і сістэмы шаблонаў Smarty.", + "SystemCheckExtensions": "Неабходныя модулі", + "SystemCheckFileIntegrity": "Цэласнасць файлаў", + "SystemCheckFunctions": "Патрабаваныя функцыі", + "SystemCheckGDHelp": "Адлюстраванне тонкіх (маленькіх) графікаў не будзе працаваць.", + "SystemCheckGlobHelp": "Гэтая убудаваная функцыя адключана на вашым хосце. Piwik паспрабуе эмуляваць гэтую функцыю, але могуць паўстаць дадатковыя абмежаванні бяспекі. Функцыянальныя магчымасці могуць быць закрануты.", + "SystemCheckGzcompressHelp": "Вам неабходна ўключыць пашырэнне zlib і функцыю gzcompress.", + "SystemCheckGzuncompressHelp": "Вам неабходна ўключыць пашырэнне zlib і функцыю gzuncompress.", + "SystemCheckIconvHelp": "Вам неабходна сканфігураваць PHP на падтрымку \"iconv\", і перакампіляваць з параметрам --with-iconv.", + "SystemCheckMailHelp": "Функцыі водгукаў о Piwik і аднаўленне пароляў не будуць працаваць без mail().", + "SystemCheckMbstring": "mbstring", + "SystemCheckMbstringExtensionHelp": "Пашырэнне mbstring патрабуецца для мултібайтавых знакаў у API адказах, якія выкарыстоўваюць значэнні, падзеленныя коскамі (CSV) або значэнні, падзеленныя табуляцыяй", + "SystemCheckMbstringFuncOverloadHelp": "Вы павінны ўсталяваць значэнне mbstring.func_overload на \"0\".", + "SystemCheckMemoryLimit": "Абмежаванне памяці", + "SystemCheckMemoryLimitHelp": "На вэб-сайтах з высокім трафікам, працэс архівавання можа запатрабаваць больш памяці, чым дазволена. Пры неабходнасці, змяніце memory_limit дырэктыву ў файле php.ini.", + "SystemCheckOpenURL": "адкрыць URL-адрас", + "SystemCheckOpenURLHelp": "Падпіскі на рассылкі, паведамленні аб абнаўленнях і абнаўленні \"аднаго націска\" патрабуюць пашырэнне \"curl\", allow_url_fopen=On або ўключаны fsockopen().", + "SystemCheckOtherExtensions": "Іншыя пашырэнні", + "SystemCheckOtherFunctions": "Іншыя функцыі", + "SystemCheckPackHelp": "Функцыя pack() патрабуецца для адсочвання наведвальнікаў у Piwik.", + "SystemCheckParseIniFileHelp": "Гэтая убудаваная функцыя адключана на вашым хосце. Piwik паспрабуе эмуляваць гэтую функцыю, але могуць паўстаць дадатковыя абмежаванні бяспекі. Трэкер прадукцыйнасці таксама будзе закрануты.", + "SystemCheckPdoAndMysqliHelp": "На Linux серверы вы можаце скампіляваць php з наступнымі параметрамі:%1$s у вашым php.ini, дадаць наступныя радкі: %2$s", + "SystemCheckPhp": "Версія PHP", + "SystemCheckPhpPdoAndMysqli": "Больш падрабязная інфармацыя па: %1$sPHP PDO%2$s and %3$sMYSQLI%4$s.", + "SystemCheckSecureProtocol": "Бяспечны пратакол", + "SystemCheckSecureProtocolHelp": "Падобна, што вы выкарыстоўваеце https на вэб-серверы. Гэтыя радкі будуць дададзеныя ў config\/config.ini.php:", + "SystemCheckSplHelp": "Вам неабходна сканфігураваць і перасабраць PHP з уключанай стандартнай бібліятэкай (значэнне па змаўчанні).", + "SystemCheckTimeLimitHelp": "На вэб-сайтах з высокім трафікам, працэс архівавання можа спатрабаваць больш часу, чым дазволена. Калі неабходна, змяніце дырэктыву max_execution_time ў файле php.ini.", + "SystemCheckTracker": "Статус адсочвання", + "SystemCheckTrackerHelp": "GET запыт да piwik.php не адбыўся. Паспрабуйце дадаць гэты URL-адрас у белы спіс з mod_security і HTTP ідэнтыфікацыі.", + "SystemCheckWarnDomHelp": "Вы павінны ўключыць пашырэнне \"dom\" (Напр., усталяваць пакеты \"php-dom\" і\/або \"php-xml\")", + "SystemCheckWarning": "Piwik будзе працаваць звычайна, але некаторыя функцыі не будуць даступныя", + "SystemCheckWarnJsonHelp": "Вы павінны ўключыць пашырэнне \"json\" (Напр., усталяваць пакет \"php-json\") для лепшай прадукцыйнасці.", + "SystemCheckWarnLibXmlHelp": "Вы павінны ўключыць пашырэнне \"libxml\" (Напр., усталяваць пакет \"php-libxml\"), гэта патрабуецца для іншых пашырэнняў ядра PHP.", + "SystemCheckWarnSimpleXMLHelp": "Вы павінны ўключыць пашырэнне \"SimpleXML\" (Напр., усталяваць пакеты \"php-simplexml\" і\/або \"php-xml\")", + "SystemCheckWinPdoAndMysqliHelp": "На Windows серверы, вы можаце дадаць наступныя радкі ў ваш php.ini: %s", + "SystemCheckWriteDirs": "Тэчкі з правамі запісу", + "SystemCheckWriteDirsHelp": "Для выпраўлення гэтай памылкі ў АС Linux, паспрабуйце ўвесці наступныя каманды", + "SystemCheckZlibHelp": "Вам неабходна сканфігураваць PHP на падтрымку \"zlib\", ці перакампіляваць з параметрам --with-zlib.", + "Tables": "Стварэнне табліц", + "TablesCreatedSuccess": "Табліцы паспяхова створаны!", + "TablesDelete": "Выдаліць знойдзеныя табліцы", + "TablesDeletedSuccess": "Існыя табліцы Piwik паспяхова выдалены", + "TablesFound": "Наступныя табліцы знойдзены ў БД", + "TablesReuse": "Выкарыстоўваць існыя табліцы", + "TablesWarningHelp": "Абярыце адно з дзвюх: выкарыстоўваць існых табліц БД, ці чыстую ўсталёўку, якая выдаліць усе існыя дадзеныя ў базе дадзеных.", + "TablesWithSameNamesFound": "Некаторыя %1$s табліцы ў Вашай базе дадзеных %2$s маюць супадальныя назвы з табліцамі, якія Piwik спрабуе стварыць.", + "Timezone": "гадзінны пояc вэб-сайта", + "Welcome": "Сардэчна запрашаем!", + "WelcomeHelp": "

    Piwik - гэта адкрытае праграмнае забеспячэнне, якое дазваляе вам весткі статыстычны ўлік наведванняў вашага сайта і праводзіць аналітычныя разлікі для атрымання інфармацыі неабходным вам інфармацыі пра наведвальнікаў. Гэты працэс складаецца з %s простых крокаў і займае каля 5 мінуць чакай.<\/p>" + }, + "LanguagesManager": { + "AboutPiwikTranslations": "Аб перакладах Piwik", + "PluginDescription": "Гэты плагін будзе адлюстроўваць спіс даступных моў інтэрфейсу Piwik. Абраная мова, будзе захавана ў наладках для кожнага карыстальніка." + }, + "Live": { + "GoalType": "Тып", + "KeywordRankedOnSearchResultForThisVisitor": "Ключавое слова %1$s заняла %2$s на %3$s старонцы вынікаў пошуку для гэтага наведвальніка", + "LastHours": "Апошніх %s гадзін", + "LastMinutes": "Апошніх %s хвілін", + "LinkVisitorLog": "Паглядзець падрабязны запіс наведвальнікаў", + "PluginDescription": "Сачыце за наведвальнікамі, у рэжыме рэальнага часу!", + "Referrer_URL": "URL спасыльніка", + "VisitorLog": "Запіс наведвальнікаў", + "VisitorLogDocumentation": "Гэтая табліца паказвае апошнія наведванні ў межах выбранага дыяпазону дат. %s Калі дыяпазон дат ўключае ў сябе сёння, то вы можаце ўбачыць вашых наведвальнікаў у рэжыме рэальнага часу! %s Дадзеныя, якія адлюстроўваюцца тут заўсёды \"жывыя\", незалежна ад таго, і як часта вы карыстаецеся cron архіваваннем.", + "VisitorsInRealTime": "Наведвальнікаў у рэжыме рэальнага часу" + }, + "Login": { + "ContactAdmin": "Магчымы чыннік: функцыя mail() адключана.
    Калі ласка, звяжыцеся з адміністратарам.", + "ExceptionPasswordMD5HashExpected": "Параметр пароля апынуўся MD5-хэшаваным паролям.", + "InvalidNonceOrHeadersOrReferrer": "Бяспека формы не атрымалася. Калі ласка, перазагрузіце форму і пераканаецеся, што вашы cookies ўключаны. Калі вы выкарыстоўваеце проксі-сервер, то неабходна %s наладзіць Piwik да прыняцця проксі загалоўкаў%s, якія перанакіроўваюць да загалоўкаў вузлоў. Акрамя таго, праверце, што ваш Referer загаловак перадаецца правільна.", + "InvalidOrExpiredToken": "Токэн з'яўляецца несапраўдным або мінуў.", + "InvalidUsernameEmail": "Няслушнае імя карыстача і\/ці e-mail", + "LogIn": "Увайсці", + "LoginOrEmail": "Лагін ці E-mail", + "LoginPasswordNotCorrect": "Лагін ці пароль няслушныя", + "LostYourPassword": "Страцілі пароль?", + "PasswordRepeat": "Пароль (паўтор)", + "PasswordsDoNotMatch": "Паролі не супадаюць.", + "RememberMe": "Запомніць мяне" + }, + "MultiSites": { + "Evolution": "Змены", + "PluginDescription": "Адлюстравае мульті-сайтавую выканаўчую рэзюмэ\/статыстыку. У цяперашні час падтрымліваецца як плагін ядра Piwik." + }, + "PrivacyManager": { + "AnonymizeIpDescription": "Абярыце \"Так\", калі вы хочаце, каб Piwik не адсочваў поўны IP-адрас.", + "AnonymizeIpInlineHelp": "Схаваць апошні байт(ы) IP-адрасоў наведвальнікаў, каб выканаць вашы мясцовыя законы кіруючых органаў.", + "AnonymizeIpMaskLengtDescription": "Выбраць, колькі байтаў IP-адрасоў наведвальнікаў павінны быць схаваныя.", + "AnonymizeIpMaskLength": "%s байт(ы) - напр., %s", + "ClickHereSettings": "Націсніце тут, каб атрымаць доступ да %s наладак.", + "DeleteLogDescription2": "Калі вы ўключыце аўтаматычнае выдаленне запісаў, вы павінны пераканацца, што ўсе папярэднія штодзённыя справаздачы былі апрацаваны, каб пазбегнуць гублення дадзеных.", + "DeleteLogInfo": "Запісы з наступнай табліцы будуць выдаленыя: %s", + "DeleteLogsOlderThan": "Выдаліць запісы старэй чым", + "DeleteMaxRows": "Максімальная колькасць радкоў для выдалення за адзін праход:", + "LastDelete": "Апошняе выдаленне было", + "LeastDaysInput": "Калі ласка, пазначце колькасць дзён больш, чым %s.", + "MenuPrivacySettings": "Прыватнасць", + "NextDelete": "Наступнае запланавана выдаленне", + "PluginDescription": "Наладзіць Piwik, каб зрабіць яго наладкі прыватнасці сумяшчальнымі з існуючым заканадаўствам.", + "TeaserHeadline": "Наладкі прыватнасці", + "UseAnonymizeIp": "Ананімазаваць IP-адрасы наведвальнікаў", + "UseDeleteLog": "Рэгулярна выдаляць старыя запісы наведвальнікаў з базы дадзеных" + }, + "Provider": { + "ColumnProvider": "Правайдар", + "PluginDescription": "Справаздача правайдараў наведвальнікаў.", + "ProviderReportDocumentation": "Гэтая справаздача паказвае, якія інтэрнэт-правайдэры наведвальнікаў выкарыстоўваюцца для доступу да вэб-сайту. Вы можаце націснуць на імя правайдэра для больш падрабязнай інфармацыі. %s Калі Piwik не можа вызначыць, правайдэра наведвальніка, ён паказваецца ў якасці IP-адраса.", + "SubmenuLocationsProvider": "Лакацыі і правайдары", + "WidgetProviders": "Правайдары" + }, + "Referrers": { + "Campaigns": "Кампаніі", + "CampaignsDocumentation": "Наведвальнікі, якія прыйшлі на ваш сайт у выніку кампаніі. %s Глядзі %s справаздачу для больш падрабязнай інфармацыі.", + "CampaignsReportDocumentation": "Гэтая справаздача паказвае, якія кампаніі спаслалі наведвальнікаў на ваш сайт. %s Для атрымання дадатковай інфармацыі аб кампаніі, чытайце %sДакументацыю па кампаніях на piwik.org%s", + "ColumnCampaign": "Кампанія", + "ColumnSearchEngine": "Пошукавая сістэма", + "ColumnWebsite": "Вэб-сайт", + "ColumnWebsitePage": "Старонка вэб-сайта", + "DetailsByReferrerType": "Дэталі па тыпе спасыльніка", + "DirectEntry": "Прамы ўваход", + "DirectEntryDocumentation": "Наведвальнік адчыніў URL у сваім браўзэры і пачаў праглядаць ваш вэб-сайт - ён зайшоў на сайт напрамую.", + "Distinct": "Розныя спасыльнікі па тыпе", + "DistinctCampaigns": "Розныя кампаніі", + "DistinctKeywords": "Розныя ключавыя словы", + "DistinctSearchEngines": "Розныя пошукавыя сістэмы", + "DistinctWebsites": "Розныя вэб-сайты", + "EvolutionDocumentation": "Гэта агляд спасыльнікаў, якія прывялі наведвальнікаў на ваш вэб-сайт.", + "EvolutionDocumentationMoreInfo": "Для атрымання дадатковай інфармацыі аб розных тыпах спасыльнікаў, звярніцеся да дакументацыі %s табліцы.", + "Keywords": "Ключавыя словы", + "KeywordsReportDocumentation": "Гэтая справаздача паказвае, якія ключавыя словы шукалі карыстальнікі, каб трапіць на ваш вэб-сайт. %s Пры націску на радок табліцы, можна ўбачыць размеркаванне пошукавых сістэм, якія былі запытаны па ключавым слове.", + "PluginDescription": "Справаздачы дадзеных аб спасыльніках: пошукавыя сістэмы, ключавыя словы, вэб-сайты, назіранне за кампаніямі, прамыя ўваходы.", + "ReferrerName": "Імя спасыльніка", + "Referrers": "Спасыльнікі", + "SearchEngines": "Пошукавыя сістэмы", + "SearchEnginesDocumentation": "Наведвальнік спасланы на ваш вэб-сайт пошукавай сістэмай. %s Глядзі %s справаздачу для больш падрабязнай інфармацыі.", + "SearchEnginesReportDocumentation": "Гэтая справаздача паказвае, якія пошукавыя сістэмы спасылаюць карыстальнікаў на ваш вэб-сайт. %s Пры націску на радок табліцы, можна ўбачыць, якія карыстальнікі шукалі з дапамогай якой пошукавай сістэмы.", + "SubmenuSearchEngines": "Пошукавыя сістэмы і ключавые словы", + "SubmenuWebsites": "Вэб-сайты", + "Type": "Тып спасыльнікі", + "TypeCampaigns": "%s уваходаў з кампаній", + "TypeDirectEntries": "%s прамых уваходаў", + "TypeReportDocumentation": "Гэтая табліца змяшчае інфармацыю аб размеркаванні тыпаў спасыльнікаў.", + "TypeSearchEngines": "%s уваходаў з пошукавых сістэм", + "TypeWebsites": "%s уваходаў з вэб-сайтаў", + "UsingNDistinctUrls": "(з выкарыстаннем %s розных URL-адрасоў)", + "Websites": "Вэб-сайты", + "WebsitesDocumentation": "Наведвальнік перайшоў па спасылцы на іншы сайце, які прывёў да вашага сайту. %s Глядзі %s справаздачу для для больш падрабязнай інфармацыі.", + "WebsitesReportDocumentation": "У гэтай табліцы вы можаце ўбачыць, якія сайты спасылалі наведвальнікаў на ваш сайт. %s Пры націску на радок табліцы, можна ўбачыць URL старонкак, якія ўтрымліваюць спасылкі на на ваш вэб-сайт.", + "WidgetExternalWebsites": "Спіс вонкавых вэбсайтаў", + "WidgetKeywords": "Спіс ключавых слоў" + }, + "ScheduledReports": { + "AlsoSendReportToTheseEmails": "Акрамя таго, адправіць справаздачу на гэтыя электронныя адрасы (па аднаму ў радку):", + "AreYouSureDeleteReport": "Вы сапраўды жадаеце выдаліць гэты даклад і яго расклад?", + "CancelAndReturnToReports": "Адмяніць і %sвярнуцца да спісу справаздач %s", + "CreateAndScheduleReport": "Стварыць справаздачу і зрабіць расклад", + "CreateReport": "Стварыць справаздачу", + "DescriptionOnFirstPage": "Апісанне справаздачы будзе адлюстроўвацца на першай старонцы справаздачы.", + "EmailHello": "Прывітанне,", + "EmailReports": "Справаздачы па Email", + "EmailSchedule": "Расклад электроннай пошты", + "FrontPage": "Галоўная старонка", + "ManageEmailReports": "Упраўленне справаздачамі па электроннай пошце", + "MonthlyScheduleHelp": "Штомесячны расклад: справаздача будзе адпраўлена першым днём кожнага месяца.", + "MustBeLoggedIn": "Вы павінны ўвайсці ў сістэму для стварэння і планавання карыстацкіх справаздач.", + "Pagination": "Старонка %s з %s", + "PiwikReports": "Piwik справаздачы", + "PleaseFindAttachedFile": "Вы можаце знайсці ў прыкладаемым файле вашу %1$s справаздачу для %2$s.", + "PleaseFindBelow": "Ніжэй вы знойдзеце вашу %1$s справаздачу для %2$s.", + "PluginDescription": "Стварэнне і запампоука карыстацкіх справаздач, і адпраўка іх па электроннай пошце штодзень, штотыдзень або штомесяц.", + "ReportFormat": "Фармат справаздачы", + "ReportsIncluded": "Уключаная статыстыка", + "SendReportNow": "Адправіць справаздачу зараз", + "SendReportTo": "Адправіць справаздачу да", + "SentToMe": "Адправіць да мяне", + "TableOfContent": "Спіс справаздач", + "ThereIsNoReportToManage": "Не існуе ніякіх справаздач для гэтага вэб-сайта %s", + "TopOfReport": "Вярнуцца наверх", + "UpdateReport": "Абнавіць справаздачу", + "WeeklyScheduleHelp": "Штотыднёвы расклад: справаздача будзе адпраўлена першым панядзелакам кожнага тыдня." + }, + "SEO": { + "AlexaRank": "Alexa ранг", + "DomainAge": "Узрост дамена", + "Rank": "Ранг", + "SeoRankings": "SEO рэйтынгі", + "SEORankingsFor": "SEO рэйтынгі для %s" + }, + "SitesManager": { + "AddSite": "Дадаць новы сайт", + "AdvancedTimezoneSupportNotFound": "Пашыраная падтрымка гадзінных паясоў не была знойдзена ў вашай версіі PHP (падтрымліваецца ў PHP>=5.2). Вы можаце выбраць UTC у ручным рэжыме.", + "AliasUrlHelp": "Рэкамендуецца, але не патрабуецца, задаць розныя URL-адрасы, па адным у радку, якія наведвальнікі выкарыстоўваюць для доступу да гэтага вэб-сайта. Псеўданімы URL-адрасоў для вэб-сайта не будуць адлюстроўвацца ў Спасыльнікі > Справаздачы Сайтаў. Звярніце ўвагу, што няма неабходнасці пазначаць URL-адрасы з і без \"WWW\", Piwik аўтаматычна ўлічвае абодва тыпа.", + "ChangingYourTimezoneWillOnlyAffectDataForward": "Змяна гадзіннага пояса ўплывае толькі на будучыя дадзеныя, і не будуць мець зваротнай сілы.", + "ChooseCityInSameTimezoneAsYou": "Абярыце горад у тым жа часавым поясе, у якім і вы.", + "Currency": "Валюта", + "CurrencySymbolWillBeUsedForGoals": "Сімвал валюты будзе адлюстроўвацца побач з Мэтай прыбыткаў.", + "DefaultCurrencyForNewWebsites": "Валюта па змаўчанні для новых вэб-сайтаў", + "DefaultTimezoneForNewWebsites": "Гадзінны пояс па змаўчанні для новых вэб-сайтаў", + "DeleteConfirm": "Вы сапраўды жадаеце выдаліць гэты сайт %s?", + "EcommerceHelp": "Калі функцыя \"Мэты\" ўключана, у справаздачах з'явіцца новая частцка \"Электронная камерцыя\".", + "EnableEcommerce": "Электронная камерцыя уключана", + "ExceptionDeleteSite": "Немагчыма выдаліць, бо гэта адзіны сайт у вашым спісе. Дадайце яшчэ які-небудзь сайт для выдалення дадзенага.", + "ExceptionEmptyName": "Назва сайта не можа быць пустое.", + "ExceptionInvalidCurrency": "Валюта \"%s\" з'яўляецца недапушчальнай. Калі ласка, увядзіце правільны сімвал валюты (напр., %s)", + "ExceptionInvalidIPFormat": "IP-адрас для выключэння \"%s\" не мае правільны фармат IP-адраса (напр., %s).", + "ExceptionInvalidTimezone": "Гадзінны пояс \"%s\" з'яўляецца недапушчальным. Калі ласка, увядзіце правільны гадзінны пояс.", + "ExceptionInvalidUrl": "URL '%s' не дакладны.", + "ExceptionNoUrl": "Вы павінны паказаць хоць бы адзін URL для гэтага сайта.", + "ExcludedIps": "Выключаныя IP-адрасы", + "ExcludedParameters": "Сключаныя параметры", + "GlobalListExcludedIps": "Глабальны спіс выключаных IP-адрасоў", + "GlobalListExcludedQueryParameters": "Глабальны спіс параметраў запыту URL-адрасоў да сключэння", + "GlobalWebsitesSettings": "Глабальныя наладкі вэб-сайтаў", + "HelpExcludedIps": "Калі ласка, увядзіце спіс IP-адрасоў, па адным у радку, якія патрэбна сключыць з адсочвання Piwik. Вы можаце выкарыстоўваць групавыя сімвалы, напр. %1$s або %2$s", + "JsTrackingTagHelp": "Гэта код JavaScript, які неабходна ўставіць ва ўсе вашы старонкі", + "ListOfIpsToBeExcludedOnAllWebsites": "IP-адрасы паказаныя ніжэй, будуць выключаныя з адсочвання на ўсіх вэб-сайтах.", + "ListOfQueryParametersToBeExcludedOnAllWebsites": "Параметры запыту URL-адрасоў, паказаныя ніжэй, будуць сключаныя з URL-адрасоў на ўсіх вэб-сайтаў.", + "ListOfQueryParametersToExclude": "Калі ласка, увядзіце спіс параметраў запыту URL-адрасоў, па адным у радку, каб сключыць з справаздач гэтыя адрасы.", + "MainDescription": "Для вядзення статыстыкі патрабуецца дадаць сайты! Дадавайце, абнаўляйце, выдаляйце інфармацыю пра сайты, а таксама прагледзіце Java-код для ўстаўкі на Вашы старонкі.", + "NotAnEcommerceSite": "Некамерцыйны сайт", + "NotFound": "Няма знойдзеных сайтаў", + "NoWebsites": "Вы не маеце ніводнага сайта на ўліку.", + "OnlyOneSiteAtTime": "Вы можаце рэдагаваць толькі адзін вэб-сайт за адзін раз. Калі ласка, захаваце ці адмяніце змены ў бягучым сайце %s.", + "PiwikOffersEcommerceAnalytics": "Piwik прапаноўвае пашыранае адсочванне і рэпартаванне для камерцыйных вэб-сайтаў. Даведайцеся больш пра %s Аналітыку Электронна-камерцыйных сайтаў%s.", + "PiwikWillAutomaticallyExcludeCommonSessionParameters": "Piwik аўтаматычна выключае агульныя параметры сесіі (%s).", + "PluginDescription": "Кіраванне Сайтамі ў Piwik: Дадаць новы сайт, Рэдагаваць існуючы, паказаць код JavaScript, каб дадаць яго на старонкі свайго сайта. Усе дзеянні, таксама даступныя праз API.", + "SelectACity": "Абярыце горад", + "SelectDefaultCurrency": "Вы можаце выбраць валюту для ўстаноўкі па змаўчанні для новых вэб-сайтаў.", + "SelectDefaultTimezone": "Вы можаце абраць гадзінны пояс па змаўчанні для новых вэб-сайтаў.", + "ShowTrackingTag": "Паказаць код", + "Sites": "Вэб-сайты", + "Timezone": "Гадзінны пояс", + "TrackingTags": "Код адсочвання для %s", + "Urls": "URL-ы", + "UTCTimeIs": "UTC час - %s.", + "WebsitesManagement": "Кіраванне сайтамі", + "YouCurrentlyHaveAccessToNWebsites": "Вы маеце доступ да %s сайтаў.", + "YourCurrentIpAddressIs": "Ваш бягучы IP-адрас - %s" + }, + "UserCountry": { + "Continent": "Кантынент", + "continent_afr": "Афрыка", + "continent_amc": "Цэнтральная Амерыка", + "continent_amn": "Паўночная Амерыка", + "continent_ams": "Паўднёвая і Цэнтральная Амерыка", + "continent_ant": "Антарктыда", + "continent_asi": "Азія", + "continent_eur": "Еўропа", + "continent_oce": "Акіянія", + "Country": "Краіна", + "country_a1": "Ананімныя проксі", + "country_a2": "Спадарожнікавы правайдар", + "country_ac": "Выспы Ўшэсці", + "country_ad": "Андора", + "country_ae": "Аб'яднаныя Арабскія Эміраты", + "country_af": "Афганістан", + "country_ag": "Антыгуа і Барбуда", + "country_ai": "Ангвіла", + "country_al": "Албанія", + "country_am": "Арменія", + "country_an": "Антыльскія выспы", + "country_ao": "Ангола", + "country_ap": "Азія\/Ціхаакіянскі рэгіён", + "country_aq": "Антарктыка", + "country_ar": "Аргентына", + "country_as": "Амерыканскае Самоа", + "country_at": "Аўстрыя", + "country_au": "Аўстралія", + "country_aw": "Аруба", + "country_ax": "Аландскіе выспы", + "country_az": "Азербайджан", + "country_ba": "Боснія і Герцагавіна", + "country_bb": "Барбадос", + "country_bd": "Бангладэш", + "country_be": "Бельгія", + "country_bf": "Буркіна-Фаса", + "country_bg": "Балгарыя", + "country_bh": "Бахрэйн", + "country_bi": "Бурундзі", + "country_bj": "Бенін", + "country_bl": "Сен-Бартельмі", + "country_bm": "Бярмудскія выспы", + "country_bn": "Бруней", + "country_bo": "Балівія", + "country_bq": "Бонайрэ, Санкт-Эстатіус і Саба", + "country_br": "Бразілія", + "country_bs": "Багамскія выспы", + "country_bt": "Бутан", + "country_bu": "Бірма", + "country_bv": "Выспа Буве", + "country_bw": "Батсвана", + "country_by": "Беларусь", + "country_bz": "Беліз", + "country_ca": "Канада", + "country_cat": "Абшчыны, якія гавораць па Каталонскі", + "country_cc": "Какосавыя выспы", + "country_cd": "Конга, дэмакратычная рэспубліка", + "country_cf": "Цэнтральнаафрыканская Рэспубліка", + "country_cg": "Конга", + "country_ch": "Швейцарыя", + "country_ci": "Кот Д'івуар", + "country_ck": "Выспы Кука", + "country_cl": "Чылі", + "country_cm": "Камерун", + "country_cn": "Кітай", + "country_co": "Калумбія", + "country_cp": "Кліппертонскіе выспы", + "country_cr": "Коста-Ріка", + "country_cs": "Сербія і Чарнагорыя", + "country_cu": "Куба", + "country_cv": "Кабо Верде", + "country_cw": "Кюрасаа", + "country_cx": "Выспа Каляд", + "country_cy": "Кіпр", + "country_cz": "Чэшская Рэспубліка", + "country_de": "Нямеччына", + "country_dg": "Діего-Гарсія", + "country_dj": "Джібуті", + "country_dk": "Данія", + "country_dm": "Дамініка", + "country_do": "Дамініканская Рэспубліка", + "country_dz": "Алжыр", + "country_ea": "Сеута і Мелілья", + "country_ec": "Эквадор", + "country_ee": "Эстонія", + "country_eg": "Егіпет", + "country_eh": "Заходняя Сахара", + "country_er": "Эрытрэя", + "country_es": "Іспанія", + "country_et": "Эфіёпія", + "country_eu": "Еўрапейскі Звяз", + "country_fi": "Фінляндыя", + "country_fj": "Фіджы", + "country_fk": "Фалклэндскія выспы (Мальвінскіе)", + "country_fm": "Федэратыўныя Штаты Мікранэзіі", + "country_fo": "Фарэрскія выспы", + "country_fr": "Францыя", + "country_fx": "Метраполія Францыі", + "country_ga": "Габон", + "country_gb": "Вялікабрытанія", + "country_gd": "Грэнада", + "country_ge": "Грузія", + "country_gf": "Фр. Гаяна", + "country_gg": "Гернсі", + "country_gh": "Гана", + "country_gi": "Гібралтар", + "country_gl": "Грэнландыя", + "country_gm": "Гамбія", + "country_gn": "Гвінея", + "country_gp": "Гвадэлупа", + "country_gq": "Экватарыяльная Гвінея", + "country_gr": "Грэцыя", + "country_gs": "Паўднёвая Джорджыя і Паўднёвыя Сандвічевы выспы", + "country_gt": "Гватэмала", + "country_gu": "Гуам", + "country_gw": "Гвінея-Бісаў", + "country_gy": "Гаяна", + "country_hk": "Ганконг", + "country_hm": "Выспы Херда і Макдональда", + "country_hn": "Гандурас", + "country_hr": "Харватыя", + "country_ht": "Гаіці", + "country_hu": "Вугоршчына", + "country_ic": "Канарскія выспы", + "country_id": "Інданэзія", + "country_ie": "Ірландыя", + "country_il": "Ізраіль", + "country_im": "Выспа Мэн", + "country_in": "Індыя", + "country_io": "Брытанская тэрыторыя Індыйскага акіян", + "country_iq": "Ірак", + "country_ir": "Іран", + "country_is": "Ісландыя", + "country_it": "Італія", + "country_je": "Джэрсі", + "country_jm": "Ямайка", + "country_jo": "Іярданія", + "country_jp": "Японія", + "country_ke": "Кенія", + "country_kg": "Кіргізстан", + "country_kh": "Камбоджа", + "country_ki": "Кірыбаты", + "country_km": "Каморскія выспы", + "country_kn": "Сэнт-Кітс і Невіс", + "country_kp": "КНДР", + "country_kr": "Карэя", + "country_kw": "Кувейт", + "country_ky": "Каймановы выспы", + "country_kz": "Казахстан", + "country_la": "Лаос", + "country_lb": "Ліван", + "country_lc": "Сэнт-Люсія", + "country_li": "Ліхтэнштэйн", + "country_lk": "Шрі-Ланка", + "country_lr": "Ліберыя", + "country_ls": "Лесота", + "country_lt": "Літва", + "country_lu": "Люксембург", + "country_lv": "Латвія", + "country_ly": "Лівія", + "country_ma": "Марока", + "country_mc": "Манака", + "country_md": "Малдова", + "country_me": "Чарнагорыя", + "country_mf": "Выспа Святога Марціна", + "country_mg": "Мадагаскар", + "country_mh": "Маршалавы выспы", + "country_mk": "Македонія", + "country_ml": "Малі", + "country_mm": "М'янма", + "country_mn": "Манголія", + "country_mo": "Макау", + "country_mp": "Паўночныя Маріанскіе выспы", + "country_mq": "Мартыніка", + "country_mr": "Маўрытанія", + "country_ms": "Монсеррат", + "country_mt": "Мальта", + "country_mu": "Маўрыкій", + "country_mv": "Мальдыўскія выспы", + "country_mw": "Малаві", + "country_mx": "Мексіка", + "country_my": "Малайзія", + "country_mz": "Мазамбік", + "country_na": "Намібія", + "country_nc": "Новая Калядонія", + "country_ne": "Нігер", + "country_nf": "Норфолкскіе выспы", + "country_ng": "Нігерыя", + "country_ni": "Нікарагуа", + "country_nl": "Нідэрланды", + "country_no": "Нарвегія", + "country_np": "Непал", + "country_nr": "Наўру", + "country_nt": "Нейтральныя тэрыторыі", + "country_nu": "Ніе", + "country_nz": "Новая Зеландыя", + "country_o1": "Іншыя краіны", + "country_om": "Аман", + "country_pa": "Панама", + "country_pe": "Перу", + "country_pf": "Фр. Палінезія", + "country_pg": "Папуа - Новая Гвінея", + "country_ph": "Філіпіны", + "country_pk": "Пакістан", + "country_pl": "Польшча", + "country_pm": "Сен-П'ер і Мікелон", + "country_pn": "Піткэрн", + "country_pr": "Пуэрта-Рыко", + "country_ps": "Палестынская тэрыторыя", + "country_pt": "Партугалія", + "country_pw": "Палау", + "country_py": "Парагвай", + "country_qa": "Катар", + "country_re": "Выспа Реюньон", + "country_ro": "Румынія", + "country_rs": "Сербія", + "country_ru": "Расія", + "country_rw": "Руанда", + "country_sa": "Саудаўская Аравія", + "country_sb": "Саламонавы выспы", + "country_sc": "Сейшэльскія выспы", + "country_sd": "Судан", + "country_se": "Швецыя", + "country_sf": "Фінляндыя", + "country_sg": "Сінгапур", + "country_sh": "Выспа Святой Алены", + "country_si": "Славенія", + "country_sj": "Свольбар", + "country_sk": "Славакія", + "country_sl": "Сьера-Леонэ", + "country_sm": "Сан-Марына", + "country_sn": "Сенегал", + "country_so": "Самалі", + "country_sr": "Сурынам", + "country_ss": "Паўднёвы Судан", + "country_st": "Сан-Томе і Прінсіпі", + "country_su": "СССР (былы)", + "country_sv": "Сальвадор", + "country_sx": "сінт-Маартен", + "country_sy": "Сірыйская Арабская Рэспубліка", + "country_sz": "Свазіленд", + "country_ta": "Трістан-так-Кунья", + "country_tc": "Выспы Турцыі і Каікоса", + "country_td": "Дзяцей", + "country_tf": "Паўднёвыя тэрыторыі Францыі", + "country_tg": "Таго", + "country_th": "Тайланд", + "country_tj": "Таджыкістан", + "country_tk": "Токело", + "country_tl": "Усходні Тымор", + "country_tm": "Туркменістан", + "country_tn": "Туніс", + "country_to": "Тонга", + "country_tp": "Усходні Тымор", + "country_tr": "Турцыя", + "country_tt": "Трынідад і Табага", + "country_tv": "Тувалу", + "country_tw": "Тайвань", + "country_tz": "Танзанія", + "country_ua": "Украіна", + "country_ug": "Уганда", + "country_uk": "Злучанае Каралеўства", + "country_um": "Вонкавыя малыя выспы ЗША", + "country_us": "ЗША", + "country_uy": "Уругвай", + "country_uz": "Узбекістан", + "country_va": "Ватыкан", + "country_vc": "Сэнт-Вінцэнт і Гренадіны", + "country_ve": "Венесуэла", + "country_vg": "Віргінскія выспы Брытаніі", + "country_vi": "Віргінскія выспы ЗША", + "country_vn": "В'етнам", + "country_vu": "Вануату", + "country_wf": "Уолліс і Футуна", + "country_ws": "Самоа", + "country_ye": "Емен", + "country_yt": "Майотта", + "country_yu": "Югаславія", + "country_za": "Паўднёва-Афрыканская Рэспубліка", + "country_zm": "Замбія", + "country_zr": "Заір", + "country_zw": "Зімбабвэ", + "DistinctCountries": "%s унікальных краін", + "Location": "Лакаця", + "PluginDescription": "Справаздачы краін наведвальнікаў.", + "SubmenuLocations": "Лакацыі" + }, + "UserCountryMap": { + "map": "карта" + }, + "UserSettings": { + "BrowserFamilies": "Па сямействе браўзараў", + "Browsers": "Па браўзарах", + "ColumnBrowser": "Браўзэр", + "ColumnBrowserFamily": "Сямейства браўзэраў", + "ColumnBrowserVersion": "Версія браўзэра", + "ColumnConfiguration": "Канфігурацыя", + "ColumnOperatingSystem": "Аперацыйная сістэма", + "ColumnResolution": "Дазвол", + "ColumnTypeOfScreen": "Тып экрана", + "Configurations": "Па канфігурацыі", + "Language_ab": "абхазская", + "Language_af": "афрыкаанс", + "Language_am": "амхарская", + "Language_an": "арагонская", + "Language_ar": "арабская", + "Language_as": "асамская", + "Language_av": "аварская", + "Language_ay": "аймара", + "Language_az": "азербайджанская", + "Language_ba": "башкірская", + "Language_be": "беларуская", + "Language_bg": "балгарская", + "Language_bh": "біхары", + "Language_bn": "бенгальская", + "Language_br": "брэтонская", + "Language_bs": "баснійская", + "Language_ca": "каталонская", + "Language_ce": "чачэнская", + "Language_cs": "чэшская", + "Language_cv": "чувашская", + "Language_cy": "валійская", + "Language_da": "дацкая", + "Language_de": "нямецкая", + "Language_el": "грэцкая", + "Language_en": "англійская", + "Language_eo": "эсперанта", + "Language_es": "іспанская", + "Language_et": "эстонская", + "Language_eu": "баскская", + "Language_fa": "фарсі", + "Language_fi": "фінская", + "Language_fo": "фарэрская", + "Language_fr": "французская", + "Language_fy": "фрызская", + "Language_ga": "ірландская", + "Language_gd": "шатландская гэльская", + "Language_gl": "галісійская", + "Language_gn": "гуарані", + "Language_gu": "гуяраці", + "Language_he": "іўрыт", + "Language_hi": "хіндзі", + "Language_hr": "харвацкая", + "Language_hu": "венгерская", + "Language_hy": "армянская", + "Language_ia": "інтэрлінгва", + "Language_id": "інданезійская", + "Language_ie": "інтэрлінгве", + "Language_is": "ісландская", + "Language_it": "італьянская", + "Language_ja": "японская", + "Language_jv": "яванская", + "Language_ka": "грузінская", + "Language_kk": "казахская", + "Language_kn": "каннада", + "Language_ko": "карэйская", + "Language_ku": "курдская", + "Language_la": "лацінская", + "Language_ln": "лінгала", + "Language_lo": "лаоская", + "Language_lt": "літоўская", + "Language_lv": "латышская", + "Language_mg": "мальгашская", + "Language_mk": "македонская", + "Language_ml": "малаяламская", + "Language_mn": "мангольская", + "Language_mr": "маратхі", + "Language_ms": "малайская", + "Language_mt": "мальтыйская", + "Language_nb": "нарвэская букмал", + "Language_ne": "непальская", + "Language_nl": "галандская", + "Language_nn": "нарвежская (нюнорск)", + "Language_no": "нарвежская", + "Language_oc": "правансальская", + "Language_oj": "аджыбве", + "Language_or": "орыя", + "Language_os": "асецінская", + "Language_pa": "панджабі", + "Language_pl": "польская", + "Language_ps": "пушту", + "Language_pt": "партугальская", + "Language_qu": "кечуа", + "Language_rm": "рэта-раманская", + "Language_ro": "румынская", + "Language_ru": "руская", + "Language_sa": "санскрыт", + "Language_sd": "сіндхі", + "Language_si": "сінгальская", + "Language_sk": "славацкая", + "Language_sl": "славенская", + "Language_so": "самалійская", + "Language_sq": "албанская", + "Language_sr": "сербская", + "Language_su": "суданская", + "Language_sv": "шведская", + "Language_sw": "суахілі", + "Language_ta": "тамільская", + "Language_te": "тэлугу", + "Language_tg": "таджыкская", + "Language_th": "тайская", + "Language_ti": "тыгрынья", + "Language_tk": "туркменская", + "Language_tr": "турэцкая", + "Language_tt": "татарская", + "Language_ug": "уйгурская", + "Language_uk": "украінская", + "Language_ur": "урду", + "Language_uz": "узбекская", + "Language_vi": "в'етнамская", + "Language_vo": "валапюк", + "Language_xh": "хоса", + "Language_yi": "ідыш", + "Language_zh": "кітайская", + "Language_zu": "зулу", + "LanguageCode": "Код мовы", + "OperatingSystems": "Па аперацыйных сістэмах", + "PluginDescription": "Справаздачы наладак карыстальнікаў: Браўзэр, Сямейства Браўзэр сям'і, Аперацыйная сістэма, Плагіны, Глабальныя параметры.", + "PluginDetectionDoesNotWorkInIE": "Заўважце: Плагіны не вызначаюцца ў Internet Explorer. Гэта справаздача заснавана на не-IE браўзарах.", + "Resolutions": "Па дазволе манітораў", + "VisitorSettings": "Налады карыстача", + "WideScreen": "Па шырыні экрана", + "WidgetBrowserFamilies": "Браўзэры па сямействе", + "WidgetBrowserFamiliesDocumentation": "Гэты графік паказвае браўзэры наведвальнікаў, разбітыя па сем'ях. %s Найбольш важная інфармацыю для вэб-распрацоўнікаў у тым, што я ны могуць даведацца аб тыпах вэб-рэндэрынгу сваіх наведвальнікаў. Пазнакі ўтрымліваюць імёны рухавікоў найбольш распаўсюджаныя адзначаныя ў браўзэрам дужках.", + "WidgetBrowsers": "Браўзары карыстачоў", + "WidgetBrowsersDocumentation": "Гэтая справаздача змяшчае інфармацыю аб тым, які браўзэр выкарыстоўвалі наведвальнікі. Кожная версія браўзэра пералічана асобна.", + "WidgetGlobalVisitors": "Глабальная канфігурацыя", + "WidgetGlobalVisitorsDocumentation": "Гэтая справаздача паказвае найбольш распаўсюджаныя агульныя канфігурацыі, якія мелі вашы наведвальнікі. Канфігурацыя - гэта спалучэнне аперацыйнай сістэмы, тыпу браўзэра і дазволу экрана.", + "WidgetOperatingSystems": "Аперацыйныя сістэмы", + "WidgetPlugins": "Спіс плагінаў", + "WidgetPluginsDocumentation": "Гэтая справаздача паказвае, якія плагіны были ўключаны ў браўзэраў Вашых наведвальнікаў. Гэтая інфармацыя можа мець важнае значэнне для выбару правільнага спосабу дастаўкі кантэнту.", + "WidgetResolutions": "Дазвол манітораў", + "WidgetWidescreen": "Звычайны \/ Шырокаэкранны" + }, + "UsersManager": { + "AddUser": "Дадаць новага карыстача", + "Alias": "Аліас", + "AllWebsites": "Усе сайты", + "ApplyToAllWebsites": "Ужыць да ўсіх сайтаў", + "ChangeAllConfirm": "Вы сапраўды жадаеце змяніць правы '%s' на ўсе вэбсайты?", + "ChangePasswordConfirm": "Зменяючы парол, вы таксама зменяеце token_auth карыстальніка. Вы сапраўды жадаеце працягнуць?", + "ClickHereToDeleteTheCookie": "Націсніце тут, каб выдаліць cookie і пачаць адсочванне вашых наведванняў Piwik", + "ClickHereToSetTheCookieOnDomain": "Націсніце тут, каб усталяваць cookie, які выключыць адсочванне вашых наведванняў на вэб-сайтах з Piwik на %s", + "DeleteConfirm": "Вы сапраўды жадаеце выдаліць карыстача %s?", + "ExceptionAccessValues": "Параметр доступу можа мець толькі адно з наступных значэнняў: [ %s ]", + "ExceptionAdminAnonymous": "Вы не можаце даваць правы 'Адмін' ананімнаму карыстачу.", + "ExceptionDeleteDoesNotExist": "Карыстача '%s' не існуе, таму ён не можа быць выдалены.", + "ExceptionEditAnonymous": "Ананімны карыстач не можа быць выдалены. Ён неабходзен Piwik для ідэнтыфікацыі карыстачоў, якія не ўвайшлі ў сістэму. Дапушчальна, вы можаце зрабіць статыстыку публічнай, падаючы права 'Прагляд' ананімнаму карыстачу.", + "ExceptionEmailExists": "Карыстач з Email '%s' ужо існуе.", + "ExceptionInvalidEmail": "Email няправільнага фармату", + "ExceptionInvalidLoginFormat": "Лагін павінен быць даўжынёй ад %1$s да %2$s знакаў, а таксама ўтрымоўваць толькі літары, лічбы ці знакі '_', '-', '.'", + "ExceptionInvalidPassword": "Даўжыня пароля павінна быць паміж %1$s і %2$s знакаў.", + "ExceptionLoginExists": "Лагін '%s' ужо існуе.", + "ExceptionPasswordMD5HashExpected": "UsersManager.getTokenAuth партабуе MD5-хэшаваны пароль ( даужыней ў 32 знака). Калі ласка, запытайце md5() функцыю перад выклікам гэтага метаду.", + "ExceptionUserDoesNotExist": "Карыстач '%s' не існуе.", + "ExcludeVisitsViaCookie": "Сключыць вашыя наведванні выкарыстоўваючы cookie", + "ForAnonymousUsersReportDateToLoadByDefault": "Дата справаздачы для загрузкі па змаўчанні, для ананімных карыстальнікаў", + "IfYouWouldLikeToChangeThePasswordTypeANewOne": "Калі вы жадаеце змяніць пароль, то ўвядзіце новы. У адваротным выпадку пакіньце поле пустым.", + "MainDescription": "Пакажыце, якія карыстачы маюць доступ да Piwik на Вашым сайце. Таксама Вы можаце задаць правы доступу на ўсе сайты.", + "ManageAccess": "Кіраванне правамі дазволу", + "MenuAnonymousUserSettings": "Ананімныя карыстацкія наладкі", + "MenuUsers": "Карыстачы", + "MenuUserSettings": "Наладкі карыстальніка", + "PluginDescription": "Кіраванне карыстальнікамі ў Piwik: даданне новых карыстальнікаў, рэдагаванне ужо існуючых, абнаўленне правоў дазволу. Усе дзеянні, таксама даступныя праз API.", + "PrivAdmin": "Адмін", + "PrivNone": "Няма доступу", + "PrivView": "Прагляд", + "ReportDateToLoadByDefault": "Дата справаздачы для загрузкі па змаўчанні", + "ReportToLoadByDefault": "Справаздача, якая загружаецца па змаўчанні", + "TheLoginScreen": "Форма аўтарызацыі", + "ThereAreCurrentlyNRegisteredUsers": "Зарэгістраваныя карыстальнікі %s.", + "TypeYourPasswordAgain": "Калі ласка, увядзіце новы пароль яшчэ раз.", + "User": "Карыстач", + "UsersManagement": "Кіраванне карыстачамі", + "UsersManagementMainDescription": "Стварэнне новых карыстачоў ці рэдагаванне існуючых. Таксама Вы можаце задаць правы дазволу для карыстача.", + "WhenUsersAreNotLoggedInAndVisitPiwikTheyShouldAccess": "Калі карыстальнікі не аўтарызаваны і наведваюць Piwik, яны павінны атрымліваць доступ да", + "YourUsernameCannotBeChanged": "Ваша імя карыстальніка не можа быць зменена.", + "YourVisitsAreIgnoredOnDomain": "%sВашы наведванні ігнаруюцца Piwik на %s %s (Piwik знайшоў ў вашым браўзэры ігнаруючыя cookie).", + "YourVisitsAreNotIgnored": "%sВашы наведванні не ігнаруюцца Piwik%s (Piwik не знайшоў ў вашым браўзэры ігнаруючыя cookie)." + }, + "VisitFrequency": { + "ColumnActionsByReturningVisits": "Дзеянняў за паўторнае наведванне", + "ColumnAverageVisitDurationForReturningVisitors": "Сяр. працягласць наведванняў для вяртаючыхся наведвальнікаў (у секундах)", + "ColumnAvgActionsPerReturningVisit": "Сяр. колькасць дзеянняў за паўторнае наведванне", + "ColumnBounceRateForReturningVisits": "Паказчык адмоў для паўторных наведванняў", + "ColumnReturningVisits": "Паўторныя наведванні", + "PluginDescription": "Справаздачы статыстычных дадзеных аб наведвальніках, якія вяртаюцца на вэб-сайт, у параўнанні з наведвальнікамі якія трапілі на вэб-сайт у першы раз.", + "ReturnActions": "%s дзеянняў за паўторныя наведванні", + "ReturnAverageVisitDuration": "%s сярэдняя працягласць наведванняў для вяртаючыхся наведвальнікаў", + "ReturnAvgActions": "%s дзеянняў за паўторнае наведванне", + "ReturnBounceRate": "%s раз карыстачы выйшлі пасля прагляду адной старонкі", + "ReturningVisitDocumentation": "Паўторнае наведванне (у адрозненне ад новага наведвання) зрабіў той, хто наведаў вэб-сайт па крайняй меры двойчы.", + "ReturningVisitsDocumentation": "Гэта агляд паўторных наведванняў.", + "ReturnVisits": "%s паўторных наведванняў", + "SubmenuFrequency": "Чашчыня", + "WidgetGraphReturning": "Графік паўторных наведванняў", + "WidgetOverview": "Агляд чашчыні наведванняў" + }, + "VisitorInterest": { + "BetweenXYMinutes": "%1$s-%2$s хв", + "BetweenXYSeconds": "%1$s-%2$sс", + "ColumnPagesPerVisit": "Старонак за наведванне", + "ColumnVisitDuration": "Даўжыня наведванняў", + "Engagement": "Абавязацельства", + "NPages": "%s старонак", + "OnePage": "1 старонка", + "PluginDescription": "Справаздачы аб інтарэсах наведвальнікаў: колькасць прагледжаных старонак, час, праведзены на вэб-сайце.", + "PlusXMin": "%s мін.", + "VisitsPerDuration": "Наведванняў па даўжыні візіту", + "VisitsPerNbOfPages": "Наведванняў па колькасці старонак", + "WidgetLengths": "Даўжыня наведванняў", + "WidgetLengthsDocumentation": "У гэтай справаздачы, можна ўбачыць, колькі наведванняў была па пэнай працягласці часу на вэб-сайце. Першапачаткова, справаздачы паказаны, як воблака тэгаў, буйным шрыфтам адлюстроўваюцца працягласці, якия адбыліся часцей за ўсё.", + "WidgetPages": "Старніц за наведванне", + "WidgetPagesDocumentation": "У гэтай справаздачы, можна ўбачыць, колькі наведванняў была па пэўнай колькасці праглядаў старонак. Першапачаткова, справаздачы паказаны, як воблака тэгаў, буйным шрыфтам адлюстроўваюцца прагляды, якия адбыліся часцей за ўсё." + }, + "VisitsSummary": { + "AverageVisitDuration": "%s сярэдняя працягласць наведвання", + "GenerateQueries": "%s запытаў выканана", + "GenerateTime": "Старонка згенеравана за %s секунд", + "MaxNbActions": "%s макс. дзеянняў за адно наведванне", + "NbActionsDescription": "%s дзеянні (прагляды старонак, запампоўка і знешнія спасылкі)", + "NbActionsPerVisit": "%s дзеянняў за наведванне", + "NbUniqueVisitors": "%s унікальных наведвальнікаў", + "NbVisitsBounced": "%s наведвальнікаў сышло пасля наведвання адной старонкі", + "PluginDescription": "Справаздачы у агульных аналітычных лічбах: наведванні, унікальныя наведвальнікі, колькасць дзеянняў, узровень адмоў і г.д.", + "VisitsSummary": "Наведванні сумарна", + "VisitsSummaryDocumentation": "Гэта агляд зменаў наведванняў.", + "WidgetLastVisits": "Графік апошніх наведванняў", + "WidgetOverviewGraph": "Агляд па ўсіх графіках", + "WidgetVisits": "Агляд наведванняў" + }, + "VisitTime": { + "ColumnLocalTime": "Мясцовы час", + "ColumnServerTime": "Серверны час", + "LocalTime": "Наведванняў па мясцовым часе", + "NHour": "%s ч.", + "PluginDescription": "Паказвае мясцовых і серверны час. Інфармацы абя серверным часе можа быць выкарыставана для планавання тэхнічнага абслугоўвання вэб-сайта.", + "ServerTime": "Наведванняў па серверным часе", + "SubmenuTimes": "Па часе", + "WidgetLocalTime": "Наведванняў па мясцовым часе", + "WidgetLocalTimeDocumentation": "Гэты графік паказвае, які %s час быў у наведвальніка %s пад час наведвання веб-сайту.", + "WidgetServerTime": "Наведванняў па серверным часе", + "WidgetServerTimeDocumentation": "Гэты графік паказвае, які %s час быў на серверы %s пад час наведвання веб-сайту." + }, + "Widgetize": { + "OpenInNewWindow": "Адкрыць у новым акне", + "PluginDescription": "Плагін дазваляе лёгка экспартаваць любы віджэт Piwik у блог, вэб-сайт або на iGoogle і Netvibes!" + } +} \ No newline at end of file diff --git a/www/analytics/lang/bg.json b/www/analytics/lang/bg.json new file mode 100644 index 00000000..907fb7b8 --- /dev/null +++ b/www/analytics/lang/bg.json @@ -0,0 +1,2301 @@ +{ + "Actions": { + "AvgGenerationTimeTooltip": "Средно базирано на %s посещение(я) %s между %s и %s", + "ColumnClickedURL": "Посетена връзка", + "ColumnClicks": "Щраквания", + "ColumnClicksDocumentation": "Брой щраквания на тази връзка", + "ColumnDownloadURL": "Свален URL", + "ColumnEntryPageTitle": "Входно заглавие", + "ColumnEntryPageURL": "Входна страница", + "ColumnExitPageTitle": "Изходно заглавие", + "ColumnExitPageURL": "Изходна страница", + "ColumnNoResultKeyword": "Ключова дума без резултати в търсенето", + "ColumnPageName": "Име на страницата", + "ColumnPagesPerSearch": "Страници с резултати от търсене", + "ColumnPagesPerSearchDocumentation": "Посетителите ще търсят във вашия сайт, а понякога избират „Напред“, за да видят повече резултати. Това е средният брой на страниците с резултати от търсенето, видян за тази ключова дума.", + "ColumnPageURL": "Страница URL", + "ColumnSearchCategory": "Търсена категория", + "ColumnSearches": "Търсачки", + "ColumnSearchesDocumentation": "Брой посещения търсениза тази ключова дума от системата за търсене на твоя сайт.", + "ColumnSearchExits": "% Изход от търсене", + "ColumnSearchExitsDocumentation": "Процент посещения, след които, потребителите са напуснали сайта, при търсене за тази ключова дума.", + "ColumnSearchResultsCount": "Брой резултати от търсенето", + "ColumnSiteSearchKeywords": "Уникални ключови думи", + "ColumnUniqueClicks": "Уникални кликове", + "ColumnUniqueClicksDocumentation": "Броят на посещенията които са кликнали на тази връзка. Ако на тази връзка е било кликано няколко пъти, при едно посещение, отчита се само веднъж.", + "ColumnUniqueDownloads": "Уникални сваляния", + "ColumnUniqueOutlinks": "Уникални изходящи", + "DownloadsReportDocumentation": "В този отчет можете да видите файловете, които вашите посетители са изтеглили. %s Piwik отчита броя щракванията върху връзката за сваляне. Дали изтеглянето е завършено или не, не е ясно за Piwik.", + "EntryPagesReportDocumentation": "Този отчет съдържа информация за входните страници, които са били използвани през определен период. Входна страница е първата страница, която потребителя е видял по времето на посещениета си. %s входните URL са изведени в директорийна структура.", + "EntryPageTitles": "Входни заглавия", + "EntryPageTitlesReportDocumentation": "Този отчет съдържа информация за заглавията на входните страници, които са били използвани през определен период.", + "ExitPagesReportDocumentation": "Този отчет съдържа информация за изходните страници, които са използвани през определен период. Изходна страница е последната страница, която потребителя вижда при неговото посещение. %s Изходните страници са изведени в директорийна структура.", + "ExitPageTitles": "Изходни заглавия", + "ExitPageTitlesReportDocumentation": "Този отчет съдържа информация за заглавията на изходните страници, които са били използвани през определен период.", + "LearnMoreAboutSiteSearchLink": "Научете повече за проследяването на посетителите използващи търсачката във Вашия сайт.", + "OneSearch": "1 търсене", + "OutlinkDocumentation": "Изходящата връзка е връзка, която води посетителя извън Вашия сайт (към друг домейн).", + "OutlinksReportDocumentation": "Този доклад показва йерархичен списък на изходящите адреси, които са избрани от посетителите.", + "PagesReportDocumentation": "Този доклад включва информация за посетените връзки. %s Таблицата е организирана йерархично, връзките са показани като структура от папки.", + "PageTitlesReportDocumentation": "Този доклад включва информация за заглавията на посетена страница. %s Заглавието на страницата е HTML %s таг, който се изобразява в заглавната част на повечето браузери.", + "PageUrls": "URLs страница", + "PluginDescription": "Доклади за посещения, изходящи връзки и сваляния. Изходящите връзки и Свалянията се отчитат автоматично!", + "SiteSearchCategories1": "Този доклад показва списък с категориите, които посетителите са избрали при търсене във Вашия сайт.", + "SiteSearchCategories2": "Например, електронните магазини обикновено имат категоризатор, така че посетителите да могат да филтрират търсенията си за всички продукти по категория.", + "SiteSearchFollowingPagesDoc": "Когато посетителите разглеждат Вашия сайт, те търсят определена страница, продукт или услуга. Този доклад показва списъка със страници, които са били посетени предимно след използване на търсачката на сайта. С други думи, това е списъкът с най-посещаваните страници, които са били търсени от посетителите намиращи се във Вашия сайт.", + "SiteSearchIntro": "Проследявайки търсенията, които посетителите правят на вашия сайт, е един много ефективен начин да научите повече за това, което вашата аудитория търси. Това може да помогне за намиране на идеи за ново съдържание, нови продукти и търговия, които потенциалните клиенти могат да търсят, и като цяло да подобри впечатлението на потребителя от вашия сайт.", + "SiteSearchKeyword": "Ключова дума (Търсене на сайта)", + "SiteSearchKeywordsDocumentation": "Този отчет описва ключовите думи, които посетителите търсят през търсачката на сайта.", + "SiteSearchKeywordsNoResultDocumentation": "Този отчет съдържа „Търсене по ключови думи“, в който няма резултати от търсенето: може би алгоритъмът на търсачката може да се подобри, или може би вашите посетители търсят съдържание, което (все още) не е на вашия сайт?", + "SubmenuPagesEntry": "Входни страници", + "SubmenuPagesExit": "Изходни страници", + "SubmenuPageTitles": "Заглавия на страници", + "SubmenuSitesearch": "Търсене на сайт", + "WidgetEntryPageTitles": "Входящо заглавие на страницата", + "WidgetExitPageTitles": "Изходящо заглавие на страницата", + "WidgetPagesEntry": "Входящи страници", + "WidgetPagesExit": "Изходящи страници", + "WidgetPageTitles": "Заглавия на страниците", + "WidgetPageTitlesFollowingSearch": "Заглавия на страници следящи търсенията на сайтове", + "WidgetPageUrlsFollowingSearch": "Страници следящи търсенията на сайтове", + "WidgetSearchCategories": "Търси категории", + "WidgetSearchKeywords": "Ключови думи търсени от сайта", + "WidgetSearchNoResultKeywords": "Търсене по ключови думи без резултати" + }, + "Annotations": { + "AddAnnotationsFor": "Добави анотации за %s...", + "AnnotationOnDate": "Анотация на %1$s: %2$s", + "Annotations": "Анотации", + "ClickToDelete": "Натиснете, за да изтриете този коментар.", + "ClickToEdit": "Цъкни за да редактираш тази анотация.", + "ClickToEditOrAdd": "Натисни за да редактираш или добавиш нова анотация.", + "ClickToStarOrUnstar": "Натиснете, за да отбележите или премахнете звезда за този коментар.", + "CreateNewAnnotation": "Създайте нова анотация", + "EnterAnnotationText": "Въведете своята бележка…", + "HideAnnotationsFor": "Скриване на анотациите за %s...", + "IconDesc": "Вижте бележките за този период от време.", + "IconDescHideNotes": "Скриване на бележките за този период от време.", + "InlineQuickHelp": "Можете да създавате анотации, за да отбелязвате специални събития (като нова публикация в блог или нов изглед на сайт), за съхраняване на анализи или за запазване на нещо друго, което смятате за важно.", + "LoginToAnnotate": "Влезте в профила си за да създадете анотация.", + "NoAnnotations": "Няма анотации за този период от време.", + "PluginDescription": "Позволява да се прикрепят бележки към различни дни, за да бъдат отбелязани промените, направени във вашия сайт. Запазва анализите в зависимост от информацията, която е предоставена и споделя мнението ви с вашите колеги. Публикувайки данните ще имате възможност да запомните по какъв начин изглеждат те.", + "ViewAndAddAnnotations": "Преглеждане и добавяне на коментари за %s…", + "YouCannotModifyThisNote": "Вие не можете да променяте тази анотация, защото все още не сте я създали или нямате администраторски права за този сайт." + }, + "API": { + "GenerateVisits": "Ако не разполагате с данни за днес можете да генерирате такива с помощта на добавката %s. Вие можете да активирате добавката %s, след което натиснете на „Генератор на посещения“, намиращ се в менюто на администраторската среда на Piwik.", + "KeepTokenSecret": "Това token_auth е тайна, като Вашето потребителско име и парола, %s не го споделяйте%s!", + "LoadedAPIs": "Успешно заредени %s API-та", + "MoreInformation": "За повече информация за Piwik API-тата, моля погледнете %s Въведение в Piwik API%s и %s Piwik API Референт%s.", + "PluginDescription": "Цялата информация от Piwik е достъпна чрез просто API. Тази добавка ви дава възможност да получите данни от Вашият Уеб Анализатор под формата на xml,json,php,cvs и др.", + "QuickDocumentationTitle": "API бърза документация", + "TopLinkTooltip": "Информацията за уеб анализите може да бъде достъпена чрез прост приложно-програмен интерфейс в json, xml и др. формат.", + "UserAuthentication": "Удостоверяване на потребителя", + "UsingTokenAuth": "Ако искате да %s вмъкнете данните със скрипт, crontab, др. %s трябва да добавите параметър %s на API кода за повиквания на URL адреси, които изискват удостоверяване." + }, + "CoreAdminHome": { + "Administration": "Администрация", + "BrandingSettings": "Настройки на бранда", + "CheckReleaseGetVersion": "Когато проверявате за нови версии на Piwik, винаги взимайте", + "ClickHereToOptIn": "Натиснете тук за съгласие.", + "ClickHereToOptOut": "Натиснете тук за отказ.", + "CustomLogoFeedbackInfo": "Ако модифицирате Piwik логото, може също да пожелаете да скриете %s връзката в главното меню. За да направите това, можете да изключите добавката за обратна връзка в страницата %sУправление на добавки%s.", + "CustomLogoHelpText": "Можете да модифицирате логото на Piwik, което да се показва в интерфейса на потребителя и имейлите с отчети.", + "EmailServerSettings": "Настройки сървър на е-поща", + "ForBetaTestersOnly": "Само за бета тестери", + "ImageTracking": "Проследяване на изображенията", + "ImageTrackingIntro1": "Когато посетителят е изключил JavaScript или когато JavaScript не може да бъде използван, може да се използва проследяващата връзка към изображение, за да бъдат проследени посетителите.", + "ImageTrackingLink": "Връзка към изображение, за което се води отчет", + "ImportingServerLogs": "Импортиране на сървърни логове", + "ImportingServerLogsDesc": "Една алтернатива за проследяване на посетителите чрез браузъра (или чрез JavaScript или препратка към файла) е непрекъснато да се внасят сървърните логове. Научете повече за %1$sServer Log File Analytics%2$s.", + "InvalidPluginsWarning": "Следните добавки не са съвместими с %1$s и не могат да бъдат заредени: %2$s.", + "InvalidPluginsYouCanUninstall": "Може да обновите или деинсталирате тези добавки чрез %1$sУправление на добавките%2$s.", + "JavaScriptTracking": "JavaScript Проследяване", + "JSTracking_CampaignKwdParam": "Параметър ключова дума на кампанията", + "JSTracking_CampaignNameParam": "Параметър име на кампанията", + "JSTracking_CodeNote": "Уверете се, че този код е на всяка една страница от вашия сайт преди %1$s таг.", + "JSTracking_CustomCampaignQueryParam": "Използвайте произволно име на параметър заявка за име и ключ на кампанията", + "JSTracking_CustomCampaignQueryParamDesc": "Забележка: %1$sPiwik автоматично ще засече Google Analytics параметрите.%2$s", + "JSTracking_EnableDoNotTrack": "Активиране на режим за засичане на включена функция „Не проследявай“", + "JSTracking_EnableDoNotTrack_AlreadyEnabled": "Забележка: От страна на сървъра е включена настройката „Не проследявай“, така че тази настройка няма да окаже ефект.", + "JSTracking_EnableDoNotTrackDesc": "Заявките за следене няма да бъдат изпратени, ако посетителите не желаят да бъде събирана информация за тях.", + "JSTracking_GroupPageTitlesByDomainDesc1": "В случай, че някой посети страница „За“ в блога, %1$s ще бъде записан\/о в 'Блог \/ За'. Това е най-лесният начин, за да се направи преглед на трафика по поддомейн.", + "JSTracking_MergeAliases": "В доклада за „Изходните страници“ скрийте щракванията до познати адреси на", + "JSTracking_MergeSubdomains": "Проследяване на посетителите във всички поддомейни на", + "JSTracking_MergeSubdomainsDesc": "Ако един посетител разгледа %1$s и %2$s, това ще се брои като едно уникално посещение.", + "JSTracking_PageCustomVars": "Проследяване на персонализирана променлива за всеки преглед на страница", + "JSTracking_PageCustomVarsDesc": "Пример: име на променливата „Категория“ и стойност „Бели книжа“.", + "JSTracking_VisitorCustomVars": "Проследяване на персонализирани променливи за този посетител", + "JSTracking_VisitorCustomVarsDesc": "Например, с име на променлива „Тип“ и стойност „Клиент“.", + "JSTrackingIntro1": "Вие може да следите потребителите, които посещават вашия сай, по много различни начини. Препоръчителният начин да го направите е чрез JavaScript. За да използвате този метод, трябва да се уверите, че всяка страница на вашия сайт има необходимия JavaScript код, който можете да генерирате тук.", + "JSTrackingIntro2": "След като имате нужния JavaScript код за вашия сайт, го копирайте и поставете във всички страници, които искате да следите с Piwik.", + "JSTrackingIntro3": "В повете сайтове, блогове, системи за управление на съдържанието (CMS) и други, можете да използвате предварително създадени добавки, които да извършват техническата работа. (Вижте нашия %1$sсписък с добавки, които се използват за интеграция с Piwik%2$s.) Ако няма добавка, която да използвате, можете да промените шаблоните на вашия сайт и да добави този код в \"footer\" файла.", + "JSTrackingIntro4": "В случай, че не искате да използвате JavaScript за водене на статистика за посетителите %1$sгенерирай изображение под формата на проследяваща връзка по-долу%2$s.", + "JSTrackingIntro5": "В случай, че желаете да събирате повече информация за посещенията, моля, вижте %1$sPiwik Javascript Tracking документацията%2$s, за списък с наличните функции. Използвайки тези функции може да следите цели, персонализирани променливи, поръчки, изоставени колички и други.", + "LatestBetaRelease": "Най-новата бета версия", + "LatestStableRelease": "Най-новата стабилна версия", + "LogoUpload": "Изберете логото за качване", + "LogoUploadHelp": "Моля, качете файла в %s формати с минимална височина %s пиксела.", + "MenuDiagnostic": "Диагностика", + "MenuGeneralSettings": "Основни настройки", + "MenuManage": "Управление", + "OptOutComplete": "Отказът е приет; вашите посещения в този уебсайт няма да бъдат записвани от Инструмента за Уеб анализ.", + "OptOutCompleteBis": "Запомнете, че ако изтриете вашите бисквитки или ако смените компютъра или уеб браузъра ще е нужно да направите процедурата за отказ отново.", + "OptOutExplanation": "Piwik е ангажиран с осигуряването на поверителност в Интернет. За да позволите на потребителите си да се откажат от Piwik Web Analytics, можете да добавите нужният HTML код в една от вашите уеб страници, например в раздела Поверителност.", + "OptOutExplanationBis": "Този код ще ви покаже рамка, съдържаща връзка, чрез която вашите посетители могат да се откажат от Piwik, като поставят бисквитка в техните браузъри. %s Натиснете тук%s за да видите съдържанието, което ще бъде показано в рамката.", + "OptOutForYourVisitors": "Piwik отказ за вашите посетители", + "PiwikIsInstalledAt": "Piwik е инсталиран на", + "PluginDescription": "Администраторски панел на Piwik", + "PluginSettingChangeNotAllowed": "Не е позволено да се променя стойността за настройка \"%s\" в добавка \"%s\"", + "PluginSettings": "Настройки на добавките", + "PluginSettingsIntro": "Тук могат да се променят настройките за следните добавки от трети страни:", + "PluginSettingsValueNotAllowed": "Стойността за поле \"%s\" за добавка \"%s\" не е позволена", + "StableReleases": "Ако Piwik е критично важен за вашият бизнес, използвайте последната стабилна версия. Ако използвате последна бета и откриете бъг или имате предложение, моля %sвижте тук%s.", + "TrackAGoal": "Проследяване на цел", + "TrackingCode": "Код за проследяване", + "TrustedHostConfirm": "Сигурен ли сте, че желаете да промените доверен Piwiki хост име", + "TrustedHostSettings": "Доверено Piwiki хост име", + "UseCustomLogo": "Използвайте изработено лого", + "ValidPiwikHostname": "Валидно Piwik хост име", + "WithOptionalRevenue": "с опция за приходите", + "YouAreOptedIn": "В момента сте избрали", + "YouAreOptedOut": "В момента сте отказали", + "YouMayOptOut": "Можете да изберете да нямате уникална уеб анализ бисквитка с идектификационен номер, свързан към вашият компютър, за да избегнете агрегацията на анализ на събраната информация на този уеб сайт.", + "YouMayOptOutBis": "За да направите този избор, моля кликнете долу за да получите биксвитката за отказване." + }, + "CoreHome": { + "CategoryNoData": "Няма данни в тази категория. Опитайте \"Включи всички данни\".", + "CheckForUpdates": "Проверка за обновления", + "CheckPiwikOut": "Проверка на Piwiki изход!", + "CloseWidgetDirections": "Можете да затворите тази джаджа, като кликнете на 'Х' иконата в горната част на джаджата.", + "DataForThisReportHasBeenPurged": "Датата на този доклад е стара с %s месеца и ще бъде изтрита.", + "DataTableExcludeAggregateRows": "Сумарните редове са показани %s Скриване", + "DataTableIncludeAggregateRows": "Сумарните редове са скрити %s Покажи ги", + "DateFormat": "%дължинаДен% %ден% %дължинаМесец% %дължинаГодина%", + "Default": "подразбиране", + "DonateCall1": "Piwik винаги ще бъде безплатен за използване, но това не означава, че ние нямаме разходи за развитието му.", + "DonateCall2": "Piwik се нуждае от вашата подкрепа, за да продължава да расте и процъфтява.", + "DonateCall3": "В случай, че Piwik помага на вашия бизнес или услуги, които сте пуснали, %1$sмоля, направете дарение!%2$s", + "DonateFormInstructions": "Изберете стойност посредством плъзгача, след което натиснете бутона за абониране, за да направите дарение.", + "ExcludeRowsWithLowPopulation": "Всички редове са показани %s С изключение на тези с ниски стойности", + "FlattenDataTable": "Този доклад е йерархичен %s Направете го плосък", + "HowMuchIsPiwikWorth": "Каква стойност има Piwik за вас?", + "IncludeRowsWithLowPopulation": "Редове с ниски стойности са скрити %s Покажи всички редове", + "InjectedHostEmailBody": "Здравейте, днес се опитах да достъпя Piwik и се натъкнах на предупреждение за неизвестно име на машината.", + "InjectedHostEmailSubject": "Piwik е бил достъпен чрез неизвестно име: %s", + "InjectedHostSuperUserWarning": "Възможно е конфигурацията на Piwik да е неправилна (това се случва когато Piwik е бил изместен на нов сървър или друг адрес). Вие можете или %1$sда щракнете тук и да добавите %2$s като валиден Piwik адрес (ако сте сигурни в достоверността му)%3$s, или %4$sда щракнете тук и да посетите %5$s, за да достъпите Piwik безпасно%6$s.", + "InjectedHostWarningIntro": "В момента достъпвате Piwik от %1$s, но Piwik е настроен да работи чрез този адрес: %2$s.", + "JavascriptDisabled": "JavaScript трябва да бъде разрешен за да използвате Piwik в стандартен изглед.
    Същото е ако JavaScript не е разрешен или браузъра не го поддържа.
    За да използвате стандартен изглед разрешете JavaScript от настройките на Вашия браузър и %1$sопитайте отново%2$s.
    ", + "MakeADifference": "Открийте разликата: %1$sДарете%2$s за фонд Piwik 2.0!", + "MakeOneTimeDonation": "Направете еднократно дарение", + "NoPrivilegesAskPiwikAdmin": "Вие се логнахте в като '%s' но изглежда че нямате разрешение от Piwik. %s Попитайте Вашият Piwik администратор (клик на email)%s да Ви даде \"поглед\" достъп до сайта.", + "PageOf": "%1$s от %2$s", + "PeriodDay": "Ден", + "PeriodDays": "дни", + "PeriodMonth": "Месец", + "PeriodMonths": "месеци", + "PeriodRange": "Период", + "PeriodWeek": "Седмица", + "PeriodWeeks": "седмици", + "PeriodYear": "Година", + "PeriodYears": "години", + "PluginDescription": "Уеб Анализи Доклади структура.", + "ReportGeneratedOn": "Доклада е генериран за %s", + "ReportGeneratedXAgo": "Доклада е генериран преди %s", + "SharePiwikLong": "Здравейте! Туко-що открих прекрасен софтуер с отворен код: Piwik! Piwik позволява проследяването на посетителите на даден сайт безплатно. Задължително трябва да го пробвате!", + "SharePiwikShort": "Piwik! Софтуер с отворен код за уеб анализ. Притежавайте вашите собствени данни.", + "ShareThis": "Сподели това", + "ShowJSCode": "Покажи JavaScript кода за вмъкване в сайта", + "SubscribeAndBecomePiwikSupporter": "Пристъпете към сигурната страница за плащане (PayPal) за да станете Piwik Supporter!", + "SupportPiwik": "Подкрепи Piwik!", + "TableNoData": "Няма данни за тази таблица.", + "ThereIsNoDataForThisReport": "Няма данни за този доклад.", + "UnFlattenDataTable": "Този доклад е плосък %s Направете го йерархичен", + "ViewAllPiwikVideoTutorials": "Виж всички Piwik помощни клипове", + "WebAnalyticsReports": "Уеб Анализи Доклади", + "YouAreUsingTheLatestVersion": "Вие използвате последна версия на Piwik!" + }, + "CorePluginsAdmin": { + "ActionActivatePlugin": "Активиране на добавка", + "ActionActivateTheme": "Активирай тапет", + "ActionInstall": "Инсталиране", + "ActionUninstall": "Деинсталиране", + "Activate": "Активирай", + "Activated": "Активиран", + "Active": "Активна", + "Activity": "Активност", + "AllowedUploadFormats": "Може да качите добавка или тапет в zip формат посредством тази страница.", + "AuthorHomepage": "Начало на автор", + "Authors": "Автори", + "BackToExtendPiwik": "Връщане към магазина за приложения", + "BeCarefulUsingPlugins": "Бъдете внимателни с добавките, които не са създадени от екипа на Piwik: те не са проверени.", + "BeCarefulUsingThemes": "Бъдете внимателни с тапетите, които не са създадени от екипа на Piwik: те не са проверени.", + "ByDesigningOwnTheme": "чрез %sсъздаване на ваш собствен тапет%s", + "ByInstallingNewPluginFromMarketplace": "чрез %sинсталиране на нова добавка от магазина за приложения%s", + "ByInstallingNewThemeFromMarketplace": "чрез %sинсталиране на нов тапет от магазина за приложения%s", + "ByWritingOwnPlugin": "като %sсъздадете ваша собствена добавка%s", + "ByXDevelopers": "от %s разработчици", + "Changelog": "Списък с промените", + "ChangeSettingsPossible": "Промяна на %sнастройките%s за тази добавка.", + "CorePluginTooltip": "Основните добавки нямат версия, тъй като се разпространяват с Piwik.", + "Deactivate": "Деактивирай", + "Developer": "Разработчик", + "DoMoreContactPiwikAdmins": "За да се инсталира нова добавка или нова тема, трябва да се свържете с вашия администратор, който отговаря за Piwik.", + "DownloadAndInstallPluginsFromMarketplace": "Можете автоматично да свалите и инсталирате нови добавки от %sмагазина за приложения%s.", + "EnjoyAnotherLookAndFeelOfThemes": "Насладете се на друг изглед и усещане", + "FeaturedPlugin": "Препоръчана добавка", + "GetEarlyAccessForPaidPlugins": "Забележка: всички от наличните добавки са безплатни за използване; в бъдеще ще има платена секция в магазина за приложения (%sсвържете се с нас%s за предварителен достъп).", + "GetNewFunctionality": "Сдобийте се с нова функционалност", + "History": "История", + "Inactive": "Неактивна", + "InfoPluginUpdateIsRecommended": "Обновете вашите добавки, за да се възползвате от най-новите подобрения.", + "InfoThemeIsUsedByOtherUsersAsWell": "Забележка: други %1$s потребители регистрирани в този Piwik също използват тапет %2$s.", + "InfoThemeUpdateIsRecommended": "Обновете вашите тапети, за да получите последната версия.", + "InstallingPlugin": "Инсталира се %s", + "InstallNewPlugins": "Инсталиране на нови добавки", + "InstallNewThemes": "Добави нови тапети", + "LastCommitTime": "(последен принос %s)", + "LastUpdated": "Последно обновена", + "LicenseHomepage": "Начало на лиценз", + "MainDescription": "Добавките разширяват функционалността на Piwik. Веднъж инсталирана, добавката може да бъде активирана (пускана) или деактивирана (спирана).", + "Marketplace": "Магазин", + "MarketplaceSellPluginSubject": "Магазин – Продайте добавка", + "MenuPlatform": "Платформа", + "MissingRequirementsNotice": "Моля, обновете %1$s %2$s до по-нова версия, %1$s %3$s се изисква.", + "NoPluginsFound": "Не е намерена добавка", + "NotAllowedToBrowseMarketplacePlugins": "Може да разгледате списъка с добавки, които могат да бъдат инсталирани, за да настроите или разширите вашата Piwik платформа. Моля, свържете се с вашия системен администратор, ако желаете да бъде инсталирана някоя добавка.", + "NotAllowedToBrowseMarketplaceThemes": "Може да разгледате списъка с тапети, които могат да бъдат инсталирани, за да променят изгледа на Piwik. Моля, свържете се с вашия системен администратор, за да извърши инсталацията им.", + "NoThemesFound": "Не са намерени тапети", + "NoZipFileSelected": "Моля, изберете zip файл.", + "NumDownloadsLatestVersion": "Последна версия:%s сваляния", + "NumUpdatesAvailable": "Налични са обновления %s", + "OrByUploadingAPlugin": "или чрез %sкачване на добавка%s", + "OrByUploadingATheme": "или чрез %sкачване на тапет%s", + "Origin": "Произход", + "OriginCore": "Ядро", + "OriginThirdParty": "Трета страна", + "PluginDescription": "Интерфейс за администрация на добавките.", + "PluginHomepage": "Сайт на добавката", + "PluginKeywords": "Ключови думи", + "PluginNotCompatibleWith": "%1$s добавка не е съвместима с %2$s.", + "PluginNotWorkingAlternative": "Ако сте използвали тази добавка, може би ще намерите по-нова версия в магазина за приложения. В случай, че няма по-нова версия, може би ще желаете да деинсталирате добавката.", + "PluginsManagement": "Управление на добавките", + "PluginUpdateAvailable": "Използва се версия %s и е налична нова версия %s.", + "PluginVersionInfo": "%1$s от %2$s", + "PluginWebsite": "Сайт за добавки", + "Screenshots": "Екранни снимки", + "SortByAlpha": "по азбучен ред", + "SortByNewest": "най-новите", + "SortByPopular": "популярно", + "Status": "Състояние", + "StepDownloadingPluginFromMarketplace": "Сваляне на добавка от магазина за приложения", + "StepDownloadingThemeFromMarketplace": "Сваляне на тапет от магазина за приложения", + "StepPluginSuccessfullyInstalled": "Успешно е инсталирана добавка %1$s %2$s.", + "StepPluginSuccessfullyUpdated": "Успешно е обновена добавка %1$s %2$s.", + "StepReplaceExistingPlugin": "Подмяна на съществуваща добавка", + "StepReplaceExistingTheme": "Замяна на съществуващ тапет", + "StepThemeSuccessfullyInstalled": "Вие успешно инсталирахте тапет %1$s %2$s.", + "StepThemeSuccessfullyUpdated": "Вие успешно обновихте тапет %1$s %2$s.", + "StepUnzippingPlugin": "Добавката се разархивира", + "StepUnzippingTheme": "Тапетът се разархивира", + "SuccessfullyActicated": "Успешно беше активирано %s<\/strong>.", + "Support": "Поддръжка", + "TeaserExtendPiwik": "Разширете възможностите на Piwik с добавки и тапети", + "TeaserExtendPiwikByPlugin": "Разширяване на възможностите на Piwik, чрез нова добавка", + "TeaserExtendPiwikByTheme": "Насладете се на друг изглед и усещане инсталирайки нов тапет", + "TeaserExtendPiwikByUpload": "Разширяване на възможностите на Piwik, чрез добавяне на zip файл", + "Theme": "Тапет", + "Themes": "Тапети", + "ThemesDescription": "Тапетите могат да променят въшншия вид на потребителския интерфейс на Piwik и да осигурят ново пълноценно визуално усещане, за да се насладите на Вашите статистики.", + "ThemesManagement": "Управление на тапетите", + "UninstallConfirm": "Вие сте на път да деинсталирате добавката %s. Добавката ще бъде напълно премахната от вашата платформа и няма да може да бъде възстановена. Наистина ли искате да направите това?", + "Updated": "Обновени", + "UpdatingPlugin": "Обновява се %s", + "UploadZipFile": "Качване на zip файл", + "Version": "Версия", + "ViewRepositoryChangelog": "Преглед на промените", + "Websites": "Сайтове" + }, + "CoreUpdater": { + "ClickHereToViewSqlQueries": "Щракнете тук, за да видите и копие на списъка на SQL заявката, която ще изпълнява", + "CreatingBackupOfConfigurationFile": "Създадено е резервно копие на конфигурационния файл в %s", + "CriticalErrorDuringTheUpgradeProcess": "Открита е КРИТИЧНА грешка по време на обновяването:", + "DatabaseUpgradeRequired": "Необходимо е да се обнови базата от данни (БД)", + "DownloadingUpdateFromX": "Изтегляне на обновлението от %s", + "DownloadX": "Изтегляне %s", + "EmptyDatabaseError": "Базата от данни (БД) %s е празна. Трябва да редактирате или изтриете конфигурационният файл на Piwik.", + "ErrorDIYHelp": "Ако сте напреднал потребител и попаднете на грешка при обновяване на базата от данни (БД):", + "ErrorDIYHelp_1": "открийте и редактирайте в конфигурационния файл (например, memory_limit или max_execution_time)", + "ErrorDIYHelp_2": "изпълни останалите заявки за обновяване от това поле", + "ErrorDIYHelp_3": "опцията `ръчно обновяване` на таблиците в Piwik базата от данни (БД), определяне на стойността на version_core на версията на провалилота се актуализация", + "ErrorDIYHelp_4": "повторно стартирайте обновлението (чрез браузъра или от командния ред) за да продължите с останалите обновления", + "ErrorDIYHelp_5": "изпратете този проблем и\/или решение на Piwik екипа - възможно е това да е важно!", + "ErrorDuringPluginsUpdates": "Открита е грешка при обновяването на добавките:", + "ExceptionAlreadyLatestVersion": "Тази версия %s на Piwik е актуална.", + "ExceptionArchiveEmpty": "Празен архив.", + "ExceptionArchiveIncompatible": "Несъвместим архив: %s", + "ExceptionArchiveIncomplete": "Несъвместим архив: някои файлове липсват (например %s).", + "HelpMessageContent": "Проверете %1$s Често Задавани Въпроси (ЧЗВ) %2$s , където са обяснени възможните грешки по време на обновяването. %3$s Посъветвайте се с вашия системен администратор - те са в състояние да ви помогнат с грешка, която най-вероятно е свързана със сървъра или MySQL настройка.", + "HelpMessageIntroductionWhenError": "Открита е грешка в ядрото на Piwik. Ако имате нужда от допълнителна помощ моля обърнете се към нашия форум и\/или прочетете документацията:", + "HelpMessageIntroductionWhenWarning": "Актуализацията приключи успешно, но бяха открити грешки по време на процеса. Моля, прочетете документацията за допълнителна информация:", + "InstallingTheLatestVersion": "Инсталиране на последната версия", + "MajorUpdateWarning1": "Това е основна актуализация! Това ще отнеме малко повече от обичайното.", + "MajorUpdateWarning2": "Следният съвет е особено важен за големи инсталации.", + "NoteForLargePiwikInstances": "Важна бележка за големи Piwik инсталации", + "NoteItIsExpectedThatQueriesFail": "Забележка: При ръчното изпълнение на тези въпроси, се очаква, че някои от тях ще се провалят. В такъв случай ги игнорирайте и продължете напред.", + "PiwikHasBeenSuccessfullyUpgraded": "Piwik успешно е обновен!", + "PiwikUpdatedSuccessfully": "Обновлението на Piwik завърши успешно!", + "PiwikWillBeUpgradedFromVersionXToVersionY": "Piwik базата от данни (БД) ще бъде обновена от %1$s версия към %2$s.", + "PluginDescription": "Механизъм за актуализиране на Piwik", + "ReadyToGo": "Готови ли сте?", + "TheFollowingPluginsWillBeUpgradedX": "Следните добавки ще бъдат обновени: %s.", + "ThereIsNewVersionAvailableForUpdate": "Открита е нова версия на Piwik, можете да обновите!", + "TheUpgradeProcessMayFailExecuteCommand": "Ако имате голяма Piwik база данни, актуализирането и може да отнеме прекалено много време, ако я стартирате през Вашият браузър. В този случай, можете да извършите актуализацията чрез команден ред: %s", + "TheUpgradeProcessMayTakeAWhilePleaseBePatient": "Обновяването на базата от данни (БД) отнема известно време! Бъдете търпеливи...", + "UnpackingTheUpdate": "Разархивиране на обновлението", + "UpdateAutomatically": "Обновете автоматично", + "UpdateHasBeenCancelledExplanation": "Автоматичното обновяване на Piwik е отказано. Опитайте отново! Ако не успеете автоматично да обновите Piwik, опитайте ръчно. %1$s Моля прочетете %2$s документацията %3$s и продължете!", + "UpdateTitle": "Обновления", + "UpgradeComplete": "Обновяването приключи!", + "UpgradePiwik": "Обновете Piwik", + "VerifyingUnpackedFiles": "Разархивираните файлове", + "WarningMessages": "Предупредителни съобщения:", + "WeAutomaticallyDeactivatedTheFollowingPlugins": "Автоматично са деактивирани следните добавки: %s", + "YouCanUpgradeAutomaticallyOrDownloadPackage": "Можете да обновите автоматично към версия %s или да изтеглите обновлението и инсталирате ръчно:", + "YouCouldManuallyExecuteSqlQueries": "Ако не сте в състояние да използвате актуализациите и ако не успеете да обновите Piwik (поради изчакване на база данни, изчакване на уеб браузъра или друго) можете ръчно да изпълните SQL заявките актуализиране на Piwik.", + "YouMustDownloadPackageOrFixPermissions": "Piwik не е в състояние да презапишете текущата си инсталация. Можете да отстраните директорията\/файла за достъп, или да изтеглите и инсталирате пакет версия %s и ръчно:", + "YourDatabaseIsOutOfDate": "Базата от данни (БД) на Piwik е остаряла. Трябва да обновите преди да продължите нататък." + }, + "CustomVariables": { + "ColumnCustomVariableName": "Име на променлива", + "ColumnCustomVariableValue": "Съдържание на променлива", + "CustomVariables": "Променливи", + "CustomVariablesReportDocumentation": "Този отчет съдържа информация за вашите Персонални променливи. Кликнете върху името на променлива, за да видите разпределението на стойностите. %s За повече информация отностно Персоналните Променливи в общ план, прочетете Документацията за %sПерсонални промеливи на Piwik.org%s", + "PluginDescription": "Персоналните променливи са име,стойност двойки данни, които можете да настроите към посещение, използвайки Javascript API и функцията setVisitCustomVariables() . Тогава Piwik ще ви изведе отчет колко посещения, страници, конверсии има за всяка една от тези персонални имена и стойности.", + "ScopePage": "Разгледай страница", + "ScopeVisit": "Разгледай посещение", + "TrackingHelp": "Помощ: %1$sСледене на персонализирани променливи в Piwik%2$s" + }, + "Dashboard": { + "AddAWidget": "Добави нова джаджа", + "AddPreviewedWidget": "Добави джаджата на таблото", + "ChangeDashboardLayout": "Сменете изледа на таблото", + "CopyDashboardToUser": "Копиране на табло при потребител", + "CreateNewDashboard": "Направете ново табло", + "Dashboard": "Табло", + "DashboardCopied": "Настоящото табло е успешно копирано при избрания потребител.", + "DashboardEmptyNotification": "Вашето табло не съдържа никакви джаджи. Започнете с добавяне на някои джаджи или просто нулирайте таблото, за да изберете джаджите по подразбиране.", + "DashboardName": "Име на табло:", + "DashboardOf": "Табло за %s", + "DefaultDashboard": "Табло по подразбиране - Използвайте джаджите по подразбиране и изгледа на колоните.", + "DeleteWidgetConfirm": "Наистина ли искате да изтриете тази джаджа от таблото?", + "EmptyDashboard": "Изпразнете таблото - Изберете любимите си джаджи", + "LoadingWidget": "Зарежда джаджата, почакайте...", + "ManageDashboard": "Управлявате таблото", + "Maximise": "Максимизиране", + "Minimise": "Минимизиране", + "NotUndo": "Вие няма да можете да отмените тази операция", + "PluginDescription": "Табло на вашия уеб анализатор. Можете да настроите таблото си по свой вкус: да добавяте нови джаджи, да променяте подредбата им. Всеки потребител има достъп до своето собствено табло.", + "RemoveDashboard": "Премахнете таблото", + "RemoveDashboardConfirm": "Сигурни ли сте че искате да премахнете таблото \"%s\"?", + "RenameDashboard": "Сменете името на таблото", + "ResetDashboard": "Зареди отново таблото", + "ResetDashboardConfirm": "Сигурен ли сте че искате да заредите отново плана на таблото с избраните по подразбиране джаджи?", + "SelectDashboardLayout": "Моля, изберете Вашият нов изглед на табло", + "SelectWidget": "Изберете джаджа, която да добавите на таблото", + "SetAsDefaultWidgets": "Поставете по подразбиране избора на джаджи", + "SetAsDefaultWidgetsConfirm": "Сигурни ли сте, че искате да настроите настоящата селекция на джаджи и оформление на таблото по подразбиране?", + "SetAsDefaultWidgetsConfirmHelp": "Селекцията на джаджи и колоните на изгледа на таблото ще бъдат използвани, когато потребителят създаде ново табло, или когато \"%s\" функция е използвана.", + "WidgetNotFound": "Несъществуваща джаджа", + "WidgetPreview": "Преглед на джаджата", + "WidgetsAndDashboard": "Джаджи и Табло" + }, + "DoNotTrack": { + "PluginDescription": "Игнориране на посещенията с X-Do-Not-Track или заглавие DNT." + }, + "Feedback": { + "ContactThePiwikTeam": "Свържете се с екипа на Piwik!", + "DoYouHaveBugReportOrFeatureRequest": "Желаете да докладвате за грешка или имате предложение?", + "IWantTo": "Желая да:", + "LearnWaysToParticipate": "Научете повече за начините, как бихте могли да %sучаствате%s", + "ManuallySendEmailTo": "Моля изпратете ръчно Вашето съобщение до", + "PluginDescription": "Изпратете обратна връзка към екипа на Piwik. Споделете вашите идеи и предложения с нас!", + "SendFeedback": "Изпрати съобщението", + "SpecialRequest": "Имате ли специална молба към екипа на Piwik?", + "ThankYou": "Благодаря, че помогнахте да направим Piwik по-добър!", + "TopLinkTooltip": "Може да ни кажете какво мислите, както и да изискате професионална помощ.", + "VisitTheForums": "Посетете нашият %sФорум%s" + }, + "General": { + "AbandonedCarts": "Отказани колички", + "AboutPiwikX": "За Piwik %s", + "Action": "Действие", + "Actions": "Действия", + "Add": "Добавяне", + "AfterEntry": "след влизане тук", + "All": "Всички", + "AllowPiwikArchivingToTriggerBrowser": "Разреши Piwik архивиране, веднага след като докладите бъдат гледани от браузър", + "AllWebsitesDashboard": "Табло за всички сайтове", + "And": "и", + "API": "API", + "ApplyDateRange": "Прилагане на периода от време", + "ArchivingInlineHelp": "За сайтовете със среден или висок трафик, препоръчваме да изключите Piwik архивиране след гледане от браузър. Съветваме ви в този случай да използвате cron job за Вашите доклади на всеки час.", + "ArchivingTriggerDescription": "За по-големи Piwik инсталации, се препоръчва %scron job%s за автоматични доклади.", + "AuthenticationMethodSmtp": "Удостоверен метод за SMTP", + "AverageOrderValue": "Средна стойност на поръчка", + "AveragePrice": "Средна цена", + "AverageQuantity": "Средно количество", + "BackToPiwik": "Върни се в Piwik", + "Broken": "Счупен", + "BrokenDownReportDocumentation": "Той е разбит в различни доклади, които са показани по-светли в долната част на страницата. Можете да увеличите графиките, като кликнете върху доклада, който бихте искали да видите.", + "Cancel": "Отказ", + "CannotUnzipFile": "Не може да се разархивира файл %1$s: %2$s", + "ChangePassword": "Смяна на парола", + "ChangeTagCloudView": "Моля, имайте предвид, че можете да видите доклада и по други начини, освен като облак от етикети. За целта, използвайте знаците в долната част на доклада.", + "ChooseDate": "Изберете дата", + "ChooseLanguage": "Избери език", + "ChoosePeriod": "Избери период", + "ChooseWebsite": "Избери уеб сайт", + "ClickHere": "Кликнете тук за повече информация.", + "ClickToChangePeriod": "Натисни отново за да смениш периода.", + "Close": "Затваряне", + "ColumnActionsPerVisit": "Действия при посещение", + "ColumnActionsPerVisitDocumentation": "Среден брой на действията (показвания на страници, изтегляния или отваряне на външни връзки), извършени по време на посещенията.", + "ColumnAverageGenerationTime": "Средно времетраене за обработка на заявката", + "ColumnAverageGenerationTimeDocumentation": "Средното време, необходимо за генериране на страницата. Този показател включва времето, необходимо на сървъра да генерира уеб страницата, плюс времето, което е необходимо на посетителя да свали информацията от сървъра. По-ниската стойност „Ср. време за генериране“, означава по-бързо зареждане на сайта за посетителите!", + "ColumnAverageTimeOnPage": "Средно време на страница", + "ColumnAverageTimeOnPageDocumentation": "Средно време, което посетителите са прекарали на тази страница (само на страницата, а не на целия сайт).", + "ColumnAvgTimeOnSite": "Средно време прекарано в сайта", + "ColumnAvgTimeOnSiteDocumentation": "Средна продължителност на посещение.", + "ColumnBounceRate": "Процент", + "ColumnBounceRateDocumentation": "Процент посещения, при които има само едно показване на страница. Това означава, че посетителят е напуснал сайта от страницата.", + "ColumnBounceRateForPageDocumentation": "Процент посещения, които започват и завършват с тази страница.", + "ColumnBounces": "Скокове", + "ColumnBouncesDocumentation": "Процент посещения, които започват и завършват с тази страница. Това означава, че посетителят е напуснал сайта след като е видял само тази страница.", + "ColumnConversionRate": "Процент на реализации", + "ColumnConversionRateDocumentation": "Процент на посещенията, които се считат като конверсия на цел.", + "ColumnDestinationPage": "Целева страница", + "ColumnEntrances": "Входовете", + "ColumnEntrancesDocumentation": "Брой посещения, които са започнали от тази страница.", + "ColumnExitRate": "Изходен процент", + "ColumnExitRateDocumentation": "Процент посещения, последвани от напускане на сайта след преглед на тази страница.", + "ColumnExits": "Изходи", + "ColumnExitsDocumentation": "Брой посещения, които са свършили на тази страница.", + "ColumnGenerationTime": "Време за генериране", + "ColumnKeyword": "Ключови думи", + "ColumnLabel": "Етикет", + "ColumnMaxActions": "Максимум действия при едно посещение", + "ColumnNbActions": "Действия", + "ColumnNbActionsDocumentation": "Броят на действията, извършвани от вашите посетители. Действията могат да бъдат показвания на страници, изтегляния или отваряне на външни връзки.", + "ColumnNbUniqVisitors": "Уникални посетители", + "ColumnNbUniqVisitorsDocumentation": "Брой уникални посетители на сайта. Всеки посетител се брои само веднъж, дори ако посещава сайта няколко пъти дневно.", + "ColumnNbVisits": "Посещения", + "ColumnNbVisitsDocumentation": "Ако посетителят идва на сайта за първи път или ако посети дадена страница след повече от 30 минути от последното си посещение на страницата, ще бъде отчетено като ново посещение.", + "ColumnPageBounceRateDocumentation": "Процент посещения, които започват от тази страница и са последвани от незабавно напускане на сайта.", + "ColumnPageviews": "Входящи прегледи", + "ColumnPageviewsDocumentation": "Брой посещения на тази страница.", + "ColumnPercentageVisits": "Процент", + "ColumnRevenue": "Приход", + "ColumnSumVisitLength": "Цялото време прекарано от потребителите (в секунди)", + "ColumnTotalPageviews": "Общо показвания", + "ColumnUniqueEntrances": "Уникални влизания", + "ColumnUniqueExits": "Уникални напускания", + "ColumnUniquePageviews": "Уникални входящи прегледи", + "ColumnUniquePageviewsDocumentation": "Брой посещения, включващи тази страница. Ако страницата е видяна няколко пъти по време на посещението, се отчита като едно посещение.", + "ColumnValuePerVisit": "Стойност на посещението", + "ColumnViewedAfterSearch": "Избран от резултатите при търсене", + "ColumnViewedAfterSearchDocumentation": "Броят пъти, в който тази страница е била посетена, след като посетител е извършил търсене във вашия сайт и е щракнал върху връзка от списъка с резултати.", + "ColumnVisitDuration": "Продължителност на посещение (в секунди)", + "ColumnVisitsWithConversions": "Посещения с Конверсия", + "ConfigFileIsNotWritable": "Конфигурационният файл %s на Piwik не е достъпен и Вашите настройки няма да бъдат запазени. %s Моля променете правата на файла, така, че да може да се пише в него.", + "Continue": "Продължи", + "ContinueToPiwik": "Продължете към Piwik", + "CurrentMonth": "Този месец", + "CurrentWeek": "Тази седмица", + "CurrentYear": "Тази година", + "Daily": "Ежедневно", + "DailyReport": "дневно", + "DailyReports": "Дневни доклади", + "DailySum": "дневна сума", + "DashboardForASpecificWebsite": "Табло за определен сайт", + "DataForThisGraphHasBeenPurged": "Данните за тази графика са отпреди повече от %s месеца и са премахнати.", + "DataForThisTagCloudHasBeenPurged": "Данните за този таг са отпреди повече от %s месеца и са премахнати.", + "Date": "Дата", + "DateRange": "Период от време:", + "DateRangeFrom": "От", + "DateRangeFromTo": "От %s до %s", + "DateRangeInPeriodList": "Период от време", + "DateRangeTo": "До", + "DayFr": "Пт", + "DayMo": "По", + "DaySa": "Съ", + "DaysHours": "%1$s дни %2$s часа", + "DaysSinceFirstVisit": "Дни след първото посещение", + "DaysSinceLastEcommerceOrder": "Дни след последната поръчка от електронен магазин", + "DaysSinceLastVisit": "Дни след последното посещение", + "DaySu": "Нд", + "DayTh": "Чт", + "DayTu": "Вт", + "DayWe": "Ср", + "Default": "По подразбиране", + "DefaultAppended": "(по подразбиране)", + "Delete": "Изтрий", + "Description": "Описание", + "Desktop": "Десктоп", + "Details": "Детайли", + "Discount": "Отстъпка", + "DisplaySimpleTable": "Покажи проста таблица", + "DisplayTableWithGoalMetrics": "Покажи таблица с повече целеви показатели", + "DisplayTableWithMoreMetrics": "Покажи таблица с повече показатели", + "Documentation": "Документация", + "Donate": "Дарение", + "Done": "Готово", + "Download": "Изтегли", + "DownloadFail_FileExists": "Файлът %s вече съществува!", + "DownloadFail_FileExistsContinue": "Направен е опит да се продължи свалянето за %s, но изцяло изтеглен файл вече съществува!", + "DownloadFail_HttpRequestFail": "Файлът не може да бъде свален! Възможно е да има проблем със сайта, от който се опитвате да сваляте. Може да се опитате по-късно отново да свалите файла или да го намерите от друго място.", + "DownloadFullVersion": "%1$sИзтегли%2$s пълната версия! Проверка %3$s", + "DownloadPleaseRemoveExisting": "В случай, че желаете да бъде сменен, трябва наличният файл да бъде премахнат.", + "Downloads": "Сваляния", + "EcommerceOrders": "Поръчки Електронна търговия", + "EcommerceVisitStatusDesc": "Статус на посещението на електронен магазин в неговия край", + "EcommerceVisitStatusEg": "Например, за да изберете всички посещения, които са направили поръчка от електронен магазин, API заявката ще съдържа %s", + "Edit": "Редактирай", + "EncryptedSmtpTransport": "Въведете криптирания транспортен слой, който се изисква от Вашия SMTP сървър.", + "EnglishLanguageName": "Bulgarian", + "Error": "Грешка", + "ErrorRequest": "Ооопс… възникна грешка! Опитайте отново.", + "EvolutionOverPeriod": "Развитие за периода", + "EvolutionSummaryGeneric": "%1$s в %2$s сравнено с %3$s в %4$s. Нарастване: %5$s", + "ExceptionCheckUserHasSuperUserAccessOrIsTheUser": "Потребителят трябва да бъде или привилигирован потребител или потребител '%s'.", + "ExceptionConfigurationFileNotFound": "Конфигурационния файл {%s} не бе намерен.", + "ExceptionDatabaseVersion": "Вашата %1$s версия е %2$s ,но Piwik изисква най-малко %3$s.", + "ExceptionFileIntegrity": "Цялостната проверка неуспешна: %s", + "ExceptionFilesizeMismatch": "Размерът на файла не съответства: %1$s (очаквана дължина: %2$s, намерен: %3$s)", + "ExceptionIncompatibleClientServerVersions": "Вашата %1$s клиентска версия е %2$s ,която е несъвместима с версията на сървъра %3$s.", + "ExceptionInvalidAggregateReportsFormat": "Формат на обобщените доклади '%s' не е валиден. Опитайте някое от следните вместо това: %s.", + "ExceptionInvalidArchiveTimeToLive": "Днес времето за живот на архива трябва да бъде число в секунди по-голямо от нула", + "ExceptionInvalidDateFormat": "Формата на датата трябва да е: %s или ключова дума, поддържана от %s функция (виж %s за повече информация)", + "ExceptionInvalidDateRange": "Датата '%s' не е правилен период от време. Тя трябва да има следния формат: %s.", + "ExceptionInvalidPeriod": "Периодът '%s' не се поддържа. Опитайте някоя от следните вместо това: %s", + "ExceptionInvalidRendererFormat": "Renderer формат '%s' не е валиден. Опитайте някоя от следните вместо това: %s.", + "ExceptionInvalidReportRendererFormat": "Формат на доклада '%s' не е валиден. Опитайте вместо това някое от следните: %s.", + "ExceptionInvalidStaticGraphType": "Графика от типа '%s' не е валидна. Опитайте вместо това някое от следните: %s.", + "ExceptionInvalidToken": "Знакът не е валиден.", + "ExceptionLanguageFileNotFound": "Езиковия файл '%s' не бе намерен.", + "ExceptionMethodNotFound": "Методът '%s' не съществува или не е наличен в модулът '%s'.", + "ExceptionMissingFile": "Липсващ файл: %s", + "ExceptionNonceMismatch": "Не може да се провери кода за сигурност на тази форма.", + "ExceptionPrivilege": "Нямате достъп до този ресурс, тъй като изисква %s достъп.", + "ExceptionPrivilegeAccessWebsite": "Нямате достъп до този ресурс, тъй като изисква %s достъп за следното уеб сайт ID = %d.", + "ExceptionPrivilegeAtLeastOneWebsite": "Нямате достъп до този ресурс, тъй като изисква %s достъп в продължение на поне една Интернет страница.", + "ExceptionUnableToStartSession": "Невъзможно е да се стартира сесия.", + "ExceptionUndeletableFile": "Не мога да изтрия %s", + "ExceptionUnreadableFileDisabledMethod": "Конфигурационния файл {%s} не може да бъде прочетен. Вашият хостинг може да е забранил %s.", + "Export": "Запазване", + "ExportAsImage": "Запазване като изображение", + "ExportThisReport": "Запази в други формати", + "Faq": "Често задавани въпроси", + "FileIntegrityWarningExplanation": "Проверката за целостта на файла се провали и се докладват някои грешки. Това най-вероятно се дължи на частичен файл или не е качен някой от файловете на Piwik. Трябва да качите отново всички файлове на Piwik в BINARY форма и да обновите тази страница, докато тя спре да показва грешки.", + "First": "Първи", + "Flatten": "Изравнено", + "ForExampleShort": "например", + "Forums": "Форуми", + "FromReferrer": "от", + "GeneralInformation": "Обща информация", + "GeneralSettings": "Основни настройки", + "GetStarted": "Как да започнем", + "GiveUsYourFeedback": "Обратна връзка", + "Goal": "Цел", + "GoTo": "Към %s", + "GraphHelp": "Повече информация за показаните графики в Piwik.", + "HelloUser": "Здравей, %s!", + "Help": "Помощ", + "HelpTranslatePiwik": "Може би желаете да ни %1$sпомогнете с подобряването на превода на Piwik%2$s?", + "Hide": "скриване", + "HoursMinutes": "%1$s часа %2$s мин.", + "Id": "ИД", + "IfArchivingIsFastYouCanSetupCronRunMoreOften": "Ако приемем, архивирането е бързо за Вашата настройка, можете да настроите crontab да се стартира по-често.", + "InfoFor": "Информация за %s", + "Installed": "Инсталирано", + "InvalidDateRange": "Невалиден период от време. Моля, опитайте отново.", + "InvalidResponse": "Получените данни е невалидни.", + "IP": "IP адрес", + "JsTrackingTag": "JavaScript код", + "Language": "Език", + "LastDays": "Последните %s дни (с днес)", + "LastDaysShort": "Последните %s дни", + "LayoutDirection": "ltr", + "Live": "На живо", + "Loading": "Зарежда...", + "LoadingData": "Зарежда данни...", + "LoadingPopover": "Зареждане на %s…", + "LoadingPopoverFor": "Зареждане на %s за", + "Locale": "bg_BG.UTF-8", + "Logout": "Изход", + "LongDay_1": "Понеделник", + "LongDay_2": "Вторник", + "LongDay_3": "Сряда", + "LongDay_4": "Четвъртък", + "LongDay_5": "Петък", + "LongDay_6": "Събота", + "LongDay_7": "Неделя", + "LongMonth_1": "Януари", + "LongMonth_10": "Октомври", + "LongMonth_11": "Ноември", + "LongMonth_12": "Декември", + "LongMonth_2": "Февруари", + "LongMonth_3": "Март", + "LongMonth_4": "Април", + "LongMonth_5": "Май", + "LongMonth_6": "Юни", + "LongMonth_7": "Юли", + "LongMonth_8": "Август", + "LongMonth_9": "Септември", + "MainMetrics": "Главни метрики", + "Matches": "Съвпадения", + "MediumToHighTrafficItIsRecommendedTo": "За среден до висок трафик на сайтове, ние препоръчваме да обработвате докладите за днес на всеки половин час (%s секунди) или всеки час (%s секунди).", + "Metadata": "Мета данни", + "Metric": "Метрика", + "Metrics": "Метрики", + "MetricsToPlot": "Метрики за показване", + "MetricToPlot": "Метрика за показване", + "MinutesSeconds": "%1$s мин. %2$s с.", + "Mobile": "Мобилен", + "Monthly": "Ежемесечно", + "MonthlyReport": "месечен", + "MonthlyReports": "Месечни доклади", + "More": "Повече", + "MoreDetails": "Детайли", + "MoreLowerCase": "повече", + "MultiSitesSummary": "Всички сайтове", + "Name": "Име", + "NbActions": "Брой действията", + "NbSearches": "Брой вътрешни търсения", + "NDays": "%s дни", + "Never": "Никога", + "NewReportsWillBeProcessedByCron": "Когато Piwik архивирането не е предизвикано от браузърът, новите доклади ще бъдат обработени от crontab.", + "NewUpdatePiwikX": "Нова версия: Piwik %s", + "NewVisitor": "Нов посетител", + "NewVisits": "Нови посещения", + "Next": "Напред", + "NMinutes": "%s минути", + "No": "Не", + "NoDataForGraph": "Няма информация за тази графика.", + "NoDataForTagCloud": "Няма данни за този таг облак.", + "NotDefined": "%s недефинирани", + "Note": "Бележка", + "NotInstalled": "Не е иснталиран", + "NotRecommended": "(не се препоръчва)", + "NotValid": "%s не е валиден", + "NSeconds": "%s секунди", + "NumberOfVisits": "Брой посетители", + "NVisits": "%s посещения", + "Ok": "Ок", + "OneAction": "1 действие", + "OneDay": "1 ден", + "OneMinute": "1 минута", + "OneVisit": "1 посещение", + "OnlyEnterIfRequired": "Въведете само потребителя ако Вашият SMTP сървър го изисква", + "OnlyEnterIfRequiredPassword": "Въведете само паролата ако Вашият SMTP сървър го изисква", + "OnlyUsedIfUserPwdIsSet": "Изисква се ако потребителят или паролата са зададени, посъветвайте се с Вашия доставчик, ако не сте сигурни кой метод да използвате.", + "OpenSourceWebAnalytics": "Уеб Брояч с Отворен Код", + "OperationAtLeast": "Най-малко", + "OperationAtMost": "Най-много", + "OperationContains": "Съдържа", + "OperationDoesNotContain": "Не съдържа", + "OperationEquals": "Еквивалентно", + "OperationGreaterThan": "Повече от", + "OperationIs": "е", + "OperationIsNot": "Не е", + "OperationLessThan": "По-малко от", + "OperationNotEquals": "Еквивалентно на", + "OptionalSmtpPort": "Не е задължително. По подразбиране е 25 за некриптиран и TLS SMTP, и 465 за SSL SMTP.", + "Options": "Настройки", + "OrCancel": "или %s Затвори %s", + "OriginalLanguageName": "Български", + "Others": "Други", + "Outlink": "Външна връзка", + "Outlinks": "Изходящи", + "OverlayRowActionTooltip": "Вижте анализите директно от сайта си (отваря нов подпрозорец)", + "Overview": "Общ преглед", + "Pages": "Страници", + "ParameterMustIntegerBetween": "Параметърът %s трябва да има цифрова стойност от %s до %s.", + "Password": "Парола", + "Period": "Период", + "Piechart": "Диаграма", + "PiwikXIsAvailablePleaseUpdateNow": "Piwik %1$s е наличен. %2$s Обновете сега. %3$s (вижте %4$s промените%5$s).", + "PleaseSpecifyValue": "Моля, въведете стойност за '%s'.", + "PleaseUpdatePiwik": "Моля, актуализирайте своя Piwik", + "Plugin": "Добавка", + "Plugins": "Добавки", + "PoweredBy": "Разработка на", + "Previous": "Назад", + "PreviousDays": "Предишните %s дни (без днес)", + "PreviousDaysShort": "Предходните %s дни", + "Price": "Цена", + "ProductConversionRate": "Степен на конверсия на продукта", + "ProductRevenue": "Приходи от продукта", + "PurchasedProducts": "Купени продукти", + "Quantity": "Количество", + "RangeReports": "Зададен от вас период", + "ReadThisToLearnMore": "%1$sПрочетете това, за да научите повече.%2$s", + "Recommended": "(препоръчва се)", + "RecordsToPlot": "Записи за", + "Refresh": "Обновяване", + "RefreshPage": "Обнови страницата", + "RelatedReport": "Сроден доклад", + "RelatedReports": "Сродни доклади", + "Remove": "Премахни", + "Report": "Доклад", + "ReportGeneratedFrom": "Този доклад е генериран с данни от %s.", + "ReportRatioTooltip": "'%1$s' представя %2$s за %3$s %4$s с %5$s.", + "Reports": "Доклади", + "ReportsContainingTodayWillBeProcessedAtMostEvery": "Доклади за днес (или друг период от време, включително днес) ще бъдат обработени най-много на всеки", + "ReportsWillBeProcessedAtMostEveryHour": "Докладите ще бъдат най-добре обработени на всеки час.", + "RequestTimedOut": "Данните, искани до %s изтече. Моля опитайте отново.", + "Required": "%s е необходима!", + "ReturningVisitor": "Завърнал се посетител", + "ReturningVisitorAllVisits": "Преглед на всички посещения", + "RowEvolutionRowActionTooltip": "Преглед на показателите, за този ред, по какъв начин са се променили през времето", + "Rows": "Редове", + "RowsToDisplay": "Редове за показване", + "Save": "Запиши", + "SaveImageOnYourComputer": "За да запазите изображението на вашия компютър, натиснете с десен бутон на изображението и изберете \"Save Image As...\"", + "Search": "Търсене", + "SearchNoResults": "Няма резултати", + "Seconds": "%sс", + "SeeAll": "вижте всички", + "SeeTheOfficialDocumentationForMoreInformation": "Прегледайте %sofficial documentation%s за повече информация", + "Segment": "Сегмент", + "SelectYesIfYouWantToSendEmailsViaServer": "Изберете \"Да\", ако желаете, или изпратете електронна поща чрез именуван сървър, вместо да ползвате локално mail функцията.", + "Settings": "Настройки", + "Shipping": "Доставка", + "ShortDay_1": "Пон", + "ShortDay_2": "Вто", + "ShortDay_3": "Сря", + "ShortDay_4": "Чет", + "ShortDay_5": "Пет", + "ShortDay_6": "Съб", + "ShortDay_7": "Нед", + "ShortMonth_1": "Яну", + "ShortMonth_10": "Окт", + "ShortMonth_11": "Ное", + "ShortMonth_12": "Дек", + "ShortMonth_2": "Фев", + "ShortMonth_3": "Мар", + "ShortMonth_4": "Апр", + "ShortMonth_5": "Май", + "ShortMonth_6": "Юни", + "ShortMonth_7": "Юли", + "ShortMonth_8": "Авг", + "ShortMonth_9": "Сеп", + "Show": "покажи", + "SmallTrafficYouCanLeaveDefault": "За сайтове с малък трафик, може да оставите по подразбиране %s секунди, както и достъп до всички доклади в реално време.", + "SmtpEncryption": "SMTP криптиране", + "SmtpPassword": "SMTP парола", + "SmtpPort": "SMTP порт", + "SmtpServerAddress": "SMTP сървър адрес", + "SmtpUsername": "SMTP потребител", + "Source": "Източник", + "StatisticsAreNotRecorded": "В момента проследяването на посетителите е изключено! Активирайте го отново чрез настройката record_statistics = 1 във файл config.ini.php.", + "Subtotal": "Междинна сума", + "Summary": "Резюме", + "Table": "Таблица", + "TagCloud": "Етикети", + "Tax": "Данък", + "TimeAgo": "%s преди", + "TimeOnPage": "Време на страница", + "Today": "Днес", + "Total": "Общо", + "TotalRatioTooltip": "Това е %1$s от всички %2$s %3$s.", + "TotalRevenue": "Общо приход", + "TotalVisitsPageviewsRevenue": "(Всичко: %s посещения, %s разглеждания, %s средно)", + "TransitionsRowActionTooltip": "Вижте какво посетителите са правили преди и след посещаването на тази страница", + "TranslatorEmail": "kristalin[at]kividesign[dot]com, Virosss[at]abv[dot]bg, tomivr[at]abv[dot]bg, pak69[at]abv[dot]bg, pamir[at]abv[dot]bg", + "TranslatorName": "Kristalin Chavdarov,
    Андон Иванов<\/a>, Tom Atanasov, Dimitar Stamenov, Панайотис Кондоянис", + "UniquePurchases": "Уникални поръчки", + "Unknown": "Неизвестен", + "Upload": "Качи", + "UsePlusMinusIconsDocumentation": "Използвайте знаците плюс и минус за навигация.", + "Username": "Потребител", + "UseSMTPServerForEmail": "Използвай SMTP сървър за имейл", + "Value": "Стойност", + "VBarGraph": "Графика", + "View": "Преглед", + "ViewDocumentationFor": "Преглед на документацията за %1$s", + "Visit": "Посещение", + "VisitConvertedGoal": "Посещението се превръща в най-малко една цел", + "VisitConvertedGoalId": "Посещение, което се счита като Id на специална цел", + "VisitConvertedNGoals": "Посещението преобразува %s цели", + "VisitDuration": "Средно времетраене на посещението (в секунди)", + "Visitor": "Посетител", + "VisitorID": "ID на посетител", + "VisitorIP": "IP на посетител", + "Visitors": "Посетители", + "VisitsWith": "Посещения с %s", + "VisitType": "Тип на посетител", + "VisitTypeExample": "Например, ако изберете всички посетители, които са се завърнали на сайта, включително тези, които са купили нещо при предишните посещения, API заявката ще съдържа %s", + "Warning": "Предупреждение", + "WarningFileIntegrityNoManifest": "Цялостната проверка на файла не може да бъде изпълнена поради липсата на manifest.inc.php.", + "WarningFileIntegrityNoManifestDeployingFromGit": "В случай, че Piwik се внедрява посредством Git, е нормално това съобщение да се появява.", + "WarningFileIntegrityNoMd5file": "Цялостната проверка не може да бъде осъществена поради липсата на md5_file() функцията.", + "WarningPasswordStored": "%sВнимание:%s Тази парола ще се съхранява в конфигурационния файл видими за всички, които я ползват.", + "Website": "Уебсайт", + "Weekly": "Ежеседмично", + "WeeklyReport": "седмично", + "WeeklyReports": "Седмични доклади", + "WellDone": "Браво!", + "Widgets": "Джаджи", + "XComparedToY": "%1$s сравнено с %2$s", + "XFromY": "%1$s от %2$s", + "YearlyReport": "годишно", + "YearlyReports": "Годишни доклади", + "YearsDays": "%1$s години %2$s дни", + "YearShort": "г.", + "Yes": "Да", + "Yesterday": "Вчера", + "YouAreCurrentlyUsing": "Вие използвате Piwik %s.", + "YouAreViewingDemoShortMessage": "В момента Вие разглеждате демо версия на Piwik", + "YouMustBeLoggedIn": "Трябва да сте влязъл, за да имате достъп до тази функционалност.", + "YourChangesHaveBeenSaved": "Промените бяха запазени." + }, + "Goals": { + "AbandonedCart": "Изоставена кошница", + "AddGoal": "Добави цел", + "AddNewGoal": "Добави нова цел", + "AddNewGoalOrEditExistingGoal": "%sДобави нова цел%s или %sРедактирай%s съществуващите цели", + "AllowGoalConvertedMoreThanOncePerVisit": "Позволи Целта да бъде конвертирана повече от веднъж за едно посещение", + "AllowMultipleConversionsPerVisit": "Разреши няколко конверсии за посещение.", + "BestCountries": "Вашето най-добро конвертиране на страните е:", + "BestKeywords": "Вашите най-конвертиращи ключови думи:", + "BestReferrers": "Вашето най-добро конвертиране на посещения от сайтове е:", + "CaseSensitive": "Отчитане на съвпадения", + "ClickOutlink": "натиснат върху връзката, водеща към външен сайт", + "ColumnAveragePriceDocumentation": "Средната печалба за този %s.", + "ColumnAverageQuantityDocumentation": "Средното количество за този %s продадени чрез поръчки от електронен магазин.", + "ColumnConversionRateDocumentation": "Брой конверсии за %s.", + "ColumnConversionRateProductDocumentation": "Честотата на %s конверсията е броят на заявките, съдържащи този продукт, разделен на броя на посещенията на продуктовата страница.", + "ColumnConversions": "Конверсия", + "ColumnConversionsDocumentation": "Брой на конверсии за %s.", + "ColumnOrdersDocumentation": "Пълният брой поръчки, които съдържат %s поне веднъж.", + "ColumnPurchasedProductsDocumentation": "Броят на поръчаните продукти е сумата от всичкото количество продадени продукти във всички поръчки.", + "ColumnQuantityDocumentation": "Количеството е целият брой от продукти продаден за всеки %s.", + "ColumnRevenueDocumentation": "Общи приходи генерирани от %s.", + "ColumnRevenuePerVisitDocumentation": "Общи приходи генерирани от %s, и разделени на брой посещения.", + "ColumnVisits": "Пълният брой на посещенията, независимо дали е задействана цел или не.", + "ColumnVisitsProductDocumentation": "Броят посещения на Продуктовата\/Категорийна страница. Това се използва също за да се изчисли %s степента на конверсия. Метричните данни са в отчета, ако изгледа Електронна търговия е бил настроен в Продукт\/Категория страниците.", + "Contains": "съдържа %s", + "ConversionByTypeReportDocumentation": "Този отчет дава детайлна информация относно развитието на целта (конверсии, степен на конверсията и приход от посещението) за всяка една от категориите, показани на левия панел. %s Моля натиснете на някоя от категориите, за да видите отчета. %s За повече информация, прочетете %sдокументацията за проследяване на целите на piwik.org%s", + "ConversionRate": "%s обменният курс", + "Conversions": "%s конверсия", + "ConversionsOverview": "Общ преглед на реализациите", + "ConversionsOverviewBy": "Преглед на конверсията по тип посещения", + "CreateNewGOal": "Създай нова цел", + "DaysToConv": "Дни към Конверсии", + "DefaultGoalConvertedOncePerVisit": "(По подразбиране) Целта може да бъде конвертирана само веднъж на посещение.", + "DefaultRevenue": "Приходи по подразбиране", + "DefaultRevenueHelp": "Например, формуляр, предоставен от един посетител може да струва средно 10 долара. Piwik ще Ви помогне да разберете колко добре се представят Вашите посетители.", + "DeleteGoalConfirm": "Сигурни ли сте, че желаете да изтриете тази цел %s?", + "DocumentationRevenueGeneratedByProductSales": "Продажба на продукти. Изключва данък, доставка и отстъпка", + "Download": "Изтеглете файл", + "Ecommerce": "Електронна търговия", + "EcommerceAndGoalsMenu": "Електронна търговия и цели", + "EcommerceLog": "Електронна търговия лог", + "EcommerceOrder": "Електронна търговия поръчка", + "EcommerceOverview": "Електронна търговия преглед", + "EcommerceReports": "Електронна търговия доклади", + "ExceptionInvalidMatchingString": "Ако изберете 'точно съвпадение', съвпадащият низ, трябва да започне с URL %s. Например, '%s'.", + "ExternalWebsiteUrl": "външен URL", + "Filename": "име на файла", + "GoalConversion": "Конверсия на цел", + "GoalConversions": "Конверсии на цел", + "GoalConversionsBy": "%s Конверсия на целите по тип посещение", + "GoalIsTriggered": "Целта е задействана", + "GoalIsTriggeredWhen": "Целта е задействана, когато", + "GoalName": "Име на цел", + "Goals": "Цели", + "GoalsManagement": "Управление на целите", + "GoalsOverview": "Общ преглед на целите", + "GoalsOverviewDocumentation": "Това е преглед на конверсията на вашите цели. Поначало, графиката показва сумата от всички ваши конверсии. %s Под графиката ще видите отчетите за конверсия на всички ваши цели. Блестящите линии можете да уголемите като кликнете върху тях.", + "GoalX": "Цел %s", + "HelpOneConversionPerVisit": "Ако страница, съпадащата с тази Цел е обновена или гледана повече от веднъж за едно посещение, Целта ще бъде проследена само първият път.", + "IsExactly": "е точно %s", + "LearnMoreAboutGoalTrackingDocumentation": "Научете повече за %s Проследяване на целите в Piwik%s в ръководството.", + "LeftInCart": "%s остатък в количката", + "Manually": "ръчно", + "ManuallyTriggeredUsingJavascriptFunction": "Целта е ръчно задействана с помощта на JavaScript API trackGoal()", + "MatchesExpression": "съответства на изразените %s", + "NewGoalYouWillBeAbleTo": "Вие ще бъдете в състояние да видите и анализирате представянето си за всяка цел, и да научите как да се увеличат реализациите, обменните курсове и приходите на посещение.", + "NewVisitorsConversionRateIs": "Стойност на конверсията от новите посетители е %s", + "NewWhatDoYouWantUsersToDo": "Какво искате потребителите да правят във вашия сайт?", + "Optional": "(по избор)", + "OverallConversionRate": "%s цялостно обменния курс (посещения със завършена цел)", + "OverallRevenue": "%s общи приходи", + "PageTitle": "Заглавие на страница", + "Pattern": "Модел", + "PluginDescription": "Създай цел и виж докладите на отчетите за Вашата цел по: развитие с течение на времето, приходите на посещение, реализации на референтите, ключови думи и др.", + "ProductCategory": "Категория на продукт", + "ProductName": "Име на продукт", + "Products": "Продукти", + "ProductSKU": "SKU на продукта", + "ReturningVisitorsConversionRateIs": "Стойност на конверсията от върналите се посетители е %s", + "SingleGoalOverviewDocumentation": "Това е преглед на конверсиите за една цел. %s Блестящите линии могат да бъдат уголемени като кликнете върху тях.", + "UpdateGoal": "Обнови цел", + "URL": "URL", + "ViewAndEditGoals": "Виж и редактирай цели", + "ViewGoalsBy": "Преглед на цели по %s", + "VisitPageTitle": "Посещения по заглавие на страница", + "VisitsUntilConv": "Посещения към Конверсии", + "VisitUrl": "посетят определен URL (страница или група от страници)", + "WhenVisitors": "когато посетителите", + "WhereThe": "където", + "WhereVisitedPageManuallyCallsJavascriptTrackerLearnMore": "когато посетена страница съдържа призив към JavaScript piwikTracker.trackGoal() метод (%sнаучете повече%s)", + "YouCanEnableEcommerceReports": "Можете да включите %s за този уебсайт в %s страницата." + }, + "ImageGraph": { + "ColumnOrdinateMissing": "Колоната '%s' не е намерена в този доклад. Опитайте с %s", + "PluginDescription": "Генерирайте красиви статични PNG графични изображения за всеки Piwik доклад." + }, + "Installation": { + "CollaborativeProject": "Piwik е съвместен проект, изграден с много любов от хора от всички краища на света.", + "CommunityNewsletter": "изпращай по пощата информация за нови добавки, функции и др.", + "ConfigurationHelp": "Вашият Piwik конфигурационен файл изглежда не добре конфигуриран. Можете да премахнете config\/config.ini.php и да започнете инсталирането, или да поправите настройките за връзка към БД-то.", + "ConfirmDeleteExistingTables": "Наистина ли искате да изтриете следните таблици: %s от базата от данни (БД)? ПРЕДУПРЕЖДЕНИЕ: ДАННИТЕ ОТ ТАЗИ ТАБЛИЦА НЕ МОГАТ ДА БЪДАТ ВЪЗСТАНОВЕНИ!!!", + "Congratulations": "Поздравления", + "CongratulationsHelp": "

    Поздравления! Инсталацията на вашия Piwik завърши.<\/p>

    Убедете се, че сте добавили вашия JavaScript код в сайта!<\/p>", + "DatabaseAbilities": "Възможности на базата данни", + "DatabaseCheck": "БД проверка", + "DatabaseClientVersion": "БД клиентска версия", + "DatabaseCreation": "БД създаване", + "DatabaseErrorConnect": "ГРЕШКА! Няма връзка с БД сървъра", + "DatabaseServerVersion": "БД сървър версия", + "DatabaseSetup": "Настройки на базата от данни (БД)", + "DatabaseSetupAdapter": "адаптер", + "DatabaseSetupDatabaseName": "БД име", + "DatabaseSetupLogin": "вход", + "DatabaseSetupServer": "БД сървър", + "DatabaseSetupTablePrefix": "префикс на таблицата", + "Email": "имейл", + "ErrorInvalidState": "Грешка: възможно е да сте опитали да прескочите стъпка от инсталацията или е забранена поддръжката на бисквитките (cookies) или конфигурационният файл на Piwik вече съществува. %1$sПроверете дали са разрешени бисквитките (cookies)%2$s и се върнете %3$s на първата страница на инсталацията%4$s.", + "Extension": "разширения", + "Filesystem": "Файлова система", + "GetInvolved": "Ако харесвате това, което виждате, може да се %1$sвключите%2$s.", + "GoBackAndDefinePrefix": "Върнете се за да зададете Prefix за Piwik таблиците в базата от данни (БД)", + "HappyAnalysing": "Приятно анализиране!", + "Installation": "Инсталация", + "InstallationStatus": "Състояние на инсталацията", + "InsufficientPrivilegesHelp": "Можете да добавите тези привилегии, като използвате инструмент, като например phpMyAdmin или като стартирате правилната SQL поръчка. Ако не знаете как се правят тези неща, попитайте вашият системен администратор да ви даде тези привилегии.", + "JSTracking_EndNote": "Бележка: След инсталационният процес, можете да генерирате проследяващ код от администраторската секция %1$sПроследяващ код%2$s", + "JSTracking_Intro": "За да може да се осъществи проследяване на трафика посредством Piwik, е нужно да се добави допълнителен код във всяка от вашите страници.", + "LargePiwikInstances": "Помощ за големи Piwik случаи", + "Legend": "Легенда", + "LoadDataInfileRecommended": "Ако Piwik събира статистика за сайтове с голям брой посещения (пример: > 100 000 страници на месец), препоръчваме да се опитате да оправите този проблем.", + "NfsFilesystemWarning": "Вашият сървър ползва NFS файлова система.", + "NfsFilesystemWarningSuffixAdmin": "Това означава, че Piwik ще бъде изключително бавен, когато използвате файлови базирани сесии.", + "NoConfigFound": "Piwik конфигурационният файл не е открит.
      » Можете да
    инсталирате Piwik сега<\/a><\/b>
    Ако преди това сте инсталирали Piwik и имате в базата от данни (БД) таблици - можете да запазите Вашите данни!<\/small>", + "Optional": "По избор", + "Password": "парола", + "PasswordDoNotMatch": "паролата не съвпада", + "PasswordRepeat": "парола (повторно)", + "PercentDone": "%s %% развитие", + "PleaseFixTheFollowingErrors": "Моля, поправете следните грешки", + "PluginDescription": "Инсталационния процес на Piwik. Инсталацията обикновено се прави само веднъж. Ако конфигурационния файл config\/ config.inc.php е изтрит, инсталацията ще започне отново.", + "Requirements": "Piwik Изисквания", + "RestartWebServer": "След като направите промените, рестартирайте уеб сървара.", + "SecurityNewsletter": "изпращай на имейл информация за обновленията и сигнали относно сигурността", + "SeeBelowForMoreInfo": "Вижте по-долу за повече информация.", + "SetupWebsite": "Настройки на сайт", + "SetupWebsiteError": "Възникнала е грешка при добавянето на сайт", + "SetupWebSiteName": "Уеб сайт име", + "SetupWebsiteSetupSuccess": "Сайта %s е добавен успешно!", + "SetupWebSiteURL": "Уеб сайт URL", + "SiteSetup": "Моля, настройте първият сайт, за който желаете да водите статистика посредством Piwik:", + "SiteSetupFootnote": "Забележка: след като инсталацията на Piwik е приключила, ще имате възможност да добавите допълнителни сайтове, които да следите!", + "SuperUser": "Супер потребител", + "SuperUserLogin": "СУПЕР ПОТРЕБИТЕЛСКИ вход", + "SuperUserSetupError": "Възникна грешка при добавянето на привилигирован потребител", + "SuperUserSetupSuccess": "Супер потребителят беше създаден успешно!", + "SystemCheck": "Проверка на системата", + "SystemCheckAutoUpdateHelp": "Забележка: Piwik One Click обновяване изисква разрешение за папката Piwik и нейното съдържание.", + "SystemCheckCreateFunctionHelp": "Piwik използва анонимни функции за извиквания.", + "SystemCheckDatabaseHelp": "Необходимо е за да инстаалирате Piwik базата от данни(БД) да поддържа mysqli или PDO и pdo_mysql разширенията.", + "SystemCheckDebugBacktraceHelp": "View::factory няма да бъде в състояние да създаде мнение за извикване на модул.", + "SystemCheckError": "Открита е грешка - трябва да бъде поправена преди да преминете нататък", + "SystemCheckEvalHelp": "Изисква се от HTML QuickForm и Smarty темплейт системата.", + "SystemCheckExtensions": "Други необходими разширения", + "SystemCheckFileIntegrity": "Файл целостта", + "SystemCheckFunctions": "Необходими функции", + "SystemCheckGDFreeType": "GD > 2.x + Freetype (графики)", + "SystemCheckGDHelp": "„Блещукащите линии“ (малки графики) и графичните диаграми (в Piwik Mobile приложението и докладите по пощата), няма да работят.", + "SystemCheckGlobHelp": "Вградената функция е изключена от Вашият хостинг. Piwik ще се опитва да подражава на тази функция, но може да се сблъска с допълнителни ограничения за сигурност. Функционалността може да бъде засегната.", + "SystemCheckGzcompressHelp": "Трябва да активирате zlib и gzcompress разширенията.", + "SystemCheckGzuncompressHelp": "Трябва да активирате zlib и gzcompress разширенията.", + "SystemCheckIconvHelp": "Трябва да конфигурирате и възстановите PHP с \"iconv\" активна поддръжка, --with-iconc", + "SystemCheckMailHelp": "Модулите Обратна връзка и Забравена парола не биха работили без mail() функцията на PHP.", + "SystemCheckMbstring": "mbstring", + "SystemCheckMbstringExtensionGeoIpHelp": "Необходимо е за интегрирането на GeoIP, за да работи.", + "SystemCheckMbstringExtensionHelp": "В mbstring удължаване е необходимо за многобайтови символи в отговорите API използват разделени със запетая стойности (CSV) или разделени с табулатор стойности (TSV).", + "SystemCheckMbstringFuncOverloadHelp": "Трябва да зададете mbstring.func_overload на \"0\".", + "SystemCheckMemoryLimit": "Лимит на паметта", + "SystemCheckMemoryLimitHelp": "За силно натоварените сайтове, времето за архивиране на данните отнема повече ресурси. Ако е необходимо моля редактирайте memory_limit директивата в php.ini.", + "SystemCheckOpenURL": "Отворен адрес(URL)", + "SystemCheckOpenURLHelp": "Новините, съобщенията за обновления и автоматичното обновяване за да функционират е необходимо сървъра да поддържа \"curl\" разширение, allow_url_fopen=On, или fsockopen() да бъдат поддържани.", + "SystemCheckOtherExtensions": "Други разширения", + "SystemCheckOtherFunctions": "Други функции", + "SystemCheckPackHelp": "Функцията \"pack()\" е нужна, за да следите посетителите в Piwik.", + "SystemCheckParseIniFileHelp": "Вградената функция е изключена от Вашият хостинг. Piwik ще се опитва да подражава на тази функция, но може да се сблъска с допълнителни ограничения за сигурност. Следването на изпълнението също ще бъде засегнато.", + "SystemCheckPdoAndMysqliHelp": "За Linux сървър компилирайте php със следните опции: %1$s във Вашия php.ini, добавете следните редове: %2$s", + "SystemCheckPhp": "PHP версия", + "SystemCheckPhpPdoAndMysqli": "Повече информация: %1$sPHP PDO%2$s и %3$sMYSQLI%4$s.", + "SystemCheckSecureProtocol": "Протокол за сигурност", + "SystemCheckSecureProtocolHelp": "Изглежда, че вие използвате https с вашия браузер. Тези линии ще се добавят към config\/config.ini.php:", + "SystemCheckSplHelp": "Трябва да конфигурирате PHP - Standard PHP Library (SPL) да се поддържа (по подразбиране).", + "SystemCheckSummaryNoProblems": "Ура-а-а! Няма проблем с настройката на вашия Piwik. Успокойте се, всичко е наред.", + "SystemCheckSummaryThereWereWarnings": "Има проблеми, които засягат вашата система. Piwik ще работи, но може да срещнете някои дребни трудности при работа.", + "SystemCheckTimeLimitHelp": "За силно натоварените сайтове, времето за архивиране на данните отнема повече време. Ако е необходимо моля редактирайте max_execution_time директивата в php.ini файла.", + "SystemCheckTracker": "Статус на проследяване", + "SystemCheckTrackerHelp": "GET поискването от piwik.php е неуспешно. Опитайте да \"whitelist\"-нете този URL от HTTP Authentication и изключете mod_security (може да ви се наложи да попитате вашият уеб хост).", + "SystemCheckWarnDomHelp": "Трябва да включите \"dom\" разширенето (напр: install the \"php-dom\" и\/или \"php-xml\" пакета).", + "SystemCheckWarning": "Piwik ще работи нормално, но някои услуги ще липсват", + "SystemCheckWarnJsonHelp": "Трябва да включите \"json\" разширението (пр: install the \"php-json\" пакета) за по-добро представяне.", + "SystemCheckWarnLibXmlHelp": "Трябва да включите \"libxml\" разширението (напр. \"install the php-libxml package\"), тъй като това е нужно на други главни PHP разширения.", + "SystemCheckWarnSimpleXMLHelp": "Трябва да включите \"SimpleXML\" разширенето (напр: install the \"php-simplexml\" и\/или \"php-xml\" пакета).", + "SystemCheckWinPdoAndMysqliHelp": "За Windows сървър добавете следните редове в php.ini: %s", + "SystemCheckWriteDirs": "Директории с права за писане", + "SystemCheckWriteDirsHelp": "За да поправите тази грешка (на Linux сървър), опитайте със следните команди", + "SystemCheckZlibHelp": "Трябва да конфигурирате PHP \"zlib\" да се поддържа, --with-zlib.", + "Tables": "Създаване на таблици", + "TablesCreatedSuccess": "Таблиците са създадени успешно!", + "TablesDelete": "Изтрий съществуващите таблици", + "TablesDeletedSuccess": "Съществуващите Piwik таблици бяха изтрити усшешно", + "TablesFound": "Следните таблици бяха открити в базата от данни (БД)", + "TablesReuse": "Използвай съществуващите таблици", + "TablesUpdatedSuccess": "Базата от данни беше успешно обновена от %1$s до %2$s!", + "TablesWarningHelp": "Изберете дали да обновите данните в съществуващите таблици или да инсталирате Piwik на чисто.", + "TablesWithSameNamesFound": "Някои %1$s таблици в базата от данни (БД) %2$s имат същите имена, както тези, които Piwik се опитва да създаде", + "Timezone": "Уеб сайт времева зона", + "WeHopeYouWillEnjoyPiwik": "Надяваме се, че ще се радвате на работата с Piwik толкова, колкото ние се радваме при разработването на Piwik.", + "Welcome": "Добре дошли!", + "WelcomeHelp": "

    Piwik е анализатор (уеб брояч) с отворен код, който гъвкаво и лесно предоставя информация за посетителите на сайта ви.<\/p>

    Този процес е съкратен в %s лесни стъпки и не отнема повече от 5 минути.<\/p>", + "WelcomeToCommunity": "Добре дошли в Piwik общността!" + }, + "LanguagesManager": { + "AboutPiwikTranslations": "За Piwik преводите", + "PluginDescription": "Тази добавка ще покаже списък с наличните езици за интерфейса на Piwik. Избраният език ще бъде записан в предпочитанията за всеки потребител." + }, + "Live": { + "AveragePageGenerationTime": "Всяка страница отнема средно по %1$s, за да бъде заредена от този посетител.", + "ClickToViewMoreAboutVisit": "Щракнете, за да видите повече информация за това посещение", + "ConvertedNGoals": "Конвертирани %s цели", + "EcommerceSummaryConversions": "%1$s%2$s поръчки от общо %3$s%4$s, закупени %5$s артикули.", + "FirstVisit": "Първо посещение", + "GoalType": "Тип", + "HideMap": "скриване на картата", + "LastHours": "Последните %s часа", + "LastMinutes": "Последните %s минути", + "LastVisit": "Последно посещение", + "LinkVisitorLog": "Виж детайли на лога с посетителите", + "LoadMoreVisits": "Зареждане на повече посещения", + "MorePagesNotDisplayed": "повечето страници от този посетител не се показват", + "NbVisitor": "1 посетител", + "NbVisitors": "%s посетители", + "NextVisitor": "Следващ посетител", + "NoMoreVisits": "Няма повече посещения за този посетител.", + "PageRefreshed": "Броят пъти, които тази страница е гледана \/ обновена в ред.", + "PluginDescription": "Следете Вашите посетители, на живо, в реално време!", + "PreviousVisitor": "Предишен посетител", + "RealTimeVisitorCount": "Броене на посетителите в реално време", + "Referrer_URL": "URL Референции", + "ShowMap": "покажи картата", + "SimpleRealTimeWidget_Message": "%s и %s в последното %s", + "ViewVisitorProfile": "Преглед профила на посетителя", + "VisitedPages": "Посетени страници", + "VisitorLog": "Статистика за посетителя", + "VisitorLogDocumentation": "Тази таблица показва последните посещения, включени в избраният обхват от време. Можете да видите кога се е случило последното посещение на посетител, като посочите върху датата на посещението. %s Ако обхвата на датата включва днешния ден, можете да видите вашите посетители в реално време! %s Информацията тук винаги е в реално време, независимо дали и колко често използвате инструментите за архивиране.", + "VisitorProfile": "Профил на посетителя", + "VisitorsInRealTime": "Посетители в реално време", + "VisitorsLastVisit": "Последното посещение от този посетител беше от преди %s дни.", + "VisitsFrom": "%1$s%2$s посещения%3$s от", + "VisitSummary": "Общо прекарано време %1$s%2$s в сайта%3$s, и %4$sразгледани %5$s страници за %6$s посещения.%7$s" + }, + "Login": { + "ConfirmationLinkSent": "Изпратена е връзка за потвърждение. Проверете пощенската си кутия, за да потвърдите заявката за смяна на парола.", + "ContactAdmin": "Възможни причини: функцията mail() е липсваща или забранена на сървъра.
    Свържете се с вашия Piwik администратор.", + "ExceptionPasswordMD5HashExpected": "Параметърът на паролата се очаква да бъде MD5 хеш на парола.", + "InvalidNonceOrHeadersOrReferrer": "Сигурността на формата е нарушена. Моля презаредете формата и проверете отново дали бисквитките са включени. Ако използвате прокси сървър, трябва да %s конфигурирате Piwik да приема хеадъра%s на проксито, който препраща хост хеадъра. Също така проверете, дали вашият хеадър за прапращане(Referer header) е изпратен правилно.", + "InvalidOrExpiredToken": "Token е грешен или изтекъл", + "InvalidUsernameEmail": "Грешно потребителско име и\/или имейл адрес", + "LogIn": "Вход", + "LoginOrEmail": "потребителско име или имейл", + "LoginPasswordNotCorrect": "Потребителското име & Паролата не са верни", + "LostYourPassword": "Забравена парола?", + "MailTopicPasswordChange": "Потвърждение смяната на паролата", + "PasswordChanged": "Вашата парола е сменена.", + "PasswordRepeat": "Парола (повторно)", + "PasswordsDoNotMatch": "Паролите не съвпадат.", + "RememberMe": "Запомни ме", + "ResetPasswordInstructions": "Въведете нова парола за акаунта си." + }, + "Mobile": { + "AboutPiwikMobile": "За Piwik Mobile", + "AccessUrlLabel": "Piwik URL достъп", + "Account": "Профил", + "Accounts": "Акаунти", + "AddAccount": "Добавяне на акаунт", + "AddPiwikDemo": "Добавяне на Piwik демонстрация", + "Advanced": "Разширени", + "AnonymousAccess": "Анонимен достъп", + "AnonymousTracking": "Анонимно проследяване", + "AskForAnonymousTrackingPermission": "Когато е активиран, Piwik Mobile ще изпраща анонимни данни за употребата му на piwik.org. Целта е тези данни да се използват, за да се помогне на разработчиците на Piwik Mobile, по-добре да разберат как се използва приложението. Информацията, която се изпраща е: менюта и настройки, които са посетени; името и версията на операционната система; всяка грешка, която се показва в Piwik Mobile. Ние няма да следим каквато и да е информация от вашите данни за анализ. Тези анонимни данни никога няма да бъдат обявявани публично. Можете да деактивирате \/ активирате анонимното проследяване чрез бутона „Настройки“ по всяко време.", + "ChooseHttpTimeout": "Изберете HTTP timeout стойност", + "ChooseMetric": "Избери Метрика", + "ChooseReport": "Избери доклад", + "ConfirmRemoveAccount": "Желаете ли да премахнете този профил?", + "DefaultReportDate": "Дата на отчета", + "EmailUs": "Пишете ни", + "EnableGraphsLabel": "Покажи графики", + "EvolutionGraph": "Графика показваща историята", + "HelpUsToImprovePiwikMobile": "Желаете ли да активирате анонимното проследяване в Piwik Mobile?", + "HowtoDeleteAnAccount": "Натиснете продължително, за да изтриете профил.", + "HowtoDeleteAnAccountOniOS": "Плъзнете от ляво на дясно за да изтриете акаунта", + "HowtoLoginAnonymous": "Оставете потребителско име и парола празно за анонимен вход", + "IncompatiblePiwikVersion": "Piwik версията, която използвате не е съвместима с Piwik Mobile 2. Обновете вашата инсталация на Piwik и пробвайте отново или инсталирайте Piwik Mobile 1.", + "LastUpdated": "Последна актуализация: %s", + "LoadingReport": "Зарежда се %s", + "LoginCredentials": "Потребителски данни за вписване", + "LoginUseHttps": "Използване на HTTPS", + "MultiChartLabel": "Покажи sparklines", + "NavigationBack": "Назад", + "NetworkError": "Мрежова грешка", + "NetworkErrorWithStatusCodeShort": "Мрежова грешка %s", + "NetworkNotReachable": "Мрежата не постижима", + "NoAccountIsSelected": "Трябва да изберете профил. Добавете нов, ако не сте създали до момента такъв.", + "NoDataShort": "Без данни", + "NoPiwikAccount": "Нямате Piwik акаунт?", + "NoReportsShort": "Без отчети", + "NoVisitorFound": "Няма посетители", + "NoVisitorsShort": "Без посетители", + "NoWebsiteFound": "Няма открит уебсайт", + "NoWebsitesShort": "Без сайтове", + "PullDownToRefresh": "Дръпнете надолу за да опресните...", + "RatingDontRemindMe": "Не ми напомняй", + "RatingNotNow": "Не сега", + "RatingNow": "ОК, ще го оценя сега", + "RatingPleaseRateUs": "Piwik Mobile е свободен софтуер, ето защо ние наистина ще сме Ви благодарни, ако отделите една минута, за да оцените приложението в %s. Ако имате предложения за нови функции или желаете да съобщите за проблем, моля свържете се с нас %s", + "ReleaseToRefresh": "Освободете за да се обнови...", + "Reloading": "Презареждане…", + "RequestTimedOutShort": "Мрежова грешка за изтекла сесия", + "RestrictedCompatibility": "Ограничена съвместимост", + "RestrictedCompatibilityExplanation": "Версията на Piwik %s, която използвате не се поддържа изцяло от Piwik Mobile 2. Възможно е да наблюдавате проблеми при работата с приложението. Препоръчваме Ви, или да обновите Piwik до последната версия, или да използвате Piwik Mobile 1.", + "SaveSuccessError": "Моля, проверете настройките", + "SearchWebsite": "Търсене в сайтове", + "ShowAll": "Покажи всички", + "ShowLess": "Покажи по-малко", + "TopVisitedWebsites": "Най-посещаваните сайтове", + "TryIt": "Изпробвайте!", + "UseSearchBarHint": "Само първите %s сайтове са показани тук. За да бъдат достъпени другите сайтове е нужно да се използва лентата за търсене.", + "VerifyAccount": "Потвърди акаунт-а", + "VerifyLoginData": "Уверете се, че комбинацията, от потребителско име и парола, е правилна.", + "YouAreOffline": "За съжаление, в момента не сте на линия" + }, + "MobileMessaging": { + "Exception_UnknownProvider": "Името на доставчика '%s' е неизвестно. Пробвайте друго име вместо: %s.", + "MobileReport_AdditionalPhoneNumbers": "Могат да бъдат добавени повече телефонни номера достъпвайки", + "MobileReport_MobileMessagingSettingsLink": "Страницата за настройка на мобилните съобщения", + "MobileReport_NoPhoneNumbers": "Моля, активирайте поне един телефонен номер, достъпвайки", + "PhoneNumbers": "Телефонни номера", + "Settings_APIKey": "API ключ", + "Settings_CountryCode": "Код на държавата", + "Settings_CredentialNotProvided": "Преди да можете да създавате и управлявате телефонни номера, моля свържете Piwik с вашия SMS профил по-горе.", + "Settings_CredentialNotProvidedByAdmin": "Преди да можете да добавяте и управлявате телефонни номера, моля, свържете се с вашия администратор, за да свърже Piwik с SMS профил.", + "Settings_CredentialProvided": "Вашият %s SMS приложно-програмен интерфейсен профил е правилно настроен!", + "Settings_DeleteAccountConfirm": "Сигурни ли сте, че искате да изтриете този SMS профил?", + "Settings_InvalidActivationCode": "Въведеният код не е валиден, моля опитайте отново.", + "Settings_LetUsersManageAPICredential": "Позволява на потребителите да управляват своите собствени идентификационни данни за SMS API", + "Settings_LetUsersManageAPICredential_Yes_Help": "Всеки потребител има възможност да си настрои свой собствен SMS приложно-програмен интерфейсен профил, като по този начин няма да използва вашия профил.", + "Settings_ManagePhoneNumbers": "Управление на телефонните номера", + "Settings_PhoneActivated": "Телефонният номер е потвърден! Вече имате възможност да получавате кратки съобщения (SMS) с вашите статистики.", + "Settings_PhoneNumber": "Телефонен номер", + "Settings_PhoneNumbers_Add": "Добави нов телефонен номер", + "Settings_PhoneNumbers_CountryCode_Help": "Ако не знаете телефонния код за вашата държава, може да го проверите тук", + "Settings_SMSAPIAccount": "Управление на профила за SMS приложно-програмен интерфейс", + "Settings_SMSProvider": "SMS провайдър", + "Settings_SuperAdmin": "Настройки на супер потребителя", + "Settings_SuspiciousPhoneNumber": "Ако не получите текстовото съобщение, може да опитате без водещата нула. т.е. %s", + "Settings_UpdateOrDeleteAccount": "%sОбновяване%s или %sизтриване%s на този профил.", + "Settings_ValidatePhoneNumber": "Валидиране", + "Settings_VerificationCodeJustSent": "Туко-що беше изпратено кратко съобщение (SMS) до този номер с код: моля, въведете този код в горното поле и натиснете „Потвърди“.", + "SettingsMenu": "Мобилни съобщения", + "TopLinkTooltip": "Вземете Web Analytics Reports доставен във вашата пощенска кутия или във вашия мобилен телефон!", + "TopMenu": "Email & SMS Доклади", + "VerificationText": "Кодът е %s. За да потвърдите вашия телефонен номер и да получите Piwik SMS отчети, моля, копирайте този код във формата достъпна чрез Piwik > %s > %s." + }, + "MultiSites": { + "Evolution": "Развитие", + "PluginDescription": "Показва обобщена статистика\/резюме. В момента се поддържа като основна добавка в Piwik.", + "TopLinkTooltip": "Сравнете статистиката за всички ваши уебсайтове." + }, + "Overlay": { + "Clicks": "%s клика", + "ClicksFromXLinks": "%1$s щраквания от една от %2$s връзките", + "Domain": "Домейн", + "ErrorNotLoadingDetails": "Възможно е заредената страница вдясно да няма последяващия Piwik код. В този случай опитайте да стартирате нова връзка за различна страница от доклада на страници.", + "ErrorNotLoadingLink": "Щраквайки тук, ще получите повече съвети за отстраняване на проблеми", + "Link": "Връзка", + "Location": "Местоположение", + "NoData": "Няма данни за тази страница по време на избрания период.", + "OneClick": "1 клик", + "OpenFullScreen": "Цял екран (без странична лента)", + "RedirectUrlErrorAdmin": "Може да добавите домейнът като допълнителен адрес %sв настройките%s.", + "RedirectUrlErrorUser": "Попитайте вашият администратор да добави домейна, като допълнителен URL." + }, + "PrivacyManager": { + "AnonymizeIpDescription": "Изберете \"Да\", ако желаете Piwik да не проследява напълно квалифицирани IP адреси.", + "AnonymizeIpInlineHelp": "Скрива последните цифри на IP адреса на посетителя, за да бъде спазен закона за поверителност.", + "AnonymizeIpMaskLengtDescription": "Изберете колко байта от IP на посетителите да е маскирано.", + "AnonymizeIpMaskLength": "%s байта - пример %s", + "ClickHereSettings": "Натиснете тук, за да влезете в %s настройките.", + "CurrentDBSize": "Настоящ размер на базата данни", + "DBPurged": "Базата от данни е изтрита.", + "DeleteBothConfirm": "На път сте да включите заедно изтриването на лог информацията и изтриването на отчетите. Това перманентно ще премахне възможността да видите анализите за минали периоди. Сигурни ли сте, че искате да направите това?", + "DeleteDataDescription": "Можете да настроите Piwik периодично да изтрива старите потребителски логове и\/или преработените отчети, за да пазите базата си данни малка.", + "DeleteDataDescription2": "Ако желаете, може предварително преработените отчети да не бъдат изтрити, само посещенията, гледаните страници и конверсионния лог да бъдат изтрити. Или обратно, предварително преработените логове да бъдат изтрити, а логовете с данни да бъдат запазени.", + "DeleteDataInterval": "Изтрий стара информация на всеки", + "DeleteDataSettings": "Изтриване на старите потребителски логове и отчети", + "DeleteLogDescription2": "Ако включите автоматичното изтриване на логове, трябва да сте сигурни, че всичките предишни дневни отчети са били преработени, за да не се изгуби информация.", + "DeleteLogInfo": "Логове от дадените таблици ще бъдат изтрити: %s", + "DeleteLogsConfirm": "На път сте да включите изтриването на лог информацията. Ако старата лог информация е изтрита и не са били създадени отчети за нея, вие няма да можете да видите информацията за минал период. Сигурни ли сте, че искате да направите това?", + "DeleteLogsOlderThan": "Изтрий отчети по-стари от", + "DeleteMaxRows": "Максималният брой редове, които да бъдат изтрити на един път:", + "DeleteMaxRowsNoLimit": "без лимит", + "DeleteReportsConfirm": "На път сте да включите изтриване на отчетите. Ако старите отчети са изтрити, вие ще трябвате да ги изготвите наново за да ги видите. Сигурни ли сте, че искате да направите това?", + "DeleteReportsDetailedInfo": "Информацията от базата данни за цифровите архивни таблици (%s) и текстовите архивни таблици (%s) ще бъде изтрита.", + "DeleteReportsInfo": "Ако е включено, старите отчети ще бъдат изтрити. %sПрепоръчваме да включвате само когато мястото за база данни е ограничено.%s", + "DeleteReportsInfo2": "Ако не сте включили \"%s\", старите отчети ще се създават автоматично при поискване.", + "DeleteReportsInfo3": "Ако сте включили \"%s\", информацията ще бъде изгубена завинаги.", + "DeleteReportsOlderThan": "Изтрива отчети по-стари от", + "DeleteSchedulingSettings": "Настройки на планирането", + "DoNotTrack_Description": "Не проследявай е технология и предложение, което позволява отказ от проследяване на сайтове, които те не посещават; включително инструменти за анализ, рекламни мрежи и социални платформи.", + "DoNotTrack_Disable": "Изключете \"Не проследявай\" поддръжката.", + "DoNotTrack_Disabled": "Piwik понястоящем проследява всички постетители, дори и те да са включили \"Не искам да бъда проследяван\" в техният уеб браузър.", + "DoNotTrack_DisabledMoreInfo": "Препоръчваме да уважавате поверителността на вашите посетители и да включите \"Не проследявай\" поддръжката.", + "DoNotTrack_Enable": "Включете \"Не проследявай\" поддръжката.", + "DoNotTrack_Enabled": "Понастоящем вие уважавате поверителността на вашите потребители. Браво!", + "DoNotTrack_EnabledMoreInfo": "Когато потребителите са настроили техният уеб браузър на \"Аз не искам да бъда проследяван\" (Не проследявай е включено), Piwik няма да отчита техните посещения.", + "DoNotTrack_SupportDNTPreference": "Поддръжка на \"Не проследявай\" настройки.", + "EstimatedDBSizeAfterPurge": "Изчислен размер на базата данни след изтриване", + "EstimatedSpaceSaved": "Изчислено спестено място", + "GetPurgeEstimate": "Изчисляване на времето за изтриване", + "KeepBasicMetrics": "Запазване на основните данни (посещения, посещения на страници, степен на отпадане, цели, конверсия при електронна търговия и др.)", + "KeepDataFor": "Запази всичките данни за:", + "KeepReportSegments": "За да запазите горната информация, също запазете сегментите на отчета.", + "LastDelete": "Последното изтриване е било на", + "LeastDaysInput": "Моля укажете номер на дни по голям от %s.", + "LeastMonthsInput": "Моля укажете номер на месеци по-голям от %s.", + "MenuPrivacySettings": "Поверителност", + "NextDelete": "Следващото планирано изтриване е на", + "PluginDescription": "Персонализирайте Piwik, за да го съгласувате със съществуващото законодателство.", + "PurgeNow": "Изтриване на базата данни СЕГА", + "PurgeNowConfirm": "Вие сте на път перманентно да изтриете информацията от вашата база данни. Сигурни ли сте, че искате да продължите?", + "PurgingData": "Изтриване на базата данни...", + "RecommendedForPrivacy": "(препоръчва се за осигуряване на неприкосновеността на личните данни)", + "ReportsDataSavedEstimate": "Размер на базата данни", + "SaveSettingsBeforePurge": "Вие променихте настройките за изтриване на информация. Моля запазете ги, преди да започнете изтриване.", + "Teaser": "На тази страница можете да персонализирате Piwik, за да съгласувате поверителността със съществуващото законодателство, чрез: %s правейки посетителското IP анонимно %s, %s автоматично премахване на стари посетителски логове от базата данни %s, и %s даване на възможност за отказ от услуги за вашият уеб сайт%s.", + "TeaserHeadline": "Настройки на поверителността", + "UseAnonymizeIp": "Правене на IP-тата на посетителите анонимни", + "UseDeleteLog": "Периодично изтриване на старите посетителски логове от базата данни", + "UseDeleteReports": "Периодично изтрива старите посетителски отчети от базата данни" + }, + "Provider": { + "ColumnProvider": "Интернет доставчик", + "PluginDescription": "Доклад за доставчиците на посетителите.", + "ProviderReportDocumentation": "Отчетът показва кои доставчици на интернет услуги използват вашите потребители за достъп до уеб сайта. Можете да щракнете върху името на доставчика за повече детайли. %s Ако Piwik не може да определи доставчика на потребителя, той е показан само като IP.", + "SubmenuLocationsProvider": "Места & Доставчици", + "WidgetProviders": "Доставчици" + }, + "Referrers": { + "Campaigns": "Кампании", + "CampaignsDocumentation": "Посетители, дошли до вашият уеб сайт, в резултат на камания. %s Погледнете %s отчета за повече детайли.", + "CampaignsReportDocumentation": "Този отчет показва кои кампании са довели посетители до вашият уеб сайт. %s За повече информация относно проследяването на кампании, прочетете %sдокументацията за кампании на piwik.org%s", + "ColumnCampaign": "Кампании", + "ColumnSearchEngine": "Търсещи машини", + "ColumnSocial": "Социална мрежа", + "ColumnWebsite": "Сайт", + "ColumnWebsitePage": "Интернет страница", + "DetailsByReferrerType": "Детайли за типовете референции", + "DirectEntry": "Директни посещения", + "DirectEntryDocumentation": "Посетителят е въвел URL в браузъра си и е започнал да браузва вашия сайт - влязъл в уеб сайта директно.", + "Distinct": "Отделни референции от Тип на референциите", + "DistinctCampaigns": "отделни кампании", + "DistinctKeywords": "отделни ключови думи", + "DistinctSearchEngines": "отделни търсещи машини", + "DistinctWebsites": "отделни сайтове", + "EvolutionDocumentation": "Това е преглед на препращащите сайтове, които са довели посетители до вашият уеб сайт.", + "EvolutionDocumentationMoreInfo": "За повече информация отностно различните типове препращащи сайтове, вижте документите в %s таблицата.", + "Keywords": "Ключови думи", + "KeywordsReportDocumentation": "Този отчет показва кои ключови думи потребителите са използвали преди да бъдат препратени към вашият уеб сайт. %s Ако кликнете върху реда в таблицата, можете да видите разпределението на търсачките, в които са били търсени тези ключови думи.", + "PluginDescription": "Докладът на референтите данни: търсачки, ключови думи, уеб сайтове, кампания за проследяване, директен запис.", + "Referrer": "Препоръчител", + "ReferrerName": "Име на референт", + "Referrers": "Референции", + "ReferrersOverview": "Преглед на препоръчителите", + "SearchEngines": "Търсачки", + "SearchEnginesDocumentation": "Посетител, който е бил препратен към вашият уеб сайт от търсачка. %s Погледнете %s отчета за повече детайли.", + "SearchEnginesReportDocumentation": "Този отчет показва кои търсачки са изпратили потребители към вашият уеб сайт. %s Ако кликнете върху реда в таблицата, можете да видите какво потребителите са търсили в конкретната търсачка.", + "Socials": "Социални мрежи", + "SubmenuSearchEngines": "Търсачки & Ключови думи", + "SubmenuWebsites": "Сайтове", + "Type": "Тип на референцията", + "TypeCampaigns": "%s от кампании", + "TypeDirectEntries": "%s директни посещения", + "TypeReportDocumentation": "Тази таблица съдържа информация отностно разпределението на видовете препращащи сайтове.", + "TypeSearchEngines": "%s от търсещите машини", + "TypeWebsites": "%s от сайтове", + "UsingNDistinctUrls": "(използват се %s различни адреса)", + "ViewAllReferrers": "Преглед на всички препоръчители", + "ViewReferrersBy": "Преглед на всички препоръчители по %s", + "Websites": "Сайтове", + "WebsitesDocumentation": "Посетителят е последвал връзка в друг сайт, която сочи към вашия сайт. %s Вижте %s отчета за повече детайли.", + "WebsitesReportDocumentation": "В тази таблица, можете да видите кои уеб сайтове са препратили посетители към вашият сайт. %s Ако кликнете върху реда в таблицата, можете да видите URL-ите, чрез които потребителите са стигнали до вашият сайт.", + "WidgetExternalWebsites": "Външни сайтове", + "WidgetGetAll": "Всички препоръчители", + "WidgetKeywords": "Популярни ключови думи", + "WidgetSocials": "Списък на социалните мрежи", + "WidgetTopKeywordsForPages": "Най-използваните ключови думи", + "XPercentOfVisits": "%s%% от посещения" + }, + "RowEvolution": { + "AvailableMetrics": "Налични метрики", + "CompareRows": "Сравни записи", + "ComparingRecords": "Сравнявайки %s редове", + "MetricBetweenText": "между %s и %s", + "MetricChangeText": "%s промени през периода", + "MetricMinMax": "%1$s варират между %2$s и %3$s за периода", + "MetricsFor": "Метрики за %s", + "PickAnotherRow": "Изберете друг ред за сравнение", + "PickARow": "Изберете ред за сравнение" + }, + "ScheduledReports": { + "AggregateReportsFormat": "(по избор) Покажете опции", + "AggregateReportsFormat_GraphsOnly": "Покажете само графики (без докладни таблици)", + "AggregateReportsFormat_TablesAndGraphs": "Покажете докладни таблици и графики за всички доклади.", + "AggregateReportsFormat_TablesOnly": "(по подразбиране) Покажи таблицата с отчетите (Графики само за ключови метрични данни)", + "AlsoSendReportToTheseEmails": "Също така изпраща доклада до тези имейли (по един на ред):", + "AreYouSureDeleteReport": "Сигурни ли сте че искате да изтриете този доклад и разписание?", + "CancelAndReturnToReports": "Откажи и се %s върни в списъка с отчети %s", + "CreateAndScheduleReport": "Създайте доклад и разписание", + "CreateReport": "Създайте доклад", + "CustomVisitorSegment": "Персонализиран посетителски сегмент:", + "DescriptionOnFirstPage": "Описанието на този отчет ще бъде показано на първата страница на отчета.", + "DisplayFormat_TablesOnly": "Покажете само таблици (без графики)", + "EmailHello": "Здравей,", + "EmailReports": "Email доклади", + "EmailSchedule": "Списък с е-пощи", + "EvolutionGraph": "Показване на графиките с история за най-високите %s стойности", + "FrontPage": "Заглавна страница", + "ManageEmailReports": "Управление на Email докладите", + "MonthlyScheduleHelp": "Месечен график: докладът ще бъде изпратен на първия ден от всеки месец.", + "MustBeLoggedIn": "Трябва да сте влязъл, за да създавате и планирате персонализирани отчети.", + "NoRecipients": "Този доклад все още няма получатели", + "OClock": "часа", + "Pagination": "Страница %s от %s", + "PiwikReports": "Piwik доклади", + "PleaseFindAttachedFile": "Моля, вижте в прикачения си файл %1$s доклад за %2$s.", + "PleaseFindBelow": "Моля намерете по-долу вашият %1$s отчет за %2$s.", + "PluginDescription": "Създайте и изтеглете Вашите персонализирани отчети,и ги изпращайте по имейл ежедневно, ежеседмично или ежемесечно.", + "ReportFormat": "Формат на доклад", + "ReportHour": "Изпрати доклад в", + "ReportIncludeNWebsites": "Този отчет ще включва главните метрични данни за всички уеб сайтове, които са били посещавани поне веднъж (от %s сайта, които са налични).", + "ReportSent": "Отчетът е изпратен", + "ReportsIncluded": "Включена статистика", + "ReportType": "Изпрати доклад чрез", + "ReportUpdated": "Отчетът е обновен", + "SegmentAppliedToReports": "Сегментът '%s' се прилага към отчетите.", + "SendReportNow": "Изпрати доклад сега", + "SendReportTo": "Изпрати доклад до", + "SentToMe": "Изпрати до мен", + "TableOfContent": "Лист с отчети", + "ThereIsNoReportToManage": "Няма отчет за сайт %s", + "TopOfReport": "Върнете се в началото", + "UpdateReport": "Обновете доклад", + "WeeklyScheduleHelp": "Седмичен график: докладът ще бъде изпратен на първия понеделник на всяка седмица." + }, + "SegmentEditor": { + "AddANDorORCondition": "Добавяне на %s условие", + "AddNewSegment": "Добави нов сегмент", + "AreYouSureDeleteSegment": "Сигурни ли сте, че искате да изтриете този сегмент?", + "AutoArchivePreProcessed": "Сегментираните отчети са предварително обработени (за по-голяма бързина се изисква archive.php cron)", + "AutoArchiveRealTime": "Сегментираните отчети са обработени в реално време", + "ChooseASegment": "Избери сегмент", + "DefaultAllVisits": "Всички посещения", + "DragDropCondition": "Състояние „Плъзгане & Пускане“", + "LoadingSegmentedDataMayTakeSomeTime": "Обработката на сегментирани данни за посетителите може да отнеме няколко минути…", + "OperatorAND": "И", + "OperatorOR": "ИЛИ", + "SaveAndApply": "Запази & Приложи", + "SegmentDisplayedAllWebsites": "всички сайтове", + "SegmentDisplayedThisWebsiteOnly": "само този уебсайт", + "SegmentIsDisplayedForWebsite": "и се показва за", + "SelectSegmentOfVisitors": "Изберете сегмент на посетителите:", + "ThisSegmentIsVisibleTo": "Този сегмент е видим за:", + "VisibleToAllUsers": "всички потребители", + "VisibleToMe": "аз", + "YouMustBeLoggedInToCreateSegments": "Трябва да сте вписани, за да създавате и редактирате персонализираните посетителски сегменти." + }, + "SEO": { + "AlexaRank": "Alexa ранг", + "Bing_IndexedPages": "Bing индексирани страници", + "Dmoz": "DMOZ записи", + "DomainAge": "Възраст на домейна", + "ExternalBacklinks": "Външни препратки (Majestic)", + "Google_IndexedPages": "Google индексирани страници", + "Rank": "Ранг", + "SeoRankings": "SEO ранг", + "SEORankingsFor": "SEO ранг за %s", + "ViewBacklinksOnMajesticSEO": "Преглед на доклада за външните препратки в MajesticSEO.com" + }, + "SitesManager": { + "AddSite": "Добави нов сайт", + "AdvancedTimezoneSupportNotFound": "Разширена часова зона поддръжка не е намерена в PHP (поддържа в по-нова версия на PHP от 5.2 или точно 5.2). Все още можете да изберете ръчно UTC.", + "AliasUrlHelp": "Препоръчително е, но не е задължително, да се уточнят различните URL адреси, по един на ред, че Вашите посетители имат достъп до този сайт. Наричани още URL адреси за сайта няма да се появяват в Референции > Сайтове. Имайте в предвид, че не е необходимо да се уточнят URL адреси със и без \"WWW\" като Piwik автоматично смята така.", + "ChangingYourTimezoneWillOnlyAffectDataForward": "Промяната на часовата зона ще засегне само данни в бъдеще, и няма да се прилага със задна дата.", + "ChooseCityInSameTimezoneAsYou": "Изберете град в тази часова зона, в която сте.", + "Currency": "Валута", + "CurrencySymbolWillBeUsedForGoals": "Символът за валутата ще бъде показан на следващите целеви доходи.", + "DefaultCurrencyForNewWebsites": "Валута по подразбиране за нови уеб сайтове", + "DefaultTimezoneForNewWebsites": "Времева зона по подразбиране за нови уеб сайтове", + "DeleteConfirm": "Наистина ли желаете да изтриете този уеб сайт %s?", + "DisableSiteSearch": "Изключване на следенето за търсенията в сайта", + "EcommerceHelp": "Когато са включени, \"Цели\", отчета ще има нова секция Електронна търговия.", + "EnableEcommerce": "Електронна търговия включен", + "EnableSiteSpecificUserAgentExclude_Help": "Ако има нужда, могат да се изключат различни потребителски агенти, за различни сайтове. За целта активирайте тази отметка, щракнете „Запази“ и %1$sдобави потребителските агенти по-горе%2$s.", + "ExceptionDeleteSite": "Не е възможно да изтриете този сайт, защото той е единствения регистриран в системата. Преди да изтриете този сайт е нужно да добавите нов сайт.", + "ExceptionEmptyName": "Полето за име на сайт не може да бъде празно.", + "ExceptionInvalidCurrency": "Валутата \"%s\" не е валидна. Моля, въведете валиден символ за валутата (например %s)", + "ExceptionInvalidIPFormat": "IP адресът за изключване \"%s\" не е във валиден IP формат (например %s)", + "ExceptionInvalidTimezone": "Времевата зона \"%s\" не е валидна. Моля, въведете валидна времева зона.", + "ExceptionInvalidUrl": "Адреса '%s' не е валиден.", + "ExceptionNoUrl": "Необходимо е да въведете поне един адрес (URL) за сайта.", + "ExcludedIps": "Изключени IP адреси", + "ExcludedParameters": "Изключени параметри", + "GlobalListExcludedIps": "Глобален списък на изключените IP адреси", + "GlobalListExcludedQueryParameters": "Глобален списък на заявените URL параметри да се изключи", + "GlobalWebsitesSettings": "Глобални настройки на уеб сайтовете", + "HelpExcludedIps": "Въведете списък с IP адреси, по един на ред, които искате да бъдат изключени от проследяването на Piwik. Можете да използвате заместващи символи, например. %1$s или %2$s", + "JsTrackingTagHelp": "JavaScript кода, който трябва да вмъкнете във всички страници", + "KeepURLFragmentsHelp2": "Има възможност да отмените тази настройка за индивидуални уеб сайтове по-горе.", + "ListOfIpsToBeExcludedOnAllWebsites": "IP адресите по-долу ще бъдат изключени от броячите на всички сайтове.", + "ListOfQueryParametersToBeExcludedOnAllWebsites": "Заявка на URL адресните параметри по-долу, ще бъдат изключени от URL адресите на всички уеб сайтове.", + "ListOfQueryParametersToExclude": "Въведете списък с URL параметри на заявката, по един на ред, да се изключат от докладите на Page URL адреси.", + "MainDescription": "За да работи брояча е необходимо да добавите САЙТОВЕ! Добавете, редактирайте, изтрийте Сайт и вземете кода за вмъкване.", + "NotAnEcommerceSite": "Не сайт за Електронна търговия", + "NotFound": "Не са намерени уеб сайтове", + "NoWebsites": "Вие нямате сайт, който да администрирате.", + "OnlyOneSiteAtTime": "Не можете да променяте едновременно два сайта. Моля Запазете или Откажете вашата текуща модификация на този уеб сайт %s.", + "PiwikOffersEcommerceAnalytics": "Piwik позволява задълбочено проследяване и анализ на Електронна търговия. Научете повече относно %s Електронна търговия анализ %s.", + "PiwikWillAutomaticallyExcludeCommonSessionParameters": "Piwik автоматично ще изключи общите параметри на сесията (%s).", + "PluginDescription": "Уеб сайтове за управление в Piwik: Добавяне на нов сайт, редактиране на вече съществуващ такъв, показване на JavaScript код за добавяне на страниците си. Всички действия са достъпни чрез API.", + "SearchKeywordLabel": "Параметър за заявката", + "SearchKeywordParametersDesc": "Въведете списък, разделен със запетаи, за имената на всички заявки съдържащи търсени ключови думи в сайта.", + "SelectACity": "Изберете град", + "SelectDefaultCurrency": "Можете да изберете валута, за да зададете по подразбиране за нови уеб сайтове.", + "SelectDefaultTimezone": "Можете да изберете времева зона, за да изберете по подразбиране за нови уеб сайтове.", + "ShowTrackingTag": "покажи кода за вмъкване", + "Sites": "Сайтове", + "SiteSearchUse": "Можете да използвате Piwik да следи и докладва това, което посетителите търсят, посредством търсачката на сайта.", + "SuperUserAccessCan": "Потребител с права „привилигирован потребител“ може да достъпва също %sзадаване на глобални настройки%s за новите сайтове.", + "Timezone": "Часова зона", + "TrackingTags": "Проследяване на етикети за %s", + "Urls": "Адреси", + "UTCTimeIs": "UTC часът е %s.", + "WebsitesManagement": "Управление на сайтове", + "YouCurrentlyHaveAccessToNWebsites": "В момента имате достъп до %s уебсайта.", + "YourCurrentIpAddressIs": "Вашият IP адрес е: %s" + }, + "Transitions": { + "BouncesInline": "%s еднократни посещения", + "DirectEntries": "Директни посещения", + "ErrorBack": "Върнете се на предишната страница", + "ExitsInline": "%s изходи", + "FromCampaigns": "От кампании", + "FromPreviousPages": "От вътрешни страници", + "FromPreviousPagesInline": "%s от вътреши страници", + "FromPreviousSiteSearches": "От вътрешни търсения", + "FromPreviousSiteSearchesInline": "%s от вътрешни търсения", + "FromSearchEngines": "От търсещи машини", + "FromWebsites": "От уебсайтове", + "IncomingTraffic": "Входящ трафик", + "LoopsInline": "%s страница се презарежда", + "NoDataForAction": "Няма информация за %s", + "OutgoingTraffic": "Изходящ трафик", + "PluginDescription": "Доклади за предишни и следващи действия за всяка страница.", + "ShareOfAllPageviews": "Тази страница има %s разглеждания (от всички %s разглеждания)", + "ToFollowingPages": "Към вътрешни страници", + "ToFollowingPagesInline": "%s за вътрешни страници", + "ToFollowingSiteSearches": "Външни търсения", + "ToFollowingSiteSearchesInline": "%s вътрешни търсения", + "XOfAllPageviews": "%s на всички показания за тази страница", + "XOutOfYVisits": "%s (от %s)" + }, + "UserCountry": { + "AssumingNonApache": "Не може да бъде намерена apache_get_modules функцията, което предполага, че това не е Apache сървър.", + "CannotFindGeoIPDatabaseInArchive": "Файл %1$s не може да бъде намерен в tar архив %2$s!", + "CannotFindGeoIPServerVar": "Променливата %s не е зададена. Възможно е сървърът да не е настроен правилно.", + "CannotListContent": "Съдържанието за %1$s: %2$s не може да бъде заредено", + "CannotLocalizeLocalIP": "IP адрес %s е вътрешен (частен) адрес и не може да бъде определено местоположението му.", + "CannotSetupGeoIPAutoUpdating": "Изглежда, че GeoIP базата от данни се съхранява извън Piwik (няма бази от данни в папката \"misc\", но GeoIP работи). Piwik не може автоматично да обнови GeoIP базата от данни, ако те се намират извън директория \"misc\".", + "CannotUnzipDatFile": "Не може да се разархивира dat файл в %1$s: %2$s", + "City": "Град", + "CityAndCountry": "%1$s, %2$s", + "Continent": "Континент", + "continent_afr": "Африка", + "continent_amc": "Централна Америка", + "continent_amn": "Северна Америка", + "continent_ams": "Южна и Централна Америка", + "continent_ant": "Антарктида", + "continent_asi": "Азия", + "continent_eur": "Европа", + "continent_oce": "Океания", + "Country": "Държава", + "country_a1": "Анонимно прокси", + "country_a2": "Сателитен доставчик", + "country_ac": "Възнесение", + "country_ad": "Андора", + "country_ae": "Обединените арабски емирства", + "country_af": "Афганистан", + "country_ag": "Антигуа и Барбуда", + "country_ai": "Ангуила", + "country_al": "Албания", + "country_am": "Армения", + "country_an": "Холандски Антили", + "country_ao": "Ангола", + "country_ap": "Азия\/Тихоокеанския регион", + "country_aq": "Антарктида", + "country_ar": "Аржентина", + "country_as": "Американска Самоа", + "country_at": "Австрия", + "country_au": "Австралия", + "country_aw": "Аруба", + "country_ax": "Аландските острови", + "country_az": "Азербайджан", + "country_ba": "Босна и Херцеговина", + "country_bb": "Барбадос", + "country_bd": "Бангладеш", + "country_be": "Белгия", + "country_bf": "Буркина Фасо", + "country_bg": "България", + "country_bh": "Бахрейн", + "country_bi": "Бурунди", + "country_bj": "Бенин", + "country_bl": "Сен Бартелеми", + "country_bm": "Бермудските острови", + "country_bn": "Бруней", + "country_bo": "Боливия", + "country_bq": "Бонер, Сент Естатиус и Саба", + "country_br": "Бразилия", + "country_bs": "Бахамите", + "country_bt": "Бутан", + "country_bu": "Бирма", + "country_bv": "Остров Буве", + "country_bw": "Боцвана", + "country_by": "Беларус", + "country_bz": "Белиз", + "country_ca": "Канада", + "country_cat": "Каталонски-говорящите общности", + "country_cc": "Кокосови острови", + "country_cd": "Демократична република Конго", + "country_cf": "Централноафриканска република", + "country_cg": "Конго", + "country_ch": "Швейцария", + "country_ci": "Кот д'Ивоар", + "country_ck": "Острови Кук", + "country_cl": "Чили", + "country_cm": "Камерун", + "country_cn": "Китай", + "country_co": "Колумбия", + "country_cp": "Остров Клипертон", + "country_cr": "Коста Рика", + "country_cs": "Сърбия и Черна гора", + "country_cu": "Куба", + "country_cv": "Кабо Верде", + "country_cw": "Куракао", + "country_cx": "Рождество (остров)", + "country_cy": "Кипър", + "country_cz": "Чехия", + "country_de": "Германия", + "country_dg": "Диего Гарсия", + "country_dj": "Джибути", + "country_dk": "Дания", + "country_dm": "Доминика", + "country_do": "Доминиканска република", + "country_dz": "Алжир", + "country_ea": "Сеута, Мелила", + "country_ec": "Еквадор", + "country_ee": "Естония", + "country_eg": "Египет", + "country_eh": "Западна Сахара", + "country_er": "Еритрея", + "country_es": "Испания", + "country_et": "Етиопия", + "country_eu": "Европейски съюз", + "country_fi": "Финландия", + "country_fj": "Фиджи", + "country_fk": "Фолкландските острови (Малвински)", + "country_fm": "Микронезия", + "country_fo": "Фарьорски острови", + "country_fr": "Франция", + "country_fx": "Франция, Метрополитен", + "country_ga": "Габон", + "country_gb": "Великобритания", + "country_gd": "Гренада", + "country_ge": "Грузия", + "country_gf": "Френска Гвиана", + "country_gg": "Вълнена фланела", + "country_gh": "Гана", + "country_gi": "Гибралтар", + "country_gl": "Гренландия", + "country_gm": "Гамбия", + "country_gn": "Гвинея", + "country_gp": "Гваделупа", + "country_gq": "Екваториална Гвинея", + "country_gr": "Гърция", + "country_gs": "Южна Джорджия и Южните Сандвичеви острови", + "country_gt": "Гватемала", + "country_gu": "Гуам", + "country_gw": "Гвинея-Бисау", + "country_gy": "Гвиана", + "country_hk": "Хонконг", + "country_hm": "Остров Хърд и острови Макдоналд", + "country_hn": "Хондурас", + "country_hr": "Хърватска", + "country_ht": "Хаити", + "country_hu": "Унгария", + "country_ic": "Канарски острови", + "country_id": "Индонезия", + "country_ie": "Ирландия", + "country_il": "Израел", + "country_im": "Човекът Айлънд", + "country_in": "Индия", + "country_io": "Британска територия в Индийския океан", + "country_iq": "Ирак", + "country_ir": "Иран, Ислямска република", + "country_is": "Исландия", + "country_it": "Италия", + "country_je": "Джърси", + "country_jm": "Ямайка", + "country_jo": "Йордания", + "country_jp": "Япония", + "country_ke": "Кения", + "country_kg": "Киргизстан", + "country_kh": "Камбоджа", + "country_ki": "Кирибати", + "country_km": "Коморски острови", + "country_kn": "Сейнт Китс и Невис", + "country_kp": "Корейска народнодемократична република", + "country_kr": "Южна Корея", + "country_kw": "Кувейт", + "country_ky": "Кайманови острови", + "country_kz": "Казахстан", + "country_la": "Лаос", + "country_lb": "Ливан", + "country_lc": "Сейнт Лусия", + "country_li": "Лихтенщайн", + "country_lk": "Шри Ланка", + "country_lr": "Либерия", + "country_ls": "Лесото", + "country_lt": "Литва", + "country_lu": "Люксембург", + "country_lv": "Латвия", + "country_ly": "Либия", + "country_ma": "Мароко", + "country_mc": "Монако", + "country_md": "Молдова", + "country_me": "Монтенегро", + "country_mf": "Сейнт Мартин", + "country_mg": "Мадагаскар", + "country_mh": "Маршалови острови", + "country_mk": "Македония", + "country_ml": "Мали", + "country_mm": "Мианмар", + "country_mn": "Монголия", + "country_mo": "Макао", + "country_mp": "Северни Мариански острови", + "country_mq": "Мартиника", + "country_mr": "Мавритания", + "country_ms": "Монсерат", + "country_mt": "Малта", + "country_mu": "Мавриций", + "country_mv": "Малдивите", + "country_mw": "Малави", + "country_mx": "Мексико", + "country_my": "Малайзия", + "country_mz": "Мозамбик", + "country_na": "Намибия", + "country_nc": "Нова Каледония", + "country_ne": "Нигер", + "country_nf": "Норфолк", + "country_ng": "Нигерия", + "country_ni": "Никарагуа", + "country_nl": "Нидерландия", + "country_no": "Норвегия", + "country_np": "Непал", + "country_nr": "Науру", + "country_nt": "Неутрална зона", + "country_nu": "Ниуе", + "country_nz": "Нова Зеландия", + "country_o1": "Друга държава", + "country_om": "Оман", + "country_pa": "Панама", + "country_pe": "Перу", + "country_pf": "Френска Полинезия", + "country_pg": "Папуа - Нова Гвинея", + "country_ph": "Филипините", + "country_pk": "Пакистан", + "country_pl": "Полша", + "country_pm": "Сен Пиер и Микелон", + "country_pn": "Питкерн", + "country_pr": "Пуерто Рико", + "country_ps": "Палестина", + "country_pt": "Португалия", + "country_pw": "Палау", + "country_py": "Парагвай", + "country_qa": "Катар", + "country_re": "Реюнион", + "country_ro": "Румъния", + "country_rs": "Сърбия", + "country_ru": "Русия", + "country_rw": "Руанда", + "country_sa": "Саудитска Арабия", + "country_sb": "Соломоновите острови", + "country_sc": "Сейшелските острови", + "country_sd": "Судан", + "country_se": "Швеция", + "country_sf": "Финландия", + "country_sg": "Сингапур", + "country_sh": "Света Хелена", + "country_si": "Словения", + "country_sj": "Свалбард", + "country_sk": "Словакия", + "country_sl": "Сиера Леоне", + "country_sm": "Сан МАрино", + "country_sn": "Сенегал", + "country_so": "Сомалия", + "country_sr": "Суринам", + "country_ss": "Южен Судан", + "country_st": "Сао Томе и Принсипи", + "country_su": "СССР", + "country_sv": "Ел Салвадор", + "country_sx": "Сейнт Мартен (Холандската част)", + "country_sy": "Сирия", + "country_sz": "Швейцария", + "country_ta": "Тристан да Куня", + "country_tc": "Търкс и Кайкос", + "country_td": "Чад", + "country_tf": "Френски южни и антарктически територии", + "country_tg": "Того", + "country_th": "Тайланд", + "country_ti": "Тибет", + "country_tj": "Таджикистан", + "country_tk": "Токелау", + "country_tl": "Източен Тимор", + "country_tm": "Туркменистан", + "country_tn": "Тунис", + "country_to": "Тонга", + "country_tp": "Източен Тимор", + "country_tr": "Турция", + "country_tt": "Тринидад и Тубаго", + "country_tv": "Тувалу", + "country_tw": "Тайван", + "country_tz": "Танзания", + "country_ua": "Украйна", + "country_ug": "Уганда", + "country_uk": "Обединено Кралство", + "country_um": "Малки далечни острови на САЩ", + "country_us": "САЩ", + "country_uy": "Уругвай", + "country_uz": "Узбекистан", + "country_va": "Ватикана", + "country_vc": "Сейнт Винсент и Гренадини", + "country_ve": "Венецуела", + "country_vg": "Британски Вирджински острови", + "country_vi": "Американски Вирджински острови", + "country_vn": "Виетнам", + "country_vu": "Вануату", + "country_wf": "Уолис и Футуна", + "country_ws": "Самоа", + "country_ye": "Йемен", + "country_yt": "Майот", + "country_yu": "Югославия", + "country_za": "ЮАР", + "country_zm": "Замбия", + "country_zr": "Зайре", + "country_zw": "Зимбабве", + "CurrentLocationIntro": "Според текущия доставчик, вашето местоположение е", + "DefaultLocationProviderDesc1": "Доставчикът по подразбиране, определящ местоположението, отгатва държавата, от която е посетителят, на базата на използвания език.", + "DefaultLocationProviderDesc2": "Този метод не е много точен, затова %1$sсе препоръчва инсталирането и използването на %2$sGeoIP%3$s.%4$s", + "DefaultLocationProviderExplanation": "Използва се доставчикът по подразбиране, което означава, че Piwik ще определя местоположението на посетителите спрямо езика, който те използват. %1$sПрочетете това%2$s, за да научите как се настройва по-точен метод за определяне на местоположението.", + "DistinctCountries": "%s отделни държави", + "DownloadingDb": "Сваляне %s", + "DownloadNewDatabasesEvery": "Обновяване на базата от данни на всеки", + "FatalErrorDuringDownload": "Възникнала е фатална грешка при свалянето на този файл. Възможно е да има нещо нередно с интернет връзката, с базата данни на GeoIP, която е изтеглена или Piwik. Опитайте да изтеглите файла ръчно и да го инсталирате.", + "FoundApacheModules": "Piwik откри следните Apache модули", + "FromDifferentCities": "различни градове", + "GeoIPCannotFindMbstringExtension": "Не може да бъде намерена %1$s функция. Уверете се, че разширение %2$s е инсталирано и заредено.", + "GeoIPDatabases": "GeoIP база данни", + "GeoIPImplHasAccessTo": "Тази реализация на GeoIP има достъп до следните типове бази данни", + "GeoIpLocationProviderDesc_Pecl1": "Този доставчик използва GeoIP база данни и PECL модул, за да определи точно и ефективно местоположението на посетителите.", + "GeoIpLocationProviderDesc_Pecl2": "Няма ограничения с този доставчик. Поради тази причина, това е вариантът, който е препоръчително да бъде използван.", + "GeoIpLocationProviderDesc_Php1": "Този доставчик е най-лесен за инсталиране, тъй като не изисква сървърна конфигурация (идеален за споделен хостинг!). Той използва база данни GeoIP и MaxMind PHP API, за да определя точното местоположение на посетителите.", + "GeoIpLocationProviderDesc_Php2": "В случай, че през вашия сайт преминава голямо количество трафик, може да се окаже, че този доставчик е твърде бавен. В този случай е най-добре да се инсталира %1$sPECL разширение%2$s или %3$sсървърен модул%4$s.", + "GeoIPNoServerVars": "Piwik не може да намери GeoIP %s променливи.", + "GeoIPPeclCustomDirNotSet": "Опцията %s PHP ini не е зададена.", + "GeoIPServerVarsFound": "Piwik засече следните GeoIP %s променливи", + "GeoIPUpdaterIntro": "Piwik до момента поддържа обновления за следните GeoIP бази данни", + "GeoLiteCityLink": "Ако се използва GeoLite City база данни, е нужно да се използва тази връзка: %1$s%2$s%3$s.", + "Geolocation": "Геолокация", + "GeolocationPageDesc": "В тази страница може да се промени начинът, по който Piwik определя местоположението на посетителите.", + "getCityDocumentation": "Този отчет показва в кои градове са се намирали вашите посетители, при достъпването на сайта.", + "getContinentDocumentation": "Този отчет показва кое съдържание са разглеждали вашите посетители, при достъпването на сайта.", + "getCountryDocumentation": "Този отчет показва в коя държава са се намирали вашите посетители, при достъпването на сайта.", + "getRegionDocumentation": "Този отчет показва къде са се намирали вашите посетители, при достъпването на сайта.", + "HowToInstallApacheModule": "Как да инсталирам GeoIP модул за Apache?", + "HowToInstallGeoIPDatabases": "Как да взема GeoIP база данни?", + "HowToInstallGeoIpPecl": "Как се инсталира GeoIP PECL разширение?", + "HowToInstallNginxModule": "Как се инсталира GeoIP модул за Nginx?", + "HowToSetupGeoIP": "Как се настройва точно местоположение с GeoIP", + "HowToSetupGeoIP_Step1": "%1$sСвалете%2$s GeoLite City базата данни от %3$sMaxMind%4$s.", + "HowToSetupGeoIP_Step2": "Разархивирайте файла и копирайте резултата, %1$s в Piwik поддиректорията %2$sразни%3$s (можете да направите това посредством FTP или SSH).", + "HowToSetupGeoIP_Step3": "Презареждане на този екран. Доставчикът на %1$sGeoIP (PHP)%2$s, ще бъде %3$sинсталиран%4$s. Избиране.", + "HowToSetupGeoIP_Step4": "И сте готови! Вие току-що настроихте Piwik да използва GeoIP, което означава, че ще бъдете в състояние да видите регионите и градовете, от които посетителите достъпват вашия сайт, заедно с много точна информация за страната.", + "HowToSetupGeoIPIntro": "Изглежда, че настройката, за определяне на местоположението, не е направена. Това е полезна функция и без нея няма да се вижда точна и пълна информация за местоположението на посетителите. Ето и начин, по който може бързо да започнете да го използвате:", + "HttpServerModule": "HTTP сървърен модул", + "InvalidGeoIPUpdatePeriod": "Невалиден период за обновяването на GeoIP: %1$s. Валидните стойности са %2$s.", + "IPurchasedGeoIPDBs": "Купих още %1$sпрецизни бази данни от MaxMind%2$s и искам да настроя автоматични актуализации.", + "ISPDatabase": "ISP база данни", + "IWantToDownloadFreeGeoIP": "Искам да изтегля безплатната GeoIP база данни...", + "Latitude": "Географска ширина", + "Location": "Място", + "LocationDatabase": "Местоположение база данни", + "LocationDatabaseHint": "Базата от данни указваща местоположение е или за държава, регион или град.", + "LocationProvider": "Местоположение на доставчика", + "Longitude": "Географска дължина", + "NoDataForGeoIPReport1": "Няма данни за този доклад, защото или няма информация за местоположението, или IP адресът на посетителя не може да бъде определен къде се намира.", + "NoDataForGeoIPReport2": "За да бъде активирано по-точно определяне на местоположението е нужно да се променят настройките %1$sтук%2$s и да се използва %3$sбази данни на ниво град%4$s.", + "Organization": "Организация", + "OrgDatabase": "Организация на базата данни", + "PiwikNotManagingGeoIPDBs": "Piwik в момента не се управлява от никакви GeoIP база данни.", + "PluginDescription": "Доклад за Държавите на Вашите посетители.", + "Region": "Регион", + "SetupAutomaticUpdatesOfGeoIP": "Настройка за автоматични актуализации на GeoIP бази данни", + "SubmenuLocations": "Местонахождение", + "TestIPLocatorFailed": "Piwik направи проверка на местоположението на известен IP адрес (%1$s), но вашият сървър показва %2$s. Ако този доставчик беше настроен правилно, трябваше да показва %3$s.", + "ThisUrlIsNotAValidGeoIPDB": "Сваленият файл не е валидна GeoIP база данни. Моля, проверете отново адреса или свалете файла ръчно.", + "ToGeolocateOldVisits": "За да получите информация за предишни посещения, използвайте скриптът описан %1$sтук%2$s.", + "UnsupportedArchiveType": "Типът архив %1$s не се поддържа.", + "UpdaterHasNotBeenRun": "Не е пускана до момента задача за обновяване.", + "UpdaterIsNotScheduledToRun": "Не е планирано да се изпълнява в бъдеще.", + "UpdaterScheduledForNextRun": "Ще бъде пуснато при следващото изпълнение на archive.php cron.", + "UpdaterWasLastRun": "Задача за проверка за обновления е стартирана последно на %s.", + "UpdaterWillRunNext": "Следващото пускане е планирано за %s.", + "WidgetLocation": "Местонахождение на посетителя" + }, + "UserCountryMap": { + "AndNOthers": "и %s други", + "Cities": "Градове", + "Countries": "Държави", + "DaysAgo": "преди %s дни", + "GoalConversions": "%s достигнати цели", + "Hours": "часа", + "HoursAgo": "преди %s часа", + "map": "карта", + "Minutes": "минути", + "MinutesAgo": "преди %s минути", + "None": "Няма", + "NoVisit": "Няма посещения", + "RealTimeMap": "Карта на посетителите в реално време", + "Regions": "Региони", + "Searches": "%s търсения", + "Seconds": "секунди", + "SecondsAgo": "преди %s секунди", + "ShowingVisits": "Последни посещения базирани на географско местоположение", + "Unlocated": "%s<\/b> %p посещения от %c вашето местоположение не може да бъде открито.", + "VisitorMap": "Карта на посетителите", + "WorldWide": "По целия свят" + }, + "UserSettings": { + "BrowserFamilies": "Фамилия браузъри", + "BrowserLanguage": "Език на браузъра", + "Browsers": "Браузъри", + "BrowserWithNoPluginsEnabled": "%1$s без активни добавки", + "BrowserWithPluginsEnabled": "%1$s с добавки %2$s активиран", + "ColumnBrowser": "Браузър и версия", + "ColumnBrowserFamily": "Фамилия браузъри", + "ColumnBrowserVersion": "Версия на браузъра", + "ColumnConfiguration": "Обобщена конфигурация", + "ColumnOperatingSystem": "Операционна система и версия", + "ColumnResolution": "Разделителна способност на екрана", + "ColumnTypeOfScreen": "Тип на екрана", + "Configurations": "Конфигурации", + "GamingConsole": "Конзола за игри", + "Language_aa": "афарски", + "Language_ab": "абхазки", + "Language_ae": "авестийски", + "Language_af": "бурски език", + "Language_ak": "акански", + "Language_am": "амхарски", + "Language_an": "арагонски", + "Language_ar": "арабски", + "Language_as": "асамски", + "Language_av": "аварски", + "Language_ay": "аймара", + "Language_az": "азербайджански", + "Language_ba": "башкирски", + "Language_be": "беларуски", + "Language_bg": "български", + "Language_bh": "бихари", + "Language_bi": "бислама", + "Language_bm": "бамбара", + "Language_bn": "бенгалски", + "Language_bo": "тибетски", + "Language_br": "бретонски", + "Language_bs": "босненски", + "Language_ca": "каталонски", + "Language_ce": "чеченски", + "Language_ch": "чаморо", + "Language_co": "корсикански", + "Language_cr": "крии", + "Language_cs": "чешки", + "Language_cu": "църковно славянски", + "Language_cv": "чувашки", + "Language_cy": "уелски", + "Language_da": "датски", + "Language_de": "немски", + "Language_dv": "дивехи", + "Language_dz": "дзонха", + "Language_ee": "еуе", + "Language_el": "гръцки", + "Language_en": "английски", + "Language_eo": "есперанто", + "Language_es": "испански", + "Language_et": "естонски", + "Language_eu": "баски", + "Language_fa": "персийски", + "Language_ff": "фула", + "Language_fi": "фински", + "Language_fj": "фиджийски", + "Language_fo": "фарьорски", + "Language_fr": "френски", + "Language_fy": "фризийски", + "Language_ga": "ирландски", + "Language_gd": "шотландски галски", + "Language_gl": "галисийски", + "Language_gn": "гуарани", + "Language_gu": "гуджарати", + "Language_gv": "манкски", + "Language_ha": "хауза", + "Language_he": "иврит", + "Language_hi": "хинди", + "Language_ho": "хири моту", + "Language_hr": "хърватски", + "Language_ht": "хаитянски", + "Language_hu": "унгарски", + "Language_hy": "арменски", + "Language_hz": "хереро", + "Language_ia": "интерлингва", + "Language_id": "индонезийски", + "Language_ie": "оксидентал", + "Language_ig": "игбо", + "Language_ii": "сечуански", + "Language_ik": "инупиак", + "Language_io": "идо", + "Language_is": "исландски", + "Language_it": "италиански", + "Language_iu": "инуктитут", + "Language_ja": "японски", + "Language_jv": "явански", + "Language_ka": "грузински", + "Language_kg": "конгоански", + "Language_ki": "кикуйу", + "Language_kj": "кваняма", + "Language_kk": "казахски", + "Language_kl": "гренландски ескимоски", + "Language_km": "кхмерски", + "Language_kn": "каннада", + "Language_ko": "корейски", + "Language_kr": "канури", + "Language_ks": "кашмирски", + "Language_ku": "кюрдски", + "Language_kv": "Коми", + "Language_kw": "корнуолски келтски", + "Language_ky": "киргизски", + "Language_la": "латински", + "Language_lb": "люксембургски", + "Language_lg": "ганда", + "Language_li": "лимбургски", + "Language_ln": "лингала", + "Language_lo": "лаоски", + "Language_lt": "литовски", + "Language_lu": "луба катанга", + "Language_lv": "латвийски", + "Language_mg": "малгашки", + "Language_mh": "маршалезе", + "Language_mi": "маорски", + "Language_mk": "македонски", + "Language_ml": "малаялам", + "Language_mn": "монголски", + "Language_mr": "маратхи", + "Language_ms": "малайски", + "Language_mt": "малтийски", + "Language_my": "бирмански", + "Language_na": "науру", + "Language_nb": "норвежки бокмал", + "Language_nd": "северен ндебеле", + "Language_ne": "непалски", + "Language_ng": "ндонга", + "Language_nl": "холандски", + "Language_nn": "съвременен норвежки", + "Language_no": "норвежки", + "Language_nr": "южен ндебеле", + "Language_nv": "навахо", + "Language_ny": "чинянджа", + "Language_oc": "окситански", + "Language_oj": "оджибва", + "Language_om": "оромо", + "Language_or": "ория", + "Language_os": "осетски", + "Language_pa": "пенджабски", + "Language_pi": "пали", + "Language_pl": "полски", + "Language_ps": "пущу", + "Language_pt": "португалски", + "Language_qu": "кечуа", + "Language_rm": "реторомански", + "Language_rn": "рунди", + "Language_ro": "румънски", + "Language_ru": "руски", + "Language_rw": "киняруанда", + "Language_sa": "санкскритски", + "Language_sc": "сардински", + "Language_sd": "синдхи", + "Language_se": "северен сами", + "Language_sg": "санго", + "Language_si": "синхалски", + "Language_sk": "словашки", + "Language_sl": "словенски", + "Language_sm": "самоански", + "Language_sn": "шона", + "Language_so": "сомалийски", + "Language_sq": "албански", + "Language_sr": "сръбски", + "Language_ss": "суази", + "Language_st": "сесуто", + "Language_su": "сундански", + "Language_sv": "шведски", + "Language_sw": "суахили", + "Language_ta": "тамилски", + "Language_te": "телугу", + "Language_tg": "таджикски", + "Language_th": "таи", + "Language_ti": "тигриня", + "Language_tk": "туркменски", + "Language_tl": "тагалог", + "Language_tn": "тсвана", + "Language_to": "тонга", + "Language_tr": "турски", + "Language_ts": "тсонга", + "Language_tt": "татарски", + "Language_tw": "туи", + "Language_ty": "таитянски", + "Language_ug": "уйгурски", + "Language_uk": "украински", + "Language_ur": "урду", + "Language_uz": "узбекски", + "Language_ve": "венда", + "Language_vi": "виетнамски", + "Language_vo": "волапюк", + "Language_wa": "валонски", + "Language_wo": "волоф", + "Language_xh": "ксоса", + "Language_yi": "идиш", + "Language_yo": "йоруба", + "Language_za": "зуанг", + "Language_zh": "китайски", + "Language_zu": "зулуски", + "LanguageCode": "Код на езика", + "MobileVsDesktop": "Мобилни срещу Настолни", + "OperatingSystemFamily": "Семейство на оперативната система", + "OperatingSystems": "Операционни системи", + "PluginDescription": "Докладът за различните потребителски настройки: Браузър, Браузър Семейство, операционна система, модули, резолюция, Глобални настройки.", + "PluginDetectionDoesNotWorkInIE": "Забележка: Засичането на добавки не работи при Internet Explorer. Този доклад е базиран само на браузъри, различни от IE.", + "Resolutions": "Разделителна способност", + "VisitorSettings": "Настройки на посетителя", + "WideScreen": "Екран", + "WidgetBrowserFamilies": "Браузъри", + "WidgetBrowserFamiliesDocumentation": "Тази таблица показва браузърите на вашите потребители ,разделени по фамилии. %s Най-важната информация за уеб разработчиците е какъв тип технология за обработка са използвали посетителите. Етикета показва имената на технологиите, следвани от браузера, който е бил използван, поставен в скоби.", + "WidgetBrowsers": "Браузъри на посетителите", + "WidgetBrowsersDocumentation": "Този отчет показва информация, за това какъв браузър са използвали вашите потребители. Всеки браузер е показан поотделно.", + "WidgetBrowserVersion": "Версия на браузъра", + "WidgetGlobalVisitors": "Конфигурация на гло", + "WidgetGlobalVisitorsDocumentation": "Този отчет показва повечето общопознати цялостни конфигурации, които вашите посетители са имали. Конфигурация е комбинацията от операционна система, тип на браузера и резолюция на екрана.", + "WidgetOperatingSystems": "Операционни системи", + "WidgetPlugins": "Добавки", + "WidgetPluginsDocumentation": "Този отчет показва каква добавка на браузъра са използвали вашите посетители. Тази информация може да е важна, за да изберете правилния начин за доставяне на вашето съдържание.", + "WidgetResolutions": "Разделителна способност", + "WidgetWidescreen": "Нормален\/Широкоекранен" + }, + "UsersManager": { + "AddUser": "Добави нов потребител", + "Alias": "Псевдоним", + "AllWebsites": "Всички сайтове", + "AnonymousUserHasViewAccess": "Забележка: потребител %1$s има %2$s достъп до този сайт.", + "AnonymousUserHasViewAccess2": "Аналитичните доклади и информацията за посетителите, са публично видими.", + "ApplyToAllWebsites": "Запомни за всички сайтове", + "ChangeAllConfirm": "Сигурен ли сте, че искате да промените на '%s' правата за всички сайтове?", + "ChangePasswordConfirm": "Промяната на паролата ще промени и токена за верификация на потребителя. Желаете ли да продължите?", + "ClickHereToDeleteTheCookie": "Натиснете тук, за да изтриете бисквитката и Piwik да отчита Вашите посещения", + "ClickHereToSetTheCookieOnDomain": "Натиснете тук, за да зададете бисквитка, която ще Ви гарантира, че Вашите посещения няма да се отчитат от Piwik в %s", + "DeleteConfirm": "Наистина ли искате да изтриете потребителя %s?", + "Email": "Имейл", + "EmailYourAdministrator": "%1$sПишете на администратора Ви за този проблем%2$s.", + "ExceptionAccessValues": "Този параметър може да има само един от следните параметри : [ %s ]", + "ExceptionAdminAnonymous": "Не може да зададете 'админ' права на 'анонимен' потребител.", + "ExceptionDeleteDoesNotExist": "Потребителя '%s' не съществува, по тази причина не може да бъде изтрит.", + "ExceptionDeleteOnlyUserWithSuperUserAccess": "Изтриването на потребител '%s' не е възможно.", + "ExceptionEditAnonymous": "Анонимният потребител не може да бъде редактиран или изтрит. Piwik по този начин дефинира в система потребители, които не са влезли. Например можете да направите данните на брояча ви публични, като зададете 'преглед' права на 'анонимен' потребител.", + "ExceptionEmailExists": "Потребител с имейл '%s' вече съществува.", + "ExceptionInvalidEmail": "Е-пощата, който сте въвели не е валиден.", + "ExceptionInvalidLoginFormat": "Потребителското име трябва да бъде между %1$s и %2$s символа дълго и може да съдържа само букви, цифри и\/или символите '_' и\/или '-' и\/или '.'", + "ExceptionInvalidPassword": "Дължината на паролата трябва да бъде между %1$s и %2$s символа.", + "ExceptionLoginExists": "Потребител с име '%s' вече съществува.", + "ExceptionPasswordMD5HashExpected": "UsersManager.getTokenAuth очаква MD5-хеширана парола (32 символа дълъг низ). Моля, извикайте md5() функцията на паролата преди да извикате този метод.", + "ExceptionRemoveSuperUserAccessOnlySuperUser": "Премахването на права на привилигирован потребител за потребител '%s' не е възможно.", + "ExceptionUserDoesNotExist": "Потребителя '%s' не съществува.", + "ExceptionYouMustGrantSuperUserAccessFirst": "Трябва да съществува поне един привилигирован потребител. Моля, дайде нужните права на друг потребител.", + "ExcludeVisitsViaCookie": "Изключете Вашите посещения с помощта на бисквитка", + "ForAnonymousUsersReportDateToLoadByDefault": "За анонимни потребители, дата доклад да се зареди по подразбиране", + "IfYouWouldLikeToChangeThePasswordTypeANewOne": "Ако желаете да промените паролата си, въведете нова. В противен случай оставете полето празно.", + "InjectedHostCannotChangePwd": "В момента вие посещавате страница от неизвестен хост(%1$s). Не можете да смените паролата, преди да отстраните този проблем.", + "LastSeen": "Последно видяно", + "MainDescription": "Можете да управлявате правата на потребителите в Piwik, които да имат достъп до статистиките на вашия сайт. Също така можете да зададете права над всички сайтове.", + "ManageAccess": "Управление на правата", + "MenuAnonymousUserSettings": "Настройки на анонимните потребители", + "MenuUsers": "Потребители", + "MenuUserSettings": "Потребителски настройки", + "NoteNoAnonymousUserAccessSettingsWontBeUsed2": "Бележка: Не можете да променяте настройките в тази секция, защото нямате никакви сайтове с разрешен достъп за анонимни потребители.", + "NoUsersExist": "Не са налични потребители, все още.", + "PluginDescription": "Управлението на потребители в Piwik: добавяне на нов потребител, редактиране на съществуващ, актуализиране на разрешения. Всички действия са достъпни чрез API.", + "PrivAdmin": "Админ", + "PrivNone": "Без права", + "PrivView": "Преглед", + "ReportDateToLoadByDefault": "Отчет от дата да се зареди по подразбиране", + "ReportToLoadByDefault": "Доклад за зареждане по подразбиране", + "SuperUserAccessManagement": "Управление на достъпа на привилигированите потребители", + "SuperUserAccessManagementGrantMore": "Тук могат да се предоставят права „привилигирован потребител“ на потребителите. Тази функция трябва да се използва внимателно.", + "SuperUserAccessManagementMainDescription": "Привилигированите потребители имат пълни права. Те могат да извършват всички административни задачи, като например добавяне на нови уебсайтове за наблюдение, добавяне на потребители, промяна на потребителския достъп, активиране и деактивиране на добавки и дори инсталация на нови добавки от магазина.", + "TheLoginScreen": "В екрана за вход", + "ThereAreCurrentlyNRegisteredUsers": "Има %s регистрирани потребители.", + "TypeYourPasswordAgain": "Вашата нова парола отново.", + "User": "Потребител", + "UsersManagement": "Управление на потребители", + "UsersManagementMainDescription": "Създайте нови потребители или управлявайте вече съществуващи. Също така и можете да задавате правата за достъп.", + "WhenUsersAreNotLoggedInAndVisitPiwikTheyShouldAccess": "Когато потребителите не са влезли и посетят Piwik, те се нуждаят от достъп", + "YourUsernameCannotBeChanged": "Потребителското име не може да се променя.", + "YourVisitsAreIgnoredOnDomain": "%sВашите посещения са игнорирани от Piwik в %s %s (Piwik игнорира бисквитката, намерена във Вашият браузър).", + "YourVisitsAreNotIgnored": "%sВашите посещения не се игнорират от Piwik %s(Бисквитката за игнориране на Вашите посещения, не е открита във Вашият браузър)." + }, + "VisitFrequency": { + "ColumnActionsByReturningVisits": "Действия от Върнали се посетители", + "ColumnAverageVisitDurationForReturningVisitors": "Средно времетраене на посещението на върналите се посетители (в секунди)", + "ColumnAvgActionsPerReturningVisit": "Средно действия на завърналия се посетител", + "ColumnBounceCountForReturningVisits": "Брой повторни посещения.", + "ColumnBounceRateForReturningVisits": "Bounce rate за върнали се посетители", + "ColumnMaxActionsInReturningVisit": "Максимум действия при едно повторно посещение.", + "ColumnNbReturningVisitsConverted": "Брой конвертирани повторни посещения", + "ColumnReturningVisits": "Върнали се посетители", + "ColumnSumVisitLengthReturning": "Време прекарано по време на повторните посещения (в секунди)", + "ColumnUniqueReturningVisitors": "Уникални повторни посетители", + "PluginDescription": "Статистики на Завърналите се посетители срещу уникалните посетители.", + "ReturnActions": "%s действия от върнали се посетители", + "ReturnAverageVisitDuration": "%s средна продължителност на посещенията от върналите се посетители", + "ReturnAvgActions": "%s дейности на върналите се посетители", + "ReturnBounceRate": "%s върнали се потребители, които са отскочили (напуснали са сайта още след първата страница)", + "ReturningVisitDocumentation": "Повторно посещение (за разлиса от ново посещение) се прави от някой, който е посещавал сайта поне веднъж в миналото.", + "ReturningVisitsDocumentation": "Това е преглед на повторните посещения.", + "ReturnVisits": "%s върнали се посетители", + "SubmenuFrequency": "Честота", + "WidgetGraphReturning": "Графика на завърналите се посетители", + "WidgetOverview": "Резюме на честотата" + }, + "VisitorInterest": { + "BetweenXYMinutes": "%1$s-%2$s мин", + "BetweenXYSeconds": "%1$s-%2$s сек", + "ColumnPagesPerVisit": "Импресии\/Посещения", + "ColumnVisitDuration": "Продължителност на посещение", + "Engagement": "Ангажимент", + "NPages": "%s страници", + "OneMinute": "1 минута", + "OnePage": "1 страница", + "PluginDescription": "Статистики за посетителите: разгледани страници, прекарано време в сайта.", + "PlusXMin": "%s мин", + "VisitNum": "Брой посещения", + "VisitsByDaysSinceLast": "Посещения на ден от последното посещение", + "visitsByVisitCount": "Посещения по брой на посещенията", + "VisitsPerDuration": "Посещения по продължителност", + "VisitsPerNbOfPages": "Посещения по прегледани страници", + "WidgetLengths": "Продължителност на посещенията", + "WidgetLengthsDocumentation": "В този отчет, можете да видите колко посещения са имали дадена продължителност. Първоначално, отчета се показва като \"облак от етикети\", по-често срещаните са изобразени с голям шрифт.", + "WidgetPages": "Импресии\/Посещения", + "WidgetPagesDocumentation": "В този отчет, можете да видите в колко посещения са се видяли определен брой страници. Първоначално, отчета се показва като \"облак от етикети\", по-често срещаните са изобразени с голям шрифт.", + "WidgetVisitsByDaysSinceLast": "Посещения по дни след последното посещение", + "WidgetVisitsByDaysSinceLastDocumentation": "В този отчет, можете да видито колко посещения са направени от посетители, чието последно посещение е било преди определен брой от дни.", + "WidgetVisitsByNumDocumentation": "В този отчет можете да видите броя посещения, които са били N-тото посещение, тоест посетителите, които са посетили вашият уеб сайт поне N пъти." + }, + "VisitsSummary": { + "AverageGenerationTime": "%s средно времетраене за обработка на заявката", + "AverageVisitDuration": "%s средно времетраене на посещението", + "GenerateQueries": "%s заявки са изпълнени", + "GenerateTime": "%s секунди за генериране на страницата", + "MaxNbActions": "%s макс. действия при едно посещение", + "NbActionsDescription": "%s действия (показвания, изтегляния и outlinks)", + "NbActionsPerVisit": "%s действия на посещение", + "NbDownloadsDescription": "%s изтегляния", + "NbKeywordsDescription": "%s уникални ключови думи", + "NbOutlinksDescription": "%s външни връзки", + "NbPageviewsDescription": "%s преглеждания на страница", + "NbSearchesDescription": "%s общо търсения в вашият сайт", + "NbUniqueDownloadsDescription": "%s уникални изтегляния", + "NbUniqueOutlinksDescription": "%s уникални външни връзки", + "NbUniquePageviewsDescription": "%s уникални преглеждания на страница", + "NbUniqueVisitors": "%s уникални посетителя", + "NbVisitsBounced": "%s посещения са отскочили (напуснали още след първата разгледана страница)", + "PluginDescription": "Доклад за генералния анализ по номера: посещения, уникални посещения, поредица от действия, Bounce Rate и други", + "VisitsSummary": "Резюме на посещенията", + "VisitsSummaryDocumentation": "Това е преглед на еволюцията на посещенията.", + "WidgetLastVisits": "Последни посетители", + "WidgetOverviewGraph": "Резюме с графика", + "WidgetVisits": "Резюме на посещенията" + }, + "VisitTime": { + "ColumnLocalTime": "Локално време (на посетителя)", + "ColumnServerTime": "Сървърно време (на сървъра)", + "DayOfWeek": "Ден от седмицата", + "LocalTime": "Посещения по локално време", + "NHour": "%sч", + "PluginDescription": "Доклад за сървърно и местно време. Сървърното време, може да бъде полезно при предвиждане на спиране за поддръжка на сайта.", + "ServerTime": "Посещения по сървърно време", + "SubmenuTimes": "Време", + "VisitsByDayOfWeek": "Посещения по ден от седмицата", + "WidgetByDayOfWeekDocumentation": "Графиката показва посещенията за всеки ден от седмицата.", + "WidgetLocalTime": "Посещения по локално време", + "WidgetLocalTimeDocumentation": "Тази графика показва колко е бил часът в %s часовия пояс на потребителите %s по време на посещенията им.", + "WidgetServerTime": "Посещения по сървърно време", + "WidgetServerTimeDocumentation": "Тази графика показва колко е бил часът в %s часовия пояс използван от сървъра %s по време на посещенията." + }, + "Widgetize": { + "OpenInNewWindow": "Отвори в нов прозорец", + "PluginDescription": "Тази добавка позволява по много лесен начин всяка Piwik джаджа да бъде добавена във вашия блог, уебсайт или в Igoogle и Netvibes!", + "TopLinkTooltip": "Запазете Piwik докладите като джаджи и вградете таблото във вашето приложение като iframe." + } +} \ No newline at end of file diff --git a/www/analytics/lang/bn.json b/www/analytics/lang/bn.json new file mode 100644 index 00000000..6bec9881 --- /dev/null +++ b/www/analytics/lang/bn.json @@ -0,0 +1,587 @@ +{ + "CoreHome": { + "PeriodDay": "দিন", + "PeriodMonth": "মাস", + "PeriodWeek": "সপ্তাহ", + "PeriodYear": "বছর" + }, + "CorePluginsAdmin": { + "Activate": "সক্রিয় করুন", + "Activated": "সক্রিয় হয়েছে", + "Active": "সক্রিয়", + "Deactivate": "নিষ্ক্রিয়করণ", + "Inactive": "নিষ্ক্রিয়", + "PluginHomepage": "প্লাগইনসমূহের মূলপাতা", + "Status": "অবস্থা", + "Version": "সংস্করণ" + }, + "Dashboard": { + "CreateNewDashboard": "নতুন ড্যাশবোর্ড তৈরি করুন", + "Dashboard": "ড্যাশবোর্ড", + "DashboardName": "ড্যাশবোর্ডের নাম:", + "RemoveDashboard": "ড্যাশবোর্ড মুছে ফেলুন", + "RenameDashboard": "ড্যাশবোর্ডের নাম পরিবর্তন" + }, + "Feedback": { + "IWantTo": "আমি চাই:" + }, + "General": { + "AllWebsitesDashboard": "সকল ওয়েবসাইটের ড্যাশবোর্ড", + "API": "এপিআই", + "Cancel": "বাতিল করুন", + "Close": "বন্ধ করুন", + "Daily": "দৈনিক", + "Date": "তারিখ", + "DateRangeFrom": "থেকে", + "DateRangeFromTo": "থেকে %s পর্যন্ত %s", + "DateRangeTo": "পর্যন্ত", + "DaySa": "শনি", + "DaySu": "রবি", + "DayTh": "সোম", + "DayTu": "মঙ্গল", + "DayWe": "বুধ", + "Delete": "মুছে ফেলুন", + "Description": "বিবরণ", + "Desktop": "ডেস্কটপ", + "Details": "বিস্তারিত", + "Done": "সম্পন্ন", + "Download": "ডাউনলোড করুন", + "Edit": "সম্পাদনা করুন", + "EnglishLanguageName": "Bengali", + "Error": "ত্রুটি", + "Export": "এক্সপোর্ট করুন", + "Faq": "বহুল জিজ্ঞাসিত প্রশ্নসমূহ", + "First": "প্রথম", + "FromReferrer": "থেকে", + "GeneralInformation": "সাধারণ তথ্য", + "GeneralSettings": "সাধারণ সেটিংসমূহ", + "Goal": "লক্ষ্য", + "Help": "সাহায্য", + "Language": "ভাষা", + "LayoutDirection": "ltr", + "Loading": "লোড করা হচ্ছে...", + "LoadingData": "ডাটা লোড করা হচ্ছে...", + "Locale": "bn_BD.UTF-8", + "Logout": "সাইন আউট", + "LongDay_1": "রবিবার", + "LongDay_2": "মঙ্গলবার", + "LongDay_3": "বুধবার", + "LongDay_4": "বৃহস্পতিবার", + "LongDay_5": "শুক্রবার", + "LongDay_6": "শনিবার", + "LongDay_7": "শনিবার", + "LongMonth_1": "জানুয়ারী", + "LongMonth_10": "অক্টোবর", + "LongMonth_11": "লভেম্বর", + "LongMonth_12": "ডিসেম্বর", + "LongMonth_2": "ফেব্রুয়ারি", + "LongMonth_3": "মার্চ", + "LongMonth_4": "এপ্রিল", + "LongMonth_5": "মে", + "LongMonth_6": "জুন", + "LongMonth_7": "জুলাই", + "LongMonth_8": "আগষ্ট", + "LongMonth_9": "সেপ্টেম্বর", + "Metadata": "মেটাডাটা", + "Mobile": "মোবাইল", + "Name": "নাম", + "Never": "কখনও না", + "NewVisitor": "নতুন পরিদর্শনকারী", + "No": "না", + "Ok": "ঠিক আছে", + "OrCancel": "অথবা %s পরিবর্তন করুন %s", + "OriginalLanguageName": "ইংরেজি", + "Plugin": "প্লাগইন", + "Plugins": "প্লাগইনসমূহ", + "Price": "মূল্য", + "Quantity": "পরিমাণ", + "Refresh": "রিফ্রেশ", + "Save": "সংরক্ষণ করুন", + "Search": "অনুসন্ধান", + "ShortDay_1": "সোম", + "ShortDay_2": "মঙ্গল", + "ShortDay_3": "বুধ", + "ShortDay_4": "বৃহঃ", + "ShortDay_5": "শুক্র", + "ShortDay_6": "শুক্র", + "ShortDay_7": "শনি", + "ShortMonth_1": "জানুঃ", + "ShortMonth_10": "অক্টোঃ", + "ShortMonth_11": "লভেঃ", + "ShortMonth_12": "ডিসেঃ", + "ShortMonth_2": "ফেব্রুঃ", + "ShortMonth_3": "মার্চ", + "ShortMonth_4": "এপ্রিল", + "ShortMonth_5": "মে", + "ShortMonth_6": "জুন", + "ShortMonth_7": "জুলাঃ", + "ShortMonth_8": "আগঃ", + "ShortMonth_9": "সেপ্টঃ", + "SmtpPassword": "SMTP পাসওয়ার্ড", + "SmtpPort": "SMTP পোর্ট", + "SmtpServerAddress": "SMTP সার্ভারের ঠিকানা", + "SmtpUsername": "SMTP ব্যবহারকারীর নাম", + "Today": "আজ", + "Total": "সর্বমোট", + "TranslatorEmail": "-", + "TranslatorName": "Anjan Dutta, Rezaul Hasan", + "Upload": "আপলোড", + "Username": "ব্যবহারকারীর নাম", + "Warning": "সতর্কতা", + "Website": "ওয়েবসাইট", + "Weekly": "সাপ্তাহিক", + "Yesterday": "গতকাল" + }, + "SitesManager": { + "Currency": "মুদ্রা", + "Timezone": "সময় জোন" + }, + "UserCountry": { + "country_ac": "অ্যাসসেনশন আইল্যান্ড", + "country_ad": "এ্যান্ডোরা", + "country_ae": "সংযুক্ত আরব আমিরাত", + "country_af": "আফগানিস্তান", + "country_ag": "এন্টিগুয়া ও বারবুডা", + "country_ai": "এ্যাঙ্গুইলা", + "country_al": "আলব্যানিয়া", + "country_am": "আর্মেনিয়া", + "country_an": "নেদারল্যান্ডস এ্যান্টিলিস", + "country_ao": "এ্যাঙ্গোলা", + "country_aq": "এন্টার্কটিকা", + "country_ar": "আর্জেণ্টাইনা", + "country_as": "আমেরিকান সামোয়া", + "country_at": "অস্ট্রিয়া", + "country_au": "অস্ট্রেলিয়া", + "country_aw": "আরুবা", + "country_ax": "আলান্ড দ্বীপপুঞ্জ", + "country_az": "আজারবাইজান", + "country_ba": "বসনিয়া ও হার্জেগোভিনা", + "country_bb": "বারবাদোস", + "country_bd": "বাংলাদেশ", + "country_be": "বেলজিয়াম", + "country_bf": "বুরকিনা ফাসো", + "country_bg": "বুলগেরিয়া", + "country_bh": "বাহরাইন", + "country_bi": "বুরুন্ডি", + "country_bj": "বেনিন", + "country_bl": "সেন্ট বারথেলিমি", + "country_bm": "বারমুডা", + "country_bn": "ব্রুনেই", + "country_bo": "বোলিভিয়া", + "country_bq": "ক্যারিবিয়ান নেদারল্যান্ডস", + "country_br": "ব্রাজিল", + "country_bs": "বাহামা দ্বীপপুঞ্জ", + "country_bt": "ভুটান", + "country_bv": "বোভেট দ্বীপ", + "country_bw": "বতসোয়ানা", + "country_by": "বেলোরুশিয়া", + "country_bz": "বেলিয", + "country_ca": "কানাডা", + "country_cc": "কোকোস দ্বীপপুঞ্জ", + "country_cd": "কঙ্গো - কিনসাসা", + "country_cf": "মধ্য আফ্রিকান প্রজাতন্ত্র", + "country_cg": "কঙ্গো", + "country_ch": "সুইজর্লণ্ড", + "country_ci": "আইভরি কোস্ট", + "country_ck": "কুক দ্বীপপুঞ্জ", + "country_cl": "চিলি", + "country_cm": "ক্যামেরুন", + "country_cn": "চীন", + "country_co": "কোলোম্বিয়া", + "country_cp": "ক্লিপারটন আইল্যান্ড", + "country_cr": "কোস্টারিকা", + "country_cs": "সারবিয়ান এবং মন্টেনিগ্রো", + "country_cu": "কিউবা", + "country_cv": "কেপভার্দে", + "country_cw": "কুরাসাও", + "country_cx": "ক্রিসমাস দ্বীপ", + "country_cy": "সাইপ্রাস", + "country_cz": "চেক প্রজাতন্ত্র", + "country_de": "জার্মানি", + "country_dg": "দিয়েগো গার্সিয়া", + "country_dj": "জিবুতি", + "country_dk": "ডেনমার্ক", + "country_dm": "ডোমিনিকা", + "country_do": "ডোমেনিকান প্রজাতন্ত্র", + "country_dz": "এলজিরিয়া", + "country_ea": "কুউটা এবং মেলিলা", + "country_ec": "ইকুয়েডর", + "country_ee": "এস্তোনিয়া", + "country_eg": "মিশর", + "country_eh": "পশ্চিমী সাহারা", + "country_er": "ইরিত্রিয়া", + "country_es": "স্পেন", + "country_et": "ইফিওপিয়া", + "country_fi": "ফিন্ল্যাণ্ড", + "country_fj": "ফিজি", + "country_fk": "ফকল্যান্ড দ্বীপপুঞ্জ", + "country_fm": "মাইক্রোনেশিয়া", + "country_fo": "ফ্যারও দ্বীপপুঞ্জ", + "country_fr": "ফ্রান্স", + "country_ga": "গ্যাবন", + "country_gb": "গ্রেটবৃটেন", + "country_gd": "গ্রেনাডা", + "country_ge": "জর্জিয়া", + "country_gf": "ফরাসী গায়ানা", + "country_gg": "গ্রাঞ্জি", + "country_gh": "ঘানা", + "country_gi": "জিব্রাল্টার", + "country_gl": "গ্রীনল্যান্ড", + "country_gm": "গাম্বিয়া", + "country_gn": "গিনি", + "country_gp": "গুয়াদেলৌপ", + "country_gq": "নিরক্ষীয় গিনি", + "country_gr": "গ্রীস্", + "country_gs": "দক্ষিণ জর্জিয়া ও দক্ষিণ স্যান্ডউইচ দ্বীপপুঞ", + "country_gt": "গোয়াটিমালা", + "country_gu": "গুয়াম", + "country_gw": "গিনি-বিসাউ", + "country_gy": "গিয়ানা", + "country_hk": "হংকং এসএআর চীনা", + "country_hm": "হার্ড দ্বীপ এবং ম্যাকডোনাল্ড দ্বীপপুঞ্জ", + "country_hn": "হণ্ডুরাস", + "country_hr": "ক্রোয়েশিয়া", + "country_ht": "হাইতি", + "country_hu": "হাঙ্গেরি", + "country_ic": "ক্যানারি দ্বীপপুঞ্জ", + "country_id": "ইন্দোনেশিয়া", + "country_ie": "আয়ার্লণ্ড", + "country_il": "ইস্রায়েল", + "country_im": "ম্যানদ্বীপ", + "country_in": "ভারত", + "country_io": "ব্রিটিশ ভারত মহাসাগরীয় অঞ্চল", + "country_iq": "ইরাক", + "country_ir": "ইরান", + "country_is": "আইসলণ্ড", + "country_it": "ইতালী", + "country_je": "জার্সি", + "country_jm": "জ্যামেকা", + "country_jo": "জর্ডন", + "country_jp": "জাপান", + "country_ke": "কেনিয়া", + "country_kg": "কির্গিজিয়া", + "country_kh": "কাম্বোজ", + "country_ki": "কিরিবাতি", + "country_km": "কমোরোস", + "country_kn": "সেন্ট কিটস ও নেভিস", + "country_kp": "উত্তর কোরিয়া", + "country_kr": "দক্ষিণ কোরিয়া", + "country_kw": "কুয়েত", + "country_ky": "কেম্যান দ্বীপপুঞ্জ", + "country_kz": "কাজাকস্থান", + "country_la": "লাওস", + "country_lb": "লেবানন", + "country_lc": "সেন্ট লুসিয়া", + "country_li": "লিচেনস্টেইন", + "country_lk": "শ্রীলঙ্কা", + "country_lr": "লাইবেরিয়া", + "country_ls": "লেসোথো", + "country_lt": "লিত্ভা", + "country_lu": "লাক্সেমবার্গ", + "country_lv": "লাত্ভিয়া", + "country_ly": "লিবিয়া", + "country_ma": "মোরক্কো", + "country_mc": "মোনাকো", + "country_md": "মোল্দাভিয়া", + "country_me": "মন্টিনিগ্রো", + "country_mf": "সেন্ট মার্টিন", + "country_mg": "মাদাগাস্কার", + "country_mh": "মার্শাল দ্বীপপুঞ্জ", + "country_mk": "ম্যাসাডোনিয়া", + "country_ml": "মালি", + "country_mm": "মায়ানমার", + "country_mn": "মঙ্গোলিয়া", + "country_mo": "ম্যাকাও এসএআর চীনা", + "country_mp": "উত্তরাঞ্চলীয় মারিয়ানা দ্বীপপুঞ্জ", + "country_mq": "মার্টিনিক", + "country_mr": "মরিতানিয়া", + "country_ms": "মন্টসেরাট", + "country_mt": "মাল্টা", + "country_mu": "মরিশাস", + "country_mv": "মালদ্বীপ", + "country_mw": "মালাউই", + "country_mx": "মক্সিকো", + "country_my": "মাল্যাশিয়া", + "country_mz": "মোজাম্বিক", + "country_na": "নামিবিয়া", + "country_nc": "নিউ ক্যালেডোনিয়া", + "country_ne": "নাইজার", + "country_nf": "নিরফোক দ্বীপ", + "country_ng": "নাইজেরিয়া", + "country_ni": "নিকারাগুয়া", + "country_nl": "হলণ্ড", + "country_no": "নরওয়ে", + "country_np": "নেপাল", + "country_nr": "নাউরু", + "country_nu": "নিউয়ে", + "country_nz": "নিউ জিলণ্ড", + "country_om": "ওমান", + "country_pa": "পানামা", + "country_pe": "পিরু", + "country_pf": "ফরাসী পলিনেশিয়া", + "country_pg": "পাপুয়া নিউ গিনি", + "country_ph": "ফিলিপাইন", + "country_pk": "পাকিস্তান", + "country_pl": "পোল্যাণ্ড", + "country_pm": "সেন্ট পিয়ের ও মিকুয়েলন", + "country_pn": "পিটকেয়ার্ন", + "country_pr": "পুয়ের্টোরিকো", + "country_ps": "ফিলিস্তিন অঞ্চল", + "country_pt": "পর্তুগাল", + "country_pw": "পালাউ", + "country_py": "প্যারাগোয়ে", + "country_qa": "কাতার", + "country_re": "রিইউনিয়ন", + "country_ro": "রুমানিয়া", + "country_rs": "সারবিয়া", + "country_ru": "রাশিয়া", + "country_rw": "রুয়ান্ডা", + "country_sa": "সাউদি আরব", + "country_sb": "সলোমন দ্বীপপুঞ্জ", + "country_sc": "সিসিলি", + "country_sd": "সুদান", + "country_se": "সুইডেন", + "country_sg": "সিঙ্গাপুর", + "country_sh": "সেন্ট হেলেনা", + "country_si": "স্লোভানিয়া", + "country_sj": "স্বালবার্ড ও জান মেয়েন", + "country_sk": "শ্লোভাকিয়া", + "country_sl": "সিয়েরালিওন", + "country_sm": "সান মারিনো", + "country_sn": "সেনেগাল", + "country_so": "সোমালি", + "country_sr": "সুরিনাম", + "country_ss": "দক্ষিন সুদান", + "country_st": "সাওটোমা ও প্রিন্সিপি", + "country_sv": "এল সালভেদর", + "country_sx": "সিন্ট মার্টেন", + "country_sy": "সিরিয়া", + "country_sz": "সোয়াজিল্যান্ড", + "country_ta": "ট্রিস্টান ডা কুনা", + "country_tc": "তুর্কস ও কাইকোস দ্বীপপুঞ্জ", + "country_td": "চাদ", + "country_tf": "ফরাসী দক্ষিণাঞ্চল", + "country_tg": "টোগো", + "country_th": "থাই", + "country_tj": "তাজিকস্থান", + "country_tk": "টোকেলাউ", + "country_tl": "পূর্ব-তিমুর", + "country_tm": "তুর্কমেনিয়া", + "country_tn": "টিউনিস্", + "country_to": "টোঙ্গা", + "country_tr": "তুরস্ক", + "country_tt": "ত্রিনিনাদ ও টোব্যাগো", + "country_tv": "টুভালু", + "country_tw": "তাইওয়ান", + "country_tz": "তাঞ্জানিয়া", + "country_ua": "ইউক্রেইন", + "country_ug": "উগান্ডা", + "country_um": "যুক্তরাষ্ট্রের ক্ষুদ্র ও পার্শ্ববর্তী দ্বীপপুঞ্জ", + "country_us": "মার্কিন যুক্তরাষ্ট্র", + "country_uy": "উরুগোয়ে", + "country_uz": "উজ্বেকিস্থান", + "country_va": "ভ্যাটিকান সিটি", + "country_vc": "সেন্ট ভিনসেন্ট ও দ্যা গ্রেনাডিনস", + "country_ve": "ভেনেজুয়েলা", + "country_vg": "ব্রিটিশ ভার্জিন দ্বীপপুঞ্জ", + "country_vi": "মার্কিন ভার্জিন দ্বীপপুঞ্জ", + "country_vn": "ভিয়েতনাম", + "country_vu": "ভানুয়াটু", + "country_wf": "ওয়ালিস ও ফুটুনা", + "country_ws": "সামোয়া", + "country_ye": "ইমেন", + "country_yt": "মায়োত্তে", + "country_za": "দক্ষিণ আফ্রিকা", + "country_zm": "জাম্বিয়া", + "country_zw": "জিম্বাবুয়ে" + }, + "UserSettings": { + "Language_aa": "আফার", + "Language_ab": "আব্খাজিয়", + "Language_ae": "আবেস্তীয়", + "Language_af": "আফ্রিকান্স", + "Language_ak": "আকান", + "Language_am": "আমহারিক", + "Language_an": "আর্গোনিজ", + "Language_ar": "আরবী", + "Language_as": "আসামি", + "Language_av": "আভেরিক", + "Language_ay": "আয়মারা", + "Language_az": "আজারবাইজানীয়", + "Language_ba": "বাশকির", + "Language_be": "বেলারুশিয়", + "Language_bg": "বুলগেরিয়", + "Language_bh": "বিহারি", + "Language_bi": "বিসলামা", + "Language_bm": "বামবারা", + "Language_bn": "বাংলা", + "Language_bo": "তিব্বতি", + "Language_br": "ব্রেটোন", + "Language_bs": "বসনীয়", + "Language_ca": "কাতালান", + "Language_ce": "চেচেন", + "Language_ch": "চামেরো", + "Language_co": "কর্সিকান", + "Language_cr": "ক্রি", + "Language_cs": "চেক", + "Language_cu": "চার্চ স্লাভিও", + "Language_cv": "চুবাস", + "Language_cy": "ওয়েলশ", + "Language_da": "ডেনিশ", + "Language_de": "জার্মান", + "Language_dv": "দিবেহি", + "Language_dz": "ভুটানি", + "Language_ee": "ইওয়ে", + "Language_el": "গ্রিক", + "Language_en": "ইংরেজি", + "Language_eo": "এস্পেরান্তো", + "Language_es": "স্পেনীয়", + "Language_et": "এস্তোনীয়", + "Language_eu": "বাস্ক", + "Language_fa": "ফার্সি", + "Language_ff": "ফুলাহ্", + "Language_fi": "ফিনিশ", + "Language_fj": "ফিজিও", + "Language_fo": "ফেরাউনি", + "Language_fr": "ফরাসি", + "Language_fy": "পশ্চিম ফ্রিসিয়", + "Language_ga": "আইরিশ", + "Language_gd": "স্কটস-গ্যেলিক", + "Language_gl": "গ্যালিশিয়", + "Language_gn": "গুয়ারানি", + "Language_gu": "গুজরাটি", + "Language_gv": "ম্যাঙ্কস", + "Language_ha": "হাউসা", + "Language_he": "হিব্রু", + "Language_hi": "হিন্দি", + "Language_ho": "হিরি মোতু", + "Language_hr": "ক্রোয়েশীয়", + "Language_ht": "হাইতিয়ান", + "Language_hu": "হাঙ্গেরীয়", + "Language_hy": "আর্মেনিয়", + "Language_hz": "হেরেরো", + "Language_ia": "ইন্টারলিঙ্গুয়া", + "Language_id": "ইন্দোনেশীয়", + "Language_ie": "ইন্টারলিঙ্গ", + "Language_ig": "ইগ্‌বো", + "Language_ii": "সিচুয়ান য়ি", + "Language_ik": "ইনুপিয়াক", + "Language_io": "ইডো", + "Language_is": "আইসল্যান্ডীয়", + "Language_it": "ইতালীয়", + "Language_iu": "ইনুক্টিটুট", + "Language_ja": "জাপানি", + "Language_jv": "জাভানি", + "Language_ka": "জর্জিয়ান", + "Language_kg": "কোঙ্গো", + "Language_ki": "কিকু্ইয়ু", + "Language_kj": "কোয়ানিয়ামা", + "Language_kk": "কাজাখ", + "Language_kl": "ক্যালাল্লিসুট", + "Language_km": "খমের", + "Language_kn": "কান্নাড়ী", + "Language_ko": "কোরিয়ান", + "Language_kr": "কানুরি", + "Language_ks": "কাশ্মীরী", + "Language_ku": "কুর্দি", + "Language_kv": "কোমি", + "Language_kw": "কর্ণিশ", + "Language_ky": "কির্গিজ", + "Language_la": "লাটিন", + "Language_lb": "লুক্সেমবার্গীয়", + "Language_lg": "গ্যান্ডা", + "Language_li": "লিম্বুর্গিশ", + "Language_ln": "লিঙ্গালা", + "Language_lo": "লাও", + "Language_lt": "লিথুয়েনীয", + "Language_lu": "লুবা-কাটাঙ্গা", + "Language_lv": "লাত্‌ভীয়", + "Language_mg": "মালাগাসি", + "Language_mh": "মার্শালিজ", + "Language_mi": "মাওরি", + "Language_mk": "ম্যাসেডোনীয", + "Language_ml": "মালেয়ালাম", + "Language_mn": "মঙ্গোলিয়", + "Language_mr": "মারাঠি", + "Language_ms": "মালে", + "Language_mt": "মল্টিয়", + "Language_my": "বর্মি", + "Language_na": "নাউরু", + "Language_nb": "নরওয়ে বোকমাল", + "Language_nd": "উত্তর এন্দেবিলি", + "Language_ne": "নেপালী", + "Language_ng": "এন্দোঙ্গা", + "Language_nl": "ডাচ", + "Language_nn": "নরওয়েজীয়ান নিনর্স্ক", + "Language_no": "নরওয়েজীয়", + "Language_nr": "দক্ষিণ এনডেবেলে", + "Language_nv": "নাভাজো", + "Language_ny": "নায়াঞ্জা", + "Language_oc": "অক্সিটান", + "Language_oj": "ওজিবওয়া", + "Language_om": "অরোমো", + "Language_or": "উড়িয়া", + "Language_os": "ওসেটিক", + "Language_pa": "পাঞ্জাবী", + "Language_pi": "পালি", + "Language_pl": "পোলিশ", + "Language_ps": "পশ্তু", + "Language_pt": "পর্তুগীজ", + "Language_qu": "কেচুয়া", + "Language_rm": "রেটো-রোমানীয়", + "Language_rn": "রুন্দি", + "Language_ro": "রোমানীয়", + "Language_ru": "রুশ", + "Language_rw": "কিনয়ারোয়ান্ডা", + "Language_sa": "সংষ্কৃত", + "Language_sc": "সার্ডিনিয়ান", + "Language_sd": "সিন্ধি", + "Language_se": "উত্তরাঞ্চলীয় সামি", + "Language_sg": "সাঙ্গো", + "Language_si": "সিংহলী", + "Language_sk": "স্লোভাক", + "Language_sl": "স্লোভেনীয়", + "Language_sm": "সামোয়ান", + "Language_sn": "শোনা", + "Language_so": "সোমালী", + "Language_sq": "আলবেনীয়", + "Language_sr": "সার্বীয়", + "Language_ss": "সোয়াতি", + "Language_st": "দক্ষিন সোথো", + "Language_su": "সুদানী", + "Language_sv": "সুইডিশ", + "Language_sw": "সোয়াহিলি", + "Language_ta": "তামিল", + "Language_te": "তেলেগু", + "Language_tg": "তাজিক", + "Language_th": "থাই", + "Language_ti": "তিগরিনিয়া", + "Language_tk": "তুর্কমেনী", + "Language_tl": "তাগালগ", + "Language_tn": "সোয়ানা", + "Language_to": "টঙ্গা", + "Language_tr": "তুর্কী", + "Language_ts": "সঙ্গা", + "Language_tt": "তাতার", + "Language_tw": "টোয়াই", + "Language_ty": "তাহিতিয়ান", + "Language_ug": "উইঘুর", + "Language_uk": "ইউক্রেনীয়", + "Language_ur": "উর্দু", + "Language_uz": "উজবেকীয়", + "Language_ve": "ভেন্ডা", + "Language_vi": "ভিয়েতনামী", + "Language_vo": "ভোলাপুক", + "Language_wa": "ওয়ালুন", + "Language_wo": "উওলোফ", + "Language_xh": "জোসা", + "Language_yi": "য়িদ্দিশ", + "Language_yo": "ইওরুবা", + "Language_za": "ঝু্য়াঙ", + "Language_zh": "চীনা", + "Language_zu": "জুলু" + }, + "Widgetize": { + "OpenInNewWindow": "নতুন উইন্ডোতে খুলুন" + } +} \ No newline at end of file diff --git a/www/analytics/lang/bs.json b/www/analytics/lang/bs.json new file mode 100644 index 00000000..0aa72fcb --- /dev/null +++ b/www/analytics/lang/bs.json @@ -0,0 +1,975 @@ +{ + "Actions": { + "AvgGenerationTimeTooltip": "Prosijek baziran na %s pogodak(a) %s između %s i %s", + "ColumnClickedURL": "Kliknut URL", + "ColumnClicks": "Klikovi", + "ColumnClicksDocumentation": "Broj klikova na ovaj link", + "ColumnDownloadURL": "Download URL", + "ColumnEntryPageTitle": "Naslov ulazne stranice", + "ColumnEntryPageURL": "URL ulazne stranice", + "ColumnExitPageTitle": "Naslov izlazne stranice", + "ColumnExitPageURL": "URL izlazne stranice", + "ColumnNoResultKeyword": "Riječ bez rezultata u pretrazi", + "ColumnPageName": "Ime stranice", + "ColumnPagesPerSearch": "Pretraži stranice rezultata", + "ColumnPagesPerSearchDocumentation": "Posjetioci će pretraživati vašu web-stranicu, i ponekada kliknuti na \"sljedeće\" da bi dobili više rezultata. Ovo je prosječan broj rezultata u pretrazi koje se prikazuju za ovu riječ.", + "ColumnPageURL": "URL stranice", + "ColumnSearchCategory": "Kategorija pretrage", + "ColumnSearches": "Pretrage", + "ColumnSearchesDocumentation": "Broj posjeta u kojima je tražena ova riječ preko vaše stranice.", + "ColumnSearchExits": "% Pretražna napuštanja", + "ColumnSearchExitsDocumentation": "Percentaža posjeta kod kojih je stranica napuštena poslije pretrage za ovu riječ, izvršeno preko vašeg pretraživača.", + "ColumnSearchResultsCount": "Broj rezultata u pretragama", + "ColumnSiteSearchKeywords": "Unikatne riječi", + "ColumnUniqueClicks": "Jedinstveni klikovi", + "ColumnUniqueClicksDocumentation": "Broj posjeta koji su umiješan sa klikom na ovaj link. Ako je link kliknut više puta prilikom posjete, uračunat je samo jednom.", + "ColumnUniqueDownloads": "Jedinstveni downloadi", + "ColumnUniqueOutlinks": "Jedinstveni izlazni linkovi", + "DownloadsReportDocumentation": "U ovom izvještaju možete vidjeti koje fajlove je posjetioc skinuo. %s je šta Piwik računa kao download je klik na download link. Da li je download kompletiran, Piwik ne zna.", + "EntryPagesReportDocumentation": "Ovaj izvještaj sadrži informacije o ulaznim stranicama koje su korištene prilikom specifičnog perioda. Ulazna stranica je prva stranica koju je posjetioc posjetio. %s Ulazni URL-i su pokazani kao struktura foldera.", + "EntryPageTitles": "Naslovi ulaznih stranica", + "EntryPageTitlesReportDocumentation": "Ovaj izvještaj sadrži informacije o naslovima ulaznih stranica koje su korištene prilikom specifičnog vremena.", + "ExitPagesReportDocumentation": "Ovaj izvještaj sadrži informacije o izlaznim stranicama koje su se dogodile prilikom specifičnom perioda. Izlazna stranica je posljednja stranica koju je posjetioc pregledao. %s Izlazni URL-i su prikazani kao struktura foldera.", + "ExitPageTitles": "Naslovi izlaznih stranica", + "ExitPageTitlesReportDocumentation": "Ovaj izvještaj sadrži informacije o naslovima izlaznih stranica koji su se dogodili prilikom specifičnom perioda.", + "LearnMoreAboutSiteSearchLink": "Nauči više o praćenju kako vaši posjetioci koriste pretragu na vašoj stranici.", + "OneSearch": "1 traži", + "OutlinkDocumentation": "Izlazni link je link na kojeg je izašao posjetilac sa tvoje stranica na neki drugi websajt (druga domena)", + "OutlinksReportDocumentation": "Ovaj izvještaj sadrži hijerarhalnu listu izlaznih linkova koji su kliknuti od strane tvojih posjetilaca.", + "PagesReportDocumentation": "Ovaj izvještaj sadrži informacije o stranicama koje su posjećene. %s tabela je organizovana hijerarhijski, URL-ovi su prikazani kao struktura foldera.", + "PageTitlesReportDocumentation": "Ovaj raport sadrži informacije o naslovima stranica koje su posjećene. %s Naslov stranice je HTML %s Oznaka koju većina pregledača prikazuje u njihovom prozornom imenu.", + "PageUrls": "Veze stranice", + "PluginDescription": "Izvješća o pregledima stranica, vanjskim vezama i preuzimanjima. Praćenje vanjskih veza i preuzimanja se vrši automatski.", + "SiteSearchCategories1": "Ovaj raport prikazuje spisak kategorija koje su posjetioci odabrali kad su vršili istragu na vašoj stranici.", + "SiteSearchCategories2": "Na primjer, stranice sa elektronskom trgovinom tipično imaju birač kategorija tako da se posjetioci mogu ograničiti na pretrage na svim proizvodima u specifičnoj kategoriji.", + "SiteSearchFollowingPagesDoc": "Kada posjetioci traže na vašoj stranici, oni su u potrazi za određenu stranicu, sadržaj, proizvod, ili uslugu. Ovaj raport prikazuje spisak stranica koje su najviše kliknute poslije interne pretrage. S drugim riječima, ovo je spisak stranica koje su posjetioci najčešće pretraživali na vašoj stranici.", + "SiteSearchIntro": "Praćenje pretraga koje vrše vaši posjetioci je efikasan način shvatanja šta vaši posjetioci traže, pomaže u iznalaženju novih ideja i koncepata, novih proizvoda koje potencijalni kupci traže i generalno unapređuje korisničko iskustvo na vašem sajtu.", + "SiteSearchKeyword": "Ključna riječ (pretraživanje)", + "SiteSearchKeywordsDocumentation": "Ovaj izvještaj prikazuje ključne riječi koje su vaši posjetioci tražili u polju za pretragu.", + "SiteSearchKeywordsNoResultDocumentation": "Ovaj izvještaj prikazuje ključne reči koje nisu vratile nijedan rezultat pretrage. Možda algoritam pretrage može da se poboljša ili možda posjetioci traže sadržaje koji (još uvjek) nisu na sajtu?", + "SubmenuPagesEntry": "Ulazne stranice", + "SubmenuPagesExit": "Izlazna stranica", + "SubmenuPageTitles": "Nazivi stranica", + "SubmenuSitesearch": "Pretraživanje sajta", + "WidgetEntryPageTitles": "Nazivi ulaznih stranica", + "WidgetExitPageTitles": "Naslovi izlaznih stranica", + "WidgetPagesEntry": "Ulazne stranice", + "WidgetPagesExit": "Izlazne stranice", + "WidgetPageTitles": "Naslovi stranica", + "WidgetPageTitlesFollowingSearch": "Naslovi stranica kod pretraživanja", + "WidgetPageUrlsFollowingSearch": "Stranice koje slijede poslije pretrage sajta", + "WidgetSearchCategories": "Kategorije za pretrage", + "WidgetSearchKeywords": "Ključne riječi za pretragu na sajtu", + "WidgetSearchNoResultKeywords": "Ključne riječi za pretragu bez rezultata" + }, + "Annotations": { + "AddAnnotationsFor": "Dodaj bilješku za %s...", + "AnnotationOnDate": "Bilješka za %1$s: %2$s", + "Annotations": "Bilješke", + "ClickToDelete": "Kliknite da obrišete ovu bilješku.", + "ClickToEdit": "Kliknite da uredite ovu bilješku.", + "ClickToEditOrAdd": "Kliknite da uredite ili dodate novu bilješku.", + "ClickToStarOrUnstar": "Kliknite da markirate bilješku ili da uklonite markiranje.", + "CreateNewAnnotation": "Napravi novu bilješku...", + "EnterAnnotationText": "Unesite vašu bilješku...", + "HideAnnotationsFor": "Sakrij bilješke za %s...", + "IconDesc": "Prikaži bilješke za ovaj vremenski period.", + "IconDescHideNotes": "Sakrij bilješke za ovaj vremenski period.", + "InlineQuickHelp": "Možete napraviti bilješke kako bi markirali posebne događaje (kao na primjer pravljenje novog članka na blogu, ili ponovno dizajniranje internet stranice), sačuvali vašu analizu podataka ili da sačuvate bilo šta drugo što smatrate važnim.", + "LoginToAnnotate": "Prijavite se da napravite novu bilješku.", + "NoAnnotations": "Nema bilješki za ovaj vremenski period." + }, + "API": { + "GenerateVisits": "Ako nemate podataka za danas onda možete prvo generisati neke podatke sa dodatkom %s. Ovo možete uraditi ako uključite dodatak %s i zatim kliknete na 'Proizvođač posjetilaca' u meniju koja se nalazi u Piwik adminskom prostoru.", + "KeepTokenSecret": "Ovaj token_auth je povjerljiv podatak poput vašeg korisničkog imena i lozine. %s Nemojte ga dijeliti sa drugima%s!", + "LoadedAPIs": "Uspješno učitani %s API(-ovi)" + }, + "CoreAdminHome": { + "Administration": "Administracija", + "EmailServerSettings": "Postavke email servera", + "LatestBetaRelease": "Zadnje beta izdanje", + "LatestStableRelease": "Zadnje stabilno izadnje", + "MenuDiagnostic": "Dijagnostika", + "MenuGeneralSettings": "Opšte postavke", + "MenuManage": "Upravljaj" + }, + "CoreHome": { + "CheckForUpdates": "Provjeri nove verzije", + "CheckPiwikOut": "Probajte Piwik!", + "PeriodDay": "dan", + "PeriodDays": "dana", + "PeriodMonth": "mesec", + "PeriodMonths": "mjeseci", + "PeriodRange": "Domet", + "PeriodWeek": "nedelja", + "PeriodWeeks": "sedmica", + "PeriodYear": "godina", + "PeriodYears": "godina", + "ShareThis": "Podijeli ovo" + }, + "CorePluginsAdmin": { + "Activate": "Aktiviraj", + "Activated": "Aktivirano", + "Active": "Aktivno", + "AuthorHomepage": "Početna stranica autora", + "Deactivate": "Deaktiviraj", + "Inactive": "Neaktivno", + "MenuPlatform": "Platforma", + "Status": "Status", + "Theme": "Tema", + "Themes": "Teme", + "ThemesManagement": "Uredi teme", + "Version": "Verzija" + }, + "CoreUpdater": { + "DownloadX": "Preuzmi %s", + "ExceptionArchiveEmpty": "Prazna arhiva", + "ExceptionArchiveIncompatible": "Nekompatibilna arhiva: %s", + "PluginDescription": "Pwikijev mehanizam za ažuriranje" + }, + "Dashboard": { + "Dashboard": "Kontrolna ploča", + "Maximise": "Maksimiziraj", + "Minimise": "Minimiziraj" + }, + "General": { + "AbandonedCarts": "Otkazane korpe", + "AboutPiwikX": "O Piwik-u%s", + "Action": "Akcija", + "Actions": "Akcije", + "Add": "Dodaj", + "AfterEntry": "poslije ulaza ovdje", + "AllowPiwikArchivingToTriggerBrowser": "Dozvoli Piwik-u da arhivira pokretač kad su reporti pregledani u browseru.", + "AllWebsitesDashboard": "Kontrolne ploče svih web stranica", + "And": "i", + "API": "API", + "ApplyDateRange": "Prihvati raspon datuma", + "ArchivingInlineHelp": "Za web stranice koje imaju veću posjećenost preporučeno je isključiti Piwik arhiviranje pokretača iz browsera. Umjesto toga, preporučujemo cron job koji se procesira svaki sat.", + "ArchivingTriggerDescription": "Preporučeno za veće Piwik instalacije, moraš %s postaviti cron job%s da procesiras reporte automatski.", + "AuthenticationMethodSmtp": "Metoda provjere za SMTP", + "AverageOrderValue": "Prosječna vrijednost narudžbe", + "AveragePrice": "Prosječna cijena", + "AverageQuantity": "Prosječna količina", + "BackToPiwik": "Nazad na Piwik", + "Broken": "Pokvareno", + "BrokenDownReportDocumentation": "Razlomljeno je u različite reporte koji su prikazani na dnu stranice. Možete povećati grafikone klikanjem na reporte koje biste voljeli vidjeti.", + "Cancel": "Otkaži", + "ChangeTagCloudView": "Molimo zapamtite, možete vidjeti reporte na druge načine pored oblaka sa etiketama. Koristite kontrole na dnu reporta da to uradite.", + "ChooseDate": "Izaberi datum", + "ChooseLanguage": "Izaberite jezik", + "ChoosePeriod": "Izaberite period", + "ChooseWebsite": "Izaberite web sajt", + "ClickHere": "Klikni ovdje za više informacija", + "Close": "Zatvori", + "ColumnActionsPerVisit": "Akcije po posjeti", + "ColumnActionsPerVisitDocumentation": "Prosječan broj akcija (pregleda, downloda ili outlinkova) koji su se desili prilikom posjeta.", + "ColumnAverageTimeOnPage": "Prosječno vrijeme na stranici", + "ColumnAverageTimeOnPageDocumentation": "Prosječno vrijeme na ovoj stranici (samo na ovoj stranici, ne cijelom web sajtu)", + "ColumnAvgTimeOnSite": "Prosječno vrijeme na stranici", + "ColumnAvgTimeOnSiteDocumentation": "Prosječno trajanje posjete", + "ColumnBounceRate": "Bounce stopa", + "ColumnBounceRateDocumentation": "Postotak posjetioca koji su pregledali samo jednu stranicu. To znači da je posjetioc napustio stranicu direktno sa ulazne stranice.", + "ColumnBounceRateForPageDocumentation": "Postotak posjeta koji su započeli i završili na ovoj stranici.", + "ColumnBounces": "Bounces (odskakivanja)", + "ColumnBouncesDocumentation": "Broj posjeta koji je započeo i završio na ovoj stranici. To znači da je posjetnik napustio stranicu odmah nakon pregleda te iste stranice.", + "ColumnConversionRate": "Stopa konverzije", + "ColumnConversionRateDocumentation": "Stop posjeta koja je pokrenula cilj konverzije.", + "ColumnEntrances": "Ulazi", + "ColumnEntrancesDocumentation": "Broj posjeta započeni na ovoj stranici.", + "ColumnExitRate": "Stopa izlaza", + "ColumnExitRateDocumentation": "Postotak posjetilaca koji su napustili web sajt nakon pregleda ove stranice.", + "ColumnExits": "Izlazi", + "ColumnExitsDocumentation": "Broj posjeta koji su završili na ovoj stranici.", + "ColumnKeyword": "Ključna riječ", + "ColumnLabel": "Oznaka", + "ColumnMaxActions": "Maksimalan broj akcija u jednoj posjeti", + "ColumnNbActions": "Akcije", + "ColumnNbActionsDocumentation": "Broj akcija pokrenut od tvojih posjetilaca. Akcije mogu biti pregledi stranice, downloadi ili klikanje na izlazeće linkove.", + "ColumnNbUniqVisitors": "Jedinstveni posjetioci", + "ColumnNbUniqVisitorsDocumentation": "Broj nedupliciranih posjetilaca na tvoju stranicu. Svaki korisnik je računat samo jednom čak i ako je posjetio web sajt više puta na dan.", + "ColumnNbVisits": "Posjete", + "ColumnNbVisitsDocumentation": "Ako posjetioc dođe na tvoju stranicu prvi put ili ako ostane na stranici više od 30 minuta poslije prve posjete, ova posjeta će biti računata kao nova posjeta.", + "ColumnPageBounceRateDocumentation": "Postotak posjeta koji su započeli na ovoj stranici i napustili stranicu odmah.", + "ColumnPageviews": "Pregledi stranice", + "ColumnPageviewsDocumentation": "Broj posjeta na ovu stranicu.", + "ColumnPercentageVisits": "% posjete", + "ColumnSumVisitLength": "Ukupno vrijeme posjetioca (u sekundama)", + "ColumnUniqueEntrances": "Jedinstveni ulazi", + "ColumnUniqueExits": "Jedinstveni izlazi", + "ColumnUniquePageviews": "Jedinstvene posjete stranice", + "ColumnUniquePageviewsDocumentation": "Broj posjeta koji uključuje ovu stranicu. Ako je stranica pregledana više puta, uračunata je samo jednom.", + "ColumnValuePerVisit": "Zarada po posjeti", + "ColumnVisitDuration": "Trajanje posjete (u sekundama)", + "ColumnVisitsWithConversions": "Posjete sa konverzijom", + "ConfigFileIsNotWritable": "Piwik konfiguracijski fajl %s nije otvoren za pisanje i neke promjene se neće sačuvati. %s molimo promijenite postavke (permissions) kako bi config file bio dostupan za pisanje.", + "CurrentMonth": "Trenutni mjesec", + "CurrentWeek": "Trenutna sedmica", + "CurrentYear": "Trenutna godina", + "Daily": "Dnevno", + "DailyReports": "Dnevni reporti", + "DailySum": "dnevna suma", + "DashboardForASpecificWebsite": "Kontrolna ploča za specifičnu web stranicu", + "DataForThisGraphHasBeenPurged": "Podaci za ovaj grafikon su stariji od %s mjeseci i informacije su očišćene.", + "DataForThisTagCloudHasBeenPurged": "Podaci za ovaj oblak su više od %s mjeseci stari i očišćeni su.", + "Date": "Datum", + "DateRange": "Raspon datuma:", + "DateRangeFrom": "Iz", + "DateRangeFromTo": "Iz %s u %s", + "DateRangeInPeriodList": "Raspon datuma::", + "DateRangeTo": "U", + "DayFr": "Pe", + "DayMo": "Po", + "DaySa": "Su", + "DaysHours": "%1$s dani %2$s sati", + "DaysSinceFirstVisit": "Dani nakon prve posjete", + "DaysSinceLastEcommerceOrder": "Dani nakon posljednje E-comerce narudzbe", + "DaysSinceLastVisit": "Dani nakon zadnje posjete", + "DaySu": "Ne", + "DayTh": "Če", + "DayTu": "Ut", + "DayWe": "Sr", + "Default": "Default", + "Delete": "Izbriši", + "Description": "Objašnjenje", + "Desktop": "Desktop", + "Details": "Detalji", + "Discount": "Popust", + "DisplaySimpleTable": "Prikaži jednostavnu tabelu", + "DisplayTableWithGoalMetrics": "Prikaži tabelu sa metrikama ciljeva", + "DisplayTableWithMoreMetrics": "Prikažu tabelu sa više metrika", + "Donate": "Doniraj", + "Done": "Odrađeno", + "Download": "Skini", + "DownloadFullVersion": "%1$sDownload%2$s cijelu verziju! Pogledaj %3$s", + "Downloads": "Downloadi", + "EcommerceOrders": "E-comerce narudžbe", + "EcommerceVisitStatusDesc": "Posjeti e-comerce status na kraju posjete", + "EcommerceVisitStatusEg": "Na primjer, izaberite sve posjete koje su uradile narudžbu i API zahtjev bi sadržavao %s", + "Edit": "Uredi", + "EncryptedSmtpTransport": "Upisite transportni sloj (layer) enkripcije potreban za vaš SMTP server.", + "EnglishLanguageName": "Bosnian", + "Error": "Greška", + "ErrorRequest": "Ups... problem prilikom zahtjeva, probaj ponovo.", + "ExceptionConfigurationFileNotFound": "Konfiguracijski fajl {%s} nije pronađen.", + "ExceptionDatabaseVersion": "Vaša %1$s verzija je %2$s ali Piwik traži da bude barem %3$s.", + "ExceptionFileIntegrity": "Integracijska provjera je zaustavljena: %s", + "ExceptionFilesizeMismatch": "Veličina fajla nevažeća: %1$s (očekivana veličina: %2$s, pronađeno: %3$s)", + "ExceptionIncompatibleClientServerVersions": "Vaša %1$s klijent verzija je %2$s i nije kompatibilna sa verzijom servija %3$s.", + "ExceptionInvalidAggregateReportsFormat": "Skupljeni reporti u formatu '%s' nisu važeći. Probajte jedan od ovih umjesto toga: %s.", + "ExceptionInvalidArchiveTimeToLive": "Današnje vrijeme arhiviranja mora biti u sekundama više od nule.", + "ExceptionInvalidDateFormat": "Format datuma mora biti: %s ili bilo koja ključna riječa podržana od %s funkcije (vidi %s za više informacija)", + "ExceptionInvalidDateRange": "Datum '%s' nije važeći raspon datuma. Probajte jedan od ovih umjesto toga: %s.", + "ExceptionInvalidPeriod": "Period '%s' nije podržan. Probajte jedan od ovih umjesto toga: %s.", + "ExceptionInvalidRendererFormat": "Format prikazivača (renderer) '%s' nije važeći. Molimo probajte ponovo sa: %s.", + "ExceptionInvalidReportRendererFormat": "Format reporta '%s' nije važeći. Probajte jedan od ovih: %s.", + "ExceptionInvalidStaticGraphType": "Statički grafikon tipa '%s' nije važeći. Probajte jedan od ovih umjesto toga: %s.", + "ExceptionInvalidToken": "Token (žeton) nije važeći.", + "ExceptionLanguageFileNotFound": "Fajl jezika '%s' nije pronađen.", + "ExceptionMethodNotFound": "Metoda '%s' ne postoji ili nije dostupna modulu '%s'.", + "ExceptionMissingFile": "Nedostajući fajl: %s", + "ExceptionNonceMismatch": "Nemogućnost provjere sigurnosti uzete sa ove forme.", + "ExceptionPrivilege": "Ne možeš dostupiti ovim resursima jer zahtijeva %s pristup.", + "ExceptionPrivilegeAccessWebsite": "Ne možeš pristupiti ovom resursu pošto zahtijeva %s pristup za web stranicu sa id-om: %d.", + "ExceptionPrivilegeAtLeastOneWebsite": "Ne možeš pristupiti ovim resursima pošto zahtijeva %s pristup za barem jednu web stranicu.", + "ExceptionUnableToStartSession": "Nije moguće pokrenuti sesiju.", + "ExceptionUndeletableFile": "Nije moguće izbrisati %s", + "ExceptionUnreadableFileDisabledMethod": "Konfigracijski fajl {%s} nije moguće čitati. Vaš host mora onesposobiti %s.", + "Export": "Exportiraj", + "ExportAsImage": "Exportiraj kao sliku", + "ExportThisReport": "Exportiraj skup podataka u drugim formatima", + "Faq": "Pitanja", + "FileIntegrityWarningExplanation": "Provjera integracije fajlova je zaustavljena i reportirala neke greške. Ovo je najvjerovatnije zbog neuspješnog uploada svih fajlova. Trebali biste uploadati sve fajlove ponovo u BINARY modu i osvježiti stranicu.", + "First": "Prvo", + "ForExampleShort": "npr.", + "FromReferrer": "iz", + "GeneralInformation": "Generalne informacije", + "GeneralSettings": "Generalne postavke", + "GiveUsYourFeedback": "Recite nam sta mislite!", + "Goal": "Cilj", + "GoTo": "Idi u %s", + "GraphHelp": "Više informacija o pokazivanju grafikona na Piwik-u", + "HelloUser": "Mir s tobom, %s!", + "Help": "Pomoć", + "HoursMinutes": "%1$s sati %2$s minute", + "Id": "Id", + "IfArchivingIsFastYouCanSetupCronRunMoreOften": "Pretpostavimo da je arhiviranje brzo za tvoje postavke, mozete postaviti crontab da procesuira mnogo češće.", + "InvalidDateRange": "Nevažeći raspon datuma, pokusajte ponovo", + "InvalidResponse": "Primljeni podacu su nevažeći.", + "IP": "IP", + "Language": "Jezik", + "LastDays": "Posljednji %s dani (uključujući danas)", + "LastDaysShort": "Posljednji %s dani", + "LayoutDirection": "ltr", + "Live": "Uživo", + "Loading": "Učitavanje...", + "LoadingData": "Učitavanje informacija...", + "Locale": "bs_BA.UTF-8", + "Logout": "Izloguj se", + "LongDay_1": "Ponedjeljak", + "LongDay_2": "Utorak", + "LongDay_3": "Srijeda", + "LongDay_4": "Četvrtak", + "LongDay_5": "Petak", + "LongDay_6": "Subota", + "LongDay_7": "Nedjelja", + "LongMonth_1": "Januar", + "LongMonth_10": "Oktobar", + "LongMonth_11": "Novembar", + "LongMonth_12": "Decembar", + "LongMonth_2": "Februar", + "LongMonth_3": "Mart", + "LongMonth_4": "April", + "LongMonth_5": "Maj", + "LongMonth_6": "Juni", + "LongMonth_7": "Juli", + "LongMonth_8": "August", + "LongMonth_9": "Septembar", + "MainMetrics": "Glavne metrike", + "MediumToHighTrafficItIsRecommendedTo": "Za stranice sa većom posjećenosti preporučujemo da se reporti procesiraju najviše svakih pola sata (%s sekundi) ili svakog sata (%s sekundi)", + "Metadata": "Metadata", + "Metric": "Metrika", + "Metrics": "Metrike", + "MetricsToPlot": "Metrike na parceli", + "MetricToPlot": "Metrika na parceli", + "MinutesSeconds": "%1$s minute %2$ss", + "Mobile": "Mobitel", + "Monthly": "Mjesečno", + "MonthlyReport": "mjesečno", + "MonthlyReports": "Mjesečni reporti", + "More": "Više", + "MultiSitesSummary": "Sve web stranice", + "Name": "Ime", + "NbActions": "Broj akcija", + "NDays": "%s dani", + "Never": "Nikad", + "NewReportsWillBeProcessedByCron": "Ako Piwik arhiviranje nije pokrenuto od browsera, novi reporti ce biti procesirani od strane crontab-a.", + "NewUpdatePiwikX": "Novi update: Piwik %s", + "NewVisitor": "Novi posjetioci", + "NewVisits": "Nove posjete", + "Next": "Sljedeće", + "No": "No", + "NoDataForGraph": "Nema podataka za ovaj grafikon.", + "NoDataForTagCloud": "Nema podataka za ovaj oblak sa etiketama.", + "NotDefined": "%s nije definiran", + "NotRecommended": "(nije preporučeno)", + "NotValid": "%s nije važeći", + "NSeconds": "%s sekundi.", + "NumberOfVisits": "Broj posjeta", + "NVisits": "%s posjete", + "Ok": "Ok", + "OneAction": "1 akcija", + "OneDay": "1 dan", + "OneMinute": "1 minuta", + "OneVisit": "1 posjeta", + "OnlyEnterIfRequired": "Samo upišite korisničko ime ako vaš SMTP server to zahtijeva.", + "OnlyEnterIfRequiredPassword": "SaSamo upišite password ako vaš SMTP server to zahtijeva.", + "OnlyUsedIfUserPwdIsSet": "Samo ako je korisničko ime\/password postavljen, pitajte svog provajdera ako ste nesigurni u vezi metode koju trebate koristiti.", + "OpenSourceWebAnalytics": "Web Analitika Otvorenog Koda", + "OperationAtLeast": "Najmanje", + "OperationAtMost": "Najviše", + "OperationContains": "Sadrži", + "OperationDoesNotContain": "Nesadrži", + "OperationEquals": "Jednako je", + "OperationGreaterThan": "Veće je od", + "OperationIs": "Je", + "OperationIsNot": "Nije", + "OperationLessThan": "Manje od", + "OperationNotEquals": "nije jednako", + "OptionalSmtpPort": "Optimalno default je 25 za nekriptovano i TLS SMTP i 465 za SSL SMTP.", + "Options": "Opcije", + "OrCancel": "ili %s otkaži %s", + "OriginalLanguageName": "bosanski jezik", + "Others": "Drugo", + "Outlink": "Izlazeći link", + "Outlinks": "Izlazni linkovi", + "Overview": "Pregled", + "Pages": "Stranice", + "ParameterMustIntegerBetween": "Parameter %s mora biti cijela vrijednost broja između %s i %s", + "Password": "Lozinka", + "Period": "Period", + "Piechart": "Pita grafikon", + "PiwikXIsAvailablePleaseUpdateNow": "Piwik %1$s je važeća. %2$s Molimo nadogradite sada!%3$s (see %4$s promjene%5$s).", + "PleaseSpecifyValue": "Molimo navedite vrijednost '%s'.", + "PleaseUpdatePiwik": "Molimo nadogradite Piwik", + "Plugin": "Priključak", + "Plugins": "Priključci", + "PoweredBy": "Pokrenuto od", + "Previous": "Prethodno", + "PreviousDays": "Prethodni dani %s (ne uključujući danas)", + "PreviousDaysShort": "Prethodni %s dani", + "Price": "Cijena", + "ProductConversionRate": "Proizvodna konverzna stopa", + "ProductRevenue": "Proizvodna zarada", + "PurchasedProducts": "Kupljeni proizvodi", + "Quantity": "Količina", + "RangeReports": "Kustomizirani rasponi datuma", + "Recommended": "(preporučeno)", + "RecordsToPlot": "Rekordi na parceli", + "Refresh": "Osvježi", + "RefreshPage": "Osvježi stranicu", + "RelatedReport": "Vezani report", + "RelatedReports": "Vezani reporti", + "Remove": "Ukloni", + "Report": "Report", + "ReportGeneratedFrom": "Ovaj report je generisan koristeći podatke iz %s.", + "Reports": "Reporti", + "ReportsContainingTodayWillBeProcessedAtMostEvery": "Reporti za danas (ili neki drugi datum uključujući danas)će biti procesiran na najviše svakih", + "ReportsWillBeProcessedAtMostEveryHour": "Reporti ce biti procesuirani najvise svakog sata.", + "RequestTimedOut": "Zahtjev podataka za %s je istekao. Molimo pokušajte ponovo.", + "Required": "%s potrebno", + "ReturningVisitor": "Vracajuci posjetioc", + "Rows": "Redovi", + "RowsToDisplay": "Redovi za prikazivanje", + "Save": "Sačuvaj", + "SaveImageOnYourComputer": "Da sačuvaš sliku na računar, klikni desni klik na sliku i klikni \"Save Image As...\"", + "Search": "Traži", + "Seconds": "%ss", + "SeeAll": "pogledaj sve", + "SeeTheOfficialDocumentationForMoreInformation": "Vidi the %sofficial documentation%s za više informacija.", + "SelectYesIfYouWantToSendEmailsViaServer": "Izaberite \"Da\" ako želite da pošaljete e-mail preko servera umjesto lokalne mail funkcije.", + "Settings": "Postavke", + "Shipping": "Utovar", + "ShortDay_1": "Pon", + "ShortDay_2": "Uto", + "ShortDay_3": "Sri", + "ShortDay_4": "Čet", + "ShortDay_5": "Pet", + "ShortDay_6": "Sub", + "ShortDay_7": "Ned", + "ShortMonth_1": "Jan", + "ShortMonth_10": "Okt", + "ShortMonth_11": "Nov", + "ShortMonth_12": "Dec", + "ShortMonth_2": "Feb", + "ShortMonth_3": "Mar", + "ShortMonth_4": "Apr", + "ShortMonth_5": "Maj", + "ShortMonth_6": "Jun", + "ShortMonth_7": "Jul", + "ShortMonth_8": "Aug", + "ShortMonth_9": "Sep", + "Show": "prikaži", + "SingleWebsitesDashboard": "Kontrolna ploča web stranice", + "SmallTrafficYouCanLeaveDefault": "Za manje web stranice možete ostaviti %s sekundi i sve reporte u realnom vremenu.", + "SmtpEncryption": "SMTP enkripcija", + "SmtpPassword": "SMTP password", + "SmtpPort": "SMTP Port", + "SmtpServerAddress": "SMTP server adresa", + "SmtpUsername": "SMTP korisničko ime", + "Source": "Izvor", + "Subtotal": "Suma stavke", + "Table": "Tabela", + "TagCloud": "Oblak sa etiketama", + "Tax": "Taksa", + "TimeOnPage": "Vrijeme na stranici", + "Today": "Danas", + "Total": "Ukupno", + "TotalRevenue": "Ukupna zarada", + "TranslatorEmail": "translations@piwik.org", + "TranslatorName": "Piwik", + "UniquePurchases": "Jedinstvene narudžbe", + "Unknown": "Nepoznato", + "Upload": "Posalji", + "UsePlusMinusIconsDocumentation": "Koriste plus i minus ikone za navigaciju.", + "Username": "Korisničko ime", + "UseSMTPServerForEmail": "Koristi SMTP server za e-mail", + "Value": "Vrijednost", + "VBarGraph": "Vertikalni bar grafikon", + "View": "Vidi", + "Visit": "Posjete", + "VisitConvertedGoal": "Posjeta je konvertirala po barem jednom cilju", + "VisitConvertedGoalId": "Posjeta se konvertirala po specifičnom cilju", + "VisitConvertedNGoals": "Posjete konvertirane %s u ciljeve", + "VisitDuration": "Prosječno trajanje posjete (u sekundama)", + "Visitor": "Posjetilac", + "VisitorID": "ID posjetioca", + "VisitorIP": "IP posjetioca", + "Visitors": "Posjetioci", + "VisitsWith": "Posjete sa %s", + "VisitType": "Tip posjetioca", + "VisitTypeExample": "Na primjer, da izabrete sve posjetioce koji su se vratili na stranicu uključujući one koji su kupili nesto u prethodnoj posjeti, API zahtjev bi sadržavao %s", + "Warning": "Upozorenje", + "WarningFileIntegrityNoManifest": "Provjera integracije fajlova nije mogla biti pokrenuta zbog nedostatka manifest.inc.php.", + "WarningFileIntegrityNoMd5file": "Provjera fajla nije mogla biti pokrenuta zbog nedostatka md5_file() function.", + "WarningPasswordStored": "%sUpozorenje:%s Ovaj password će biti sačuvan u config fajl koji će biti vidljiv svima koji mogu pristupiti tom fajlu.", + "Website": "Web stranica", + "Weekly": "Sedmično", + "WeeklyReport": "sedmično", + "WeeklyReports": "Sedmični reporti", + "WellDone": "Bravo!", + "Widgets": "Dodaci", + "YearlyReport": "godišnje", + "YearlyReports": "Godišnji reporti", + "YearsDays": "%1$s godine %2$s dani", + "Yes": "Da", + "Yesterday": "Jučer", + "YouAreCurrentlyUsing": "Trenutno koristite piwik %s.", + "YouAreViewingDemoShortMessage": "Trenutno pregledate demo veziju Piwik-a", + "YouMustBeLoggedIn": "Moras biti ulogovan za ovu funkciju.", + "YourChangesHaveBeenSaved": "Vaše promjene su sačuvane." + }, + "Goals": { + "ColumnConversions": "Konverzije", + "Contains": "sadrži %s", + "Download": "Preuzmi datoteku", + "GoalName": "Naziv cilja", + "IsExactly": "je tačno %s", + "Manually": "ručno", + "ProductCategory": "Kategorija proizvoda", + "ProductName": "Ime proizvoda", + "Products": "Proizvodi", + "URL": "Veza" + }, + "Installation": { + "DatabaseSetupAdapter": "Adapter", + "DatabaseSetupLogin": "Prijavi se", + "Email": "e-pošta" + }, + "SitesManager": { + "Currency": "Valuta" + }, + "UserCountry": { + "country_ac": "Ostrvo Asension", + "country_ad": "Andora", + "country_ae": "Ujedinjeni Arapski Emirati", + "country_af": "Avganistan", + "country_ag": "Antigva i Barbuda", + "country_ai": "Angvila", + "country_al": "Albanija", + "country_am": "Armenija", + "country_an": "Holandski Antili", + "country_ao": "Angola", + "country_aq": "Antarktika", + "country_ar": "Argentina", + "country_as": "Američka Samoa", + "country_at": "Austrija", + "country_au": "Australija", + "country_aw": "Aruba", + "country_ax": "Alandska ostrva", + "country_az": "Azerbejdžan", + "country_ba": "Bosna i Hercegovina", + "country_bb": "Barbados", + "country_bd": "Bangladeš", + "country_be": "Belgija", + "country_bf": "Burkina Faso", + "country_bg": "Bugarska", + "country_bh": "Bahrein", + "country_bi": "Burundi", + "country_bj": "Benin", + "country_bl": "Sv. Bartolomej", + "country_bm": "Bermuda", + "country_bn": "Brunej", + "country_bo": "Bolivija", + "country_br": "Brazil", + "country_bs": "Bahami", + "country_bt": "Butan", + "country_bv": "Buve Ostrva", + "country_bw": "Bocvana", + "country_by": "Belorusija", + "country_bz": "Belise", + "country_ca": "Kanada", + "country_cc": "Kokos (Keling) Ostrva", + "country_cd": "Demokratska Republika Kongo", + "country_cf": "Centralno Afrička Republika", + "country_cg": "Kongo", + "country_ch": "Švajcarska", + "country_ci": "Obala Slonovače", + "country_ck": "Kukova Ostrva", + "country_cl": "Čile", + "country_cm": "Kamerun", + "country_cn": "Kina", + "country_co": "Kolumbija", + "country_cp": "Ostrvo Kliperton", + "country_cr": "Kostarika", + "country_cu": "Kuba", + "country_cv": "Kape Verde", + "country_cx": "Božićna Ostrva", + "country_cy": "Kipar", + "country_cz": "Češka", + "country_de": "Nemačka", + "country_dg": "Dijego Garsija", + "country_dj": "Džibuti", + "country_dk": "Danska", + "country_dm": "Dominika", + "country_do": "Dominikanska Republika", + "country_dz": "Alžir", + "country_ea": "Seuta i Melilja", + "country_ec": "Ekvador", + "country_ee": "Estonija", + "country_eg": "Egipat", + "country_eh": "Zapadna Sahara", + "country_er": "Eritreja", + "country_es": "Španija", + "country_et": "Etiopija", + "country_fi": "Finska", + "country_fj": "Fidži", + "country_fk": "Folklandska Ostrva", + "country_fm": "Mikronezija", + "country_fo": "Farska Ostrva", + "country_fr": "Francuska", + "country_ga": "Gabon", + "country_gb": "Velika Britanija", + "country_gd": "Grenada", + "country_ge": "Gruzija", + "country_gf": "Francuska Gvajana", + "country_gg": "Gurnsi", + "country_gh": "Gana", + "country_gi": "Gibraltar", + "country_gl": "Grenland", + "country_gm": "Gambija", + "country_gn": "Gvineja", + "country_gp": "Gvadelupe", + "country_gq": "Ekvatorijalna Gvineja", + "country_gr": "Grčka", + "country_gs": "Južna Džordžija i Južna Sendvič Ostrva", + "country_gt": "Gvatemala", + "country_gu": "Guam", + "country_gw": "Gvineja-Bisao", + "country_gy": "Gvajana", + "country_hk": "Hong Kong (S. A. R. Kina)", + "country_hm": "Herd i Mekdonald Ostrva", + "country_hn": "Honduras", + "country_hr": "Hrvatska", + "country_ht": "Haiti", + "country_hu": "Mađarska", + "country_ic": "Kanarska ostrva", + "country_id": "Indonezija", + "country_ie": "Irska", + "country_il": "Izrael", + "country_im": "Ostrvo Man", + "country_in": "Indija", + "country_io": "Britansko Indijska Okeanska Teritorija", + "country_iq": "Irak", + "country_ir": "Iran", + "country_is": "Island", + "country_it": "Italija", + "country_je": "Džersi", + "country_jm": "Jamajka", + "country_jo": "Jordan", + "country_jp": "Japan", + "country_ke": "Kenija", + "country_kg": "Kirgizstan", + "country_kh": "Kambodža", + "country_ki": "Kiribati", + "country_km": "Komorska Ostrva", + "country_kn": "Sent Kits i Nevis", + "country_kp": "Severna Koreja", + "country_kr": "Južna Koreja", + "country_kw": "Kuvajt", + "country_ky": "Kajmanska Ostrva", + "country_kz": "Kazahstan", + "country_la": "Laos", + "country_lb": "Liban", + "country_lc": "Sent Lucija", + "country_li": "Lihtenštajn", + "country_lk": "Šri Lanka", + "country_lr": "Liberija", + "country_ls": "Lesoto", + "country_lt": "Litvanija", + "country_lu": "Luksemburg", + "country_lv": "Letonija", + "country_ly": "Libija", + "country_ma": "Maroko", + "country_mc": "Monako", + "country_md": "Moldavija", + "country_me": "Crna Gora", + "country_mf": "Sv. Martin", + "country_mg": "Madagaskar", + "country_mh": "Maršalska Ostrva", + "country_mk": "Makedonija", + "country_ml": "Mali", + "country_mm": "Mijanmar", + "country_mn": "Mongolija", + "country_mo": "Makao (S. A. R. Kina)", + "country_mp": "Severna Marijanska Ostrva", + "country_mq": "Martinik", + "country_mr": "Mauritanija", + "country_ms": "Monserat", + "country_mt": "Malta", + "country_mu": "Mauricius", + "country_mv": "Maldivi", + "country_mw": "Malavi", + "country_mx": "Meksiko", + "country_my": "Malezija", + "country_mz": "Mozambik", + "country_na": "Namibija", + "country_nc": "Nova Kaledonija", + "country_ne": "Niger", + "country_nf": "Norfolk Ostrvo", + "country_ng": "Nigerija", + "country_ni": "Nikaragva", + "country_nl": "Holandija", + "country_no": "Norveška", + "country_np": "Nepal", + "country_nr": "Nauru", + "country_nu": "Niue", + "country_nz": "Novi Zeland", + "country_om": "Oman", + "country_pa": "Panama", + "country_pe": "Peru", + "country_pf": "Francuska Polinezija", + "country_pg": "Papua Nova Gvineja", + "country_ph": "Filipini", + "country_pk": "Pakistan", + "country_pl": "Poljska", + "country_pm": "Sen Pjer i Mikelon", + "country_pn": "Pitcairn", + "country_pr": "Porto Riko", + "country_ps": "Palestinska Teritorija", + "country_pt": "Portugal", + "country_pw": "Palau", + "country_py": "Paragvaj", + "country_qa": "Katar", + "country_re": "Rejunion", + "country_ro": "Rumunija", + "country_rs": "Srbija", + "country_ru": "Rusija", + "country_rw": "Ruanda", + "country_sa": "Saudijska Arabija", + "country_sb": "Solomonska Ostrva", + "country_sc": "Sejšeli", + "country_sd": "Sudan", + "country_se": "Švedska", + "country_sg": "Singapur", + "country_sh": "Sveta Jelena", + "country_si": "Slovenija", + "country_sj": "Svalbard i Janmajen Ostrva", + "country_sk": "Slovačka", + "country_sl": "Sijera Leone", + "country_sm": "San Marino", + "country_sn": "Senegal", + "country_so": "Somalija", + "country_sr": "Surinam", + "country_ss": "Južni Sudan", + "country_st": "Sao Tome i Principe", + "country_sv": "Salvador", + "country_sy": "Sirija", + "country_sz": "Svazilend", + "country_ta": "Tristan da Kunja", + "country_tc": "Turks i Kajkos Ostrva", + "country_td": "Čad", + "country_tf": "Francuske Južne Teritorije", + "country_tg": "Togo", + "country_th": "Tajland", + "country_tj": "Tadžikistan", + "country_tk": "Tokelau", + "country_tl": "Timor Leste", + "country_tm": "Turkmenistan", + "country_tn": "Tunis", + "country_to": "Tonga", + "country_tr": "Turska", + "country_tt": "Trinidad i Tobago", + "country_tv": "Tuvalu", + "country_tw": "Tajvan", + "country_tz": "Tanzanija", + "country_ua": "Ukrajina", + "country_ug": "Uganda", + "country_um": "Manja Udaljena Ostrva SAD", + "country_us": "Sjedinjene Američke Države", + "country_uy": "Urugvaj", + "country_uz": "Uzbekistan", + "country_va": "Vatikan", + "country_vc": "Sent Vinsent i Grenadini", + "country_ve": "Venecuela", + "country_vg": "Britanska Devičanska Ostrva", + "country_vi": "S.A.D. Devičanska Ostrva", + "country_vn": "Vijetnam", + "country_vu": "Vanuatu", + "country_wf": "Valis i Futuna Ostrva", + "country_ws": "Samoa", + "country_ye": "Jemen", + "country_yt": "Majote", + "country_za": "Južnoafrička Republika", + "country_zm": "Zambija", + "country_zw": "Zimbabve" + }, + "UserSettings": { + "Language_aa": "afarski", + "Language_ab": "abkazijski", + "Language_ae": "avestanski", + "Language_af": "afrikaans", + "Language_ak": "akan", + "Language_am": "amharski", + "Language_an": "aragonežanski", + "Language_ar": "arapski", + "Language_as": "asameski", + "Language_av": "avarski", + "Language_ay": "ajmara", + "Language_az": "azerbejdžanski", + "Language_ba": "baškir", + "Language_be": "bjeloruski", + "Language_bg": "bugarski", + "Language_bh": "bihari", + "Language_bi": "bislama", + "Language_bm": "bambara", + "Language_bn": "bengalski", + "Language_bo": "tibetanski", + "Language_br": "bretonac", + "Language_bs": "bosanski", + "Language_ca": "katalonski", + "Language_ce": "čečenski", + "Language_ch": "čamoro", + "Language_co": "korzikanski", + "Language_cr": "kri", + "Language_cs": "češki", + "Language_cu": "staroslovenski", + "Language_cv": "čuvaški", + "Language_cy": "velški", + "Language_da": "danski", + "Language_de": "njemački", + "Language_dv": "divehijski", + "Language_dz": "džonga", + "Language_ee": "eve", + "Language_el": "grčki", + "Language_en": "engleski", + "Language_eo": "esperanto", + "Language_es": "španjolski", + "Language_et": "estonski", + "Language_eu": "baskijski", + "Language_fa": "perzijski", + "Language_ff": "fulah", + "Language_fi": "finski", + "Language_fj": "fidžijski", + "Language_fo": "farski", + "Language_fr": "francuski", + "Language_fy": "frizijski", + "Language_ga": "irski", + "Language_gd": "škotski gelski", + "Language_gl": "galicijski", + "Language_gn": "guarani", + "Language_gu": "gudžarati", + "Language_gv": "manks", + "Language_ha": "hausa", + "Language_he": "hebrejski", + "Language_hi": "hindu", + "Language_ho": "hiri motu", + "Language_hr": "hrvatski", + "Language_ht": "haićanski", + "Language_hu": "mađarski", + "Language_hy": "armenski", + "Language_hz": "herero", + "Language_ia": "interlingua", + "Language_id": "indonezijski", + "Language_ie": "međujezični", + "Language_ig": "igbo", + "Language_ii": "sičuan ji", + "Language_ik": "inupiak", + "Language_io": "ido", + "Language_is": "islandski", + "Language_it": "talijanski", + "Language_iu": "inuktitut", + "Language_ja": "japanski", + "Language_jv": "javanski", + "Language_ka": "gruzijski", + "Language_kg": "kongo", + "Language_ki": "kikuju", + "Language_kj": "kuanjama", + "Language_kk": "kozački", + "Language_kl": "kalalisutski", + "Language_km": "kambodžanski", + "Language_kn": "kannada", + "Language_ko": "koreanski", + "Language_kr": "kanuri", + "Language_ks": "kašmiri", + "Language_ku": "kurdski", + "Language_kv": "komi", + "Language_kw": "korniški", + "Language_ky": "kirgiski", + "Language_la": "latinski", + "Language_lb": "luksemburški", + "Language_lg": "ganda", + "Language_li": "limburgiš", + "Language_ln": "n\/a", + "Language_lo": "laothian", + "Language_lt": "litvanski", + "Language_lu": "luba-katanga", + "Language_lv": "latvijski", + "Language_mg": "malagazijski", + "Language_mh": "maršalski", + "Language_mi": "maorski", + "Language_mk": "makedonski", + "Language_ml": "malajalamski", + "Language_mn": "mongolski", + "Language_mr": "marati", + "Language_ms": "malajski", + "Language_mt": "malteški", + "Language_my": "burmanski", + "Language_na": "nauru", + "Language_nb": "norveški bokmål", + "Language_nd": "severni ndebele", + "Language_ne": "nepalski", + "Language_ng": "ndonga", + "Language_nl": "holandski", + "Language_nn": "norveški (novonorveški)", + "Language_no": "norveški", + "Language_nr": "južni ndebele", + "Language_nv": "navaho", + "Language_ny": "njanja", + "Language_oc": "oksitanski", + "Language_oj": "ojibva", + "Language_om": "oromo", + "Language_or": "indijski", + "Language_os": "osetski", + "Language_pa": "pendžabi", + "Language_pi": "pali", + "Language_pl": "poljski", + "Language_ps": "pakistanski", + "Language_pt": "portugalski", + "Language_qu": "kvenča", + "Language_rm": "reto-romanski", + "Language_rn": "rundi", + "Language_ro": "rumunski", + "Language_ru": "ruski", + "Language_rw": "kinjarvanda", + "Language_sa": "sanskrit", + "Language_sc": "sardinijski", + "Language_sd": "sindi", + "Language_se": "severni sami", + "Language_sg": "sango", + "Language_si": "sinhaleski", + "Language_sk": "slovački", + "Language_sl": "slovenački", + "Language_sm": "samoanski", + "Language_sn": "šona", + "Language_so": "somalski", + "Language_sq": "albanski", + "Language_sr": "srpski", + "Language_ss": "svati", + "Language_st": "sesoto", + "Language_su": "sudanski", + "Language_sv": "švedski", + "Language_sw": "svahili", + "Language_ta": "tamilski", + "Language_te": "telugu", + "Language_tg": "tađik", + "Language_th": "tajlandski", + "Language_ti": "tigrinya (eritrejski)", + "Language_tk": "turkmenski", + "Language_tl": "tagalski", + "Language_tn": "tsvana", + "Language_to": "tonga", + "Language_tr": "turski", + "Language_ts": "tsonga", + "Language_tt": "tatarski", + "Language_tw": "twi", + "Language_ty": "tahićanski", + "Language_ug": "uighur", + "Language_uk": "ukrajinski", + "Language_ur": "urdu", + "Language_uz": "uzbekistanski", + "Language_ve": "venda", + "Language_vi": "vijetnamski", + "Language_vo": "volapük", + "Language_wa": "valun", + "Language_wo": "volof", + "Language_xh": "bantu", + "Language_yi": "jidiš", + "Language_yo": "jorubanski", + "Language_za": "zuang", + "Language_zh": "kineski", + "Language_zu": "zulu" + }, + "Widgetize": { + "OpenInNewWindow": "Otvori u novom prozoru" + } +} \ No newline at end of file diff --git a/www/analytics/lang/ca.json b/www/analytics/lang/ca.json new file mode 100644 index 00000000..9fd7dc72 --- /dev/null +++ b/www/analytics/lang/ca.json @@ -0,0 +1,2022 @@ +{ + "Actions": { + "ColumnClickedURL": "URL picada", + "ColumnClicks": "Clics", + "ColumnClicksDocumentation": "Nombre de vegades que s'ha fet clic en aquest enllaç", + "ColumnDownloadURL": "URL de descàrrega", + "ColumnEntryPageTitle": "Títol de la pàgina d'entrada", + "ColumnEntryPageURL": "URL de la pàgina d'entrada", + "ColumnExitPageTitle": "URL de la pàgina de sortida", + "ColumnExitPageURL": "URL de la pàgina de sortida", + "ColumnNoResultKeyword": "Paraula clau sense cap resultat de cerca", + "ColumnPageName": "Nom de la pàgina", + "ColumnPagesPerSearch": "Pàgines dels resultats de la cerca", + "ColumnPagesPerSearchDocumentation": "Els visitants busquen al vostre lloc web i a vegades cliquen sobre el botó següent. Aquest nombre es la mitga de pàgines de resultats de la cerca vistes per aquesta paraula clau.", + "ColumnPageURL": "URL de la pàgina", + "ColumnSearchCategory": "Categoria de cerca", + "ColumnSearches": "Cerques", + "ColumnSearchesDocumentation": "Nombre de visitants que han cercat aquesta paraula clau al cercador de la vostra pàgina web.", + "ColumnSearchExits": "% Sortides de les cerques", + "ColumnSearchExitsDocumentation": "El percentatge de visites que marxen del vostre lloc web desprès de cercar aquesta paraula al cercador del vostre lloc web.", + "ColumnSearchResultsCount": "Resultats de la cerca", + "ColumnSiteSearchKeywords": "Paraules clau úniques", + "ColumnUniqueClicks": "Clics únics", + "ColumnUniqueClicksDocumentation": "EL nombre de visites que han fet click en aquest enllaç. Si l'enllaç s'ha clicat més d'una vegada durant una visita només es conta un.", + "ColumnUniqueDownloads": "Descàrregues úniques", + "ColumnUniqueOutlinks": "Enllaços de sortida únics", + "DownloadsReportDocumentation": "En aquest informe podeu observar quins fitxers han descarrregats els visitants. %s El Piwik només té constància dels clicks als enllaços de descarga, desconeix si la descàrrega s'ha completat o no.", + "EntryPagesReportDocumentation": "Aquest informe conté la informació sobre les pàgines d'entrada que s'han fet servir durant el període especificat. Una pàgina d'entrada es la primera pàgina que veu un usuari durant la seva visita. %s Les URL de les pàgines es mostren en estructura de carpetes", + "EntryPageTitles": "Títols de les pàgines d'entrada", + "EntryPageTitlesReportDocumentation": "Aquest informe conté la informació sobre els títols de les pàgines d'entrada que s'han utilitzat durant el període especificat.", + "ExitPagesReportDocumentation": "Aquest informe conté les pàgines que s'han utilitzat com a sortida durant el període especificat. Una pàgina de sortida es l'ultima pàgina que veu un usuari durant la seva visita. %s Les pàgines de sortida es mostren amb estructura de directoris.", + "ExitPageTitles": "Títols de les pàgines de sortida", + "ExitPageTitlesReportDocumentation": "Aquest informe conté els títols de les pàgins de sortida que s'han utiltizat durant el període especificat.", + "LearnMoreAboutSiteSearchLink": "Apren més sobre com els teus visitants utiltizent el motor de cerca del teu lloc web.", + "OneSearch": "1 cerca", + "OutlinkDocumentation": "Un enllaç de sortida es un enllaç que porta el visitant fora del teu lloc web (a un altre domini)", + "OutlinksReportDocumentation": "Aquest informe mostra una lista jeràrquica de les URL de sortida que han estat clicades pels vostres visitants.", + "PagesReportDocumentation": "Aquest informe conté les URL de les pàgines que s'han visitat. %s La taula s'organitza jerarquicament, i les URL es mostren amb estructura de directoris.", + "PageTitlesReportDocumentation": "Aquest informe conté informació sobre els títols de les pàgines que s'han visitat. %s El títol de la pàgina es el tag HTML: %s, que es mostra al títol de la finestra en la majoría de navegadors.", + "PageUrls": "URLs de les pàgines", + "PluginDescription": "Informe sobre les pàgines vistes, les enllaços de sortida i descarregues. Els enllaços de sortida i el seguiment de les descàrregues és automàtic.", + "SiteSearchCategories1": "Aquest informe mostra les categoríes que han seleccionat els visitants quan han fet una cerca al vostre lloc web", + "SiteSearchCategories2": "Per exemple, els llocs de Ecommers normalment tenen un selector de categoria que permet als visitants restringir les seves cerces als productes d'una Categoría concreta.", + "SiteSearchFollowingPagesDoc": "Quan els visitants cerquen al teu lloc web, estan buscant una pàgina, un contingut, un producte o un servei en particular. Aquest informe mostra les pàgines que han estat clicades més vegades desprès d'una cerca interna. En altres paraules, la llista de pàgines que han estat més cercades pels visitants que ja están al vostre lloc web.", + "SiteSearchIntro": "Observar les cerques que els visitants fan al vostre lloc web és una forma molt efectiva d'aprendre més sobre el que està cercant la teva audiència. Pot ajudar a trobar idees per a nou contingut, nous productes que els teus visitants poden estar buscar i permet millorar l'experiència dels visitants al vostre lloc web.", + "SiteSearchKeywordsDocumentation": "Aquest informe mostra les Paraules clau que els visitants han utiltizat per cercar al cercador del vostre lloc web.", + "SiteSearchKeywordsNoResultDocumentation": "Aquest informe llista les paraules clau que no han tingut cap resultat. Potser es pot optimitzar l'algorisme de cerca o potser els vostres visitants estan cercant contingut que (encara) no existeix al vostre lloc web?", + "SubmenuPagesEntry": "Pàgines d'entrada", + "SubmenuPagesExit": "Pàgines de sortida", + "SubmenuPageTitles": "Títols de les pàgines", + "SubmenuSitesearch": "Cerca al lloc", + "WidgetEntryPageTitles": "Títols de les pàgines d'entrada", + "WidgetExitPageTitles": "Títols de les pàgines de sortida", + "WidgetPagesEntry": "Pàgines d'entrada", + "WidgetPagesExit": "Pàgines de sortida", + "WidgetPageTitles": "Títols de les pàgines", + "WidgetPageTitlesFollowingSearch": "Títols de les pàgines desprès d'una cerca Interna", + "WidgetPageUrlsFollowingSearch": "Pàgines despes d'una cerca interna", + "WidgetSearchCategories": "Categoríes de cerca", + "WidgetSearchKeywords": "Paraules de cerca al lloc", + "WidgetSearchNoResultKeywords": "Paraules Clau de cerca sense resultats" + }, + "Annotations": { + "AddAnnotationsFor": "Afegir una anotació per %s", + "AnnotationOnDate": "Anotació per %1$s: %2$s", + "Annotations": "Anotacions", + "ClickToDelete": "Feu click per eliminar l'anotació", + "ClickToEdit": "Feu click per editar aquesta anotació", + "ClickToEditOrAdd": "Feu click per editar o afegir una anotació", + "ClickToStarOrUnstar": "Feu click per marcar o desmarcar aquesta nota.", + "CreateNewAnnotation": "Crea una nova anotació...", + "EnterAnnotationText": "Introduix la teva nota...", + "HideAnnotationsFor": "Amaga les anotacións per %s...", + "IconDesc": "Veure les notes d'aquest rang.", + "IconDescHideNotes": "Amaga les notes d'aquest rang.", + "InlineQuickHelp": "Podeu crear anotacions per marcar events especials (una nova entrada al blog, el rediseny del web). Les annotacions us permeten guardar el vostre anàlisis de la informació o qualsevol altra cosa que creïeu important.", + "LoginToAnnotate": "Identifiqueu-vos per crear una anotació.", + "NoAnnotations": "No hi ha notes per aquest rang de pàgines.", + "PluginDescription": "Permet afegir notes als diferents dies perquè pogueu recordar perquè la vostra inforamció es mostra d'una determinada forma.", + "ViewAndAddAnnotations": "Mostra i afegeix anotacions per %s...", + "YouCannotModifyThisNote": "No podeu modificar aquesta nota perquè o bé no l'heu creada vosatres, o no teni accès d'administrador per aquest lloc web." + }, + "API": { + "GenerateVisits": "Si no disposeu d'informació d'avui podeu generar informació utilitzant l'extensió: %s. Heu d'activar l'extensió %s i desprès anar al menú 'Generador de visites' de l'espai d'administració del Piwik.", + "KeepTokenSecret": "El token_auth es tan secret com el vostre usuari i la vostra contrasenya, %s no compartiu el seu %s!", + "LoadedAPIs": "S'ha carregat correctament un total de %s API", + "MoreInformation": "Per mes informació sobre les APIs de Piwik, siusplau reviseu %s Introducció a l'API de Piwik %s i %s la Referència de l'API de Piwik %s.", + "PluginDescription": "Tota la informació del Piwik està disponible a travès de APIs simples. Aquest plugin es el punt d'entrada que podeu ciradar per obtenir la informació de l'anàlisis Web en xml, json, php, csv, etc.", + "QuickDocumentationTitle": "Documentació ràpida de la API", + "TopLinkTooltip": "Accediu a la vostra informació de l'anàlisis Web d'una forma programada a través d'una API simple en json, xml, etc.", + "UserAuthentication": "Autentificació de l'usuari", + "UsingTokenAuth": "Si voleu %s obtenir informació a través d'un script, un crontab, etc %s heu d'afegir el paràmetre %s a les crides a la APU per les URLs que requereixen autentificació." + }, + "CoreAdminHome": { + "Administration": "Administració", + "BrandingSettings": "Preferències del Branding", + "ClickHereToOptIn": "Feu click aquí per apuntar-vos.", + "ClickHereToOptOut": "Feu click aquí per desapuntar-vos.", + "CustomLogoFeedbackInfo": "Si heu personalitzat el log de Piwik, potser també estareu interesants en amagar %s l'enllaç al menú superior. Per a fer-ho, podeu deshabilitar l'extensió de Feedback a la pàgina %sManage Plugins%s", + "CustomLogoHelpText": "Podeu personalitzar el logo de Piwik que es mostrarà a l'interfície d'usuari i als informes d'emails.", + "EmailServerSettings": "Configuració del servidor de correu", + "ImageTracking": "Seguiment per imatge", + "ImageTrackingIntro1": "Quan un visitant ha deshabilitat el JavaScript, o quan el JavaScript no es pot fer servir, podeu fer servir un enllaç a una imatge de seguiment per seguir les vistes.", + "ImageTrackingIntro2": "Generar l'enllaç de sota i copiar-enganxar el codi HTML generat a la pàgina. Si esteu fent servir això com alternativa al seguiment amb JavaScript, heu d'envoltar el codi en %1$s tags.", + "ImageTrackingIntro3": "Per la llista completa d'opcions que podeu fer servir amb una imatge de seguiment, mireu a la %1$sDocumentació de Tracking API%2$s.", + "ImageTrackingLink": "Enllaç de seguiment amb imatge", + "ImportingServerLogs": "Important els Registres del Servidor", + "ImportingServerLogsDesc": "Una alternativa a seguir els visitants a través del navegador (tant amb JavaScript com amb un enllaç de imatge) és important continuament els registres del servidor. Informeu-vos més a %1$sServer Log File Analytics%2$s.", + "JavaScriptTracking": "Seguiment amb Javascript", + "LogoUpload": "Seleccioneu un logo per pujar", + "MenuGeneralSettings": "Configuració general", + "OptOutComplete": "Baixa complerta. Les teves visites en aquest lloc web no es tindrán en compte per l'eina d'anàlisis Web.", + "OptOutCompleteBis": "Teniu en compte que si borreu les cookies, borreu la cookie de baixa o si canvieu d'ordenador o de navegadaor web, haureu de tornar a realitzar el proces de baixa.", + "OptOutExplanation": "El Piwik es dedica a garantir la privacitat a Internet. Per permetres als vostres usuaris la possibilitat de donar-se de baixa del l'análisis web del Piwik, podeu afegir el següent codi HTML a una de les pàgiens del vostre lloc web, per exemple a la pàgina de política de privacitat.", + "OptOutExplanationBis": "Aquest codi mostrarà un Iframe que conté un enllaç per a que els vostres visitants es puguin donar de baixa del Piwik. Aquest enllaç guarda un cookie al seus navegadors. %s Click here%s per veure el contingut que es mostrarà al Iframe.", + "OptOutForYourVisitors": "Pàgina de baixa del Piwik pels vostres visitants", + "PiwikIsInstalledAt": "El Piwik està instal·lat a", + "PluginDescription": "Area d'administració del Piwik", + "TrustedHostConfirm": "Esteu segur que voleu canviar el nom nom de la màquina (hostname) de confiança del Piwik?", + "TrustedHostSettings": "Nom del host de Piwik de confiança", + "UseCustomLogo": "Utilitza un logo personalitzat", + "ValidPiwikHostname": "Nom del host de Piwik vàlid", + "YouAreOptedIn": "Actualment esteu subscrit.", + "YouAreOptedOut": "Actualment esteu donat de baixa.", + "YouMayOptOut": "Podeu escollir no tenir un únic nombre d'identificació assignat al vostre ordinador per evitar l'agregació i l'anàlisi de la informació recollida en aquest lloc web.", + "YouMayOptOutBis": "Per prendre aquesta decisió, feu click a continuació per rebre una cookie de baixa." + }, + "CoreHome": { + "CategoryNoData": "No hi ha dades en aquesta categoria. Proveu d'incloure tota la població.", + "CheckForUpdates": "Cerca actualitzacions", + "DataForThisReportHasBeenPurged": "La informació d'aquest informés és anterior a %s mesos d'antiguitat i s'ha purgat.", + "DataTableExcludeAggregateRows": "Es mostren les files aggrupades %s Amaga-ho", + "DataTableIncludeAggregateRows": "No es mostren les files agrupades %s Mostra-les", + "DateFormat": "%longDay%, %day% de %longMonth% de %longYear%", + "Default": "per defecte", + "ExcludeRowsWithLowPopulation": "Es mostren totes les files %s No mostris la població inferior", + "FlattenDataTable": "L'informe es jeràrquic %s Feu-lo pla.", + "IncludeRowsWithLowPopulation": "No es mostren les files amb polbació inferior %s Mostra totes les files", + "InjectedHostEmailBody": "Hola, He provat d'accedir al Piwik avui i m'he trobat amb l'avís de nom de la màquina desconegut.", + "InjectedHostEmailSubject": "S'ha accedit al piwik amb un nom de màquina desconegut: %s", + "InjectedHostNonSuperUserWarning": "%1$sClick here to access Piwik safely%2$s i eliminar aaquest avíst. Potse també voldreu contractar amb l'administrador del vostre Piwik i notificar-li aquesta incidència (%3$sclick here to email%4$s).", + "InjectedHostSuperUserWarning": "Pot ser que el Piwik estigui mal configurat (per exemple, si el Piwik s'ha mogut a un nou servidor o URL). Podeu %1$sfer click aquí i afegir %2$s com el nom de la màquina vàlid (si hi confieu)%3$s, o bé %4$s fer click aquí %5$s per accedir al piwik de forma segura%6$s.", + "InjectedHostWarningIntro": "Esteu accedint al Piwik desde %1$s, però el Piwik està configurat per escoltar a l'adreça: %2$s.", + "JavascriptDisabled": "S'ha de tenir el Javascript activat per vistualitzar la vista estàndar del Piwik. No obstant això, sembla que el Javascript esta deshabilitat or no està suportat pel vostre navegador Per utilitzar la vista estàndarc, activeu el Javascript canviant les opcions del navegador i %1$storneu-ho a probar%2$s.
    ", + "LongMonthFormat": "%longMonth% de %longYear%", + "LongWeekFormat": "%dayFrom% %longMonthFrom% - %dayTo% %longMonthTo% del %longYearTo%", + "MakeADifference": "Contribuieu a les millores: %1$sDonar ara%2$s per col·laborar amb Piwik 2.0!", + "NoPrivilegesAskPiwikAdmin": "Esteu identificat com a '%s' però sembla que no teniu cap permís establert al Piwik. %s Pregunteu al vostre administrador de Piwik (feu click per enviar un email)%s que us dongui access per veure un lloc web.", + "PageOf": "%1$s de %2$s", + "PeriodDay": "Dia", + "PeriodDays": "dies", + "PeriodMonth": "Mes", + "PeriodMonths": "mesos", + "PeriodRange": "Rang", + "PeriodWeek": "Setmana", + "PeriodWeeks": "setmanes", + "PeriodYear": "Any", + "PeriodYears": "anys", + "PluginDescription": "Estructura dels informes de Anàlisis web.", + "ReportGeneratedOn": "Informe generat el %s", + "ReportGeneratedXAgo": "Informe generat fa %s", + "ShortDateFormat": "%shortDay%, %day% de %shortMonth%", + "ShortDateFormatWithYear": "%day% %shortMonth% %shortYear%", + "ShortMonthFormat": "%shortMonth% %longYear%", + "ShortWeekFormat": "%dayFrom% %shortMonthFrom% - %dayTo% %shortMonthTo% %shortYearTo%", + "ShowJSCode": "Mostra el codi JavaScript necessari", + "ThereIsNoDataForThisReport": "No hi ha informació per aquest informe.", + "UnFlattenDataTable": "Aquest informe es pla %s Feu-lo jeràrquic", + "WebAnalyticsReports": "Informe d'analítica web", + "YouAreUsingTheLatestVersion": "Esteu fent servir l'última versió de Piwik!" + }, + "CorePluginsAdmin": { + "ActionInstall": "Instal·la", + "Activate": "Activa", + "Activated": "Actiu", + "Active": "Actiu", + "Activity": "Activitat", + "AuthorHomepage": "Pàgina de l'autor", + "Authors": "Autors", + "Deactivate": "Desactiva", + "Inactive": "Inactiu", + "LicenseHomepage": "Pàgina de la llicència", + "MainDescription": "Els connectors augmenten la funcionalitat del Piwik. Un cop hi ha un connector instal·lat, podeu activar-lo i desactivar-lo aquí.", + "PluginDescription": "Interfíce d'administració d'extensions.", + "PluginHomepage": "Pàgina web", + "PluginsManagement": "Gestiona els connectors", + "Status": "Estat", + "Version": "Versió" + }, + "CoreUpdater": { + "ClickHereToViewSqlQueries": "Feu click aquí per veure i copiar la llista de consultes SQL que s'executaran", + "CreatingBackupOfConfigurationFile": "S'està creant una còpia de seguretat del fitxer de configuració a %s", + "CriticalErrorDuringTheUpgradeProcess": "Hi ha hagut un error crític durant el procés d'actualització:", + "DatabaseUpgradeRequired": "És necessari actualitzar la base de dades", + "DownloadingUpdateFromX": "S'està descarregant l'actualització de %s", + "DownloadX": "Descarrega %s", + "EmptyDatabaseError": "La base de dades %s està buida. Heu d'editar o esborrar el fitxer de configuració del Piwik.", + "ErrorDIYHelp": "Si sou un usuari avançat i trobeu un error en l'actualització de la base de dades:", + "ErrorDIYHelp_1": "identifiqueu i corregiu l'origen de l'error (per exemple: memory_limit o max_execution_time)", + "ErrorDIYHelp_2": "executeu les consultes restants de l'actualització que han fallat", + "ErrorDIYHelp_3": "actualitzeu la taula `option` de la base de dades del Piwik, introduint la versió que ha fallat a l'hora d'actualitzar a version_core", + "ErrorDIYHelp_4": "torneu a engegar l'actualització (a través del navegador o la línia de comandes) per a continuar amb les actualitzacions restants.", + "ErrorDIYHelp_5": "informeu sobre el problema (i la solució) per tal que puguem millorar el Piwik", + "ErrorDuringPluginsUpdates": "Hi ha hagut errors en l'actualització dels connectors", + "ExceptionAlreadyLatestVersion": "El Piwik està actualitzat a la versió %s.", + "ExceptionArchiveEmpty": "Arxiu buit", + "ExceptionArchiveIncompatible": "Arxiu incompatible: %s", + "ExceptionArchiveIncomplete": "L'arxiu és incomplet: manquen alguns fitxers (per exemple, %s).", + "HelpMessageContent": "Comproveu les %1$s PMF del Piwik (en anglès) %2$s, que intenten explicar els errors més comuns a l'actualització. %3$s Pregunteu a l'administrador del sistema, podria ajudar-vos amb l'error, que sembla estar relacionat amb el servidor o la instal·lació del MySQL.", + "HelpMessageIntroductionWhenError": "El que hi ha a sobre és l'error del nucli. Hauria d'explicar la causa, però si necessiteu més ajuda, si us plau:", + "HelpMessageIntroductionWhenWarning": "La actualització s'ha completat amb èxit, però hi ha hagut alguns problemes durant el procés. Si us plau, llegiu les descripcions que hi ha a sobre per a saber més detalls. Si voleu més informació:", + "InstallingTheLatestVersion": "S'està instal·lant la darrera versió", + "MajorUpdateWarning1": "Es tracta una actualització major! Tardarà més de l'habitual", + "MajorUpdateWarning2": "Aquest avís és extremadament important per instalacion amb gran quantitat d'informació.", + "NoteForLargePiwikInstances": "Notes importants per instalacions del Piwik amb gran quanitat d'informació", + "NoteItIsExpectedThatQueriesFail": "Nota: Si executeu les consultes manualment, s'epera que algunes d'elles fallin. En aquest cas, simplement ignoreu els error i executeu les següents a la llista.", + "PiwikHasBeenSuccessfullyUpgraded": "El Piwik s'ha actualitzat amb èxit!", + "PiwikUpdatedSuccessfully": "El Piwik s'ha actualitzat correctament!", + "PiwikWillBeUpgradedFromVersionXToVersionY": "La base de dades s'actualitzarà de la versió %1$s a la nova %2$s.", + "PluginDescription": "Mecanísme d'actualització del Piwik", + "ReadyToGo": "Preparat?", + "TheFollowingPluginsWillBeUpgradedX": "Aquests connectors s'actualitzaran: %s.", + "ThereIsNewVersionAvailableForUpdate": "Hi ha una nova versió del Piwik disponible.", + "TheUpgradeProcessMayFailExecuteCommand": "Si teuniu una gran base de dades del Piwik, les actualitzacions poden tardar massa per executar-les des del navegador. En aquesta situació podeu executar les actualitzacions desde la línia de comandes: %s", + "TheUpgradeProcessMayTakeAWhilePleaseBePatient": "El procés d'actualització pot durar una estona, tingueu paciència.", + "UnpackingTheUpdate": "S'està desempacant l'actualització", + "UpdateAutomatically": "Actualitza automàticament", + "UpdateHasBeenCancelledExplanation": "L'actualització en un clic del Piwik ha estat cancel·lada. Si no podeu arreglar l'error de més amunt, us recomanem que actualitzeu el Piwik manualment. %1$s Si us plau, mireu-vos la %2$sDocumentació d'actualització (en anglès)%3$s per a començar!", + "UpdateTitle": "Actualització del Piwik", + "UpgradeComplete": "S'ha actualitzat amb èxit!", + "UpgradePiwik": "Actualitza el Piwik", + "VerifyingUnpackedFiles": "S'estan verificant els fitxers", + "WarningMessages": "Avisos:", + "WeAutomaticallyDeactivatedTheFollowingPlugins": "S'han desactivat automàticament els connectors següents: %s", + "YouCanUpgradeAutomaticallyOrDownloadPackage": "Podeu actualitzar a la versió %s automàticament o baixar-vos el paquet i instal·lar-lo manualment.", + "YouCouldManuallyExecuteSqlQueries": "Si no podeu executar l'actualització desde la línia de comandes i el falla l'actualització del Piwik (degut a un temps d'espera massa elevat, o algún altre problema) podeu executar manualment les sentències SQL per actualitzar el Piwik.", + "YouMustDownloadPackageOrFixPermissions": "El Piwik no pot sobrescriure la instal·lació actual. Podeu corregir els permisos dels fitxers\/directoris o descarregar el paquet i instal·lar la versió %s manualment:", + "YourDatabaseIsOutOfDate": "La base de dades del Piwik és antiga i cal actualitzar-la abans de continuar." + }, + "CustomVariables": { + "ColumnCustomVariableName": "Nom de la variable personalitzada", + "ColumnCustomVariableValue": "Valor de la variable personalitzada", + "CustomVariables": "Variables personalitzades", + "CustomVariablesReportDocumentation": "Aquest informe conté informació sobre les variables personaltizades. Feu click al nom d'una variable per veure la distribució dels valors. %s Per més informació sobre les variables personalitzades, podeu llegir la %sdocumentació sobre variables peronalitzades a piwik.org%s", + "PluginDescription": "Les variables personalitzades són parelles de nom,valor que podeu assignar a una visita utilitzant la funció setVisitCustomVariables() de l'API de Javascript. El piwik us reportarà quantes visites, pàgines, conversions heu tingut per cada un d'aquest noms i valors personaltizats.", + "ScopePage": "àmbit de la pàgina", + "ScopeVisit": "àmbit de la visita", + "TrackingHelp": "Ajuda: %1$s Rastrejant Variables Personalitzades amb Piwik%2$s" + }, + "Dashboard": { + "AddAWidget": "Afegir un Giny", + "AddPreviewedWidget": "Afegeix el giny al tauler", + "ChangeDashboardLayout": "Canviar el disseny del tauler", + "CopyDashboardToUser": "Copiar tauler a l'usuari", + "CreateNewDashboard": "Crear un nou tauler", + "Dashboard": "Tauler", + "DashboardCopied": "S'ha copiat correctament el tauler a l'usuari seleccionat", + "DashboardEmptyNotification": "El vostre tauler no conté cap giny. Comenceu per afegir alguns ginys o restablieu el tauler a la selecció de ginys per defecte.", + "DashboardName": "Nom del tauler:", + "DashboardOf": "Tauler de %s", + "DefaultDashboard": "Tauler per defecte - Utilitzant la selecció de ginys i l'estructura de columnes per defecte", + "DeleteWidgetConfirm": "Realment voleu esborrar aquest giny?", + "EmptyDashboard": "Tauler buit - Seleccioneu els vostres ginys preferits", + "LoadingWidget": "S'està carregant el giny…", + "ManageDashboard": "Administrar tauler", + "Maximise": "Maximitza", + "Minimise": "Minimitza", + "NotUndo": "No podreu desfer aquesta operació", + "PluginDescription": "El teu tauler d'analítica web. Podeu personalitzar el vostre tauler: afegir nous ginys, canviar l'ordre dels ginys. Cada usuari pot accedir al seu propi tauler personalitzat.", + "RemoveDashboard": "Elimina el tauler", + "RemoveDashboardConfirm": "Esteu segurs que voleu eliminar el tauler \"%s\"?", + "RenameDashboard": "Reanomena el tauler", + "ResetDashboard": "Reinicialitza el tauler", + "ResetDashboardConfirm": "Esteu segurs que voleu reiniciar la disposició del vostre tauler a la selecció de ginys per defecte?", + "SelectDashboardLayout": "Seleccioneu la nova disposició del vostre tauler", + "SelectWidget": "Escolliu el giny que voleu afegir a la consola", + "SetAsDefaultWidgets": "Marca com a selecció de ginys per defecte.", + "SetAsDefaultWidgetsConfirm": "Esteu segurs que voleu definir la selecció de ginys i la disposició del tauler actuals per a la plantilla de tauler per defecte?", + "SetAsDefaultWidgetsConfirmHelp": "Aquesta selecció de ginys i la disposició de columnes del tauler es faran servir quan qualsevol usuari crei un nou tauler o quan s'utiltiza la funció \"%s\".", + "TopLinkTooltip": "Mostra els informes d'analítica web per %s.", + "WidgetNotFound": "Aquest giny no s'ha trobat", + "WidgetPreview": "Previsualitza el giny", + "WidgetsAndDashboard": "Tauler & Ginys" + }, + "Feedback": { + "ContactThePiwikTeam": "Contacta l'equip de Piwik!", + "DoYouHaveBugReportOrFeatureRequest": "Teniu algun error que reportar o una petició de noves funcionalitats?", + "IWantTo": "Jo vull:", + "LearnWaysToParticipate": "Apren sobre les formes en que tu pots %s participar %s", + "ManuallySendEmailTo": "Siusplau envia el vostre missatge manualmetn a", + "PluginDescription": "Envia el teu Feedback a l'equip de Piwik. Comprateix les teves idees i sugestions amb nosaltres!", + "SendFeedback": "Enviar Feedback", + "SpecialRequest": "Teniu una sol·licitud especial per l'equip de Piwik?", + "ThankYou": "Gràcies per ajudar-nos a fer el Piwik millor!", + "TopLinkTooltip": "Digue'ns que en penses o demana suport Professional.", + "VisitTheForums": "Entra als %s Forums%s" + }, + "General": { + "AbandonedCarts": "Cistelles abandonades", + "AboutPiwikX": "Sobre Piwik %s", + "Action": "Acció", + "Actions": "Accions", + "Add": "Afegir", + "AfterEntry": "després d'entrar aquí", + "AllowPiwikArchivingToTriggerBrowser": "Permetre que l'arxivat del Piwik es dispari quan els informes es veuen des del navegador", + "AllWebsitesDashboard": "Tauler de tots els llocs web", + "API": "API", + "ApplyDateRange": "Aplicar un rang de dates", + "ArchivingInlineHelp": "Per llocs amb transit entre mig i alt, es recomana desactivar l'arxivat del Piwik des del navegador. En canvi, recomanem que configureu una tasca de cron per processar els informes de Piwik cada hora.", + "ArchivingTriggerDescription": "Recomanat per instal·lacions grans de Piwik, es pot %sconfigurar una %stasca programada per processar les entrades automàticament.", + "AuthenticationMethodSmtp": "Mètode d'autenticació SMTP", + "AverageOrderValue": "Valor mig de les comandes", + "AveragePrice": "Preu mig", + "AverageQuantity": "Qualitat mitja", + "BackToPiwik": "Torna al Piwik", + "Broken": "Trencat", + "BrokenDownReportDocumentation": "Està separada en diferents informes, que es mostren amb línies de punts al peu de la pàgina. Podeu augmentar aquests gràfics clicant en l'informe que voleu veure.", + "Cancel": "Cancel·lar", + "CannotUnzipFile": "No es pot descomprimir el fitxer %1$s: %2$s", + "ChangePassword": "Canvia la contrasenya", + "ChangeTagCloudView": "Si us plau, tingueu en compte que podeu veure l'informe en altres formes que en nuvol d'etiquetes. Feu servir els controls al peu de l'informe per fer-ho.", + "ChooseDate": "Triar data", + "ChooseLanguage": "Tria idioma", + "ChoosePeriod": "Triar període", + "ChooseWebsite": "Triar lloc web", + "ClickHere": "Fes clic aquí per més informació.", + "ClickToChangePeriod": "Torneu a clickar per canviar el període", + "Close": "Tanca", + "ColumnActionsPerVisit": "Accions per visita", + "ColumnActionsPerVisitDocumentation": "El número mig d'accions (pàgines vistes, descarregues o enllaços de sortida) que s'han fet durant les visites.", + "ColumnAverageTimeOnPage": "Temps mig a la pàgina", + "ColumnAverageTimeOnPageDocumentation": "La mitjana de temps que els visitants s'han passat en aquesta pàgina (només la pàgina no el lloc enter).", + "ColumnAvgTimeOnSite": "Temps mitjà per visita", + "ColumnAvgTimeOnSiteDocumentation": "La durada mitja de una visita.", + "ColumnBounceRate": "Raó de rebots", + "ColumnBounceRateDocumentation": "El percentatge de visites que només han vist una pàgina. Això significa, que el visitant ha marxat del lloc web directament per la pàgina d'entrada.", + "ColumnBounceRateForPageDocumentation": "El percentatge de visites que han acabat en aquesta pàgina.", + "ColumnBounces": "Rebots", + "ColumnBouncesDocumentation": "El número de visites que han començat i acabat en aquesta pàgina. Això significa que la visita ha deixat el lloc web després de veure només aquesta pàgina.", + "ColumnConversionRate": "Tarifa de conversió", + "ColumnConversionRateDocumentation": "El percentatge de visites que han disparat una conversió d'objectiu.", + "ColumnDestinationPage": "Pàgina destí", + "ColumnEntrances": "Entrades", + "ColumnEntrancesDocumentation": "Número de visites que han començat en aquesta pàgina.", + "ColumnExitRate": "Taxa de sortida", + "ColumnExitRateDocumentation": "El percentatge de visites que han deixat el lloc web després de veure aquesta pàgina.", + "ColumnExits": "Sortides", + "ColumnExitsDocumentation": "Número de visites que acaben en aquesta pàgina.", + "ColumnKeyword": "Paraula clau", + "ColumnLabel": "Etiqueta", + "ColumnMaxActions": "Accions màximes en una visita", + "ColumnNbActions": "Accions", + "ColumnNbActionsDocumentation": "El número d'accions que han realitzat els vostres visitant. Les accions poden ser pàgines vistes, descarregues o enllaços externs.", + "ColumnNbUniqVisitors": "Visitants únics", + "ColumnNbUniqVisitorsDocumentation": "El número de visitant únics que han vingut al vostre lloc web. Cada usuari es compta només una vegada, encara que visiti el lloc web més d'un cop al dia.", + "ColumnNbVisits": "Visites", + "ColumnNbVisitsDocumentation": "Si la visita arriba al vostre lloc web per primera vegada o si obre una pàgina després de més de 30 minuts sense activitat, es comptarà com una nova visita.", + "ColumnPageBounceRateDocumentation": "El percentatge de visites que han començat en aquesta pàgina i han deixat el lloc web directament.", + "ColumnPageviews": "Visualitzacions de pàgina", + "ColumnPageviewsDocumentation": "El número de vegades que s'ha visitat aquesta pàgina.", + "ColumnPercentageVisits": "% Visites", + "ColumnRevenue": "Ingressos", + "ColumnSumVisitLength": "Temps total acumulat pels visitants (en segons)", + "ColumnTotalPageviews": "Total de pàgines vistes", + "ColumnUniqueEntrances": "Entrades úniques", + "ColumnUniqueExits": "Sortides úniques", + "ColumnUniquePageviews": "Visualitzacions de pàgina úniques", + "ColumnUniquePageviewsDocumentation": "El número de visites que incloïen aquesta pàgina. Si una pàgina s'ha vist múltiples vegades durant una visita, només es compta un cop.", + "ColumnValuePerVisit": "Valor per visita", + "ColumnViewedAfterSearch": "Clicat als resultats de cerca", + "ColumnViewedAfterSearchDocumentation": "El nombre de vegades que aquesta pàgina s'ha visitat desprès de que el visitant faigi una cerca al vostre lloc web i haigi clicat en aquesta pàgina als resultats.", + "ColumnVisitDuration": "Durada de la visita (en segons)", + "ColumnVisitsWithConversions": "Visites amb conversions", + "ConfigFileIsNotWritable": "El fitxer de configuració del Piwiki %s no es pot modificar, alguns dels canvis que has fet no es guardaran. Si us plau %s canvia els permisos del fitxer de configuració per tal que es pugui modificar.", + "Continue": "Continuar", + "ContinueToPiwik": "Vés cap al Piwik", + "CurrentMonth": "Mes actual", + "CurrentWeek": "Setmana actual", + "CurrentYear": "Any actual", + "Daily": "Diariament", + "DailyReports": "Informe diari", + "DailySum": "suma diaria", + "DashboardForASpecificWebsite": "Tauler per un lloc web concret", + "DataForThisGraphHasBeenPurged": "Les dades per aquest gràfic tenen més de %s mesos d'antiguitat i s'han purgat", + "DataForThisTagCloudHasBeenPurged": "Les dades per aquest nuvol d'etiquetes tenen més de %s mesos i s'han purgat.", + "Date": "Data", + "DateRange": "Rang de dates:", + "DateRangeFrom": "De", + "DateRangeFromTo": "De %s fins a %s", + "DateRangeInPeriodList": "Rang de dates", + "DateRangeTo": "Fins", + "DayFr": "dv", + "DayMo": "dl", + "DaySa": "ds", + "DaysHours": "%1$s dies %2$s hores", + "DaysSinceFirstVisit": "Dies des de la primera visita", + "DaysSinceLastEcommerceOrder": "Dies des de la última comanda d'e-commerce", + "DaysSinceLastVisit": "Dies des de l'última visita", + "DaySu": "dg", + "DayTh": "dj", + "DayTu": "dt", + "DayWe": "dc", + "Default": "Per defecte", + "Delete": "Esborra", + "Description": "Descripció", + "Desktop": "Escriptori", + "Details": "Detalls", + "Discount": "Descompte", + "DisplaySimpleTable": "Mostrar una taula simple", + "DisplayTableWithGoalMetrics": "Mostrar una taula amb paràmetres globals", + "DisplayTableWithMoreMetrics": "Mostrar una taula amb més paràmetres", + "Donate": "Donar", + "Done": "Fet", + "Download": "Descàrrega", + "DownloadFail_FileExists": "El fitxer %s ja existeix.", + "DownloadFail_FileExistsContinue": "Provant de continuar la descàrrega de %s, però ja existeix un fitxer completament descarregat.", + "DownloadFail_HttpRequestFail": "No s'ha pogut descarregar el fitxer! Alguna cosa no ha anat bé amb el lloc web d'on esteu descarregant. Podeu provar-ho més tard o obtenir el fitxer per vosaltres mateixos.", + "DownloadFullVersion": "%1$sDescarrega%2$s la versió completa! Ves a %3$s", + "DownloadPleaseRemoveExisting": "Si voleu que es sobreescrigui, elimineu el fitxer existent.", + "Downloads": "Descàrregues", + "EcommerceOrders": "Comandes d'e-commerce", + "EcommerceVisitStatusDesc": "Visita l'estat de l'e-commerce al final de la visita", + "EcommerceVisitStatusEg": "Per exempel, seleccionar totes les visites que han fet una comanda d'e-commerce, la petició de l'API tindrà %s", + "Edit": "Edita", + "EncryptedSmtpTransport": "Entreu el xifrat de la capa de transport requerit per el vostre servidor SMTP.", + "EnglishLanguageName": "Catalan", + "Error": "Error", + "ErrorRequest": "Ups! Hi ha hagut un problema amb la sol·licitud, torneu-ho a intentar.", + "EvolutionOverPeriod": "Evolució del període", + "ExceptionConfigurationFileNotFound": "El fitxer de configuració {%s} no s'ha trobat.", + "ExceptionDatabaseVersion": "La teva %1$s versió és %2$s però el Piwik necessita com a mínim %3$s", + "ExceptionFileIntegrity": "Ha fallat la verificació de integritat: %s", + "ExceptionFilesizeMismatch": "Mida de fitxer incohoerent: %1$s (mida esperada: %2$s, real: %3$s)", + "ExceptionIncompatibleClientServerVersions": "El vostre client %1$s té la versió %2$s que és incompatbile amb la versió de servidor %3$s.", + "ExceptionInvalidAggregateReportsFormat": "El format de informes agregats '%s' no és vàlid. Proveu-ne algun dels següents en el seu lloc: %s.", + "ExceptionInvalidArchiveTimeToLive": "El temps límit per fer l'arxivat avui ha de ser més gran que zero.", + "ExceptionInvalidDateFormat": "El format de data ha de ser: %s o una altra paraula clau suportada per la funció %s (vegeu %s per més informació)", + "ExceptionInvalidDateRange": "La data '%s' no és un rang correcte de data. Hauria de tenir el format següent: %s.", + "ExceptionInvalidPeriod": "El període '%s' no està suportat. Proveu-ne algun dels següents en el seu lloc: %s.", + "ExceptionInvalidRendererFormat": "El format generador '%s' no és vàlid. Proveu-ne un dels següents en el seu lloc: %s.", + "ExceptionInvalidReportRendererFormat": "El format de l'informe '%s' no és vàlid. Proveu-ne un dels següent en el seu lloc: %s.", + "ExceptionInvalidStaticGraphType": "El gràfic estàtic tipus '%s' no és vàlid. Proveu-ne un dels següents en el seu lloc: %s.", + "ExceptionInvalidToken": "El token no és vàlid.", + "ExceptionLanguageFileNotFound": "El fitxer d'idioma '%s' no s'ha trobat.", + "ExceptionMethodNotFound": "El mètode '%s' no existeix o no està disponible en el mòdul '%s'.", + "ExceptionMissingFile": "Falta fitxer: %s", + "ExceptionNonceMismatch": "No s'ha pogut verificar el token del formulari.", + "ExceptionPrivilege": "No podeu accedir a aquest recurs perquè requereix un accés de %s", + "ExceptionPrivilegeAccessWebsite": "No podeu accedir a aquest recurs perquè requereix un accés %s per el lloc web id = %d.", + "ExceptionPrivilegeAtLeastOneWebsite": "No podeu accedir a aquest recurs perquè requereix un accés %s .per al menys un lloc web", + "ExceptionUnableToStartSession": "No s'ha pogut començar la sessió.", + "ExceptionUndeletableFile": "No s'ha pogut esborrar %s", + "ExceptionUnreadableFileDisabledMethod": "El fitxer de configuració {%s} no s'ha pogut llegir. El vostre host pot tenir deshabilitat %s.", + "Export": "Exporta", + "ExportAsImage": "Exportar com a imatge", + "ExportThisReport": "Guardar aquestes dades en altres formats", + "Faq": "PMF", + "FileIntegrityWarningExplanation": "La verificació de la integritat dels fitxers ha fallat i ha trobat alguns errors. Això segurament és causa de una carrega parcial o incorrecta dels fitxers del Piwik. Haurieu de tornar a pujar tots els fitxers del Piwik en mode BINARY i refrescar aquesta pàgina fins que no mostri errors.", + "First": "Primer", + "ForExampleShort": "p.ex.", + "FromReferrer": "de", + "GeneralInformation": "Informació General", + "GeneralSettings": "Paràmetres generals", + "GetStarted": "Comenceu", + "GiveUsYourFeedback": "Què penseu del Piwik?", + "Goal": "Objectiu", + "GoTo": "Anar a %s", + "GraphHelp": "Més informació sobre mostrar gràfiques al Piwik.", + "HelloUser": "Hola, %s!", + "Help": "Ajuda", + "HoursMinutes": "%1$s hores %2$s min", + "Id": "Id", + "IfArchivingIsFastYouCanSetupCronRunMoreOften": "Assumint que l'arxivat és ràpid en el vostre entorn, podeu programar el crontab per funcionar més freqüentment.", + "InfoFor": "Informació per %s", + "Installed": "Instal·lat", + "InvalidDateRange": "Rang de dates no vàlid, si us plau proveu-ho de nou", + "InvalidResponse": "Les dades rebudes són invàlides.", + "JsTrackingTag": "Etiqueta de seguiment JavaScript", + "Language": "Idioma", + "LastDays": "Últims %s dies (incloent avui)", + "LastDaysShort": "Últims %s dies", + "LayoutDirection": "ltr", + "Loading": "Carregant…", + "LoadingData": "Les dades s'estan carregant…", + "LoadingPopover": "Carregant %s...", + "LoadingPopoverFor": "Carregant %s per", + "Locale": "ca_ES.UTF-8", + "Logout": "Surt", + "LongDay_1": "dilluns", + "LongDay_2": "dimarts", + "LongDay_3": "dimecres", + "LongDay_4": "dijous", + "LongDay_5": "divendres", + "LongDay_6": "dissabte", + "LongDay_7": "diumenge", + "LongMonth_1": "gener", + "LongMonth_10": "octubre", + "LongMonth_11": "novembre", + "LongMonth_12": "desembre", + "LongMonth_2": "febrer", + "LongMonth_3": "març", + "LongMonth_4": "abril", + "LongMonth_5": "maig", + "LongMonth_6": "juny", + "LongMonth_7": "juliol", + "LongMonth_8": "agost", + "LongMonth_9": "setembre", + "MainMetrics": "Mètriques principals", + "MediumToHighTrafficItIsRecommendedTo": "Per llocs de mig i alt transit, recomanem processar informes per avui com a molt cada mitja hora (%s segons) o cada hora (%s segons)", + "Metadata": "Metadata", + "Metric": "Mètrica", + "Metrics": "Mètriques", + "MetricsToPlot": "Valors per graficar", + "MetricToPlot": "Mètrica a mostrar", + "MinutesSeconds": "%1$s min %2$ss", + "Mobile": "Mòbil", + "Monthly": "Mensualment", + "MonthlyReports": "Informe mensual", + "MultiSitesSummary": "Tots els llocs web", + "Name": "Nom", + "NbActions": "Número d'accions", + "NbSearches": "Nombre de cerques internes", + "NDays": "%s dies", + "Never": "Mai", + "NewReportsWillBeProcessedByCron": "Quan l'arxivat del piwik no es dispara per el navegador, els nous informes es processaran per el crontab.", + "NewUpdatePiwikX": "Nova actualització: Piwik %s", + "NewVisitor": "Nova visita", + "NewVisits": "Noves visites", + "Next": "Següent", + "No": "No", + "NoDataForGraph": "No hi ha dades...", + "NoDataForTagCloud": "No hi ha dades...", + "NotDefined": "%s sense definir", + "Note": "Nota", + "NotInstalled": "No instal·lat", + "NotRecommended": "(no recomanat)", + "NotValid": "%s no és vàlid", + "NSeconds": "%s segons", + "NumberOfVisits": "Número de visites", + "NVisits": "%s visites", + "Ok": "D'acord", + "OneDay": "1 dia", + "OneVisit": "1 visita", + "OnlyEnterIfRequired": "Només entreu un usuari si el vostre servidor SMTP ho requereix.", + "OnlyEnterIfRequiredPassword": "Només entreu una contrasenya si el vostre servidor STMP ho requereix.", + "OnlyUsedIfUserPwdIsSet": "Només es fa servir si l'usuari\/contrasenya estan configurats, pregunteu al vostre proveïdor si no esteu segur de quin mètode fer servir.", + "OpenSourceWebAnalytics": "Anàlisi web de codi obert", + "OptionalSmtpPort": "Opcional. Per defecte té el valor 25 per no xifrat i TLS SMTP, i 465 per SSL SMTP.", + "Options": "Opcions", + "OrCancel": "o %s cancel·la %s", + "OriginalLanguageName": "Català", + "Others": "Altres", + "Outlink": "Enllaç extern", + "Outlinks": "Enllaços externs", + "OverlayRowActionTooltip": "Mostrar la informació analítica directament al vostre lloc web (obre una nova pestanya)", + "OverlayRowActionTooltipTitle": "Obre una pàgina superposada", + "Overview": "Resum", + "Pages": "Pàgines", + "ParameterMustIntegerBetween": "El paràmetre %s ha de ser un enter entre %s i %s", + "Password": "Contrasenya", + "Period": "Període", + "Piechart": "Gràfic de sectors", + "PiwikXIsAvailablePleaseUpdateNow": "Hi ha una nova versió del Piwik, %1$s, disponible! %2$s Si us plau, actualitzeu ara!%3$s (%4$s Mostra els canvis%5$s)", + "PleaseSpecifyValue": "Si us plau especifiqueu el valor per '%s'", + "PleaseUpdatePiwik": "Si us plau actualitzeu el Piwik", + "Plugin": "Connector", + "Plugins": "Connectors", + "PoweredBy": "Funcionant amb", + "Previous": "Anterior", + "PreviousDays": "%s dies anteriors (sense incloure avui)", + "PreviousDaysShort": "Anteriors %s dies", + "Price": "Preu", + "ProductConversionRate": "Taxa de conversió del producte", + "ProductRevenue": "Ingressos de productes", + "PurchasedProducts": "Productes comprats", + "Quantity": "Quantitat", + "RangeReports": "Rang de data personalitzat", + "Recommended": "(recomanat)", + "RecordsToPlot": "Entrades a mostrar", + "Refresh": "Actualitza", + "RefreshPage": "Actualitza la pàgina", + "RelatedReport": "Informe relacionat", + "RelatedReports": "Informes relacionats", + "Remove": "Elimina", + "Report": "Informe", + "ReportGeneratedFrom": "Aquest informe s'ha generat fent servir dades de %s.", + "Reports": "Informes", + "ReportsContainingTodayWillBeProcessedAtMostEvery": "Informes per avui (o un altre rang de dates incloent avui) es processaran com a molt cada", + "ReportsWillBeProcessedAtMostEveryHour": "El informes per tant es processaran com a molt cada hora.", + "RequestTimedOut": "Una petició a %s ha caducat. Proveu-ho de nou.", + "Required": "%s requerit", + "ReturningVisitor": "Visita que retorna", + "ReturningVisitorAllVisits": "Veure totes les visites", + "RowEvolutionRowActionTooltip": "Observeu com les mètriques d'aquesta fila han canviat durant el pas del temps", + "RowEvolutionRowActionTooltipTitle": "Obrir l'evolució de la fila", + "Rows": "Files", + "RowsToDisplay": "Files a mostrar", + "Save": "Desa", + "SaveImageOnYourComputer": "Per guardar la imatge al vostre ordinador, feu click amb el botó dret sobre la imatge i trieu \"Guardar la imatge com..\"", + "Search": "Cerca", + "Seconds": "%ss", + "SeeTheOfficialDocumentationForMoreInformation": "Vegeu la %sinformació oficial%s per més informació.", + "SelectYesIfYouWantToSendEmailsViaServer": "Trieu \"si\" si voleu o heu d'enviar el correu a través de un servidor enlloc de a través de la funció mail local.", + "Settings": "Tauler de control", + "Shipping": "Enviament", + "ShortDay_1": "dl", + "ShortDay_2": "dt", + "ShortDay_3": "dc", + "ShortDay_4": "dj", + "ShortDay_5": "dv", + "ShortDay_6": "ds", + "ShortDay_7": "dg", + "ShortMonth_1": "Gen", + "ShortMonth_10": "Oct", + "ShortMonth_11": "Nov", + "ShortMonth_12": "Des", + "ShortMonth_2": "Feb", + "ShortMonth_3": "Mar", + "ShortMonth_4": "Abr", + "ShortMonth_5": "Mai", + "ShortMonth_6": "Jun", + "ShortMonth_7": "Jul", + "ShortMonth_8": "Ago", + "ShortMonth_9": "Set", + "SingleWebsitesDashboard": "Tauler per un sol lloc web", + "SmallTrafficYouCanLeaveDefault": "Per llocs amb poc transit, pots deixar el valor per defecte de %s segons i accedir als informes en temps real.", + "SmtpEncryption": "encriptació SMTP", + "SmtpPassword": "contrasenya SMTP", + "SmtpPort": "Port SMTP", + "SmtpServerAddress": "adreça del servidor SMTP", + "SmtpUsername": "usuari SMTP", + "Subtotal": "Subtotal", + "Table": "Taula", + "TagCloud": "Núvol d'etiquetes", + "Tax": "Impostos", + "TimeOnPage": "Temps a la pàgina", + "Today": "Avui", + "Total": "Total", + "TotalRevenue": "Total Ingressos", + "TotalVisitsPageviewsRevenue": "(Total: %s visites, %s visualitzacions de pàgina, %s ingressos)", + "TransitionsRowActionTooltip": "Observar que van fer els visitants abans i desprès de veure aquesta pàgina", + "TransitionsRowActionTooltipTitle": "Obre les transicions", + "TranslatorEmail": "isb1009 [at] [don't write this] astronomipedia [dot] es,jjuvan@grn.cat", + "TranslatorName": "Isaac Sánchez Barrera, Joan Juvanteny", + "UniquePurchases": "Compres úniques", + "Unknown": "Desconegut", + "Upload": "Càrrega", + "UsePlusMinusIconsDocumentation": "Fes servir les icones de més i menys a l'esquerra per navegar.", + "Username": "Usuari", + "UseSMTPServerForEmail": "Fer servir un servidor SMTP per el correu", + "Value": "Valor", + "VBarGraph": "Gràfic de barres", + "View": "Vista", + "Visit": "Visita", + "VisitConvertedGoal": "La visita ha convertit al menys un objectiu", + "VisitConvertedGoalId": "La visita ha convertit un id d'objectiu especific", + "VisitConvertedNGoals": "La visita ha convertit %s objectius", + "VisitDuration": "Temps mig de visita (en segons)", + "Visitor": "Visita", + "VisitorID": "ID del visitant", + "VisitorIP": "Ip del visitant", + "Visitors": "Visitants", + "VisitsWith": "Visites amb %s", + "VisitType": "Tipus de visitant", + "VisitTypeExample": "Per exemple, per seleccionar tots els visitants que han retornat al lloc web, incloent els que han comprat alguna cosa en anteriors visites, la petició de l'API tindria %s", + "Warning": "Avís", + "WarningFileIntegrityNoManifest": "La verificació de la integritat dels fitxers no s'ha pogut fer perquè falta el manifest.inc.php.", + "WarningFileIntegrityNoMd5file": "La verificació de la integritat dels fitxers no s'ha pogut completar perquè falta la funció md5_file();", + "WarningPasswordStored": "%sAlerta:%s Aquesta contrasenya es guardarà en un fitxer de configuració visible on tothom pot accedir.", + "Website": "Lloc web", + "Weekly": "Setmanalment", + "WeeklyReport": "setmanal", + "WeeklyReports": "Informe setmanal", + "Widgets": "Ginys", + "YearlyReports": "Informe anual", + "YearsDays": "%1$s anys %2$s dies", + "Yes": "Sí", + "Yesterday": "Ahir", + "YouAreCurrentlyUsing": "Ara mateix estàs fent servir el Piwik %s.", + "YouAreViewingDemoShortMessage": "Ara mateix estàs veient la demo del Piwik", + "YouMustBeLoggedIn": "Has d'entrar per accedir a aquesta funcionalitat.", + "YourChangesHaveBeenSaved": "Els vostres canvis s'han guardat" + }, + "Goals": { + "AbandonedCart": "Cistella abandonada", + "AddGoal": "Afegir un objectiu", + "AddNewGoal": "Afegir un nou objectiu", + "AddNewGoalOrEditExistingGoal": "%sAfegeix un nou objectiu%s o %sEdita objectius existents%s", + "AllowGoalConvertedMoreThanOncePerVisit": "Permetre convertir un objectiu més d'una vegada per visita", + "AllowMultipleConversionsPerVisit": "Permetre múltiples conversions per visita", + "BestCountries": "Els millors paísos amb conversions són:", + "BestKeywords": "Les paraules clau amb més conversions són:", + "BestReferrers": "Els llocs webs de referència amb més conversions són:", + "CaseSensitive": "Concidència sensible a minúscules\/majúscules", + "ClickOutlink": "Click a un enllaç a un lloc web extern", + "ColumnAverageOrderRevenueDocumentation": "La Mitja de Valor d'una Commanda (MVC) és el nombre total d'ingressos de totes les comandes electròniques dividit pel nombre de comandes.", + "ColumnAveragePriceDocumentation": "La mitja d'ingresos per aquest %s.", + "ColumnAverageQuantityDocumentation": "La quantitat mitja d'aquest %s venut a les comandes Ecommerce.", + "ColumnConversionRateDocumentation": "El percentatge de visites que han arribat a l'objectiu %s.", + "ColumnConversionRateProductDocumentation": "El %s rati de conversió és el nombre de comandes que contenen aquest producte dividit pel nombre de visites a la pàgina del producte.", + "ColumnConversions": "Conversions", + "ColumnConversionsDocumentation": "El nombre de conversions per %s", + "ColumnOrdersDocumentation": "El nombre total de comandes de compra que contenen aquest %s almenys una vegada.", + "ColumnPurchasedProductsDocumentation": "El nombre de productes comprats és la suma de les quantitats de productes que s'han venut en els comandes.", + "ColumnQuantityDocumentation": "La quantitat és el nombre total de productes que s'han venut per cada %s.", + "ColumnRevenueDocumentation": "Ingressos totals generats per %s.", + "ColumnRevenuePerVisitDocumentation": "Els ingresos totals generats per %s dividit pel nombre de visites.", + "ColumnVisits": "El nombre total de visites, sense tenir en compte si s'ha assolit l'objectiu o no.", + "ColumnVisitsProductDocumentation": "El nombre de visites a la pagina del Producte\/Categoria. S'utiltiza per calcular el rati de conversió del %s. Aquesta mètrica està a l'informe si", + "Contains": "conté %s", + "ConversionByTypeReportDocumentation": "Aquest informe proporciona informació detallada sobre la productivitat dels objectius (conversions, rati de conversións i ingresos per visita) per cada una de les catagories disponibles al panell de l'esquerra. %s Cliqueu alguna de les categories per veure l'informe. %s Per mes informació, llegiu %s la documetnació sobre el rastreig d'objectius a piwik.org%s", + "ConversionRate": "Rati de coversió de %s", + "Conversions": "%s conversions", + "ConversionsOverview": "Vista general de les conversions", + "ConversionsOverviewBy": "Vista general de les conversións per tipus de visita", + "CreateNewGOal": "Crear un nou objectiu", + "DaysToConv": "Dies per la conversió", + "DefaultGoalConvertedOncePerVisit": "(per defecte) Un objectiu només es pot assolir una vegada per visita.", + "DefaultRevenue": "Els ingresos de l'objectiu per defecte son", + "DefaultRevenueHelp": "Per exemple, un formulari de contacte emplenat per un visitant pot valdre 10€ de mitja. El Piwik t'ajuda a entendre com es comportent els segments de visitants.", + "DeleteGoalConfirm": "Esteu segurs que voleu eliminar l'objectiu %s?", + "DocumentationRevenueGeneratedByProductSales": "Ventes de productes. Sense impostos, despeses d'enviament ni descomptes", + "Download": "Descarrega un fitxer", + "Ecommerce": "Ecomerç", + "EcommerceAndGoalsMenu": "Ecomerç i Objectius", + "EcommerceLog": "Registre d'ecomerç", + "EcommerceOrder": "Ordre d'ecomerç", + "EcommerceOverview": "Vista general ecomerç", + "EcommerceReports": "Informes d'Ecommerce", + "ExceptionInvalidMatchingString": "Si escolleu 'coïncidencia exacta', el cadena de coincidència ha de ser una URL que comença per %s. Per exemple, '%s'.", + "ExternalWebsiteUrl": "URL del lloc web extern", + "Filename": "nom del fitxer", + "GoalConversion": "Conversió d'objectius", + "GoalConversions": "Conversió d'objectiu", + "GoalConversionsBy": "Conversións %s d'objectius per tipus de visita", + "GoalIsTriggered": "S'ha assolit l'objectiu", + "GoalIsTriggeredWhen": "S'asoleix l'objectiu quan", + "GoalName": "Nom de l'objectiu", + "Goals": "Objectius", + "GoalsManagement": "Gestió d'objectius", + "GoalsOverview": "Vista general d'objectius", + "GoalsOverviewDocumentation": "Això es una vista global de les conversions dels vostres objectius. Inicialment el gràfic mostra la suma de totes les conversions. %s Davalla del gràfic, podeu veure els informes de conversions per cada un dels vostres objectius. Els minigràfics es pot ampliar fent clic sobre ells.", + "GoalX": "Objectiu %s", + "HelpOneConversionPerVisit": "Si la pàgina de l'objectiu es refresca o es visita més d'una vegada en una sola visita l'objectiu només es contarà una vegada (la primera de totes).", + "IsExactly": "es exactament %s", + "LearnMoreAboutGoalTrackingDocumentation": "Apren més sobre %s Rastrejant objectius al Piwik%s a la documetnació d'usuari, o creeu un objectiu ara!", + "LeftInCart": "queda %s a la cistella", + "Manually": "manualment", + "ManuallyTriggeredUsingJavascriptFunction": "L'objectiu s'asoleix manualment utilitzat la funció trackGoal() de l'API de Javascript.", + "MatchesExpression": "compleix l'expresió %s", + "NewVisitorsConversionRateIs": "El rati de conversió dels nous visitants es %s", + "Optional": "(opcional)", + "OverallConversionRate": "%s rati de conversió global (visitants amb un objectiu complert)", + "OverallRevenue": "%s ingresssos globals", + "PageTitle": "Títol de la pàgina", + "Pattern": "Patró", + "PluginDescription": "Creeu Objectius i observeu informes sobre la conversió dels vostres objectius: evolució a través del temps, ingressos per visita, conversions per referent, per paraula clau, etc.", + "ProductCategory": "Categoria de Producte", + "ProductName": "Nom del producte", + "Products": "Productes", + "ProductSKU": "Referència del producte", + "ReturningVisitorsConversionRateIs": "EL rati de conversió dels visitants que retornen és %s", + "SingleGoalOverviewDocumentation": "Això es una visió global de les conversions d'un únic objectiu. %s Els minigràfics es poden ampliar fent clic sobre ells.", + "UpdateGoal": "Actualtizar objectiu", + "URL": "URL", + "ViewAndEditGoals": "Mostra i edita els objectius", + "ViewGoalsBy": "Mostra els objectius per %s", + "VisitPageTitle": "Visitar un pàgina amb el títol donat", + "VisitsUntilConv": "Visites convertides", + "VisitUrl": "Visitar una URL donada (pàgina o grup de pàgines)", + "WhenVisitors": "quan el visitant", + "WhereThe": "quan el", + "WhereVisitedPageManuallyCallsJavascriptTrackerLearnMore": "quan la pagina visitada conté una crida al mètode piwikTracker.trackGoal() de la API de Javascript (%s saber més %s)", + "YouCanEnableEcommerceReports": "Podeu activar el %s per aquest lloc web a la pàgina %s." + }, + "ImageGraph": { + "ColumnOrdinateMissing": "La columan '%s' no s'ha trobat en aquest informe. Proveu algun dels següents: %s", + "PluginDescription": "Generar belles imatges estàtiques en gràfic PNG per qualsevol informe del Piwik." + }, + "Installation": { + "CommunityNewsletter": "Envieu-me correus-e amb les actualitzacions de la comunitat (nous connectors, funcionalitats, etc.)", + "ConfigurationHelp": "Sembla que el vostre fitxer de configuració del Piwik no està definit correctament. Podeu eliminar el fitxer confi\/config.ini.php i torna a començar la instal·lació o corregir les preferències de connexió a la Base de dades.", + "ConfirmDeleteExistingTables": "Realment voleu esborrar les taules %s de la base de dades? AVÍS: NO ES PODRAN RECUPERAR LES DADES!", + "Congratulations": "Felicitats", + "CongratulationsHelp": "

    Felicitats! La instal·lació del Piwik ha finalitzat.<\/p>

    Assegureu-vos que heu inserit el codi JavaScript a totes les pàgines, i espereu els vostres primers visitants!<\/p>", + "DatabaseAbilities": "Habilitats de la Base de Dades", + "DatabaseCheck": "Revisió de la base de dades", + "DatabaseClientVersion": "Versió del client de base de dades", + "DatabaseCreation": "Creació de la base de dades", + "DatabaseErrorConnect": "Hi ha hagut un error amb la connexió al servidor de bases de dades.", + "DatabaseServerVersion": "Versió del servidor de base de dades", + "DatabaseSetup": "Configuració de la base de dades", + "DatabaseSetupAdapter": "Adaptador", + "DatabaseSetupDatabaseName": "Nom de la base de dades", + "DatabaseSetupLogin": "Usuari", + "DatabaseSetupServer": "Servidor de la base de dades", + "DatabaseSetupTablePrefix": "Prefix de les taules", + "Email": "Correu-e", + "ErrorInvalidState": "Hi ha hagut un error: sembla que esteu intentant saltar-vos un pas de la instal·lació, o teniu les galetes desactivades o el fitxer de configuració del Piwik ja existeix. %1$sAssegureu-vos que teniu les galetes activades%2$s i torneu enrera %3$s al primer pas de la instal·lació %4$s.", + "Extension": "extensió", + "Filesystem": "Sistema de fitxers", + "GoBackAndDefinePrefix": "Torna enrere i defineix un prefix per a les taules", + "Installation": "Instal·lació", + "InstallationStatus": "Estat de la instal·lació", + "InsufficientPrivilegesHelp": "Podeu afegir aquest usuaris previlegiats utiltizat eines com el phpMyAdmin o executant les sentències SQL corresponents. Si no sabeu com fer cap d'aquestes coses, siusplau poseu-vos amb contacte amb el vostre administrador de sistemes per donar aquestos privilegis per vosaltres.", + "LargePiwikInstances": "Ajuda per grans instàncies de Piwik", + "Legend": "Llegenda", + "LoadDataInfileRecommended": "Si el vostre servidor de Piwik gestiona llocs web amb un alt tràfic (p.e. > 100,000 pàgines per més), us recomaneu que proveu de sol·lucionar aquest problema.", + "LoadDataInfileUnavailableHelp": "Fent servir %1$s millorarà la velocitat del proces d'arxiu de Piwik. Per a que estigui disponible per a Piwik, proeu actualitzant el vostre doftwar PHP & MySQL i asegureu-vos que el vostre usuari de base de dades té el privilegi %2$s.", + "NfsFilesystemWarning": "El vostre servidor està utiltizant un sistema de fitxers NFS.", + "NfsFilesystemWarningSuffixAdmin": "Això vol dir que el Piwik serà extremadament lent quan utilitzi les sessions basades en fitxers.", + "NfsFilesystemWarningSuffixInstall": "Utilitzar sessions basades en fitxers amb NFS es extremadament lent, per això Piwik utilizarà les sessions de bases de dades. Si teniu molt usuaris concurrents, potser haureu d'incrementar el nombre de conexions concurrens al servidor de bases de dades.", + "NoConfigFound": "No s'ha trobat el fitxer de configuració del Piwik i esteu intentant accedir una pàgina del Piwik.
      »Podeu
    instal·lar el Piwik ara<\/a><\/strong>.
    Si heu instal·lat el Piwik abans i teniu algunes taules a la vostra base de dades, no us amoïneu; podeu continuar fent servir les mateixes taules i les dades existents es conservaran!<\/small>", + "Optional": "Opcional", + "Password": "Contrasenya", + "PasswordDoNotMatch": "Les contrasenyes no coincideixen", + "PasswordRepeat": "Torneu a escriure la contrasenya", + "PercentDone": "%s%% fet", + "PleaseFixTheFollowingErrors": "Siusplau corregiu els següents errors", + "PluginDescription": "Procès d'instal·lació del Piwik. El procès d'instal·lació només es dur a temre una vegada. Si el fitxer de configuració config\/config.inc.php s'esborrà es tornarà a iniciar la instal·lació.", + "Requirements": "Requeriments del Piwik", + "RestartWebServer": "Desprès de fer aquest canvi, reinicieu el vostre servidor web.", + "SecurityNewsletter": "Envieu-me correus-e sobre les actualitzacions grans del Piwik i les alertes de seguretat", + "SeeBelowForMoreInfo": "Llegiu a continuació per més informació.", + "SetupWebsite": "Configura un lloc", + "SetupWebsiteError": "Hi ha hagut un problema en el moment d'afegir el lloc.", + "SetupWebSiteName": "Nom del lloc web", + "SetupWebsiteSetupSuccess": "El lloc %s s'ha creat amb èxit!", + "SetupWebSiteURL": "URL del lloc web", + "SiteSetup": "Siusplau, configureu el primer lloc web que voleu rastrejar i anarlitzar amb el Piwik", + "SiteSetupFootnote": "Nota: Quan s'haigin finalitzat la instal·lació del Piwik podreu afegir més llocs webs per fer-n'he el seguiment.", + "SuperUser": "Superusuari", + "SuperUserLogin": "Usuari administrador principal", + "SuperUserSetupSuccess": "Superusuari creat correctament", + "SystemCheck": "Comprovació del sistema", + "SystemCheckAutoUpdateHelp": "Nota: L'actualització en un click del Piwik requereix permisos d'escriptura a la carpeta Piwik i el seu contingut.", + "SystemCheckCreateFunctionHelp": "El Piwik utiltiza funcions anònimes pels callbacks.", + "SystemCheckDatabaseHelp": "El Piwik necessita l'extensió mysqli o ambdues extensións PDO i pdo_mysql.", + "SystemCheckDebugBacktraceHelp": "View::factory no serà capaç de crear vistes per mòdul que la crida.", + "SystemCheckError": "Hi ha hagut un error i s'ha d'arreglar abans de que pugueu continuar.", + "SystemCheckEvalHelp": "Necessari per Sistema de plantilles Smarty i el HTML QuickForm", + "SystemCheckExtensions": "Altres extensions necessàries", + "SystemCheckFileIntegrity": "Integritat dels fitxers", + "SystemCheckFunctions": "Funcions necessàries", + "SystemCheckGDHelp": "Els gràfics petits no funcionaran.", + "SystemCheckGlobHelp": "Aquesta funció s'ha deshabilitat al vostre host. El Piwik probarà d'emular aquesta funció però pot trobar-se amb altres restriccions de seguretat. Això impactarà a la funcionalitat.", + "SystemCheckGzcompressHelp": "Heu d'activar l'extensió zlib i la funció gzcompress.", + "SystemCheckGzuncompressHelp": "Heu d'activar l'extensió zlib i la funcció gzuncompress.", + "SystemCheckIconvHelp": "Heu de configurar i tornar a contruir el PHP amb el suport per \"iconv\" activat, --with-iconv.", + "SystemCheckMailHelp": "Les opinions i els missatges de pèrdua de la contrassenya no s'enviaran sense la funció mail().", + "SystemCheckMbstring": "mbstring", + "SystemCheckMbstringExtensionGeoIpHelp": "També es necesàri per a que funcioni la integració amb GeoIP.", + "SystemCheckMbstringExtensionHelp": "L'extensió mbstring es necesària per caràcters multibyte a les respostes de la API que utilitzen valors separats per comes (CSV) o valors separats per tabulacions (TSV).", + "SystemCheckMbstringFuncOverloadHelp": "Heu de estabilir mbstring.func_overload a \"0\".", + "SystemCheckMemoryLimit": "Límit de memòria", + "SystemCheckMemoryLimitHelp": "En un lloc web amb trànsit elevat, la creació de l'arxiu pot necessitar més memòria de l'acceptada actualment.
    Feu una ullada al 'memory_limit' del vostre fitxer php.ini si és necessari.", + "SystemCheckOpenURL": "Obre l'adreça", + "SystemCheckOpenURLHelp": "Les subscripcions a les llistes de correu, notificacions d'actualització i actualitzacions en un clic necessiten l'extensió \"curl\", allow_url_fopen=On, o fsockopen() actiu.", + "SystemCheckOtherExtensions": "Altres extensions", + "SystemCheckOtherFunctions": "Altres funcions", + "SystemCheckPackHelp": "La funció pack() es necessària per fer el seguiment de visitants al Piwik.", + "SystemCheckParseIniFileHelp": "Aquesta funció ha estat deshabilitada al vostre host. El Piwik intentarà emular la seva funcionalitat però pot trobar altres restriccions de seguretat. El rendiment del seguiment es veurà impactat.", + "SystemCheckPdoAndMysqliHelp": "En un servidor Linux podeu compilar el php amb les següents opcions: %1$s Al fitxer php.ini, afegiu les línies: %2$s", + "SystemCheckPhp": "Versió del PHP", + "SystemCheckPhpPdoAndMysqli": "Trobareu més informació a %1$sPHP PDO%2$s i %3$sMYSQLI%4$s.", + "SystemCheckSecureProtocol": "Protocol segur", + "SystemCheckSecureProtocolHelp": "Sembla que esteu fen servir https amb el vostre servidor web. S'afegiran les següents línies al fitxer config\/config.ini.php:", + "SystemCheckSplHelp": "Heu de configurar i recompilar el PHP amb la biblioteca Standard PHP Library (SPL) activada (per defecte).", + "SystemCheckSummaryNoProblems": "Urra!! No hi ha cap problema amb la vostra configuració de Piwik.", + "SystemCheckSummaryThereWereErrors": "Ohhh! El piwik ha trobat algunes %1$s incidències crítiques %2$s amb la vostra configuració de Piwik. %3$s Aquestes incidències s'han de solucionar inmediatament. %4$s", + "SystemCheckSummaryThereWereWarnings": "Hi ha alguna incidència amb el vostre sistema. El Piwik funcionarà, però podeu tenir alguns problemes menors.", + "SystemCheckTimeLimitHelp": "En un lloc web amb trànsit elevat, la creació de l'arxiu pot necessitar més temps de l'acceptada actualment.
    Feu una ullada al 'max_execution_time' del vostre fitxer php.ini si és necessari.", + "SystemCheckTracker": "Estat del rastrejador", + "SystemCheckTrackerHelp": "La petició GET a piwik.php ha fallat. Proveu de posar a la llista d'autentificació HTTP aquesta URL i deshabilitar mod_security (potser heu de preguntar al vostre proveïdor web)", + "SystemCheckWarnDomHelp": "Heu d'habilitar l'extensió \"dom\" (p.e, instalar els paquets \"php-dom\" i\/o \"php-xml\").", + "SystemCheckWarning": "El Piwik funcionarà amb normalitat, però algunes funcions potser no estaran disponibles", + "SystemCheckWarnJsonHelp": "Heu d'instalar l'extensió \"json\" (p.e., instalar el paquet \"php-json\") per un millor rendiment.", + "SystemCheckWarnLibXmlHelp": "Heu d'activar l'extensió \"libxml\" (e.g., instalar el paquet \"php-libxml\") perquè es necesària per altres extensions del nucli de PHP.", + "SystemCheckWarnSimpleXMLHelp": "Heu d'activar l'extensió \"SimpleXML\" (p.e., installar el paquet \"php-simplexml\" i\/o \"php-xml\")", + "SystemCheckWinPdoAndMysqliHelp": "En un servidor Windows podeu afegir les línies següents al fitxer php.ini: %s", + "SystemCheckWriteDirs": "Directoris amb permisos d'escriptura", + "SystemCheckWriteDirsHelp": "Per a arreglar aquest error al vostre sistema Linux, proveu d'entrar les ordres següents", + "SystemCheckZlibHelp": "Heu de configurar i recompilar el PHP amb el suport per a \"zlib\" habilitat, --with-zlib.", + "Tables": "S'estan creant les taules…", + "TablesCreatedSuccess": "Les taules s'han creat amb èxit!", + "TablesDelete": "Esborra les taules existents", + "TablesDeletedSuccess": "Les taules del Piwik existents s'han esborrat amb èxit.", + "TablesFound": "Aquestes taules s'han trobat a la base de dades:", + "TablesReuse": "Fes servir les taules existents", + "TablesWarningHelp": "Podeu escollir entre fer servir les taules existents de la base de dades o fer una instal·lació neta per a esborrar les dades existents a la base de dades.", + "TablesWithSameNamesFound": "Algunes %1$s taules de la base de dades %2$s tenen el mateix nom que les taules que el Piwik intenta crear", + "Timezone": "Zona horaria del lloc web", + "Welcome": "Benvingut\/da!", + "WelcomeHelp": "

    El Piwik és un programari d'anàlisi web de codi obert que facilita la presa d'informació dels vostres visitants.<\/p>

    Aquest procés està dividit en %s passos fàcils i trigarà uns 5 minuts.<\/p>" + }, + "LanguagesManager": { + "AboutPiwikTranslations": "Quant a les traduccions del Piwik", + "PluginDescription": "Aquesta extensió mostrarà una llista dels idiomes disponibles per l'interfície de Piwik. L'idioma seleccionat es guardarà com a preferència per a cada usuari." + }, + "Live": { + "GoalType": "Tipus", + "KeywordRankedOnSearchResultForThisVisitor": "La paraula clau %1$s està a la posició %2$s del ranking de %3$s resultats de cerca per aquest visitant.", + "LastHours": "Últimes %s hores", + "LastMinutes": "Últims %s minuts", + "LinkVisitorLog": "Veure registre detallat del visitant", + "MorePagesNotDisplayed": "No es mostre més pàgines per aquest visitant", + "PageRefreshed": "Nombre de vegades que s'ha vist\/refrescat una pàgina", + "PluginDescription": "Mireu els vostres visitants en temps real!", + "Referrer_URL": "URL del referent", + "VisitorLog": "Registre de visitants", + "VisitorLogDocumentation": "Aquesta taula mostra les últimes visites pel període de temps seleccionat. Podeu veure quan s'ha produït l'últim accès d'un visitant pasant per damunt de la data de visita. %s Si el període de temps inclou avui, podeu veure els visitants en temps real! %s La informació que es mostra és sempre en directa, sense dependre de cada quan executeu el treball programat d'arxivat.", + "VisitorsInRealTime": "Visitants en temps real", + "VisitorsLastVisit": "L'ultima visita d'aquest visitant va ser fa %s dies." + }, + "Login": { + "ConfirmationLinkSent": "S'ha enviat un enllaç de confirmació a la vostra bústia d'entrada. Reviseu el vostre correu electrònic i visiteu l'enllaç per autoritzar la sol·licitud de canvi de password.", + "ContactAdmin": "Possiblement sigui perquè el vostre proveïdor d'allotjament ha desactivat la funció mail().
    Contacteu amb l'administrador del lloc.", + "ExceptionPasswordMD5HashExpected": "El paràmetre contrasenya hauria de ser un hash MD5 de la contrasenya.", + "InvalidNonceOrHeadersOrReferrer": "El formulari de seguretat ha fallat. Sisuplau recarregeu el formaulari i reviseu que teniu les cookies activades. Si feu servir un proxy, heu de %s configurar el Piwik per a que accepti la capcelera del proxy %s que conté la capçalera del host. També reviseu que el la vostra capçalera de referent s'envia correctament.", + "InvalidOrExpiredToken": "El codi és invàlid o ha caducat", + "InvalidUsernameEmail": "Aquest usuari i\/o direcció de correu-e és invàlid.", + "LogIn": "Inicia la sessió", + "LoginOrEmail": "Nom d'usuari o correu-e", + "LoginPasswordNotCorrect": "L'usuari o la contrasenya no són correctes", + "LostYourPassword": "Heu perdut la contrasenya?", + "MailPasswordChangeBody": "Hola %1$s,\n\nS'ha rebut una petició per restablir la contrasenya de %2$s. Per confirmar aquest canvi de contrasenya (per a que pogueu entrar amb les noves credencials), visiteu el següent enllaç:\n\n%3$s\n\nNota: aquest enllaç caduca en 24 hores.\n\nUs donem les gràcies per utiltizar el Piwik!", + "MailTopicPasswordChange": "Confirmeu el canvi de contrasenya", + "PasswordChanged": "S'ha canviat la vostra contrasenya", + "PasswordRepeat": "Contrasenya (torneu-la a escriure)", + "PasswordsDoNotMatch": "Les contrasenyes no coincideixen", + "RememberMe": "Recorda'm", + "ResetPasswordInstructions": "Introduiu una nova contrasenya pel vostre compte." + }, + "Mobile": { + "AccessUrlLabel": "Url d'access al Piwik", + "Accounts": "Comptes", + "AddAccount": "Afegir un compte", + "AddPiwikDemo": "Afegir una demo del Piwik", + "Advanced": "Avançat", + "AnonymousAccess": "Accès anònim", + "AnonymousTracking": "Rastreig anònim", + "ChooseHttpTimeout": "Seleccioneu el temps d'expiració HTTP", + "ChooseMetric": "Trieu una mètrica", + "ChooseReport": "Trieu un informe", + "DefaultReportDate": "Data de l'informe", + "EnableGraphsLabel": "Mostra gràfiques", + "EvolutionGraph": "Gràfic històric", + "HelpUsToImprovePiwikMobile": "T'agradaria activar el rastreig anònim de l'us del Piwik en el teu mòbil?", + "HowtoDeleteAnAccountOniOS": "Pasa de l'esquerra cap a la dreta per eliminar un compte", + "HttpTimeout": "Temps d'expiració HTTP", + "LastUpdated": "Ultima actualització: %s", + "LoginCredentials": "Credencials", + "LoginUseHttps": "Utilitza https", + "MultiChartLabel": "Mostra minigràfics", + "NavigationBack": "Enrere", + "NetworkNotReachable": "La xarxa no és accessible", + "NoPiwikAccount": "No teniu un compte de Piwik?", + "NoVisitorFound": "No s'han trobat visitants", + "NoWebsiteFound": "No s'ha trobat el lloc web", + "PullDownToRefresh": "Estireu per actualitzar...", + "RatingDontRemindMe": "No em recordis", + "RatingNotNow": "Ara no", + "RatingNow": "D'acord, el puntuaré ara", + "ReleaseToRefresh": "Solta per refrescar...", + "Reloading": "Carregant...", + "SaveSuccessError": "La URL del Piwik o la combinació d'usuari contrasenya es incorrecta.", + "SearchWebsite": "Cerca llocs web", + "ShowAll": "Mostra-ho tot", + "ShowLess": "Mostra menys", + "StaticGraph": "Gràfic de visió general", + "UseSearchBarHint": "Nomes es mostren els %s primers llocs webs aquí. Utilitzeu la barra de cerca per accedir als altres llocs web.", + "VerifyAccount": "Verificant el compte", + "VerifyLoginData": "Asegureu-vos del que el vostre nom d'usuari i contrasenya és correcte.", + "YouAreOffline": "Ho sentim, actualment estàs fora de línia." + }, + "MobileMessaging": { + "MobileReport_NoPhoneNumbers": "Siusplau, activeu com a mínim un numbre de telèfon per accedir.", + "MultiSites_Must_Be_Activated": "Per generar SMS de les vostres estadístiques web, siusplau activeu la extensió MultiSites de Piwik.", + "PhoneNumbers": "Nombres de telèfon", + "PluginDescription": "Crea i descarrega informes personalitzats per SMS i envieu-los al vostre mobil de forma diària, semanal o mensual.", + "Settings_APIKey": "Clau API", + "Settings_CountryCode": "Codi de país", + "Settings_CredentialNotProvided": "Abans de crear i gestionar els vostres nombres de telèfon, siusplau conecteu el Piwik a la vostra compta de SMS.", + "Settings_CredentialProvided": "La vostra compta de la API de SMS %s ha estat configurada correctament!", + "Settings_DeleteAccountConfirm": "Esteu segurs d'esborrar aquest compte de SMS?", + "Settings_InvalidActivationCode": "El codi introduït és invàlid, sisplau torneu-ho a probar.", + "Settings_LetUsersManageAPICredential": "Permetre als usuaris administrar les seves credencials de la API de SMS", + "Settings_LetUsersManageAPICredential_No_Help": "Tots els usuaris poden rebre informes per SMS i faran servir els credits de la vostra compta.", + "Settings_LetUsersManageAPICredential_Yes_Help": "Cada usuari pot configurar la seva pròpia compta de API de SMS i no farà servir els vostres crèdits.", + "Settings_ManagePhoneNumbers": "Administra nombres de telèfon", + "Settings_PhoneActivated": "Nombre de telèfon validat! Ara podeu rebre SMS amb les vostres estadístiques.", + "Settings_PhoneNumber": "Telèfon", + "Settings_PhoneNumbers_Add": "Afegir un nou telèfon", + "Settings_PhoneNumbers_CountryCode_Help": "Si no sabeu el vostre codi de pais el podeu buscar aquí.", + "Settings_SMSAPIAccount": "Administra el compte de l'API de SMS", + "Settings_SMSProvider": "Proveïdor SMS", + "Settings_SuperAdmin": "Preferències del Super Usuari", + "Settings_ValidatePhoneNumber": "Valida", + "SettingsMenu": "Missatgeria Mòbil", + "SMS_Content_Too_Long": "[massa llarg]", + "TopMenu": "Informes per Email i SMS" + }, + "MultiSites": { + "Evolution": "Evolució", + "PluginDescription": "Mostra resum\/estadístiques de multiples llocs web. Actualment mangingut com una extensió del nucli de Piwik.", + "TopLinkTooltip": "Compareu les estadístiques anàlitiques de tots els vostres llocs web." + }, + "Overlay": { + "Clicks": "%s clicks", + "ClicksFromXLinks": "%1$s clicks de un de %2$s enllaços", + "Domain": "Domini", + "ErrorNotLoading": "No es pot iniciar la sessió de pàgines Overlay", + "ErrorNotLoadingDetails": "Potser la pàgina carregada a la dreta no te el codi de rastreig de Piwik. En aquest cas, proveu de llençar el Overlay d'un altra pàgina del informe de pàgines.", + "ErrorNotLoadingDetailsSSL": "Com esteu fent servir Piwik a través de https, es possible que el vostre lloc web no suporti el SSL. Proveu utilitzant el Piwik sobre http.", + "ErrorNotLoadingLink": "Feu click aquí per obtenir més informació sobre com solucionar el problema.", + "Link": "Enllaç", + "Location": "Ubicació", + "NoData": "No hi ha informació per aquesta pàgina en el període seleccionat.", + "OneClick": "1 click", + "OpenFullScreen": "Mostra a pantalla complerta (sense barra lateral)", + "Overlay": "Overlay de pàgina", + "PluginDescription": "Observeu les anàlitiques a sobre del lloc web actual.", + "RedirectUrlError": "Esteu intentant obrir un pàgina Overlay per la URL \"%s\". %s Cap dels dominis configurats al Piwik concòrda amb l'enllaç.", + "RedirectUrlErrorAdmin": "Podeu afegir una URL addicional per un domini a %s les preferències%s.", + "RedirectUrlErrorUser": "Contacteu amb el vostre administrador per afegir el domini com una URL adicional." + }, + "PrivacyManager": { + "AnonymizeIpDescription": "Seleccioneu \"Sí\" si voleu que el Piwik no registri l'adreça IP complerta.", + "AnonymizeIpInlineHelp": "Anonimitzar el(s) últim(s) de la IP de les adreces dels visitants per complir amb la vostra política de privacitat\/llei.", + "AnonymizeIpMaskLengtDescription": "Seleccioneu quans bytes de l'adreça IP del visitant voleu enmascarar.", + "AnonymizeIpMaskLength": "%s byte(s) - p.e. %s", + "CannotLockSoDeleteLogActions": "La taula log_action no es purgarà: siusplau doneu el privilegi LOCK TABLES a l'ususair de MySQL '%s'.", + "ClickHereSettings": "Feu click aquí per entrar a %s la configuració", + "CurrentDBSize": "Tamany actual de la Base de dades", + "DBPurged": "Base de dades purgada", + "DeleteBothConfirm": "Esteu apunt d'activar l'eliminació del registre i dels informes. Això esborrarà de forma permanent la posibilitat de veure informació analítica antiga. Esteu segurs que voleu fer això?", + "DeleteDataDescription": "POdeu configurar el Piwik per esborar de forma periòdica els registres de visitants i\/o procesars el informes de forma períodica. Això permet reduïr la mida de la Base de dades.", + "DeleteDataDescription2": "Si ho voleu, els informes pre-processat no s'esborraran Només el registre de visiites, d vistes de pàgines i de conversió seràn esborrat. També podeu esborrar els informes pre-processats i mantenir la informació del registre.", + "DeleteDataInterval": "Esborrar la informació antiga cada", + "DeleteDataSettings": "Eliminar el registre de visitants i informes antics", + "DeleteLogDescription2": "Quan activeu l'eliminació automàtica us heu d'asegurar que tots els informes diaris han estat processats, així no perdeu informació.", + "DeleteLogInfo": "S'esborraran els regsitres de les següents taules: %s", + "DeleteLogsConfirm": "Esteu a punt d'activar l'eliminació d'informació. Si els registres anterior s'esborren i els informes no han estat creats no podreu veure la informació històrica. Esteu segurs que voleu fer això?", + "DeleteLogsOlderThan": "Eliminar elsb registres anterior a", + "DeleteMaxRows": "Nombre màxim de files per eliminar en una sola execució:", + "DeleteMaxRowsNoLimit": "sense límit", + "DeleteReportsConfirm": "Esteu a punt d'activar l'eliminació de la informació dels informes. Si els informes antics s'eliminen, haure de tornar-los a procesar per veure'ls. Esteu segur que voleu fer això?", + "DeleteReportsDetailedInfo": "S'eliminarà la informació de les taules númeriques (%s) i de les taules binàries (%s).", + "DeleteReportsInfo": "Si s'activa els informes antic s'esborraran. %s Recomanem que només ho activeu quan l'espai de la BD es limitat. %s", + "DeleteReportsInfo2": "Si no heu activat \"%s\", els informes anteriors es recrearan de forma automàtica quan es demanin.", + "DeleteReportsInfo3": "Si heu activat \"%s\", la informació s'esborrarà permanentment.", + "DeleteReportsOlderThan": "Elmina els informes anteriors a", + "DeleteSchedulingSettings": "Preferències de la programació", + "DoNotTrack_Description": "La tecnologia de no traquejar permet als usuris donar-se de baixa de les estadístiques pels llocs web que ells no visiten, incloïent els serveis analítiques, les xarxes públicitaries i els xarxes socials.", + "DoNotTrack_Disable": "Desactiveu el suport per a la desactivació del rastreig.", + "DoNotTrack_Disabled": "El Piwik actualment gestiona tots els visitants, encara que ells haigin especificat \"No vull ser rastrejat\" en els seus navegadors web.", + "DoNotTrack_DisabledMoreInfo": "Recomanem respectar la privacitat dels vostres visitants i activar el suport per la desactivació del rastreig.", + "DoNotTrack_Enable": "Activeu el suport per la desactivació del restreig", + "DoNotTrack_Enabled": "Bravo! Esteu respectant la privacitat dels vostres usuaris.", + "DoNotTrack_EnabledMoreInfo": "Quan els usuaris han configurat el seu navegador per a a no ser rastrejats (La tecnologia de no rastreig esta activada) el Piwik no registrarà aquestes visites.", + "DoNotTrack_SupportDNTPreference": "Suportar la configuració de No Rastreig", + "EstimatedDBSizeAfterPurge": "Tamany de la base de dades desprès de la purga", + "EstimatedSpaceSaved": "Tamany estalviat (estimació)", + "GeolocationAnonymizeIpNote": "Nota: La geocalitazció tindrà els mateixos resultats amb 1 bit anònim. Amb 2 bits o més, la geocalització no serà acurada.", + "GetPurgeEstimate": "Obtenir l'estimació de la purga", + "KeepBasicMetrics": "Conserveu les mètriques bàsiques ( visites, pàgines vistes, raó de rebot, conversió d'objectius, conversións ecommerce, etc.)", + "KeepDataFor": "Mantingueu l'informació per:", + "KeepReportSegments": "Per mantenir aquesta informació també heu de mantenir els informes segmentats.", + "LastDelete": "La última eliminació va ser el", + "LeastDaysInput": "Siusplau especifieu un nombre de díes més gran que %s.", + "LeastMonthsInput": "Siusplau, especifiqueu un nombre de dies més gran que %s.", + "MenuPrivacySettings": "Privacitat", + "NextDelete": "La pròxima eliminació programada el", + "PluginDescription": "Personalitzeu el Piwik per que compleixi amb les legislacions actuals sobre privacitat.", + "PurgeNow": "Purga la BD ara", + "PurgeNowConfirm": "Esteu a punt d'esborrar tota la informació de la vostra base de ades. Esteu segur que voleu continuar?", + "PurgingData": "Prugant la informació...", + "ReportsDataSavedEstimate": "Mida de la Base de dades", + "SaveSettingsBeforePurge": "Heu canviat la configuració d'esborrament. Sisplau, guardeu les vostres preferències abans de començar la purga.", + "Teaser": "En aquesta pàgina podeu personalitzar el Piwik per tal de que compleixi amb les legislacions actuals. Podeu: %sconvertir la IP del visitant en anònima%s, %sesborrar automàticmant els registres antics de la base de dades%s i %s proporcionar un mecanisme per a que els vostreus usuaris puguin triar de no ser rastrejats %s.", + "TeaserHeadline": "Preferències de Privacitat", + "UseAnonymizeIp": "Convertir la IP dels vostres visitants en anònima", + "UseDeleteLog": "Esborrar de forma períodica el registres de visitants.", + "UseDeleteReports": "Esobrreu els informes de la base de dades de forma períodica" + }, + "Provider": { + "ColumnProvider": "Proveïdor", + "PluginDescription": "Informació del Proveïdor dels visitants.", + "ProviderReportDocumentation": "Aquest informe mostra quin Proveïdor d'Internet han utiltizat els vostres visitants per accedir al lloc. Podeu click al nom del proveïdor per més detalls. %s Si el Piwik no pot determinar el proveïdor del visitant, es mostra la seva adreça IP.", + "SubmenuLocationsProvider": "Localitzacions i proveïdors", + "WidgetProviders": "Proveïdors" + }, + "Referrers": { + "Campaigns": "Campanyes", + "CampaignsDocumentation": "Visitants que visiten el vostre lloc web com a resultat d'una campanya. %s Visualitzeu l'inforrme %s per més detalls.", + "CampaignsReportDocumentation": "Aquest informe mostra quines campanyes han portat visitants al vostre lloc web. %s Per mes informació sobre la gestió de campanyes, llegiu la %s documetnació de campanyes a piwik.org %s", + "ColumnCampaign": "Campanya", + "ColumnSearchEngine": "Cercador", + "ColumnSocial": "Xarxa social", + "ColumnWebsite": "Lloc web", + "ColumnWebsitePage": "Pàgina web", + "DetailsByReferrerType": "Detalls segons el tipus de referent", + "DirectEntry": "Registre directe", + "DirectEntryDocumentation": "Un visitant ha entrar la URL al seu navegador i ha començat a navegar pel vostre lloc web. Ell ha entrar directament al lloc web.", + "Distinct": "Referents diferents segons el tipus", + "DistinctCampaigns": "campanyes diferents", + "DistinctKeywords": "paraules clau diferents", + "DistinctSearchEngines": "cercadors diferents", + "DistinctWebsites": "llocs web diferents", + "EvolutionDocumentation": "Això es una vista global dels referents que us han portat visitants al vostre lloc web.", + "EvolutionDocumentationMoreInfo": "Per mes informació dels tipus de referents, mireu la informació de la taula %s.", + "Keywords": "Paraules clau", + "KeywordsReportDocumentation": "Aquest informe mostra quines paraules clau han utilitzat els vostres visitants abans de ser dirigits al vostre lloc web. %s Clicant en una fila de la taula podeu veure la distribució de cercadors en que s'ha cercat la paraula clau.", + "PluginDescription": "Informes sobre referents: Cercadors, Paraules Clau, Llocs web, Referent per Campanyes i Entrades Directes", + "ReferrerName": "Nom del referent", + "Referrers": "Referents", + "SearchEngines": "Cercadors", + "SearchEnginesDocumentation": "Un visitant ha vingut al vostre lloc web a través d'un navegador web. %s Visualitzeu l'informe %s per a més informació.", + "SearchEnginesReportDocumentation": "Aquest informa mostra quins motors de cerca han portat usuaris al vostre lloc web. %s Clicant en una fila de la tabla, pedeu veureu que han estat cercant els vostres usuris en aquest lloc web.", + "SocialFooterMessage": "Aquest és un subconjunt del l'informe de llocs web de l'esquerra. Filtra la informació d'altres llocs web així podeu comparar els referents de xarxes socials directament.", + "Socials": "Xarxes socials", + "SocialsReportDocumentation": "Aquest informe mostra quines xarxes socials han portat visitants al vostre lloc web. Cliqueu una fila de la taule per mostrar de quines pàgines de les xarxes socials venen els vostres visitants.", + "SubmenuSearchEngines": "Cercadors i paraules clau", + "SubmenuWebsites": "Llocs web", + "Type": "Tipus de referent", + "TypeCampaigns": "Hi ha %s visites provinents de campanyes", + "TypeDirectEntries": "Hi ha %s entrades directes", + "TypeReportDocumentation": "Aquesta table conté informació sobre la distribució dels diferents tipus de referents.", + "TypeSearchEngines": "Hi ha %s visites provinents dels motors de cerca", + "TypeWebsites": "Hi ha %s visites provinents d'altres llocs", + "UsingNDistinctUrls": "(utiltizant %s urls diferents)", + "Websites": "Llocs web", + "WebsitesDocumentation": "Els visitant ha seguit un enllaç en un altre lloc web que ha portat al vostre lloc web. %s Visualitzeu l'informe %s per a més detalls.", + "WebsitesReportDocumentation": "En aquesta taula podeu observar quins llocs web han portat visitants al vostre lloc web. %s Cliqueu en una fila de la taule per veure les URL dels enllaços a les vostres pàgines.", + "WidgetExternalWebsites": "Llistat de llocs web externs", + "WidgetKeywords": "Llistat de paraules clau", + "WidgetSocials": "Llista de xarxes socials" + }, + "RowEvolution": { + "AvailableMetrics": "Mètriques disponibles", + "CompareDocumentation": "Feu click a l'enllaç a continuació per obrir aquesta finestra emergent per la mateixa taula i comparar múltiples registres
    Utilitzeu la tecla Shit per marcar la fila per a comparació sense obrir aquesta finestra emergent.", + "CompareRows": "Compareu registres", + "ComparingRecords": "Comparant %s files", + "Documentation": "Feu click a les mètriques per mostrarle al gràfic d'evolució. Utiltizeu la tecla shift per mostrar múltiples mètriques d'una vegada.", + "MetricBetweenText": "entre %s i %s", + "MetricChangeText": "%s en el període", + "MetricsFor": "Mètriques per %s", + "MultiRowEvolutionTitle": "Evolució de múltiples files", + "PickAnotherRow": "Seleccioneu una altra fila per comparar", + "PickARow": "Seleccioneu una fila per comparar" + }, + "ScheduledReports": { + "AggregateReportsFormat": "(opcional) mostrar les opcions", + "AggregateReportsFormat_GraphsOnly": "Mostra només els gràfics (sense taules)", + "AggregateReportsFormat_TablesAndGraphs": "Mostrar les taules i els gràfics per a tots els informes.", + "AggregateReportsFormat_TablesOnly": "(per defecte) Mostra les taules d'informe (Gràfiques només per les mètriques clau)", + "AlsoSendReportToTheseEmails": "Envía també l'informe als seguents correus electrònics (un per línia):", + "AreYouSureDeleteReport": "Esteu segurs que voleu eliminar aquest informe i la seva programació?", + "CancelAndReturnToReports": "Cancela i %storna a la llista d'informes%s", + "CreateAndScheduleReport": "Crear i programar un informe", + "CreateReport": "Crear un informe", + "DescriptionOnFirstPage": "La descripció del informes es mostrarà a la primera pàgina del informe.", + "DisplayFormat_TablesOnly": "Mostra només taules (sense gràfics)", + "EmailHello": "Hola,", + "EmailReports": "Informes per correu electrònic", + "EmailSchedule": "Programació per correu electrònic", + "EvolutionGraph": "Mostrar els valors històrics pels %s valors més representatius.", + "FrontPage": "Portada", + "ManageEmailReports": "Administra els informes per correu electrònic.", + "MonthlyScheduleHelp": "Programació mensual: L'informe s'enviarà el primer día de cada més.", + "MustBeLoggedIn": "Heu d'estar identificat per crear i programar informes personalitzats", + "NoRecipients": "Aquest informe no té destinataris", + "Pagination": "Pàgina %s de %s", + "PiwikReports": "Informes Piwik", + "PleaseFindAttachedFile": "Podeu trobar al fitxer adjunt el vostre informe %1$s per a %2$s.", + "PleaseFindBelow": "A continuació podeu trobar el vostre informe %1$s per a %2$s.", + "PluginDescription": "Creeu i descarregeu els vostres informes personalitats. Envieu-vos els per correu electronic diariament, semanalment o mensualment.", + "ReportFormat": "Format de l'informe", + "ReportIncludeNWebsites": "L'informe incloura les mètriques més importants de tots els llocs webs que tinguin almenys una visita (dels %s llocs web actualment disponibles)", + "ReportsIncluded": "Estadístiques incloses", + "ReportType": "Envia l'informe a través de", + "SendReportNow": "Envia l'informe ara", + "SendReportTo": "Envia l'informe a", + "SentToMe": "Enviam l'informe a mi", + "TableOfContent": "Llista d'informes", + "ThereIsNoReportToManage": "No hi ha cap informe per administrar el lloc web %s", + "TopLinkTooltip": "Creeu informes per correu electrònic per a que les estadístiques del Piwik es dipositin al vostre correu electrònic (o als dels vostres clients) automàticament", + "TopOfReport": "Torna a dalt", + "UpdateReport": "Actualitza l'informe", + "WeeklyScheduleHelp": "Programació semanal: L'informe s'enviara el Dilluns de cada setmana." + }, + "SEO": { + "AlexaRank": "Ranking Alexa", + "Bing_IndexedPages": "Pàgines indexades per Bing", + "Dmoz": "Entrades DMOZ", + "DomainAge": "Edat del domini", + "Google_IndexedPages": "Pàgines indexades per Google", + "Rank": "Ranking", + "SeoRankings": "Ranking SEO", + "SEORankingsFor": "Ranking SEO per %s" + }, + "SitesManager": { + "AddSite": "Afegeix un lloc nou", + "AdvancedTimezoneSupportNotFound": "El suport per a zones horaries avançat no està disponible a la vostra versió de PHP (suportat a partir de la versio 5.2). Encara podeu utilitzar la diferencia UTC de forma manual.", + "AliasUrlHelp": "Es recomanat, però no obligatori, especificar totes les URL (una per líniea) que els vostres visitants utilizen per accedir al lloc web. Les URL de Alias no apareixeran al informe de Referents > LLocs web. No es necessàri especificar les URL amb i sense 'www' ja que el Piwik té en compte les dos de forma automàtica.", + "ChangingYourTimezoneWillOnlyAffectDataForward": "Canviar la vostra zona horaria només afecta les dades futures, no s'aplicarà de forma retroactiva.", + "ChooseCityInSameTimezoneAsYou": "Seleccioneu una ciutat de la mateixa zona horaria que la vosatra", + "Currency": "Moneda", + "CurrencySymbolWillBeUsedForGoals": "El simbol de la moneda es mostrarà al costat dels ingresos dels objectius.", + "DefaultCurrencyForNewWebsites": "Moneda per defecte per als nous llocs web", + "DefaultTimezoneForNewWebsites": "Zona horària per als nous llocs web", + "DeleteConfirm": "Realment voleu esborrar el lloc '%s'?", + "DisableSiteSearch": "No rastregeu la Cerca del vostre lloc web", + "EcommerceHelp": "Quan s'activi, l'apartat \"Objectius\" tindrà una nova secció \"Ecommerce\".", + "EnableEcommerce": "Ecommerce activat", + "EnableSiteSearch": "Rastreig de les cerques al lloc web activat", + "EnableSiteSpecificUserAgentExclude": "Activa l'exclusió per user-agent específic per al lloc web.", + "EnableSiteSpecificUserAgentExclude_Help": "Si necessiteu excluïr diferents user-agent per a diferents llocs webs, marque aquesta opció, guardeu les opcions i %1$s afegiu user-agents a continuaicó %2$s.", + "ExceptionDeleteSite": "No és possible eliminar aquest lloc ja que és l'únic configurat. Afegiu un altre lloc primer i, llavors, ja esborrareu aquest.", + "ExceptionEmptyName": "El nom del lloc no pot estar buit.", + "ExceptionInvalidCurrency": "La moneda \"%s\" no es vàlida. Sisplau, introduïu un símbol de moneda vàlid (p.e. %s)", + "ExceptionInvalidIPFormat": "La IP a excloure \"%s\" no te un format IP vàlid (p.e. %s).", + "ExceptionInvalidTimezone": "La zona horària \"%s\" no es vàlida. Sisplau introduïu una zona horària vàlida.", + "ExceptionInvalidUrl": "L'adreça '%s' no és vàlida.", + "ExceptionNoUrl": "Heu d'especificar una URL com a mínim.", + "ExcludedIps": "IPs excloses", + "ExcludedParameters": "Paràmetres exclosos", + "ExcludedUserAgents": "Agents d'usuari exclosos", + "GlobalExcludedUserAgentHelp1": "Introduïeu la llista de user-agents per excloure del rastreig de Piwik.", + "GlobalExcludedUserAgentHelp2": "Podeu utilizar això per excloure robots del rastreig.", + "GlobalListExcludedIps": "Llista global de IPs Excloses.", + "GlobalListExcludedQueryParameters": "Llista global de paràmetres d'URL per excloure", + "GlobalListExcludedUserAgents": "LLista global d'user-agents a excloure", + "GlobalListExcludedUserAgents_Desc": "Si el user-agent del visitant conté alguna de les cadenes especificades, el visitant serà exclòs del Piwik.", + "GlobalWebsitesSettings": "Preferències globals dels llocs web", + "HelpExcludedIps": "Introduïeu la llista d'IP, una per línia, que voleu excloure del rsatreig de Piwik. Podeu utilitzar comodins, p.e. %1$s o %2$s", + "JsTrackingTagHelp": "Aquí teniu l'etiqueta de seguiment JavaScript per tal d'incloure-la a totes les pàgines", + "ListOfIpsToBeExcludedOnAllWebsites": "Les IPs indicades a continuació serán excloses del rastreig a tots els llocs web.", + "ListOfQueryParametersToBeExcludedOnAllWebsites": "Els paràmetres de URL indicats a continuació seran exclosos de les URL a tots els llocs web.", + "ListOfQueryParametersToExclude": "Introduïu la llista de Parametres d'URL, un per línea, per a excloure del les URL de pàgina dels informes.", + "MainDescription": "Els informes d'anàlisi web necessiten llocs web! Afegiu, actualitzeu i esborreu-ne. Veieu també el codi JavaScript que heu d'inserir a les pàgines.", + "NotAnEcommerceSite": "No es un lloc d'Ecommerce", + "NotFound": "No s'han trobat llocs web per", + "NoWebsites": "No teniu cap lloc que pugueu administrar", + "OnlyOneSiteAtTime": "Només podeu editar un lloc web a la vegada. Sisplau, Guardeu o Canceleu les modificacions al lloc web %s.", + "PiwikOffersEcommerceAnalytics": "EL Piwik permet analítiques avançades per a Eccomerce. Sabeu més de les %s Analítiques per a Ecommerce %s.", + "PiwikWillAutomaticallyExcludeCommonSessionParameters": "Piwik exclourà automàticament els paràmetres de sessió comuns (%s).", + "PluginDescription": "Gestió de Llocs Web al Piwik: Afegiu un nou lloc Web, Editeu un d'existent, mostreu el codi Javascript per incloure a les vostres pàgines. Totes les accions están disponibles a través de la API.", + "SearchCategoryDesc": "El Piwik també pot gestionar la categoria de Cerca per cada una de les paraules clau de la carca interna.", + "SearchCategoryLabel": "Paràmetre de la categoria", + "SearchCategoryParametersDesc": "Podeu introduir una llista separada per comes de paràmetres que especifíquen la categoria de cerca.", + "SearchKeywordLabel": "Paràmetre", + "SearchKeywordParametersDesc": "Introduïu una llista separada per comes de paràmetres que conte la paraula clau de cerca al lloc web.", + "SearchParametersNote": "Nota: Els parametres de cerca i categoria de cerca només s'utilitzaran per llocs webs que tenen la Cerca al lloc activada pero que tenen aquest paràmetres en blanc.", + "SearchParametersNote2": "Per deshabilitar la Cerca al lloc per als nous llocs web, deixeu aquest dos camps en blanc.", + "SearchUseDefault": "Utilitzar els paràmetres de Cerca al Lloc %s per defecte %s", + "SelectACity": "Seleccioneu una ciutat", + "SelectDefaultCurrency": "Podeu seleccionar la moneda per defecte dels nous llocs web.", + "SelectDefaultTimezone": "Podeu seleccionar la zona horària per defecte dels nous llocs web.", + "ShowTrackingTag": "mostra l'etiqueta de seguiment", + "Sites": "Llocs", + "SiteSearchUse": "Podeu utilitzar el Piwik per rastrejar i vistualitzar informes sobre que estan cercant els vostres visitants a la cerca interna del vostre lloc web.", + "Timezone": "Zona horària", + "TrackingSiteSearch": "Rastreig de la Cerca Interna.", + "TrackingTags": "Codi de rastreig per %s", + "Urls": "Adreces", + "UTCTimeIs": "L'hora UTC es %s.", + "WebsitesManagement": "Gestiona els llocs", + "YouCurrentlyHaveAccessToNWebsites": "Actualment teniu accés a %s llocs web.", + "YourCurrentIpAddressIs": "La vostra adreça IP es %s" + }, + "Transitions": { + "BouncesInline": "%s rebots", + "DirectEntries": "Entrades directes", + "ErrorBack": "Ves a l'acció anterior", + "ExitsInline": "%s surts", + "FromCampaigns": "De campanyes", + "FromPreviousPages": "De pàgines internes", + "FromPreviousPagesInline": "%s de pàgines internes", + "FromPreviousSiteSearches": "De la cerca interna", + "FromPreviousSiteSearchesInline": "%s de la cerques internes", + "FromSearchEngines": "De cercadors", + "FromWebsites": "De llocs web", + "IncomingTraffic": "Tràfic entrant", + "LoopsInline": "%s refrescos de pàgina", + "NoDataForAction": "No hi ha informació per %s", + "NoDataForActionDetails": "O l'acció no ha estat vista cap vegada durant el període %s o és invàlida.", + "OutgoingTraffic": "Tràfic surtint", + "PluginDescription": "Mostra informació sobra les accions anteriors i següents a una URL.", + "ShareOfAllPageviews": "Aquesta pàgina ha tingut %s visites (%s de totes les visites)", + "ToFollowingPages": "A pàgines internes", + "ToFollowingPagesInline": "%s a pàgines internes", + "ToFollowingSiteSearches": "Cerques internes", + "ToFollowingSiteSearchesInline": "%s cerques internes", + "XOfAllPageviews": "%s de totes les visualitzacions d'aquesta pàgina", + "XOutOfYVisits": "%s (de %s)" + }, + "UserCountry": { + "AssumingNonApache": "No s'ha pogut trobar la funció apache_get_modules, s'asumeix que s'utilitza un servidor web diferent del Apache.", + "CannotFindGeoIPDatabaseInArchive": "No s'ha pogut trobar el fitxer %1$s al fitxer tar %2$s!", + "CannotFindGeoIPServerVar": "La variable %s no està definida. Pot ser que el vostre servidor web no estigui configurat correctament.", + "CannotFindPeclGeoIPDb": "No s'ha pogut trobar una base de dades de països, regions o ciutats pel modul GeoIP PECL. Assegureu-vos que la vostra base de dades GeoIP es troba a %1$s, i s'anomena %2$s o %3$s, sinó el mòdul PECL lo la detectarà.", + "CannotListContent": "No s'ha pogut llistar el contingut de %1$s: %2$s", + "CannotLocalizeLocalIP": "L'adreça IP %s és una adreça local i no pot ser Geolocalitzada.", + "CannotSetupGeoIPAutoUpdating": "Sembla que esteu guardant la vostra base de dades de GeoIP fora del Piwik (ho podem assegurar perquè no hi ha bases de dades al directori misc, però la GeoIP està funcionant). El Piwik no podrà actualitzar automàticament les vostres bases de dades GeoIP si aquestes es troben fora del directori misc.", + "CannotUnzipDatFile": "No s'ha pogut descomprimir el fitxer dat de %1$s: %2$s", + "City": "Ciutat", + "Continent": "Continent", + "continent_afr": "Àfrica", + "continent_amc": "America Central", + "continent_amn": "Amèrica del Nord", + "continent_ams": "Amèrica Central i del Sud", + "continent_ant": "Antàrtida", + "continent_asi": "Àsia", + "continent_eur": "Europa", + "continent_oce": "Oceania", + "Country": "País", + "country_a1": "Proxy Anònim", + "country_a2": "Proveïdor per satelit", + "country_ac": "Illes Ascensió", + "country_ad": "Andorra", + "country_ae": "Emirats Àrabs Units", + "country_af": "Afganistan", + "country_ag": "Antigua i Barbuda", + "country_ai": "Anguilla", + "country_al": "Albània", + "country_am": "Armènia", + "country_an": "Antilles Neerlandeses", + "country_ao": "Angola", + "country_ap": "Regió de l'Àsia\/Pacific", + "country_aq": "Antàrtida", + "country_ar": "Argentina", + "country_as": "Samoa Nord-americana", + "country_at": "Àustria", + "country_au": "Austràlia", + "country_aw": "Aruba", + "country_ax": "Illes Åland", + "country_az": "Azerbaidjan", + "country_ba": "Bòsnia i Herzegovina", + "country_bb": "Barbados", + "country_bd": "Bangla Desh", + "country_be": "Bèlgica", + "country_bf": "Burkina Faso", + "country_bg": "Bulgària", + "country_bh": "Bahrain", + "country_bi": "Burundi", + "country_bj": "Benin", + "country_bl": "Saint Barthélemy", + "country_bm": "Bermuda", + "country_bn": "Brunei", + "country_bo": "Bolívia", + "country_bq": "Bonaire, Sint Eustatius i Saba", + "country_br": "Brasil", + "country_bs": "Bahames", + "country_bt": "Bhutan", + "country_bu": "Birmània (Myanmar)", + "country_bv": "Bouvet", + "country_bw": "Botswana", + "country_by": "Bielorússia", + "country_bz": "Belize", + "country_ca": "Canadà", + "country_cat": "Comunitats catalano-parlants", + "country_cc": "Illes Cocos", + "country_cd": "República Democràtica del Congo", + "country_cf": "República Centreafricana", + "country_cg": "República del Congo", + "country_ch": "Suïssa", + "country_ci": "Costa d'Ivori", + "country_ck": "Illes Cook", + "country_cl": "Xile", + "country_cm": "Camerun", + "country_cn": "Xina", + "country_co": "Colòmbia", + "country_cp": "Illa Clipperton", + "country_cr": "Costa Rica", + "country_cs": "Sèrbia i Montenegro", + "country_cu": "Cuba", + "country_cv": "Cap Verd", + "country_cw": "Curaçao", + "country_cx": "Illa Christmas", + "country_cy": "Xipre", + "country_cz": "República Txeca", + "country_de": "Alemanya", + "country_dg": "Diego Garcia", + "country_dj": "Djibouti", + "country_dk": "Dinamarca", + "country_dm": "Dominica", + "country_do": "República Dominicana", + "country_dz": "Algèria", + "country_ea": "Ceuta, Melilla", + "country_ec": "Equador", + "country_ee": "Estònia", + "country_eg": "Egipte", + "country_eh": "Sàhara Occidental", + "country_er": "Eritrea", + "country_es": "Espanya", + "country_et": "Etiòpia", + "country_eu": "Unió Europea", + "country_fi": "Finlàndia", + "country_fj": "Illes Fiji", + "country_fk": "Illes Malvines", + "country_fm": "Estats Federats de Micronèsia", + "country_fo": "Illes Fèroe", + "country_fr": "França", + "country_fx": "França, Metropolitana", + "country_ga": "Gabon", + "country_gb": "Gran Bretanya", + "country_gd": "Grenada", + "country_ge": "Geòrgia", + "country_gf": "Guaiana Francesa", + "country_gg": "Guernsey", + "country_gh": "Ghana", + "country_gi": "Gibraltar", + "country_gl": "Grenlàndia", + "country_gm": "Gàmbia", + "country_gn": "Guinea", + "country_gp": "Illa Guadalupe", + "country_gq": "Guinea Ecuatorial", + "country_gr": "Grècia", + "country_gs": "Illes Geòrgia del Sud i Sandwich del Sud", + "country_gt": "Guatemala", + "country_gu": "Guam", + "country_gw": "Guinea-Bissau", + "country_gy": "Guyana", + "country_hk": "Hong Kong", + "country_hm": "Illes Heard i McDonald", + "country_hn": "Hondures", + "country_hr": "Croàcia", + "country_ht": "Haití", + "country_hu": "Hongria", + "country_ic": "Illes Canàries", + "country_id": "Indonèsia", + "country_ie": "Irlanda", + "country_il": "Israel", + "country_im": "Man (illa)", + "country_in": "Índia", + "country_io": "Territori Britànic de l'Oceà Índic", + "country_iq": "Iraq", + "country_ir": "Iran", + "country_is": "Islàndia", + "country_it": "Itàlia", + "country_je": "Jersey", + "country_jm": "Jamaica", + "country_jo": "Jordània", + "country_jp": "Japó", + "country_ke": "Kenya", + "country_kg": "Kirguizistan", + "country_kh": "Cambodja", + "country_ki": "Kiribati", + "country_km": "Comores", + "country_kn": "Saint Kitts i Nevis", + "country_kp": "Korea del Nord", + "country_kr": "Corea del Sud", + "country_kw": "Kuwait", + "country_ky": "Illes Caiman", + "country_kz": "Kazakhstan", + "country_la": "Laos", + "country_lb": "Líban", + "country_lc": "Saint Lucia", + "country_li": "Liechtenstein", + "country_lk": "Sri Lanka", + "country_lr": "Libèria", + "country_ls": "Lesotho", + "country_lt": "Lituània", + "country_lu": "Luxemburg", + "country_lv": "Letònia", + "country_ly": "Líbia", + "country_ma": "Marroc", + "country_mc": "Mònaco", + "country_md": "Moldàvia", + "country_me": "Montenegro", + "country_mf": "Illa de Sant Martí", + "country_mg": "Madagascar", + "country_mh": "Illes Marshall", + "country_mk": "Macedònia", + "country_ml": "Mali", + "country_mm": "Myanmar", + "country_mn": "Mongòlia", + "country_mo": "Macau", + "country_mp": "Illes Marianes Septentrionals", + "country_mq": "Martinica", + "country_mr": "Mauritània", + "country_ms": "Montserrat", + "country_mt": "Malta", + "country_mu": "Maurici", + "country_mv": "Maldives", + "country_mw": "Malawi", + "country_mx": "Mèxic", + "country_my": "Malàisia", + "country_mz": "Moçambic", + "country_na": "Namíbia", + "country_nc": "Nova Caledònia", + "country_ne": "Níger", + "country_nf": "Illa Norfolk", + "country_ng": "Nigèria", + "country_ni": "Nicaragua", + "country_nl": "Països Baixos", + "country_no": "Noruega", + "country_np": "Nepal", + "country_nr": "Nauru", + "country_nt": "Zona neutra", + "country_nu": "Niue", + "country_nz": "Nova Zelanda", + "country_o1": "Un altre pais", + "country_om": "Oman", + "country_pa": "Panamà", + "country_pe": "Perú", + "country_pf": "Polinèsia Francesa", + "country_pg": "Papua Nova Guinea", + "country_ph": "Filipines", + "country_pk": "Pakistan", + "country_pl": "Polònia", + "country_pm": "Saint-Pierre i Miquelon", + "country_pn": "Illes Pitcairn", + "country_pr": "Puerto Rico", + "country_ps": "Territori Palestí", + "country_pt": "Portugal", + "country_pw": "Palau", + "country_py": "Paraguai", + "country_qa": "Qatar", + "country_re": "Illa de la Reunió", + "country_ro": "Romania", + "country_rs": "Sèrbia", + "country_ru": "Rússia", + "country_rw": "Rwanda", + "country_sa": "Aràbia Saudita", + "country_sb": "Illes Salomó", + "country_sc": "Seychelles", + "country_sd": "Sudan", + "country_se": "Suècia", + "country_sf": "Finlàndia", + "country_sg": "Singapur", + "country_sh": "Saint Helena", + "country_si": "Eslovènia", + "country_sj": "Svalbard", + "country_sk": "Eslovàquia", + "country_sl": "Sierra Leone", + "country_sm": "San Marino", + "country_sn": "Senegal", + "country_so": "Somàlia", + "country_sr": "Surinam", + "country_ss": "Sudan del Sud", + "country_st": "São Tomé i Príncipe", + "country_su": "Antiga URSS", + "country_sv": "El Salvador", + "country_sx": "Sint Maarten (Part Alemanya)", + "country_sy": "Síria", + "country_sz": "Swazilàndia", + "country_ta": "Tristan da Cunha", + "country_tc": "Illes Turks i Caicos", + "country_td": "Txad", + "country_tf": "Terres Australs i Antàrtiques Franceses", + "country_tg": "Togo", + "country_th": "Tailàndia", + "country_ti": "Tibet", + "country_tj": "Tadjikistan", + "country_tk": "Tokelau", + "country_tl": "Timor Oriental", + "country_tm": "Turkmenistan", + "country_tn": "Tunísia", + "country_to": "Tonga", + "country_tp": "Timor Oriental", + "country_tr": "Turquia", + "country_tt": "Trinitat i Tobago", + "country_tv": "Tuvalu", + "country_tw": "Taiwan", + "country_tz": "Tanzània", + "country_ua": "Ucraïna", + "country_ug": "Uganda", + "country_uk": "Regne Unit", + "country_um": "Illes Perifèriques Menors dels EUA", + "country_us": "EUA", + "country_uy": "Uruguai", + "country_uz": "Uzbekistan", + "country_va": "Ciutat del Vaticà", + "country_vc": "Saint Vincent i les Grenadines", + "country_ve": "Veneçuela", + "country_vg": "Illes Verges Britàniques", + "country_vi": "Illes Verges Americanes", + "country_vn": "Vietnam", + "country_vu": "Vanuatu", + "country_wf": "Wallis i Futuna", + "country_ws": "Samoa", + "country_ye": "Iemen", + "country_yt": "Mayotte", + "country_yu": "Iugoslàvia", + "country_za": "Sud-àfrica", + "country_zm": "Zàmbia", + "country_zr": "Zaire", + "country_zw": "Zimbabwe", + "CurrentLocationIntro": "Segons el proveïdor, la vostra localització actual és", + "DefaultLocationProviderDesc1": "El proveïdor de localització per defecte suposa el país del visitant en funció del llenguatge que fan servir.", + "DefaultLocationProviderDesc2": "Això no és molt precís, per tant %1$s recomanem instal·lar i utilitzar %2$sGeoIP%3$s.%4$s", + "DistinctCountries": "Hi ha %s països diferents", + "DownloadingDb": "Descarregant %s", + "DownloadNewDatabasesEvery": "Actualitza la base de dades cada", + "FatalErrorDuringDownload": "S'ha produït un error fatal mentre es descarregava el fitxer. Deu haver algun problema amb la vostra connexió a Internet o amb la base de dades GeoIP que heu Baixat. Proveu de descarregar-la instal·lar-la manualment.", + "FoundApacheModules": "El Piwik ha trobat els següents mòduls d'Apache", + "GeoIPCannotFindMbstringExtension": "No s'ha pogut trobar la funció %1$s. Assegureu-vos que l'extensió %2$s està instal·lada i carregada.", + "GeoIPDatabases": "Base de dades GeoIP", + "GeoIPDocumentationSuffix": "Per a veure informació d'aquest informe, heu de configurar la GeoIP a la pestanya Geolocalització. Les bases de dades comercials de %1$sMaxmind%2$s són més acurades que les gratuïtes. Podeu veure com són d'acurades fent click %3$saquí%4$s.", + "GeoIPImplHasAccessTo": "Aquesta implementació de GeoIP té accés als següents tipus de bases de dades", + "GeoIpLocationProviderDesc_Pecl1": "Aquest proveïdor de localització utilitza una base de dades GeoIP i un mòdul PECL per determinar de forma eficient i acurada la localització dels vostres visitants.", + "GeoIpLocationProviderDesc_Pecl2": "No hi ha cap limitació amb aquest proveïdor, per tant és el que recomanem que utilzeu.", + "GeoIpLocationProviderDesc_Php1": "Aquest proveïdor és el més simple d'instal·lar i no requereix cap configuració del servidor (ideal per a hosting compartit!). Utiltiza una base de dades GeoIP i la api PHP de MaxMind per determinar de forma acurada la localització dels vostres visitants.", + "GeoIpLocationProviderDesc_Php2": "Si el vostre lloc web té molt tràfic trobareu que aquest proveïdor de localització és massa lent. En aquest cas podeu instalar la %1$s Extensió PECL %2$s o un %3$s mòdul del serviro %4$s.", + "GeoIpLocationProviderDesc_ServerBased1": "Aquest proveïdor de localització utiltiza el modul GeoIP que ha estat instal·lat al vostre servidor HTTP. Aquest proveïdor es ràpid i acurat, però %1$s només es pot fer servir l'escaneig amb el rastreig de webs normal .%2$s", + "GeoIpLocationProviderDesc_ServerBased2": "Si heu d'importar els fitxers de Log o alguna altra cosa que requereixi utilizar addreces IP, utilitzeu la %1$sImplementació GeoIP PECL (recomanada)%2$s o la %3$s implementació GeoIP PHP%4$s.", + "GeoIpLocationProviderDesc_ServerBasedAnonWarn": "Nota: L'anonimització IP no té cap efecte amb les localitzacions reportades per aquest proveïdor. Abans d'utilitzar amb l'anonimització IP, assegureu-vos que no viola cap llei de privacitat a la que podeu estar subjectes.", + "GeoIPNoServerVars": "El Piwik no ho pogut trobar cap variable GeoIP a %s", + "GeoIPPeclCustomDirNotSet": "L'opció %s del PHP ini no està definida", + "GeoIPServerVarsFound": "El Piwik detecta les següents variables GeoIP a %s", + "GeoIPUpdaterInstructions": "Introduïu els enllaços de descàrrega de les vostres bases de dades a continuació. Si heu comprat bases de dades de %3$sMaxMind%4$s podeu trobar els enllaços %1$saquí%2$s. Poseu-vos amb contacte amb %3$sMaxMind%4$s si teniu problemes per accedir-hi.", + "GeoIPUpdaterIntro": "El Piwik està gestionant les actualitzacions de les següents bases de dades GeoIP", + "GeoLiteCityLink": "Si esteu fent servir la Base de dades de ciutats GeoLite feu servir aquest enllaç: %1$s%2$s%3$s.", + "Geolocation": "Geolocalització", + "GeolocationPageDesc": "En aquesta pàgina podeu canviar com el Piwik determina la localització dels vostres visitants.", + "getCityDocumentation": "Aquest informe mostra les ciutats on estaven els vostres visitants quan van accedir el vostre lloc web.", + "getContinentDocumentation": "Aquest informe mostra el continent on eren els vostres visitants quan van accedir al vostre lloc web.", + "getCountryDocumentation": "Aquest informe mostra el país on estaven els vostres visitants quan van accedir al vostre lloc web.", + "getRegionDocumentation": "Aquest informe mostra la regió on estaven els vostres visitants quan van accedir al vostre lloc web.", + "HowToInstallApacheModule": "Com instal·lo el módul GeoIP per l'Apache?", + "HowToInstallGeoIPDatabases": "Com puc obtenir una base de dades GeoIP?", + "HowToInstallGeoIpPecl": "Com instal·lo l'extensió GeoIP PECL?", + "HowToInstallNginxModule": "Com instal·lo el modul GeoIP per Nginx?", + "HowToSetupGeoIP": "Com configuració la geocalitazció acurada amb GeoIP", + "HowToSetupGeoIP_Step1": "%1$sDescàrrega%2$s la base de dades GeoLite City de %3$sMaxMind%4$s.", + "HowToSetupGeoIP_Step2": "Extraieu el aquest fitxer i copieu el resultat, %1$s al subdirectori %2$smisc%3$s del Piwik (ho podeu fer per FTP o per SSH).", + "HowToSetupGeoIP_Step3": "Recàrrega aquesta pantalla. El proveïdor %1$sGeoIP (PHP)%2$s està ara %3$s instal·lat %4$s. Seleccioneu-lo.", + "HowToSetupGeoIP_Step4": "I ja esta tot! Acabeu de configurar el Piwik per utilitzar GeoIP que vol dir que podeu veure les regions i les ciutats dels vostres visitants i una informació molt acurada dels seus països.", + "HowToSetupGeoIPIntro": "Sembla que no teniu configurada la Geolocalització. Es tracta d'una característica molt útil, i sense ella no podreu veure de forma acurada la localització completa dels vostres visitants. Tot seguit us expliquem com podeu començar a fer-la servir:", + "HttpServerModule": "Mòdul del Servidor HTTP", + "InvalidGeoIPUpdatePeriod": "Període invàlid de l'actualització GeoIP: %1$s. Els valors vàlids són %2$s.", + "IPurchasedGeoIPDBs": "He comprat %1$suna base de dades més acurada de MaxMind%2$s i vull utilitzar les actualitzacions automàtiques", + "ISPDatabase": "Base de dades de ISP", + "IWantToDownloadFreeGeoIP": "Vull utilitzar la base de dades GeoIP gratuïta...", + "Latitude": "Latitud", + "Location": "Localització", + "LocationDatabase": "Base de dades de Localització", + "LocationDatabaseHint": "Una base de dades de localització és una base de dades de països, regions o ciutats.", + "LocationProvider": "Proveïdor de localitzacions", + "Longitude": "Longitud", + "NoDataForGeoIPReport1": "No hi ha informació per aquest informe perquè la informació de localització no esta disponible o l'adreça IP del visitant no pot ser geolocalitzada.", + "NoDataForGeoIPReport2": "Per activar la geolocalització acurada, canvieu les preferències %1$saquí%2$s i utilitzeu una %3$sbase de dades a nivell de ciutat%4$s.", + "Organization": "Organització", + "OrgDatabase": "Base de dades d'Organització", + "PeclGeoIPNoDBDir": "El mòdul de PECL està cercant la base de dades a %1$s, però aquest directori no existeix. Sisplau, creu-lo i afegiu bases de dades de Geolocalització allí. Alternativament, podeu modificar el paràmetre %2$s del vostre fitxer php.ini per utilizar el directori correcte.", + "PeclGeoLiteError": "La vostra base de dades de Geocalització es troba a %1$s amb el nom %2$s. Desafortunadament, el mòdul PECL no la pot reconèixer amb aquest nom. Sisuaplau, renombreu-la a %3$s.", + "PiwikNotManagingGeoIPDBs": "El Piwik actualment no gestiona cap base de dades de Geolocalització", + "PluginDescription": "El informes que contenen informació sobra la localització (pais, regió, ciutat i coordenades geogràfiques (latitud\/longitud)", + "Region": "Regió", + "SetupAutomaticUpdatesOfGeoIP": "Configureu les actualitzacions automàtiques de les bases de Dades de Geolocalització.", + "SubmenuLocations": "Localització", + "TestIPLocatorFailed": "El Piwik ha provat de localitzar la IP coneguda (%1$s), però el servidor ha retornat %2$s. Si el proveïdor està correctament configurat hauria d'haver retornat %3$s.", + "ThisUrlIsNotAValidGeoIPDB": "El fitxer descarregat no es una Base de dades de Geolocalització vàlida. Sisplau, reviseu la URL o descarregueu el fitxer manualment.", + "ToGeolocateOldVisits": "Per obtenir la localització de les visites anteriors, utilitzeu el script descrit %1$saquí%2$s.", + "UnsupportedArchiveType": "S'ha trobat un tipus de fitxer no soportat %1$s.", + "WidgetLocation": "Ubicació del visitant" + }, + "UserCountryMap": { + "map": "mapa" + }, + "UserSettings": { + "BrowserFamilies": "Motors dels navegadors", + "Browsers": "Navegadors", + "ColumnBrowser": "Navegador", + "ColumnBrowserFamily": "Família del navegador", + "ColumnBrowserVersion": "Versió del navegador", + "ColumnConfiguration": "Configuració", + "ColumnOperatingSystem": "Sistema operatiu", + "ColumnResolution": "Resolució", + "ColumnTypeOfScreen": "Tipus de pantalla", + "Configurations": "Configuracions", + "GamingConsole": "Consola de jocs", + "Language_aa": "Afar", + "Language_ab": "Abkhazian", + "Language_ae": "Avestan", + "Language_af": "Africaans", + "Language_ak": "Akan", + "Language_am": "Amharic", + "Language_an": "Aragonès", + "Language_ar": "Aràbic", + "Language_as": "Assamès", + "Language_av": "Avaric", + "Language_ay": "Aymara", + "Language_az": "Azerbaidjan", + "Language_ba": "Bashkir", + "Language_be": "Bielorúss", + "Language_bg": "Búlgar", + "Language_bh": "Bihari", + "Language_bi": "Bislama", + "Language_bm": "Bambara", + "Language_bn": "Bengalí", + "Language_bo": "Tibetà", + "Language_br": "Bretó", + "Language_bs": "Bosnià", + "Language_ca": "Català", + "Language_ce": "Txetxè", + "Language_ch": "Chamorro", + "Language_co": "Cors", + "Language_cr": "Cree", + "Language_cs": "Txec", + "Language_cu": "Eslau", + "Language_cv": "Chuvash", + "Language_cy": "Gal·lès", + "Language_da": "Danès", + "Language_de": "Alemany", + "Language_dv": "Divehi", + "Language_dz": "Dzongkha", + "Language_ee": "Ewe", + "Language_el": "Grec", + "Language_en": "Anglès", + "Language_eo": "Esperanto", + "Language_es": "Castellà", + "Language_et": "Estònia", + "Language_eu": "Basc", + "Language_fa": "Persa", + "Language_ff": "Fulah", + "Language_fi": "Finès", + "Language_fj": "Fiji", + "Language_fo": "Illes Fèroe", + "Language_fr": "Francès", + "Language_fy": "Frisian de l'est", + "Language_ga": "Irlandès", + "Language_gd": "Gaèlic escocès", + "Language_gl": "Gallec", + "Language_gn": "Guaraní", + "Language_gu": "Gujarati", + "Language_gv": "Illa de Man", + "Language_ha": "Hausa", + "Language_he": "Hebreu", + "Language_hi": "Hindi", + "Language_ho": "Hiri Motu", + "Language_hr": "Croat", + "Language_ht": "Creole haitià", + "Language_hu": "Hongarès", + "Language_hy": "Armeni", + "Language_hz": "Herero", + "Language_ia": "Interlingua", + "Language_id": "Indonesi", + "Language_ie": "Interlingue", + "Language_ig": "Igbo", + "Language_ii": "Sichuan Yi", + "Language_ik": "Inupiaq", + "Language_io": "Ido", + "Language_is": "Islandès", + "Language_it": "Italià", + "Language_iu": "Inuktitut", + "Language_ja": "Japonès", + "Language_jv": "Javanès", + "Language_ka": "Georgià", + "Language_kg": "Kongo", + "Language_ki": "Kukuyu", + "Language_kj": "Kuanyama", + "Language_kk": "Kazakhstan", + "Language_kl": "Groenlàndia", + "Language_km": "Khmer Central", + "Language_kn": "Kannada", + "Language_ko": "Coreà", + "Language_kr": "Kanuri", + "Language_ks": "Kashmiri", + "Language_ku": "Kurd", + "Language_kv": "Komi", + "Language_kw": "Cornish", + "Language_ky": "Kirghiz", + "Language_la": "LLatí", + "Language_lb": "Luxemburguès", + "Language_lg": "Ganda", + "Language_li": "Limburguès", + "Language_ln": "Lingala", + "Language_lo": "Lao", + "Language_lt": "Lituà", + "Language_lu": "Luba-Katanga", + "Language_lv": "Letó", + "Language_mg": "Malgaix", + "Language_mh": "Illes Marshall", + "Language_mi": "Maori", + "Language_mk": "Macedoni", + "Language_ml": "Malayalam", + "Language_mn": "Mongol", + "Language_mr": "Marathi", + "Language_ms": "Malai", + "Language_mt": "Maltès", + "Language_my": "Birmà", + "Language_na": "Nauru", + "Language_nb": "Bokmal Norueg", + "Language_nd": "Ndebele del Nord", + "Language_ne": "Nepalès", + "Language_ng": "Ndonga", + "Language_nl": "Holandès", + "Language_nn": "Nynorsk de Noruega", + "Language_no": "Noruec", + "Language_nr": "Ndebele del Sud", + "Language_nv": "Navajo", + "Language_ny": "Chichewa", + "Language_oc": "Occità", + "Language_oj": "Ojibwa", + "Language_om": "Oroma", + "Language_or": "Oriya", + "Language_os": "Ossètia", + "Language_pa": "Panjabí", + "Language_pi": "Pali", + "Language_pl": "Polonès", + "Language_ps": "Pashto", + "Language_pt": "Portuguès", + "Language_qu": "Quechua", + "Language_rm": "Retorromànic", + "Language_rn": "Rundi", + "Language_ro": "Romanès", + "Language_ru": "Rus", + "Language_rw": "Kinyarwanda", + "Language_sa": "Sànscrit", + "Language_sc": "Sardo", + "Language_sd": "Sindhi", + "Language_se": "Sami del Nord", + "Language_sg": "Sango", + "Language_si": "Sinhala", + "Language_sk": "Eslovac", + "Language_sl": "Eslovè", + "Language_sm": "Samoa", + "Language_sn": "Shona", + "Language_so": "Somali", + "Language_sq": "Albanès", + "Language_sr": "Serbi", + "Language_ss": "Swati", + "Language_st": "Sotho", + "Language_su": "Sudanès", + "Language_sv": "Suec", + "Language_sw": "Swahili", + "Language_ta": "Tamil", + "Language_te": "Telugu", + "Language_tg": "Tajik", + "Language_th": "Thai", + "Language_ti": "Tigrinya", + "Language_tk": "Turkmenistan", + "Language_tl": "Tagalog", + "Language_tn": "Tswana", + "Language_to": "Tonga", + "Language_tr": "Turc", + "Language_ts": "Tsonga", + "Language_tt": "Tatar", + "Language_tw": "Twi", + "Language_ty": "Tahitian", + "Language_ug": "Uighur", + "Language_uk": "Ucranià", + "Language_ur": "Urdu", + "Language_uz": "Uzbec", + "Language_ve": "Venda", + "Language_vi": "Vietnamita", + "Language_vo": "Volapük", + "Language_wa": "Valònia", + "Language_wo": "Wolof", + "Language_xh": "Xhosa", + "Language_yi": "Jiddisch", + "Language_yo": "Yoruba", + "Language_za": "Chuang", + "Language_zh": "Xinès", + "Language_zu": "Zulu", + "LanguageCode": "Codi de l'idioma", + "MobileVsDesktop": "Escriptori vs. Mòbil", + "OperatingSystemFamily": "Família del Sistema Operatiu", + "OperatingSystems": "Sistemes operatius", + "PluginDescription": "Informe sobre les preferències del usuari: Navegador, Família del navegador, Sistema Operatiu, Extensions, Resolució, Preferències Globals", + "PluginDetectionDoesNotWorkInIE": "Nota: La detecció d'extensions no funciona amb Internet Explorer. L'informe es basa nomes amb navegadors diferents de l'Internet Explorer", + "Resolutions": "Resolucions", + "VisitorSettings": "Configuració del visitant", + "WideScreen": "Pantalla panoràmica", + "WidgetBrowserFamilies": "Navegadors per motor", + "WidgetBrowserFamiliesDocumentation": "Aquest gràfic mostra els navegadors dels vostres visitants dividits en famílies. %s La informació més important per als desenvolupadors web es quin tipus de sistema de renderització estan utilitzant els seus visitants. Les etiquetiquetes contenen els noms dels sistemes, seguit pel navegador més comú utilitzant aquest sistema.", + "WidgetBrowsers": "Navegadors", + "WidgetBrowsersDocumentation": "Aquest informe conté informació sobre quin tipus de navegador està utilitzant els vostres visitants. Cada versió del navegador es llista per separat.", + "WidgetBrowserVersion": "Versió del navegador", + "WidgetGlobalVisitors": "Configuracions globals dels visitants", + "WidgetGlobalVisitorsDocumentation": "Aquest informe mostra les configuracions més comuns que tenen els vostres visitants. Una configuració es la combinació de Sistema Operatiu, tipus de navegador i resolució de pantalla.", + "WidgetOperatingSystems": "Sistemes operatius", + "WidgetPlugins": "Llistat de connectors", + "WidgetPluginsDocumentation": "Aquest informe mostra quines extensions tenen els vostres visitants activades. Aquesta informació pot ser important per determinar la forma correcta de mostrar el contingut.", + "WidgetResolutions": "Resolucions", + "WidgetWidescreen": "Normal \/ panoràmica" + }, + "UsersManager": { + "AddUser": "Afegeix un usuari nou", + "Alias": "Àlies", + "AllWebsites": "Tots els llocs", + "ApplyToAllWebsites": "Aplica a tots els llocs", + "ChangeAllConfirm": "Realment voleu canviar els permisos de '%s' a tots els llocs web?", + "ChangePasswordConfirm": "Canviar la contrasenya implica canviar el toquen d'autenticació del 'usuari. Esteu segurs que voleu continuar?", + "ClickHereToDeleteTheCookie": "Feu click aquí per eliminar la galeta i permetre al Piwik rastrejar les vostres visites.", + "ClickHereToSetTheCookieOnDomain": "Feu click aquí per guardar una galeta que exclourà les vostres visitis al Piwik de %s", + "DeleteConfirm": "Esteu segur que voleu eliminar l'usuari %s?", + "Email": "Correu-e", + "EmailYourAdministrator": "%1$sEnvieu un email al vostre administrador explicant el problema%2$s.", + "ExceptionAccessValues": "El paràmetre permisos ha de tenir un dels següents valors: [ %s ]", + "ExceptionAdminAnonymous": "No podeu donar permisos d'administració a l'usuari 'anonymous' (anònim).", + "ExceptionDeleteDoesNotExist": "L'usuari '%s' no existeix i, per tant, no es pot esborrar.", + "ExceptionEditAnonymous": "L'usuari anònim no es pot editar o esborrar. El Piwik el fa servir per als usuaris que no han iniciat encara la sessió. Per exemple, podeu fer públiques les vostres estadístiques garantint el permís 'vista' a l'usuari 'anonymous' (anònim).", + "ExceptionEmailExists": "La direcció de correu-e '%s' ja està en un altre compte.", + "ExceptionInvalidEmail": "La direcció de correu-e no té un format vàlid.", + "ExceptionInvalidLoginFormat": "El nom d'usuari ha de tenir una longitud d'entre %1$s i %2$s caràcters i estar format únicament per lletres, xifres o els caràcters '_', '-' o '.'", + "ExceptionInvalidPassword": "La longitud de la contrasenya ha de estar entre %1$s i %2$s caràcters.", + "ExceptionLoginExists": "L'usuari '%s' ja existeix.", + "ExceptionUserDoesNotExist": "L'usuari '%s' no existeix.", + "ExcludeVisitsViaCookie": "Exclou les teues visites utilitzant cookies", + "ForAnonymousUsersReportDateToLoadByDefault": "Pels usuaris anònims, introduïu la data dels informes per defecte", + "IfYouWouldLikeToChangeThePasswordTypeANewOne": "Si voleu canviar la contrasenya teclegeu-n'hi una de nova, sinó deixeu-ho en blanc.", + "InjectedHostCannotChangePwd": "Actualment esteu visitan amb un host desconegut (%1$s). No podeu canviar la vostra contrasenya fins que corregiu aquest problema.", + "MainDescription": "Decidiu quin tipus d'accés té cada usuari al Piwik. També podeu configurar tots els llocs web de cop.", + "ManageAccess": "Gestiona els permisos", + "MenuAnonymousUserSettings": "Preferències de l'usuari anònim", + "MenuUsers": "Usuaris", + "MenuUserSettings": "Preferències d'usuari", + "NoteNoAnonymousUserAccessSettingsWontBeUsed2": "Nota: No podeu canviar la configuració en aquest secció, perquè no teniu cap lloc web que pot ser visitat per usuaris anònims.", + "PluginDescription": "Gestió dels usuaris a Piwik: Afegiu un nou Usuari, Editeu-n'hi un d'existent o actualitzeu els permisos. Totes aquestes accions també estan disponibles a traves de l'API.", + "PrivAdmin": "Administració", + "PrivNone": "Sense accés", + "PrivView": "Vista", + "ReportDateToLoadByDefault": "Data dels informes per defecte", + "ReportToLoadByDefault": "Informe per defecte", + "TheLoginScreen": "Pàgina d'entrada", + "ThereAreCurrentlyNRegisteredUsers": "Actualment hi ha %s usuaris registrats", + "TypeYourPasswordAgain": "Introduïu de nou la nova contrasenya.", + "User": "Usuari", + "UsersManagement": "Gestiona els usuaris", + "UsersManagementMainDescription": "Creeu nous usuaris o actualitzeu els existents. Podeu configurar els seus permisos a dalt.", + "WhenUsersAreNotLoggedInAndVisitPiwikTheyShouldAccess": "Quan un usuari no esta identificat i visita el Piwik, han d'accedir", + "YourUsernameCannotBeChanged": "No es pot canviar el vostre nom d'usuari", + "YourVisitsAreIgnoredOnDomain": "%sLes vostres visites són ignorades pel Piwik %s %s (la galeta d'ignorància del Piwik s'ha trobat al vostre navegador).", + "YourVisitsAreNotIgnored": "%sLes vostres visites no són ignorades pel Piwik%s (la galeta d'ignorància del Piwik no s'ha trobat al vostre navegador)." + }, + "VisitFrequency": { + "ColumnActionsByReturningVisits": "Accions dels visitants antics", + "ColumnAverageVisitDurationForReturningVisitors": "Duració mitja d'una visita que retorna (en seg.)", + "ColumnAvgActionsPerReturningVisit": "Mitja d'accions per visitant que retorna", + "ColumnBounceCountForReturningVisits": "Nombre de rebots dels visitants que retornen", + "ColumnBounceRateForReturningVisits": "Raó de rebots", + "ColumnMaxActionsInReturningVisit": "Màxim d'accions en una vista que torna", + "ColumnNbReturningVisitsConverted": "Nombre de conversions per als visitants que retornen", + "ColumnReturningVisits": "Visitants que retornen (antics)", + "ColumnSumVisitLengthReturning": "Temps total dels visitants que retornen (en segons)", + "ColumnUniqueReturningVisitors": "Visitants únics que retornen", + "PluginDescription": "Mostra informació sobre els visitants que retornen comparat amb els visitants per primera vegada.", + "ReturnActions": "Els visitants que han tornat han fet %s accions", + "ReturnAverageVisitDuration": "%s duració mitja de la vistia dels usuaris que retornen.", + "ReturnAvgActions": "%s accions per visitant que retorna", + "ReturnBounceRate": "%s visitants antics han rebotat (abandonat el lloc després de veure una pàgina)", + "ReturningVisitDocumentation": "Un visitant que retorna és (al contrari d'un visitant nou) un usuari que ja ha visitat el vostre lloc web almenys una vegada en el passat.", + "ReturningVisitsDocumentation": "Això es una visió global dels visitants que retornen.", + "ReturnVisits": "Han tornat %s visitants", + "SubmenuFrequency": "Freqüència", + "WidgetGraphReturning": "Gràfic de les visites que han tornat", + "WidgetOverview": "Resum de la freqüència" + }, + "VisitorInterest": { + "BetweenXYMinutes": "%1$s-%2$s min", + "BetweenXYSeconds": "%1$s-%2$ss", + "ColumnPagesPerVisit": "Pàgines per visita", + "ColumnVisitDuration": "Durada de la visita", + "Engagement": "Compromís", + "NPages": "%s pàgines", + "OneMinute": "1 minut", + "OnePage": "1 pàgina", + "PluginDescription": "Informes sobre l'interes del Visitant: Nombre de pàgines vistes, temps de visita del lloc web.", + "PlusXMin": "%s min", + "VisitNum": "Nombre de visites", + "VisitsByDaysSinceLast": "Visites per dia des de l'última visita", + "visitsByVisitCount": "Visites per nombre de visita", + "VisitsPerDuration": "Visites segons la durada", + "VisitsPerNbOfPages": "Visites segons el nombre de pàgines", + "WidgetLengths": "Durada de les visites", + "WidgetLengthsDocumentation": "En aquest informe tu pots veure quantes visites han tingut una determinada duració. Inicialment, l'informe es mostra com un núvol d'etiquetes, on les duracions amb visites es mostren més gran.", + "WidgetPages": "Pàgines per visita", + "WidgetPagesDocumentation": "En aquest informe podeu veure quantes visites han produït un nombre de pàgines vistes. Inicialment, la informació es mostra com un núvol d'etiquetes, on els nombres més comuns es mostren amb una mida més gran.", + "WidgetVisitsByDaysSinceLast": "Visites per dia des de l'última visita", + "WidgetVisitsByDaysSinceLastDocumentation": "En aquest informe, podeu veure quantes visites han estat de visitant que la seva visita va ser fa un cert nombre de dies.", + "WidgetVisitsByNumDocumentation": "En aquest informe podeu veure qui va ser la visita número N. Per exemple, els visitants que han vist el vostre lloc web com a mínim N vegades." + }, + "VisitsSummary": { + "AverageVisitDuration": "%s duració mitja de la visita", + "GenerateQueries": "S'ha executat un total de %s consultes", + "GenerateTime": "S'han trigat %s segons en generar la pàgina", + "MaxNbActions": "Hi ha %s accions màximes en una visita", + "NbActionsDescription": "%s accions", + "NbActionsPerVisit": "%s accions (visualitzacions de pàgina, descàrregues i enllaços exteriors) per visita", + "NbDownloadsDescription": "%s descàrregues", + "NbKeywordsDescription": "%s paraules clau úniques", + "NbOutlinksDescription": "%s enllaços externs", + "NbPageviewsDescription": "%s visualitzacions de pàgina", + "NbSearchesDescription": "%s cerques totals al lloc web", + "NbUniqueDownloadsDescription": "%s descàrregues úniques", + "NbUniqueOutlinksDescription": "%s enllaços externs únics", + "NbUniquePageviewsDescription": "%s visualitzacions de pàgina úniques", + "NbUniqueVisitors": "%s visitants únics", + "NbVisitsBounced": "%s visites han rebotat (abandonat el lloc després de veure una pàgina)", + "PluginDescription": "Informa de les principals analítiques: visitants, visitants únics, nombre d'accions, rati de rebots,etc.", + "VisitsSummary": "Resum de les visites", + "VisitsSummaryDocumentation": "Aquesta és una visió general de l'evolució visita.", + "WidgetLastVisits": "Gràfic de les darreres visites", + "WidgetOverviewGraph": "Resum amb gràfic", + "WidgetVisits": "Resum de les visites" + }, + "VisitTime": { + "ColumnLocalTime": "Hora local", + "ColumnServerTime": "Hora del servidor", + "DayOfWeek": "Dia de la setmana", + "LocalTime": "Visites segons l'hora local", + "NHour": "%sh", + "PluginDescription": "Informe sobre l'hora local i la del servidor. L'hora del servidor pot ser útil per programar un manteniment al lloc web.", + "ServerTime": "Visites segons l'hora del servidor", + "SubmenuTimes": "Hores", + "VisitsByDayOfWeek": "Visites per día de la setmana", + "WidgetByDayOfWeekDocumentation": "Aquest gràfic mostre el nombre de visites que ha rebut el vostre lloc web cada dia de la setmana.", + "WidgetLocalTime": "Visites segons l'hora local", + "WidgetLocalTimeDocumentation": "Aquest gràfic mostra a quina hora era a %s la zona horària del visitant %s durant la seva visita.", + "WidgetServerTime": "Visites segons l'hora del servidor", + "WidgetServerTimeDocumentation": "Aquest gràfic quina hora era a la %s zona horària del servidor %s durant la visita." + }, + "Widgetize": { + "OpenInNewWindow": "Obrir en una nova finestra", + "PluginDescription": "Aquesta extensió permet exportar qualsevol Giny de Piwik al vostre bloc, lloc web, Igoogle i Netvibes!", + "TopLinkTooltip": "Exporteu informes i ginys de Piwik i incrusteu el Tauler de Control a la vostra aplicació com un iframe." + } +} \ No newline at end of file diff --git a/www/analytics/lang/cs.json b/www/analytics/lang/cs.json new file mode 100644 index 00000000..7fbbfa81 --- /dev/null +++ b/www/analytics/lang/cs.json @@ -0,0 +1,1688 @@ +{ + "Actions": { + "AvgGenerationTimeTooltip": "Průměr dle %s přístupů %s mezi %s a %s", + "ColumnClickedURL": "URL prokliku", + "ColumnClicks": "Prokliků", + "ColumnClicksDocumentation": "Počet kliknutí na odkaz v závislosti na čase.", + "ColumnDownloadURL": "URL stažení", + "ColumnEntryPageTitle": "Titulek vstupní stránky", + "ColumnEntryPageURL": "URL vstupní stránky", + "ColumnExitPageTitle": "Titulek výstupní stránky", + "ColumnExitPageURL": "URL výstupní stránky", + "ColumnNoResultKeyword": "Hledané klíčové slovo nebylo nalezeno", + "ColumnPageName": "Jméno stránky", + "ColumnPagesPerSearch": "Stránka hledaných výsledků", + "ColumnPagesPerSearchDocumentation": "Návštěvník hledal na Vaší strance a občas klikla na \"Další\" k zobrazení více výsledků. Toto je průměrné číslo stránky s výsledky vyhledávání zobrazené na toto klíčové slovo.", + "ColumnPageURL": "URL stránky", + "ColumnSearchCategory": "Hledání kategorie", + "ColumnSearches": "Vyhledávání", + "ColumnSearchesDocumentation": "Počet uživatelů, kteří hledali tyto klíčová slova ve vyhledavači na Vašich stránkách.", + "ColumnSearchExits": "% Nalezených výstupů", + "ColumnSearchExitsDocumentation": "Procento návštěv, kteří opustitli stránku po nalezení tohoto klíčového slova pomocí vyhledavače.", + "ColumnSearchResultsCount": "Počet nalezených výsledků", + "ColumnSiteSearchKeywords": "Unikátních klíčových slov", + "ColumnUniqueClicks": "Unikátních prokliků", + "ColumnUniqueClicksDocumentation": "Počet návštěv které klikli na tento odkaz. Pokud bylo na odkaz kliknuto vícekrát při jedné návštěvě, je kliknutí započítáno jen jednou.", + "ColumnUniqueDownloads": "Unikátních stažení", + "ColumnUniqueOutlinks": "Unikátní externí odkazy", + "DownloadsReportDocumentation": "V tomto hlášení můžete vidět soubory, které byly staženy. %s Které Piwik počítá jako stažené jsou jen ty, na které bylo kliknuto. Bez ohledu na to, jestli bylo stažení dokončeno nebo přerušeno. To Piwik neumí zjistit.", + "EntryPageTitles": "Vstupní titulky stránky", + "ExitPageTitles": "Výstupní titulky stránky", + "PageUrls": "URL stránky", + "PluginDescription": "Hlásí zobrazení stránek, odchozí odkazy a stažení. Zaznamenávání odchozích stránek a stahování je automatické!", + "SiteSearchKeyword": "Klíčová slova (Vyhledávání)", + "SubmenuPagesEntry": "Vstupní stránky", + "SubmenuPagesExit": "Odchozí stránky", + "SubmenuPageTitles": "Titulky stránek", + "SubmenuSitesearch": "Vyhledat stránku", + "WidgetEntryPageTitles": "Název titulky vstupní stránky", + "WidgetExitPageTitles": "Název titulky odchozí stránky", + "WidgetPagesEntry": "Vstupní stránky", + "WidgetPagesExit": "Výstupní stránky", + "WidgetPageTitles": "Titulky stránek", + "WidgetPageTitlesFollowingSearch": "Titulky stránky po hledání na stránce", + "WidgetPageUrlsFollowingSearch": "Stránky po hledání na stránce", + "WidgetSearchCategories": "Vyhledávání kategorií", + "WidgetSearchKeywords": "Klíčová slova hledané stránky", + "WidgetSearchNoResultKeywords": "Hledaná Klíčová slova bez výsledku hledání" + }, + "Annotations": { + "AddAnnotationsFor": "Přidat anotace k %s...", + "AnnotationOnDate": "Anotace na %1$s: %2$s", + "Annotations": "Anotace", + "ClickToDelete": "Kliknutím odstraníte tuto anotaci.", + "ClickToEdit": "Klikněte k úpravě této anotace.", + "ClickToEditOrAdd": "Kliknutím upravit nebo přidat novou anotaci", + "ClickToStarOrUnstar": "Kliknutím ohnodnotíte tuto anotaci", + "CreateNewAnnotation": "Vytvořit novou anotaci...", + "EnterAnnotationText": "Vložte poznámku...", + "HideAnnotationsFor": "Skrýt anotace %s...", + "IconDesc": "Zobrazit poznámky k tomuto časovému rozpětí.", + "IconDescHideNotes": "Skrýt poznámky zvoleného rozsahu.", + "LoginToAnnotate": "Přihlásit se pro vytvoření anotace.", + "NoAnnotations": "Nejsou zde žádné anotace k tomuto časovému rozpětí.", + "ViewAndAddAnnotations": "Zobrazit a přidat anotace k %s..." + }, + "API": { + "GenerateVisits": "Pokud pro dnešek nemáte data, můžete je nejprve vygenerovat pomocí zásuvného modulu %s. Můžete povolit zásuvný modul %s a poté kliknout na 'Generátor návštěv' v menu v administrační části Piwiku", + "KeepTokenSecret": "Tento token_auth je tajný jako vaše uživatelské jméno a heslo, %s neříkejte jej nikomu jinému %s!", + "LoadedAPIs": "Úspěšně načteno %s API", + "MoreInformation": "Pro více informací o API Piwiku se podívejte na %s Úvod do API Piwiku %s a %s Referenci API Piwiku %s", + "PluginDescription": "Všechna data v Piwiku jsou dostupná přes jednoduchá API. Tento zásuvný modul je vstupním bodem pro webovou službu, kterou můžete volat abyste získali data ve formátech xml, json, php, csv, atd.", + "QuickDocumentationTitle": "Rychlá dokumentace API", + "TopLinkTooltip": "Zpřístupněte Vaše Webové anylýzy programově skrze jednoduché API pomocí json, xml a dalších.", + "UserAuthentication": "Autentifikace uživatele", + "UsingTokenAuth": "Pokud chcete %s načíst data ze skritu, cronu, atd. %s Potřebujete přidat parameter %s k voláním API, které vyžadují příhlášení" + }, + "CoreAdminHome": { + "Administration": "Administrace", + "BrandingSettings": "Označení nastavení", + "ClickHereToOptIn": "Klikněte zde pro přihlášení.", + "ClickHereToOptOut": "Klikněte zde odhlásit.", + "EmailServerSettings": "Nastavení server E-mailu", + "ForBetaTestersOnly": "Pouze pro beta testery", + "ImageTracking": "Sledováním obrázkem", + "ImageTrackingIntro1": "Pokud má návštěvník vypnutý JavaScript nebo nemůže být JavaScript použit. Můžete využít obrázku k měření a sledování Vaší návštěvnosti.", + "ImageTrackingLink": "Odkaz pro sledování obrázkem", + "ImportingServerLogs": "Důležitá serverová hlášení.", + "JavaScriptTracking": "Sledování javascriptem", + "JSTracking_CampaignKwdParam": "Klíčové slovo kampaně", + "JSTracking_CampaignNameParam": "Název kampaně", + "JSTracking_MergeSubdomainsDesc": "Pokud uživatel navštíví %1$s a %2$s, budou zaznamenáni jako unikátní uživatelé.", + "JSTracking_PageCustomVarsDesc": "Například proměnná s názvem 'Kategorie' a hodnotou \"White papers\"", + "LatestBetaRelease": "Poslední testovací verze", + "LatestStableRelease": "Poslední stabilní verze", + "LogoUpload": "Vyberte logotyp", + "MenuDiagnostic": "Diagnostika", + "MenuGeneralSettings": "Hlavní nastavení", + "MenuManage": "Správa", + "OptOutForYourVisitors": "Piwik odhlášení pro Vaše návštěvníky", + "PiwikIsInstalledAt": "Piwik je nainstalován na", + "PluginDescription": "Administrační část Piwiku", + "PluginSettings": "Nastavení pluginu", + "StableReleases": "Piwik je důležitý nástroj pro měření, doporučujeme vždy používat nejnovější vydání. Pokud používáte nejnovější beta verzi a našli jste chyby, prosíme o jejich nahlášení %spřímo zde %s.", + "TrackAGoal": "Sledovat cíl", + "TrackingCode": "Sledovací kód", + "UseCustomLogo": "Použít vlastní logotyp", + "WithOptionalRevenue": "volitelné hodnoty", + "YouAreOptedIn": "Návštěva této stránky se aktuálně zaznamenává pomocí Piwik Wabanalyse.", + "YouMayOptOut": "Zde se můžete rozhodnou, zda se smí ve Vašem prohlížeči ukládat jedinečná analytická data “ Cookies” , a zda umožníte provozovateli webové stránky shromažďovat a analyzovat různé statistické údaje.", + "YouMayOptOutBis": "Pokud jste se rozhodli že ne, klikněte na přiložený odkaz pro uložení deaktivačního Cookie ve Vašem prohlížeči." + }, + "CoreHome": { + "CategoryNoData": "V této kategorii nejsou k dispozici žádná data. Zkuste \"Zobrazit všechny výsledky\".", + "CheckForUpdates": "Zkontrolovat aktualizace", + "CheckPiwikOut": "Vyzkoušejte Piwik!", + "DateFormat": "%longDay% %day% %longMonth% %longYear%", + "Default": "výchozí", + "DonateCall1": "Piwik Vás nic nestojí, ale to neznemená, že nás vývoj nic nestojí.", + "DonateCall2": "Piwik potřebuje Vaši podporu, aby mohl růst a prosperovat.", + "DonateFormInstructions": "Klikněte na posuvník ke zvolení částky, poté klikněte na tlačítko Subscribe.", + "HowMuchIsPiwikWorth": "Jak moc si ceníte Piwiku?", + "JavascriptDisabled": "Musíte mít zapnutý JavaScript, jinak Piwik nezobrazíte.
    Nebo jen není Váš prohlížeč mezi podporovanými.
    Pro běžné zobrazení zapněte JavaScript ve svém prohlížeči, poté %1$szkuste znovu%2$s.
    ", + "LongMonthFormat": "%longYear%, %longMonth%", + "MakeADifference": "Udělej změnu: %1$sPřispěj na vývoj%2$s nové verze Piwik 2.0", + "MakeOneTimeDonation": "Provést jednorázový dar.", + "NoPrivilegesAskPiwikAdmin": "Jste přihlášen jako '%s' ale zdá se, že nemáte povolení k nastavení Piwik. %s Zeptejte se Vašeho Piwik administrátora (klikem na email)%s aby Vám dal 'view' přístup na stránku.", + "PageOf": "%1$s z %2$s", + "PeriodDay": "Den", + "PeriodDays": "dnů", + "PeriodMonth": "Měsíc", + "PeriodMonths": "měsíců", + "PeriodRange": "Rozsah", + "PeriodWeek": "Týden", + "PeriodWeeks": "týdnů", + "PeriodYear": "Rok", + "PeriodYears": "let", + "PluginDescription": "Struktura hlášení", + "ReportGeneratedOn": "Hlášení vygenerované %s", + "ReportGeneratedXAgo": "Hlášení vygenerovaní již %s", + "SharePiwikShort": "Piwik! Bezplatná webová analytika. Vlastni svá data.", + "ShareThis": "Sdílet", + "ShortDateFormat": "%shortDay% %day% %shortMonth%", + "ShortDateFormatWithYear": "%day% %shortMonth% %shortYear%", + "ShortMonthFormat": "%shortMonth% %longYear%", + "ShortWeekFormat": "%dayFrom% %shortMonthFrom% - %dayTo% %shortMonthTo% %shortYearTo%", + "ShowJSCode": "Zobrazit JavaScriptový kód ke vložení", + "SupportPiwik": "Podpořte Piwik!", + "TableNoData": "Žádna data", + "ThereIsNoDataForThisReport": "Pro toto hlášení nejsou k dispozici žádná data", + "ViewAllPiwikVideoTutorials": "Zobrazit všechny návody Piwik", + "WebAnalyticsReports": "Hlášení", + "YouAreUsingTheLatestVersion": "Používáte nejnovější verzi Piwik." + }, + "CorePluginsAdmin": { + "ActionActivatePlugin": "Aktivovat plugin", + "ActionInstall": "Instalovat", + "ActionUninstall": "Odinstalovat", + "Activate": "Povolit", + "Activated": "Aktivován", + "Active": "Povolen", + "Activity": "Aktivita", + "AuthorHomepage": "Domovská stránka autorů", + "Authors": "Autoři", + "Deactivate": "Zakázat", + "Developer": "Vývojář", + "History": "Historie", + "Inactive": "Zakázán", + "LicenseHomepage": "Povolená domovská stránka", + "MainDescription": "Zásuvné moduly rozšiřují funkce Piwiku. Po instalaci zásuvného modulu jej můžete povolit nebo zakázat zde.", + "MenuPlatform": "Platforma", + "PluginDescription": "Administrační rozhraní zásuvných modulů", + "PluginHomepage": "Domovká stránka zásuvného modulu", + "PluginsManagement": "Správa zásuvných modulů", + "Status": "Stav", + "Theme": "Šablona", + "Themes": "Šablony", + "ThemesManagement": "Správce šablon", + "Version": "Verze" + }, + "CoreUpdater": { + "ClickHereToViewSqlQueries": "Klikněte zde, abyste viděli a zkopírovali SQL dotazy, které mají být spuštěny", + "CreatingBackupOfConfigurationFile": "Vytvářím zálohu konfiguračního souboru v %s", + "CriticalErrorDuringTheUpgradeProcess": "Při aktualizaci nastala kritická chyba:", + "DatabaseUpgradeRequired": "Vyžadováno povýšení databáze", + "DownloadingUpdateFromX": "Stahuji aktualizaci z %s", + "DownloadX": "Stáhnout %s", + "EmptyDatabaseError": "Databáze %s je prázdná. Musíte upravit konfigurační soubor Piwiku, nebo jej vymazat", + "ErrorDIYHelp": "Pokud jste pokročilý uživatel a zaznamenal jste chybu při aktualizaci databáze:", + "ErrorDIYHelp_1": "identifikujte problém (např.: memory_limit nebo max_execution_time)", + "ErrorDIYHelp_2": "spusťte zbývající dotazy v aktualizaci, která selhala", + "ErrorDIYHelp_3": "ručně aktualizujte tabulku `option` ve vaší databázi Piwiku, nastavte hodnotu version_core na verzi selhané aktualizace", + "ErrorDIYHelp_4": "abyste dokončili aktualizaci znovu spusťte aktualizaci (přes WWW prohlížeč, nebo příkazovou řádku", + "ErrorDIYHelp_5": "nahlašte problém a řešení, aby mohl být Piwik vylepšen", + "ErrorDuringPluginsUpdates": "Chyba při aktualizací zásuvných modulů:", + "ExceptionAlreadyLatestVersion": "Váš Piwik %s již je aktualizován", + "ExceptionArchiveEmpty": "Archiv je prázdný", + "ExceptionArchiveIncompatible": "Nekompatibilní archiv: %s", + "ExceptionArchiveIncomplete": "Archív je nekompletní: některé soubory chybí (např.: %s).", + "HelpMessageContent": "Zkontrolujte %1$s Piwik FAQ %2$s , který vysvětluje nejčastějsí chybi při aktualizaci. %3$s Požádejte vašeho systémového administrátora - může vám pomoct s chybou, která je nejčastěji způsobena nastavením serveru MySQL.", + "HelpMessageIntroductionWhenError": "Níže je hlavní chybová hláška. Pomůže vám zjistit příčinu,. ale pokud budete potřebovat další pomoc:", + "HelpMessageIntroductionWhenWarning": "Aktualizace proběhla v pořádku, ale byly zaznamenány problémy. Prosím přečtěte si nižší popis. Pro další pomoc:", + "InstallingTheLatestVersion": "Instaluji poslední verzi", + "MajorUpdateWarning1": "Toto je důležitá aktualizace! Zabere více času, než obvykle.", + "NoteForLargePiwikInstances": "Důležitá poznámka pro rozsáhlé instalace Piwiku", + "NoteItIsExpectedThatQueriesFail": "Poznámka: Pokud budete SQL dotazy spouštět ručně, některé z nich selžou. V tom případě chyby jednoduše ignorujte a spusťte další dotazy", + "PiwikHasBeenSuccessfullyUpgraded": "Piwik byl úspěšně aktualizován!", + "PiwikUpdatedSuccessfully": "Piwik úspěšně aktualizován!", + "PiwikWillBeUpgradedFromVersionXToVersionY": "Databáze Piwiku bude aktualizována z verze %1$s na novou verzi %2$s.", + "PluginDescription": "Mechanismus aktualizace Piwiku", + "ReadyToGo": "Připraven pokračovat?", + "TheFollowingPluginsWillBeUpgradedX": "Následující zásuvné moduly budou aktualizovány: %s.", + "ThereIsNewVersionAvailableForUpdate": "Je k dispozici nová verze Piwiku", + "TheUpgradeProcessMayFailExecuteCommand": "Pokud máte velkou databázi Piwiku, aktualizace v prohlížeči může trvat dlouhou dobu. V této situaci můžete spustit aktualizaci z příkazového řádku: %s", + "TheUpgradeProcessMayTakeAWhilePleaseBePatient": "Aktualizace databáze může chvíli trvat, buďte prosím trpěliví.", + "UnpackingTheUpdate": "Rozbaluji aktualizaci", + "UpdateAutomatically": "Aktualizovat automaticky", + "UpdateHasBeenCancelledExplanation": "One Click aktualizace Piwiku byla stornována. Pokud nemůžete opravit chybu je doporučeno Piwik aktualizovat manuálně. %1$s Prosím zkontrolujte pro začátek %2$sDokumentaci k aktualizaci%3$s!", + "UpdateTitle": "Piwik › Update", + "UpgradeComplete": "Aktualizace je kompletní!", + "UpgradePiwik": "Aktiualizovat Piwik", + "VerifyingUnpackedFiles": "Ověřuji rozbalené soubory", + "WarningMessages": "Hlášky upozornění:", + "WeAutomaticallyDeactivatedTheFollowingPlugins": "Automaticky jsme zakázali následující zásuvné moduly: %s", + "YouCanUpgradeAutomaticallyOrDownloadPackage": "Můžete aktualizovat na verzi %s automaticky, nebo si stáhněte balíček a nainstalujte jej manuálně:", + "YouCouldManuallyExecuteSqlQueries": "Pokud nemůžete použít aktualizaci z příkazového řádku a Piwik nelze aktualizovat (díky vypršení časového limitu v databázi, prohlížeči, nebo z jakéhokoliv jiného důvodu), můžete spustit SQL dotazy ručně", + "YouMustDownloadPackageOrFixPermissions": "Piwik nemůže přepsat Vaší stávající instalaci. Můžete buď opravit oprávnění k adresářům\/souborům, nebo stáhnout balíček a nainstalovat verzi %s ručně", + "YourDatabaseIsOutOfDate": "Vaše databáze Piwiku je zastaralá a musí být aktualizována než budete pokračovat." + }, + "CustomVariables": { + "ColumnCustomVariableName": "Název vlastní proměnné", + "ColumnCustomVariableValue": "Hodnota vlastní proměnné", + "CustomVariables": "Vlastní proměnné", + "ScopePage": "rozsah stránky", + "ScopeVisit": "rozsah návštěvy" + }, + "Dashboard": { + "AddAWidget": "Přidat widget", + "AddPreviewedWidget": "Přidat widget v náhledu na nástěnku", + "ChangeDashboardLayout": "Změnit rozvržení nástěnky", + "CopyDashboardToUser": "Zkopírovat přehled uživateli", + "CreateNewDashboard": "Vytvořit novou nástěnku", + "Dashboard": "Nástěnka", + "DashboardCopied": "Přehled byl úspěšně nakopírován vybranému uživateli.", + "DashboardName": "Jméno nástěnky:", + "DashboardOf": "Nástěnka %s", + "DefaultDashboard": "Výchozí nástěnka - používáte výchozí rozvržení nástěnky", + "DeleteWidgetConfirm": "Jste si jistí, že chcete odstranit tento widget z nástěnky?", + "EmptyDashboard": "Prázdná nástěnka - vyberte si své oblíbené widgety", + "LoadingWidget": "Načítám widget, prosím čekejte...", + "ManageDashboard": "Správa nástěnky", + "Maximise": "Maximalizovat", + "Minimise": "Minimalizovat", + "NotUndo": "Nemůžete vrátit zpět tuto operaci.", + "PluginDescription": "Váše nástěnka analýzy Web stránek. Můžete si jí upravit: přidat nové widgety, změnit jejich pořadí. Každý uživatel přistupuje ke své vlastní nástěncei", + "RemoveDashboard": "Odstranit nástěnku", + "RemoveDashboardConfirm": "Opravdu chcete odstranit nástěnku: %s?", + "RenameDashboard": "Přejmenovat nástěnku", + "ResetDashboard": "Obnovit nástěnku", + "ResetDashboardConfirm": "Opravdu chcete obnovit nastavení nástěnky do továrního stavu?", + "SelectDashboardLayout": "Prosím vyberte nové rozvržení návštěnky", + "SelectWidget": "Zvolte widget pro přidání na nástěnku", + "SetAsDefaultWidgets": "Nastavit výchozí výběr widgetů", + "SetAsDefaultWidgetsConfirm": "Opravdu chcete nastavit toto rozvržení widgetů jako výchozí rozvržení?", + "TopLinkTooltip": "Zobraz analytické hlášení webu pro %s.", + "WidgetNotFound": "Widget nenalezen", + "WidgetPreview": "Náhled widgetu", + "WidgetsAndDashboard": "Widgety a Nástěnka" + }, + "Feedback": { + "ContactThePiwikTeam": "Kontaktujte tým Piwiku", + "DoYouHaveBugReportOrFeatureRequest": "Máte hlášení chyby, nebo námět na vylepšení?", + "IWantTo": "Chci:", + "LearnWaysToParticipate": "Naučit způsoby jak %s spolupracovat %s", + "ManuallySendEmailTo": "Prosím pošlete ručně zprávu", + "PluginDescription": "Pošlete ohlas teamu Piwiku. Sdílejte s námi vaše nápady a návrhy!", + "SendFeedback": "Odeslat odezvu", + "SpecialRequest": "Máte speciální požadavek na tým Piwiku?", + "ThankYou": "Děkujeme vám, že pomáháte Piwik dělat lepším!", + "TopLinkTooltip": "Řekni co si myslíš, nebo pořádek pomoc profesionála.", + "VisitTheForums": "Navštivte %s forum%s" + }, + "General": { + "AbandonedCarts": "Neobjednané košíky", + "AboutPiwikX": "O Piwiku %s", + "Action": "Akce", + "Actions": "Akce", + "Add": "Přidat", + "AfterEntry": "Po vložení dne", + "AllowPiwikArchivingToTriggerBrowser": "Spouštět archovování když jsou hlášení prohlížena ve Web prohlížeči", + "AllWebsitesDashboard": "Nástěnka pro všechny weby", + "And": "a", + "API": "API", + "ApplyDateRange": "Aplikovat vybraný rozsah", + "ArchivingInlineHelp": "Pro web stránky se střední, nebo vysokou návštěvností je doporučeno zakázat archivování Piwiku z web prohlížeče. Místo toho doporučujeme vytvoření úlohy pro cron", + "AuthenticationMethodSmtp": "Autentizační metoda SMTP", + "AverageOrderValue": "Průměrná hodnota objednávky", + "AveragePrice": "Průměrná cena", + "AverageQuantity": "Průměrné množství", + "BackToPiwik": "Zpět do Piwiku", + "Broken": "Rozbité", + "Cancel": "Zrušit", + "CannotUnzipFile": "Nelze rozbalit soubor %1$s: %2$s", + "ChangePassword": "Změnit heslo", + "ChooseDate": "Vyber datum", + "ChooseLanguage": "Zvolte jazyk", + "ChoosePeriod": "Zvolte období", + "ChooseWebsite": "Zvolte web", + "ClickHere": "Klikněte zde pro více informací", + "ClickToChangePeriod": "Klikni znovu pro změnu období.", + "Close": "Zavřít", + "ColumnActionsPerVisit": "Akcí za návštěvu", + "ColumnActionsPerVisitDocumentation": "Průměrní počet akcí (zobrazení stránek, stažení nebo externích odkazů) za návštěvu.", + "ColumnAverageGenerationTime": "Průměrný generovaný čas", + "ColumnAverageTimeOnPage": "Přůměrně času na stránce", + "ColumnAverageTimeOnPageDocumentation": "Průměrný celkový čas strávený na stránce (pouze konkrétní stránky),", + "ColumnAvgTimeOnSite": "Průměrně času na stránkách", + "ColumnAvgTimeOnSiteDocumentation": "Průměrná doba jedné návštěvy", + "ColumnBounceRate": "Odchozí frekvence", + "ColumnBounceRateDocumentation": "Procento návštěv, které měly jedno zobrazení. To znamená návštěvníci, kteří po zobrazení stránky okamžitě odešli.", + "ColumnBounceRateForPageDocumentation": "Procento návštěv, které začaly a skončily touto stránkou", + "ColumnBounces": "Ihned odchozí", + "ColumnBouncesDocumentation": "Počet návštěv, které začaly a skončily na touto stránkou. To znamená, kteří navštívily jen tuto stránku.", + "ColumnConversionRate": "Frekvence konverzí", + "ColumnConversionRateDocumentation": "Procento návštěv, které provedli konverzi Cíle,", + "ColumnDestinationPage": "Cílová stránka", + "ColumnEntrances": "Vstupy", + "ColumnEntrancesDocumentation": "Počet návštěvníků, kteří započali návštěvu touto stránkovu.", + "ColumnExitRate": "Frekvence odchodů", + "ColumnExitRateDocumentation": "Procentuální vyjádření těch, kteří opustili tuto stránku po jejím zobrazení.", + "ColumnExits": "Odchody", + "ColumnExitsDocumentation": "Počet návštěv, které skončily touto stránkovu.", + "ColumnGenerationTime": "Čas generování", + "ColumnKeyword": "Klíčové slovo", + "ColumnLabel": "Popisek", + "ColumnMaxActions": "Maximální počet akcí při jedné návštěvě", + "ColumnNbActions": "Akce", + "ColumnNbActionsDocumentation": "Počet akcí Vašich návstěvníků. Akcí se rozumí zobrazení stránky, stažení a kliknutí na externí odkazy.", + "ColumnNbUniqVisitors": "Jedineční návštěvníci", + "ColumnNbVisits": "Návštěv", + "ColumnPageBounceRateDocumentation": "Procento návštěv, které začaly touto stránkou a ihned ji opustili.", + "ColumnPageviews": "Zobrazení stránek", + "ColumnPageviewsDocumentation": "Počet navštívení této stránky.", + "ColumnPercentageVisits": "% návštěv", + "ColumnRevenue": "Příjem", + "ColumnSumVisitLength": "Celkový čas strávený návštěvníky (v sekundách)", + "ColumnTotalPageviews": "Celkem zobrazených stránek", + "ColumnUniqueEntrances": "Unikátních navštívení stránky", + "ColumnUniqueExits": "Unikátních opuštění stránky", + "ColumnUniquePageviews": "Jedinečná zobrazení stránek", + "ColumnUniquePageviewsDocumentation": "Počet návštěvníků, kteří navštívily tuto stránku. Pokud byla tato stránka navštívena několikrát, návštěva je započítána jen jednou.", + "ColumnValuePerVisit": "Hodnota za návštěvu", + "ColumnVisitDuration": "Doba návštěvy (v sekundách)", + "ColumnVisitsWithConversions": "Návštěvy s přechodem", + "ConfigFileIsNotWritable": "Konfigurační soubor Piwiku %s není zapisovatelný, některé změny nebudou uloženy. %s Prosím upravte oprávnění ke konfiguračnímu souboru", + "ContinueToPiwik": "Pokračujte do Piwiku", + "CurrentMonth": "Tento měsíc", + "CurrentWeek": "Tento týden", + "CurrentYear": "Tento rok", + "Daily": "Denně", + "DailyReports": "Denní hlášení", + "DailySum": "denní součet", + "DashboardForASpecificWebsite": "Nástěnka pro vybraný web", + "Date": "Datum", + "DateRange": "Rozsah:", + "DateRangeFrom": "Od", + "DateRangeFromTo": "Od %s do %s", + "DateRangeInPeriodList": "rozsah", + "DateRangeTo": "Do", + "DayFr": "pá", + "DayMo": "po", + "DaySa": "so", + "DaysHours": "%1$s dni %2$s hodin", + "DaysSinceFirstVisit": "Dnů od první návštěvy", + "DaysSinceLastEcommerceOrder": "Dnů od poslední elektronické objednávky", + "DaysSinceLastVisit": "Dnů od poslední návštěvy", + "DaySu": "ne", + "DayTh": "čt", + "DayTu": "út", + "DayWe": "st", + "Default": "Výchozí", + "Delete": "Vymazat", + "Description": "Popis", + "Desktop": "Desktop", + "Details": "Detaily", + "Discount": "Sleva", + "DisplaySimpleTable": "Zobrazit jednotuchou tabulku", + "DisplayTableWithGoalMetrics": "Zobrazit tabulku s měřením cílů", + "DisplayTableWithMoreMetrics": "Zobrazit tabulku s více měřeními", + "Documentation": "Dokumentace", + "Donate": "Přispějte", + "Done": "Hotovo", + "Download": "Stáhnout", + "DownloadFail_FileExists": "Sobor %s již existuje!", + "Downloads": "Stažení", + "EcommerceOrders": "Elektronické objednávky", + "Edit": "Upravit", + "EnglishLanguageName": "Czech", + "Error": "Chyba", + "ErrorRequest": "Jej... během požadavku se vyskytla chyba, prosím zkuste to znovu.", + "EvolutionOverPeriod": "Vývoj za periodu", + "ExceptionConfigurationFileNotFound": "Konfigurační soubor {%s} nebyl nalezen", + "ExceptionDatabaseVersion": "Vaše %1$s verze je %2$s ale Piwik vyžaduje minimálně %3$s.", + "ExceptionFileIntegrity": "Test integrity selhal: %s", + "ExceptionFilesizeMismatch": "Nesouhlasí velikost souboru: %1$s (očekávaná délka: %2$s, nalezeno: %3$s)", + "ExceptionIncompatibleClientServerVersions": "Vaše %1$s verze klienta je %2$s tato je ale nekompatibilní se serverem %3$s.", + "ExceptionInvalidArchiveTimeToLive": "Dnešní doba života archivu musí být číslo větší než nula", + "ExceptionInvalidDateFormat": "Formát data musí být: %s nebo klíčové slovo podporované funkcí %s (více informací viz %s)", + "ExceptionInvalidDateRange": "Datum '%s' není v platném rozmezí. Může mít následující formát: %s.", + "ExceptionInvalidPeriod": "Perioda '%s' není podporovaná. Vyzkoušejte nějakou z následujících místo: %s.", + "ExceptionInvalidRendererFormat": "Formát rendereru '%s' není platný. Vyzkoušejte nějaký jiný místo: %s.", + "ExceptionInvalidToken": "Token je neplatný", + "ExceptionLanguageFileNotFound": "Jazykový soubor '%s' nenalezen", + "ExceptionMethodNotFound": "Metoda '%s' neexistuje nebo není dostupná v modulu '%s'.", + "ExceptionMissingFile": "Chybějící soubor: %s", + "ExceptionNonceMismatch": "Nemůžu ověřit bezpečnostní token tohoto formuláře", + "ExceptionPrivilege": "Nemůžete přistupovat k tomuto zdroji, protože vyžaduje oprávnění %s.", + "ExceptionPrivilegeAtLeastOneWebsite": "Nemůžete přistupovat k tomuto zdroji, protože vyžaduje oprávnění %s alespoň pro jeden web", + "ExceptionUnableToStartSession": "Otevřeno k start session", + "ExceptionUndeletableFile": "Nelze vymazat soubor %s", + "ExceptionUnreadableFileDisabledMethod": "Konfigurační soubor {%s} je nečitelný. Host může být zakázán %s.", + "Export": "Exportovat", + "ExportAsImage": "Exportovat obrázek", + "ExportThisReport": "Exportovat data v ostatních formátech", + "Faq": "FAQ", + "FileIntegrityWarningExplanation": "Test integrity selhal a nahlásil nějaké chyby. To je díky částečně nebo špatně nahraným souborům Piwiku. Měli byste znova nahrát všechny soubory Piwiku v módu BINARY.", + "First": "První", + "ForExampleShort": "např.", + "FromReferrer": "z", + "GeneralInformation": "Hlavní informace", + "GeneralSettings": "Hlavní nastavení", + "GetStarted": "Začít", + "GiveUsYourFeedback": "Dejte nám zpětnou vazbu!", + "Goal": "Cíl", + "GoTo": "Jdi na %s", + "GraphHelp": "Více informací o zobrazování grafů v Piwiku", + "HelloUser": "Ahoj, %s!", + "Help": "Pomoc", + "Hide": "skrýt", + "HoursMinutes": "%1$s hodin %2$s min.", + "Id": "ID", + "IfArchivingIsFastYouCanSetupCronRunMoreOften": "V případě, že je zálohování dost rychlé, můžete jej spouštět v cronu častěji", + "InfoFor": "Informace o %s", + "Installed": "Nainstalováno", + "InvalidDateRange": "Chybný rozsah, vyberte jej znovu", + "InvalidResponse": "Obdržená data jsou neplatná.", + "JsTrackingTag": "JavaScriptový zaznamenávací tag", + "Language": "Jazyk", + "LastDays": "Posledních %s dní (včetně dnes)", + "LastDaysShort": "Posledních %s dní", + "LayoutDirection": "ltr", + "Loading": "Načítám...", + "LoadingData": "Načítám data...", + "LoadingPopover": "Načítám %s...", + "LoadingPopoverFor": "Načítám %s", + "Locale": "cs_CZ.UTF-8", + "Logout": "Odhlásit se", + "LongDay_1": "Pondělí", + "LongDay_2": "Úterý", + "LongDay_3": "Středa", + "LongDay_4": "Čtvrtek", + "LongDay_5": "Pátek", + "LongDay_6": "Sobota", + "LongDay_7": "Neděle", + "LongMonth_1": "Leden", + "LongMonth_10": "Říjen", + "LongMonth_11": "Listopad", + "LongMonth_12": "Prosinec", + "LongMonth_2": "Únor", + "LongMonth_3": "Březen", + "LongMonth_4": "Duben", + "LongMonth_5": "Květen", + "LongMonth_6": "Červen", + "LongMonth_7": "Červenec", + "LongMonth_8": "Srpen", + "LongMonth_9": "Září", + "MainMetrics": "Vlastní měření", + "MediumToHighTrafficItIsRecommendedTo": "Pro weby se středním, nebo velkým provozem doporučujeme zpracovat dnešní hlášení každou půlhodinu (%s vteřin), nebo každou hodinu (%s vteřin)", + "Metadata": "Meta data", + "Metric": "Měření", + "Metrics": "Měření", + "MetricsToPlot": "Měření k vykreslení", + "MetricToPlot": "Měření k vykreslení", + "MinutesSeconds": "%1$s minut %2$s sek.", + "Mobile": "Mobil", + "Monthly": "Měsíčně", + "MonthlyReports": "Měsíční hlášení", + "More": "Více", + "MoreDetails": "Více podrobností", + "MoreLowerCase": "více", + "MultiSitesSummary": "všechny weby", + "Name": "Jméno", + "NbActions": "Počet akcí", + "NDays": "%s dní", + "Never": "Nikdy", + "NewReportsWillBeProcessedByCron": "Pokud není archivování spouštěno web prohlížečem budou nová hlášení zpracovávaná cronem", + "NewUpdatePiwikX": "Nová aktualizace: Piwik %s", + "NewVisitor": "Nový návštěvník", + "NewVisits": "Nové návštěvy", + "Next": "Další", + "NMinutes": "%s minut", + "No": "Ne", + "NoDataForGraph": "Pro tento graf nejsou k dispozici žádná data", + "NoDataForTagCloud": "Pro tento oblak tagů nejsou k dispozici žádná data.", + "NotDefined": "%s není definováno", + "Note": "Poznámka", + "NotInstalled": "Nenaistalováno", + "NotRecommended": "(nedoporučuje se)", + "NotValid": "%s není platné", + "NSeconds": "%s sekund", + "NumberOfVisits": "Počet návštěv", + "NVisits": "%s návštěv", + "Ok": "OK", + "OneAction": "1 akce", + "OneDay": "1 den", + "OneMinute": "1 minuta", + "OneVisit": "1 návštěva", + "OnlyEnterIfRequired": "Uživatelské jméno zadejte pouze v případě, že jej váš SMTP server vyžaduje", + "OnlyEnterIfRequiredPassword": "Heslo zadejte pouze v případě, že jej váš SMTP server vyžaduje", + "OnlyUsedIfUserPwdIsSet": "Použito pouze v případě, že je nastaveno uživatelské jméno\/heslo. V případě že si nejste jisti jakou metodu použít, zeptejte se vašeho poskytovatele", + "OpenSourceWebAnalytics": "Open Source Web Analytics", + "Options": "Nastavení", + "OrCancel": "nebo %s Zrušit %s", + "OriginalLanguageName": "Česky", + "Others": "Ostatní", + "Outlink": "Odchozí odkaz", + "Outlinks": "Externí odkazy", + "Overview": "Přehled", + "Pages": "Stránky", + "ParameterMustIntegerBetween": "Proměnná %s musí být číselná hodnota mezi %s a %s.", + "Password": "Heslo", + "Period": "Období", + "Piechart": "Koláčový graf", + "PiwikXIsAvailablePleaseUpdateNow": "Je k dispozici Piwik %1$s. %2$s Prosím aktualizujte jej!%3$s (viz %4$s změny%5$s).", + "PleaseSpecifyValue": "Prosím zapiště hodnotu pro '%s'.", + "PleaseUpdatePiwik": "Prosím aktualizujte Piwik", + "Plugin": "Plugin", + "Plugins": "Zásuvné moduly", + "PoweredBy": "Běží na", + "Previous": "Předchozí", + "PreviousDays": "předešlých %s dní (kromě dneška)", + "Price": "Cena", + "ProductConversionRate": "Konverzní poměr", + "ProductRevenue": "Hodnota produktu", + "PurchasedProducts": "Zakoupené produkty", + "Quantity": "Množství", + "RangeReports": "Upravit rozmezí dní", + "Recommended": "(doporučuje se)", + "RecordsToPlot": "Záznamy k vykreslení", + "Refresh": "Obnovit", + "RefreshPage": "Obnovit stránku", + "RelatedReport": "Podobné hlášení", + "RelatedReports": "Podobná hlášení", + "Remove": "Odstranit", + "Report": "Hlášení", + "Reports": "Hlášení", + "ReportsWillBeProcessedAtMostEveryHour": "Proto budou hlášení zpracovávaná každou hodinu", + "RequestTimedOut": "Datový požadavek na %s vypršel. Prosím vyzkoušejte jej znovu", + "Required": "%s požadováno", + "ReturningVisitor": "Vracející se návštěvníci", + "ReturningVisitorAllVisits": "Zobrazit všechny návštěvy", + "Rows": "Řádek", + "RowsToDisplay": "Řádků k zobrazení", + "Save": "Uložit", + "SaveImageOnYourComputer": "K uložení obrázku na váš počítač klikněte na obrázek pravým tlačítkem myši a zvolte \"Uložit obrázek jako...\"", + "Search": "Hledat", + "SearchNoResults": "Žádné výsledky", + "Seconds": "%s sekund", + "SelectYesIfYouWantToSendEmailsViaServer": "Zvolte \"Ano\" pokud chcete e-mail posílat pomocí uvedeného serveru místo lokální funkce PHP mail", + "Settings": "Vlastnostni", + "Shipping": "Doprava", + "ShortDay_1": "Po", + "ShortDay_2": "Út", + "ShortDay_3": "St", + "ShortDay_4": "Čt", + "ShortDay_5": "Pá", + "ShortDay_6": "So", + "ShortDay_7": "Ne", + "ShortMonth_1": "Led", + "ShortMonth_10": "Říj", + "ShortMonth_11": "Lis", + "ShortMonth_12": "Pro", + "ShortMonth_2": "Úno", + "ShortMonth_3": "Bře", + "ShortMonth_4": "Dub", + "ShortMonth_5": "Kvě", + "ShortMonth_6": "Čer", + "ShortMonth_7": "Čvc", + "ShortMonth_8": "Srp", + "ShortMonth_9": "Zář", + "Show": "zobrazit", + "SmallTrafficYouCanLeaveDefault": "Pro weby s malým provozem můžete ponechat výchozích %s sekund a uvidíte všechna hlášení v reálném čase", + "SmtpEncryption": "SMTP šifrování", + "SmtpPassword": "Heslo SMTP", + "SmtpPort": "Post SMTP", + "SmtpServerAddress": "Adresa SMTP serveru", + "SmtpUsername": "Uživatelské jméno SMTP", + "Source": "Zdroj", + "Subtotal": "Mezisoučet", + "Table": "Tabulka", + "TagCloud": "Oblak tagů", + "Tax": "Daň", + "TimeOnPage": "Čas na stránce", + "Today": "Dnes", + "Total": "Celkem", + "TotalRevenue": "Celková hodnota", + "TotalVisitsPageviewsRevenue": "(Celkem: %s návštěv, %s zobrazení stránek, %s tržby)", + "TransitionsRowActionTooltipTitle": "Otevřít přechody", + "TranslatorEmail": "info@joomladev.eu, salab@email.cz, michal@cihar.com", + "TranslatorName": "Filip Bartmann, Jakub Baláš, Michal Čihař", + "UniquePurchases": "Jedineční nakupující", + "Unknown": "Neznámý", + "Upload": "Nahrát", + "UsePlusMinusIconsDocumentation": "Použijte ikonu plus a mínus vlevo od navigace.", + "Username": "Uživatelské jméno", + "UseSMTPServerForEmail": "Pro e-mail použít SMTP server", + "Value": "Hodnota", + "VBarGraph": "Svislý sloupcový graf", + "View": "Zobrazit", + "Visit": "Návštěva", + "VisitConvertedGoal": "Návštěva, která převedla alespoň jeden Cíl.", + "VisitConvertedGoalId": "Návštěva, která převedla konkrétní ID Cíl.", + "VisitConvertedNGoals": "Návstěva převedla %s Cílů.", + "VisitDuration": "Průměrná doba trvání návštěv (v sekundách)", + "Visitor": "Návštěvník", + "VisitorID": "ID návštěvníka", + "VisitorIP": "IP návštěvníka", + "Visitors": "Návštěvníci", + "VisitsWith": "Návštěv s %s", + "VisitType": "Typ návštěvníka", + "Warning": "Varování", + "WarningFileIntegrityNoManifest": "Test integrity nemůže být proveden z důvodů chybějícího souboru manifest.inc.php.", + "WarningFileIntegrityNoMd5file": "Test integrity nemůže být dokončen z důvodů chybějící funkce md5_file().", + "WarningPasswordStored": "%sUpozornění:%s Toto heslo bude uloženo v konfiguračním souboru viditelné pro všechny s přístupem k němu.", + "Website": "Web stránky", + "Weekly": "Týdně", + "WeeklyReports": "Tydenní hlášení", + "WellDone": "Výborně!", + "Widgets": "Widgety", + "YearlyReports": "Roční hlášení", + "YearsDays": "%1$s roků %2$s dní", + "YearShort": "yr", + "Yes": "Ano", + "Yesterday": "Včera", + "YouAreCurrentlyUsing": "Používáte Piwik verze %s.", + "YouAreViewingDemoShortMessage": "Právě si prohlížíte demoverzi Piwiku", + "YouMustBeLoggedIn": "Abyste mohli využívat tyto funkce musíte být přihlášeni", + "YourChangesHaveBeenSaved": "Vaše změny byly uloženy" + }, + "Goals": { + "AbandonedCart": "Neobjednané košíky", + "AddGoal": "Přidat cíl", + "AddNewGoal": "Přidat nový cíl", + "AddNewGoalOrEditExistingGoal": "%sPřidat nový cíl%s, nebo %sUpravit%s existující cíle", + "BestCountries": "Země s nejvyšším počtem konverzí jsou:", + "BestKeywords": "Klíčová slova s nejvyšším počtem konverzí jsou:", + "BestReferrers": "Odkazující stránky s nejvyšším počtem konverzí jsou:", + "CaseSensitive": "shoda s velikostí písmen", + "ClickOutlink": "Kliknout na odkaz na externí web", + "ColumnConversions": "Přechody", + "Contains": "obsahuje %s", + "ConversionRate": "%s frekvence konverzí", + "Conversions": "%s konverzí", + "ConversionsOverview": "Přehled konverzí", + "ConversionsOverviewBy": "Přehled konverzí podle typu návštěvy", + "CreateNewGOal": "Vytvořit nový cíl", + "DaysToConv": "Dnů do konverze", + "DefaultRevenue": "Výchozí příjem cíle je", + "DefaultRevenueHelp": "Na příklad kontaktní formulář odeslaný návštěvníkem má průměrnou cenu $10. Piwik vám pomůže dobře pochopit chování skupin uživatelů", + "DeleteGoalConfirm": "Jste si jisti, že chcete vymazat tento cíl %s?", + "Download": "Stáhnout soubor", + "Ecommerce": "Obchody", + "EcommerceAndGoalsMenu": "Obchody a Cíle", + "EcommerceLog": "Logy", + "EcommerceOrder": "Objednávky", + "EcommerceOverview": "Ochody - Přehled", + "EcommerceReports": "Hlášení obchodů", + "ExceptionInvalidMatchingString": "Pokud zvolíte 'přesnou shodu' odpovídající řetězec musí být URL začínající s %s. Například, '%s'.", + "ExternalWebsiteUrl": "URL externího webu", + "Filename": "jméno souboru", + "GoalConversion": "Cíl konverze", + "GoalIsTriggered": "Cíle je zaznamenáván", + "GoalIsTriggeredWhen": "Cíl je zaznamenáván, když", + "GoalName": "Jméno cíle", + "Goals": "Cíle", + "GoalsManagement": "Správa cílů", + "GoalsOverview": "Přehled cílů", + "GoalX": "Cíl: %s", + "IsExactly": "je přesně %s", + "LearnMoreAboutGoalTrackingDocumentation": "Více o %s Cílech hlášení v Piwiku%s najdete v uživatelské dokumentaci.", + "LeftInCart": "%s Zbylo v košíku", + "Manually": "ručně", + "ManuallyTriggeredUsingJavascriptFunction": "Cíl je ručně zaznamenáván pomocí JavaScriptového API trackGoal()", + "MatchesExpression": "odpovídá %s", + "NewVisitorsConversionRateIs": "Poměr konverze nové příchozích uživatelů je %s", + "Optional": "(volitelné)", + "OverallConversionRate": "%s celková frekvence konverzí (návštěv se splněným cílem)", + "OverallRevenue": "%s celkový příjem", + "PageTitle": "Titulek stránky", + "Pattern": "Vzor", + "PluginDescription": "Vytvořít cíle a zobrazit hlášení o konverzi cílů: vývoj v čase, příjem za návštěvu, konverze za refereru, klíčové slovo, atd.", + "ProductCategory": "Kategorie produktu", + "ProductName": "Název produktu", + "Products": "Produktů", + "ProductSKU": "SKU produktu", + "ReturningVisitorsConversionRateIs": "Poměr konverze navracejících se uživatelů je %s", + "UpdateGoal": "Aktualizovat cíl", + "URL": "URL", + "ViewAndEditGoals": "Zobrazit a editovat cíle", + "ViewGoalsBy": "Zobrazit cíle podle %s", + "VisitPageTitle": "Navštívit stránku s daným titulkem", + "VisitsUntilConv": "Návštěv ke konverzi", + "VisitUrl": "Navštívít zadanou URL (stránku, nebo skupiny stránek)", + "WhenVisitors": "když návštěvníci", + "WhereThe": "když", + "WhereVisitedPageManuallyCallsJavascriptTrackerLearnMore": "kde návštívená stránka obsahuhe volání metody JavaScriptu piwikTracker.trackGoal() (%svíce%s)" + }, + "ImageGraph": { + "PluginDescription": "Generuje nádherné statické PNG grafy pro jakékoli Piwik hlášení." + }, + "Installation": { + "CommunityNewsletter": "pošli mi e mail s komunitními aktualizacem (nové zásuvné moduly, nové funkce, atd.)", + "ConfigurationHelp": "Váš konfigurační soubor Piwiku je špatně nastavený. Můžete buď odstranit soubor config\/config.ini a znovu začít instalaci, nebo opravit nastavení databáze.", + "ConfirmDeleteExistingTables": "Jste si jistí, že chcete vymazat tabulky: %s z vaší databáze? UPOZORNĚNÍ: DATA Z TĚCHTO TABULEK NEPŮJDOU OBNOVIT!", + "Congratulations": "Gratulujeme", + "CongratulationsHelp": "

    Gratulujeme! Vaše instalace Piwiku je komplentní.<\/p>

    Ujistěte se, že máte JavaScriptový kód vložen do vašich stránek a čekejte na první návštěvníky!<\/p>", + "DatabaseAbilities": "Schopnosti databáze", + "DatabaseCheck": "Kontrola databáze", + "DatabaseClientVersion": "Verze klienta databáze", + "DatabaseCreation": "Vytvoření databáze", + "DatabaseErrorConnect": "Nastala chyba při připojování k databázovému serveru", + "DatabaseServerVersion": "Verze databázového serveru", + "DatabaseSetup": "Nastavení databáze", + "DatabaseSetupAdapter": "adaptér", + "DatabaseSetupDatabaseName": "jméno databáze", + "DatabaseSetupLogin": "uživatelské jméno", + "DatabaseSetupServer": "server s databází", + "DatabaseSetupTablePrefix": "prefix tabulek", + "Email": "e-mail", + "ErrorInvalidState": "Chyba: zdá se, že se snažíte přeskočit krok instalace, nebo máte zakázány cookies, nebo již je konfigurační soubor Piwiku vytvořen. %1$sUjistěte se, že máte povoleny cookies%2$s a vraťte se zpět %3$s na první stránku instalace %4$s.", + "Extension": "přípona", + "Filesystem": "Souborový systém", + "GoBackAndDefinePrefix": "Jít zpět a nastavit prefix pro tabulky Piwiku", + "Installation": "Instalace", + "InstallationStatus": "Stav instalace", + "LargePiwikInstances": "Nápověda pro velké instalace Piwiku", + "Legend": "Legenda", + "NfsFilesystemWarning": "Váš server používá soborový systém NFS.", + "NoConfigFound": "Konfigurační soubor Piwiku nebyl nalezen a snažíte se vstoupit na stránku Piwiku.
    » Můžete
    teď nainstalovat<\/a><\/b>
    Pokud jste Piwik již instalovali a máte v DB nějaké tabulky, nemějte obavy. Můžete je použít a zachovat jejich data.!<\/small>", + "Optional": "Volitelné", + "Password": "heslo", + "PasswordDoNotMatch": "hesla si neodpovídají", + "PasswordRepeat": "heslo (opakování)", + "PercentDone": "%s %% hotovo", + "PleaseFixTheFollowingErrors": "Prosím opravte následující chyby", + "PluginDescription": "Instalační proces Piwiku. Instalace se obvykle používá jednou. V případě vymazání konfiguračního souboru config\/config.inc.php se instalace zahájí znovu", + "Requirements": "Požadavky Piwiku", + "RestartWebServer": "Po uložení změn restartujte Vás web server.", + "SecurityNewsletter": "pošli mi e-mail při hlavních aktualizacích a bezpečnostních oznámeních Piwiku", + "SetupWebsite": "Nastavit Web", + "SetupWebsiteError": "Při přidávání Webu se vyskytla chyba", + "SetupWebSiteName": "jméno webu", + "SetupWebsiteSetupSuccess": "Web %s byl úspěšně vytvořen", + "SetupWebSiteURL": "URL webu", + "SuperUser": "Super uživatel", + "SuperUserLogin": "přihlašovací jméno 'super uživatele'", + "SuperUserSetupSuccess": "Super uživatel úspěšně vytvořen", + "SystemCheck": "Kontrola systému", + "SystemCheckAutoUpdateHelp": "Poznámla: Aktualizace pomocí jednoho kliknutí vyžaduje práva pro zápis do adresářů Piwiku a jejich obsahu", + "SystemCheckCreateFunctionHelp": "Piwik pro zpětná volání využívá anonymní funkce", + "SystemCheckDatabaseHelp": "Piwik vyžaduje buď rozšíření mysqli, nebo PDO s pdo_mysql rozšířením", + "SystemCheckDebugBacktraceHelp": "View::factory není schopna vytvořit pohled pro volající modul.", + "SystemCheckError": "Vyskytla se chyba - musí být opravena než budete pokračovat", + "SystemCheckEvalHelp": "Vyžadováno šablonovacími systémy HTML QuickForm a Smarty", + "SystemCheckExtensions": "Další požadovaná rozšíření", + "SystemCheckFileIntegrity": "Integrita souborů", + "SystemCheckFunctions": "Vyžadované funkce", + "SystemCheckGDHelp": "Sparkliny (malé grafy) nebudou fungovat.", + "SystemCheckGlobHelp": "Tato vestavěná funkce byla na vašem hostiteli zakázaná. Piwik se pokusí tuto funkci emulovat, ale můžete zaznamenat další bezpečnostní omezení. Toto bude mít vliv na funčnost", + "SystemCheckIconvHelp": "Musíte nakonfigurovat a překompilovat PHP s podporou pro \"icont\", --with-iconv", + "SystemCheckMailHelp": "Bez funkce mail(), nebudou odeslány zprávy s odezvou, nebo při zapomenutém heslu", + "SystemCheckMbstring": "mbstring", + "SystemCheckMbstringExtensionHelp": "Rozšíření mbstring je vyžadováno pro multibyte znaky v odezvách API při použití CSV, nebo TSV", + "SystemCheckMbstringFuncOverloadHelp": "Měli byste nastavit mbstring.func_overload na \"0\"", + "SystemCheckMemoryLimit": "Limit paměti", + "SystemCheckMemoryLimitHelp": "Na webech s vysokým provozem, může archivace vyžadovat více paměti něž je nyní povoleno.
    Pokud je potřeba podívejte se na direktivu memory_limit ve vašem souboru php.ini.", + "SystemCheckOpenURL": "Otevřít URL", + "SystemCheckOpenURLHelp": "Předplatné novinek, informace o aktualizaci a aktualizace jedním kliknutím vyžadují rozšíření \"curl\", allow_url_fopen=On, nebo povolené fsockopen().", + "SystemCheckOtherExtensions": "Ostatní rozšíření", + "SystemCheckOtherFunctions": "Ostatní funkce", + "SystemCheckPackHelp": "Funkce pack() je nutná ke sledování návštěvnosti v Piwiku.", + "SystemCheckParseIniFileHelp": "Tato vestavěná funkce byla na vašem hostiteli zakázaná. Piwik se pokusí tuto funkci emulovat, ale můžete zaznamenat další bezpečnostní omezení. Výkonnost trackeru bude také omezena.", + "SystemCheckPhp": "Verze PHP", + "SystemCheckPhpPdoAndMysqli": "Více informací na: %1$sPHP PDO%2$s and %3$sMYSQLI%4$s.", + "SystemCheckSecureProtocol": "Zabezpečený protokol", + "SystemCheckSplHelp": "Musíte překompilovat PHP s povolenou standardní PHP knihovou (ve výchozím stavu povolena).", + "SystemCheckSummaryNoProblems": "Hurááá! Nejsou zde žádné problémy s nastavením Piwiku. Gratulujeme", + "SystemCheckTimeLimitHelp": "na webech s vysokým přenosem, může archivace vyžadovat více času, než je nyní povoleno.
    Pokud je potřeba podívejte se na direktivu max_execution_time ve vašem souboru php.ini", + "SystemCheckWarning": "Piwik bude pracovat normálně, ale některé funkce budou chybět", + "SystemCheckWinPdoAndMysqliHelp": "Na serveru s Winwdows můžete do vašeho php.ini přidat následující řádky: %s", + "SystemCheckWriteDirs": "Adresáře s přístupem k zápisu", + "SystemCheckWriteDirsHelp": "Pro opravu této chyby v Linuxu zkuste napsat následující příkaz(y)", + "SystemCheckZlibHelp": "Musíte nakonfigurovat a překompilovat PHP s podporou pro \"zlib\", --with-zlib", + "Tables": "Vytváření tabulek", + "TablesCreatedSuccess": "Tabulky vytvořeny úspěšně!", + "TablesDelete": "Vymazat detekované tabulky", + "TablesDeletedSuccess": "Existujicí tabulky Piwiku úspěšně vymazány", + "TablesFound": "V databázi byly nalezeny následující tabulky", + "TablesReuse": "Použít existujicí tabulky", + "TablesWarningHelp": "Vyberte buď použití existujicích tabulek, nebo čistou instalaci, která v databázi smaže existující data.", + "TablesWithSameNamesFound": "Některé %1$s tabulky ve vaší databázi %2$s mají stejná jména jako tabulky, které se snaží vytvořit Piwik", + "Timezone": "časová zóna webu", + "Welcome": "Vítejte!", + "WelcomeHelp": "

    Piwik je open source program pro analýzu webu, pomocí kterého můžete jednoduše získat informace, které chcete od vaších návštěvníků.<\/p>

    Tento proces je rozdělen to %s jednoduchých kroků a zabere přibližne 5 minut.<\/p>" + }, + "LanguagesManager": { + "AboutPiwikTranslations": "O překladech Piwiku", + "PluginDescription": "Tento zásuvný modul zobrazí seznam jazyků pro rozhraní Piwiku. Vybraný jazyk bude uložen v nastaveních každého uživatele" + }, + "Live": { + "GoalType": "Typ", + "LastHours": "Posledních %s hodin", + "LastMinutes": "Posledních %s minut", + "LinkVisitorLog": "Zobrazit detailní pohled na návštěvníky", + "MorePagesNotDisplayed": "další stránky tohoto návštěvníka nejsou zobrazeny", + "NbVisitor": "1 návštěvník", + "NbVisitors": "%s návštěvníků", + "PluginDescription": "Sledujte Vaše uživatele živě v reálném čase", + "RealTimeVisitorCount": "Počet návštěvníků v reálném čase", + "Referrer_URL": "Odkazující URL", + "SimpleRealTimeWidget_Message": "%s a %s v posledních %s.", + "VisitorLog": "Pohled na návštěvníky", + "VisitorsInRealTime": "Návstěv v reálném čase" + }, + "Login": { + "ContactAdmin": "Možná příčina: Váš hosting zakázal funkci mail()..
    Prosím kontaktujte vašeho administrátora Piwiku.", + "ExceptionPasswordMD5HashExpected": "Parametr hesla je očekáván jako MD5 hash hesla", + "InvalidOrExpiredToken": "Klíč je neplatný, nebo vypršel", + "InvalidUsernameEmail": "Neplatné uživatelské jméno a\/nebo e-mailová adresa", + "LogIn": "Přihlásit", + "LoginOrEmail": "Uživatelské jméno nebo E-mail", + "LoginPasswordNotCorrect": "Uživatelské jméno & heslo nejsou správné", + "LostYourPassword": "Zapomněli jste vaše heslo?", + "MailTopicPasswordChange": "Potvrďte změnu hesla", + "PasswordChanged": "Vaše heslo bylo změněno.", + "PasswordRepeat": "Heslo (pro kontrolu)", + "PasswordsDoNotMatch": "Hesla si neodpovídají", + "RememberMe": "Zapamatovat si", + "ResetPasswordInstructions": "Zadejte nové heslo k Vašemu účtu." + }, + "Mobile": { + "AboutPiwikMobile": "O Piwik Mobile", + "AccessUrlLabel": "Přístupová URL Piwiku", + "Account": "Účet", + "Accounts": "Účty", + "AddAccount": "Přidat účet", + "Advanced": "Rozšířené", + "AnonymousAccess": "Anonymní přístup", + "AnonymousTracking": "Anonymní sledování", + "ChooseMetric": "Zvolte měření", + "ChooseReport": "Vyberte hlášení", + "EmailUs": "Napište nám", + "EnableGraphsLabel": "Zobrazit grafy", + "LastUpdated": "Poslední aktualizace: %s", + "MultiChartLabel": "Zobrazit multi grafy", + "NavigationBack": "Zpět", + "NetworkNotReachable": "Síť je nedosažitelná", + "NoWebsiteFound": "Žádný web nenalezen", + "RatingNotNow": "Ne nyní", + "Reloading": "Znovu nahrávám...", + "SaveSuccessError": "Prosím zkontrolujte si vaše nastavení", + "ShowAll": "Zobrazit vše", + "YouAreOffline": "Omlouváme se, momentálně jste offline" + }, + "MobileMessaging": { + "PhoneNumbers": "Telefonní čísla", + "Settings_CountryCode": "Kód země", + "Settings_PhoneNumber": "Telefonní číslo", + "Settings_PhoneNumbers_Add": "Přidat nové telefonní číslo", + "Settings_SMSAPIAccount": "Správa SMS API účtu", + "Settings_SMSProvider": "SMS Provider", + "SMS_Content_Too_Long": "[příliš dlouhé]", + "TopMenu": "Email & SMS Reporty" + }, + "MultiSites": { + "Evolution": "Vývoj", + "PluginDescription": "Zobrazí souhrn\/statistiky pro více sídel. Současně je spravován jako základní zásuvný modul Piwiku" + }, + "Overlay": { + "Clicks": "%s kliknutí", + "Domain": "Doména", + "Link": "Odkaz", + "Location": "Umístění", + "OneClick": "1 kliknutí" + }, + "PrivacyManager": { + "AnonymizeIpInlineHelp": "Skryje poslední byte IP adresy návštěvníka, aby souhlasila se zákony vaší země.", + "DeleteLogsOlderThan": "Vymazat logy starší než", + "DeleteMaxRowsNoLimit": "bez limitu", + "KeepBasicMetrics": "Uchovat základní měření (návštěvy, zobrazení stránek, odchody, cíle, atd.)", + "MenuPrivacySettings": "Ochrana soukromí", + "ReportsDataSavedEstimate": "Velikost databáze", + "TeaserHeadline": "Nastavení ochrany soukromí" + }, + "Provider": { + "ColumnProvider": "Poskytovatel", + "PluginDescription": "Zobrazí poskytovatele připojení návštěvníků", + "SubmenuLocationsProvider": "Umístění a poskytovatel", + "WidgetProviders": "Poskytovatelé" + }, + "Referrers": { + "Campaigns": "Kampaně", + "ColumnCampaign": "Kampaň", + "ColumnSearchEngine": "Vyhledávací stroj", + "ColumnWebsite": "Web", + "ColumnWebsitePage": "Web stránka", + "DetailsByReferrerType": "Podrobnosti podle typu refereru", + "DirectEntry": "Přímy vstup", + "Distinct": "Jedineční refereři podle typu", + "DistinctCampaigns": "jedinečné kampaně", + "DistinctKeywords": "jedinečná klíčová slova", + "DistinctSearchEngines": "jedinečné vyhledávače", + "DistinctWebsites": "jedinečná weby", + "Keywords": "Klíčových slov", + "PluginDescription": "Zobrazí data refererů: vyhledávací stroje, klíčová slova, weby, správu kampaní, přímé vstupy", + "Referrers": "Odkazující stránky", + "SearchEngines": "Vyhledávacích strojů", + "SubmenuSearchEngines": "Vyhledávače & klíčová slova", + "SubmenuWebsites": "Web", + "Type": "Typ refereru", + "TypeCampaigns": "%s z kampaní", + "TypeDirectEntries": "%s přímých vstupů", + "TypeSearchEngines": "%s z vyhledavačů", + "TypeWebsites": "%s z web sídel", + "UsingNDistinctUrls": "(používá %s jedinečných url)", + "Websites": "Web", + "WidgetExternalWebsites": "Seznam externích web sídel", + "WidgetKeywords": "Seznam klíčových slov" + }, + "RowEvolution": { + "AvailableMetrics": "Dostupná měření", + "MetricBetweenText": "mezi %s a %s", + "MetricChangeText": "%s za interval", + "MetricsFor": "Měření pro %s" + }, + "ScheduledReports": { + "AggregateReportsFormat": "Možnosti zobrazení", + "AlsoSendReportToTheseEmails": "Odesílat hlášení také na tyto e-maily (jeden e-mail na řádek):", + "CreateReport": "Vytvořit hlášení", + "EmailHello": "Ahoj,", + "EmailReports": "Poslat hlášení na e-mail", + "EmailSchedule": "Plánování zasílání hlášení", + "FrontPage": "Hlavní strana", + "ManageEmailReports": "Spravovat emailové hlášení", + "MonthlyScheduleHelp": "Měsíční plán: hlášení bude odesláno první den měsíce", + "Pagination": "Stránka %s z %s", + "PiwikReports": "Hlášení Piwiku", + "PleaseFindAttachedFile": "Prosím najděte v přiloženém souboru %1$s hlášení pro %2$s", + "PluginDescription": "Vytvořit a uložit vlastní hlášení a dostávat denní, týdenní nebo měsíční emailové hlášení.", + "ReportFormat": "Formát hlášení", + "SendReportNow": "Odeslat hlášení ihned", + "SendReportTo": "Odeslat hlášení", + "SentToMe": "Odeslat hlášení mně", + "TopOfReport": "Zpět nahoru", + "WeeklyScheduleHelp": "Týdenní plán: hlášení bude odesláno každé pondělí" + }, + "SEO": { + "AlexaRank": "Hodnocení Alexa", + "Bing_IndexedPages": "Stránek zaindexovaných Bingem", + "Dmoz": "Záznamů v katalogu DMOZ", + "DomainAge": "Staří domény", + "ExternalBacklinks": "Externí zpětné odkazy (Majestic)", + "Google_IndexedPages": "Stránek zaindexovaných Googlem", + "Rank": "Hodnocení", + "ReferrerDomains": "Odkazující domény (Majestic)", + "SeoRankings": "Hodnocení SEO", + "SEORankingsFor": "SEO hodnocení pro %s", + "ViewBacklinksOnMajesticSEO": "Zobrazit externí zpětné odkazy na MajesticSEO.com" + }, + "SitesManager": { + "AddSite": "Přídat nový web", + "AdvancedTimezoneSupportNotFound": "Pokročilá podpora časových zón nebyla ve vašem PHP (podporována v PHP >=5.2) nalezena. Stále můžete použít ruční UTF offset.", + "AliasUrlHelp": "Je doporučeno, ale ne vyžadováno, specifikovat různé URL, jednu na řádku, které vaší návštěvníci používají pro přístup ke stránkám. Aliasy se neobjeví hlášení Odkazující stránky>WWW. Všimněte si, že není nutné zadávat URL s a bez 'www'. Piwik automaticky rozezná obě.", + "ChangingYourTimezoneWillOnlyAffectDataForward": "Změna vaší časové zóny se projeví pouze v nových datech a nebude aplikována zpětně.", + "ChooseCityInSameTimezoneAsYou": "Zvolte město ve stejné časové zóně jako se nacházíte vy", + "Currency": "Měna", + "CurrencySymbolWillBeUsedForGoals": "Symbol měny bude zobrazen vedle příjmů z cílů.", + "DefaultCurrencyForNewWebsites": "Výchozí měna pro nové weby", + "DefaultTimezoneForNewWebsites": "Výchozí časová zóna pro nové weby", + "DeleteConfirm": "Jste si jisti, že chcete vymazat Web %s?", + "EcommerceHelp": "Pokud zapnete Cíle, tak hlášení bude mít novou sekci Obchod.", + "EnableEcommerce": "Obchod zapnutý", + "ExceptionDeleteSite": "Není možné vymazat toto Web, protože je jediné registrované. Nejprve přidejte nový web, poté jej vymažte.", + "ExceptionEmptyName": "Jméno Webu nemůže zůstat prázdné.", + "ExceptionInvalidCurrency": "Měna %s je neplatná. Prosím zadejte platný symbol měny. (např.: %s)", + "ExceptionInvalidIPFormat": "IP adresa k vynechání \"%s\" není v platném formátu (např.: %s)", + "ExceptionInvalidTimezone": "Časová zóna %s je neplatná. Prozím zadejte platnou zónu.", + "ExceptionInvalidUrl": "URL '%s' není platné URL.", + "ExceptionNoUrl": "Musíte zadat alespoň jednu URL pro web.", + "ExcludedIps": "Vynechané IP adresy", + "ExcludedParameters": "Vynechané parametry", + "GlobalListExcludedIps": "Globální seznam vynechaných IP adres", + "GlobalListExcludedQueryParameters": "Globální seznam vynechaných URL parametrů", + "GlobalWebsitesSettings": "Globální nastavení sídel", + "HelpExcludedIps": "Zatejte seznam IP adrese, jednu na řádek, které chcete vynechat ze záznamů Piwiku. Můžete použít zástupné znaky např. %1$s nebo %2$s", + "JsTrackingTagHelp": "Zde je JavaScriptový zaznamenávací tag pro vložení na všechny vaše stránky", + "ListOfIpsToBeExcludedOnAllWebsites": "IP adresy uvedené níže budou vynechány ze záznamu pro všechny weby.", + "ListOfQueryParametersToBeExcludedOnAllWebsites": "URL parametry uvedené níže budou vynechány z URL pro všechny www adresy", + "ListOfQueryParametersToExclude": "Zadejte seznam URL parametrů, jeden na řádek, které budou vynechány z hlášení Piwiku", + "MainDescription": "Vaše analýzy webu potřebují Web! Přidejte, aktualizujte, nebo vymažte je a zobrazte si JavaScriptový kód pro vložení do vaších stránek.", + "NotAnEcommerceSite": "Žádná stránka obchodu", + "NotFound": "Žádné www nenalezený pro", + "NoWebsites": "Nemáte žádný Weby k administraci.", + "OnlyOneSiteAtTime": "Můžete upravit zároveň pouze jednu stránku. Uložte nebo zrušte současné úpravy stránky %s.", + "PiwikOffersEcommerceAnalytics": "Piwik povolen pro rozšířenou analytiku a měření stránek typu Obchod. Číst o %s analytice %s více.", + "PiwikWillAutomaticallyExcludeCommonSessionParameters": "Piwik automaticky vynechá běžné parametry sezení (%s)", + "PluginDescription": "Správa sídel v Piwiku: Přidat nový web, Upravit existující, Zobrazit JavaScriptový kód při umístění na stránky. Všechny akce jsou k dispozici také přes API.", + "SelectACity": "Zvolte město", + "SelectDefaultCurrency": "Můžete zvolit výchozí měnu pro nové weby", + "SelectDefaultTimezone": "Můžete zvolit výchozí časovou zónu pro nové weby", + "ShowTrackingTag": "zobrazit zaznamenávací tagy", + "Sites": "Weby", + "Timezone": "Časová zóna", + "TrackingTags": "Zaznamenávací tagy pro %s", + "Urls": "URL", + "UTCTimeIs": "UTC čas je %s", + "WebsitesManagement": "Nastavení Web sídel", + "YouCurrentlyHaveAccessToNWebsites": "Momentálně máte přístup k %s stránce\/stránkám.", + "YourCurrentIpAddressIs": "Vaše současná IP adresa je %s" + }, + "UserCountry": { + "City": "Město", + "Continent": "Kontinent", + "continent_afr": "Afrika", + "continent_amc": "Středná Amerika", + "continent_amn": "Jižní Amerika", + "continent_ams": "Severní a střední Amerika", + "continent_ant": "Antarktida", + "continent_asi": "Asie", + "continent_eur": "Evropa", + "continent_oce": "Oceanie", + "Country": "Státy", + "country_a1": "Anonymní proxy", + "country_a2": "Staelitní poskytovatel", + "country_ac": "Ascension Islands", + "country_ad": "Andorra", + "country_ae": "United Arab Emirates", + "country_af": "Afghanistan", + "country_ag": "Antigua and Barbuda", + "country_ai": "Anguilla", + "country_al": "Albania", + "country_am": "Armenia", + "country_an": "Netherlands Antilles", + "country_ao": "Angola", + "country_ap": "Region Ásie\/Pacifik", + "country_aq": "Antarctica", + "country_ar": "Argentina", + "country_as": "American Samoa", + "country_at": "Austria", + "country_au": "Australia", + "country_aw": "Aruba", + "country_ax": "Aland Islands", + "country_az": "Azerbaijan", + "country_ba": "Bosnia and Herzegovina", + "country_bb": "Barbados", + "country_bd": "Bangladesh", + "country_be": "Belgium", + "country_bf": "Burkina Faso", + "country_bg": "Bulgaria", + "country_bh": "Bahrain", + "country_bi": "Burundi", + "country_bj": "Benin", + "country_bl": "Saint Barthelemy", + "country_bm": "Bermuda", + "country_bn": "Bruneo", + "country_bo": "Bolivia", + "country_bq": "Karibské Nizozemsko", + "country_br": "Brazil", + "country_bs": "Bahamas", + "country_bt": "Bhutan", + "country_bu": "Burma", + "country_bv": "Bouvet Island", + "country_bw": "Botswana", + "country_by": "Belarus", + "country_bz": "Belize", + "country_ca": "Canada", + "country_cat": "Katalánsky mluvící země", + "country_cc": "Cocos (Keeling) Islands", + "country_cd": "Congo, The Democratic Republic of the", + "country_cf": "Central African Republic", + "country_cg": "Congo", + "country_ch": "Switzerland", + "country_ci": "Cote D'Ivoire", + "country_ck": "Cook Islands", + "country_cl": "Chile", + "country_cm": "Cameroon", + "country_cn": "China", + "country_co": "Colombia", + "country_cp": "Clipperton Island", + "country_cr": "Costa Rica", + "country_cs": "Serbia Montenegro", + "country_cu": "Cuba", + "country_cv": "Cape Verde", + "country_cw": "Curaçao", + "country_cx": "Christmas Island", + "country_cy": "Cyprus", + "country_cz": "Česká republika", + "country_de": "Germany", + "country_dg": "Diego Garcia", + "country_dj": "Djibouti", + "country_dk": "Denmark", + "country_dm": "Dominica", + "country_do": "Dominican Republic", + "country_dz": "Algeria", + "country_ea": "Ceuta, Melilla", + "country_ec": "Ecuador", + "country_ee": "Estonia", + "country_eg": "Egypt", + "country_eh": "Western Sahara", + "country_er": "Eritrea", + "country_es": "Spain", + "country_et": "Ethiopia", + "country_eu": "European Union", + "country_fi": "Finland", + "country_fj": "Fiji", + "country_fk": "Falkland Islands (Malvinas)", + "country_fm": "Micronesia, Federated States of", + "country_fo": "Faroe Islands", + "country_fr": "France", + "country_fx": "France, Metropolitan", + "country_ga": "Gabon", + "country_gb": "Great Britain", + "country_gd": "Grenada", + "country_ge": "Georgia", + "country_gf": "French Guyana", + "country_gg": "Guernsey", + "country_gh": "Ghana", + "country_gi": "Gibraltar", + "country_gl": "Greenland", + "country_gm": "Gambia", + "country_gn": "Guinea", + "country_gp": "Guadeloupe", + "country_gq": "Equatorial Guinea", + "country_gr": "Greece", + "country_gs": "South Georgia and the South Sandwich Islands", + "country_gt": "Guatemala", + "country_gu": "Guam", + "country_gw": "Guinea-Bissau", + "country_gy": "Guyana", + "country_hk": "Hong Kong", + "country_hm": "Heard Island and McDonald Islands", + "country_hn": "Honduras", + "country_hr": "Croatia", + "country_ht": "Haiti", + "country_hu": "Hungary", + "country_ic": "Canary Islands", + "country_id": "Indonesia", + "country_ie": "Ireland", + "country_il": "Israel", + "country_im": "Man Island", + "country_in": "India", + "country_io": "British Indian Ocean Territory", + "country_iq": "Iraq", + "country_ir": "Iran, Islamic Republic of", + "country_is": "Iceland", + "country_it": "Italy", + "country_je": "Jersey", + "country_jm": "Jamaica", + "country_jo": "Jordan", + "country_jp": "Japan", + "country_ke": "Kenya", + "country_kg": "Kyrgyzstan", + "country_kh": "Cambodia", + "country_ki": "Kiribati", + "country_km": "Comoros", + "country_kn": "Saint Kitts and Nevis", + "country_kp": "Korea, Democratic People's Republic of", + "country_kr": "Korea, Republic of", + "country_kw": "Kuwait", + "country_ky": "Cayman Islands", + "country_kz": "Kazakhstan", + "country_la": "Laos", + "country_lb": "Lebanon", + "country_lc": "Saint Lucia", + "country_li": "Liechtenstein", + "country_lk": "Sri Lanka", + "country_lr": "Liberia", + "country_ls": "Lesotho", + "country_lt": "Lithuania", + "country_lu": "Luxembourg", + "country_lv": "Latvia", + "country_ly": "Libya", + "country_ma": "Morocco", + "country_mc": "Monaco", + "country_md": "Moldova, Republic of", + "country_me": "Montenegro", + "country_mf": "Saint Martin", + "country_mg": "Madagascar", + "country_mh": "Marshall Islands", + "country_mk": "Macedonia", + "country_ml": "Mali", + "country_mm": "Myanmar", + "country_mn": "Mongolia", + "country_mo": "Macau", + "country_mp": "Northern Mariana Islands", + "country_mq": "Martinique", + "country_mr": "Mauritania", + "country_ms": "Montserrat", + "country_mt": "Malta", + "country_mu": "Mauritius", + "country_mv": "Maldives", + "country_mw": "Malawi", + "country_mx": "Mexico", + "country_my": "Malaysia", + "country_mz": "Mozambique", + "country_na": "Namibia", + "country_nc": "New Caledonia", + "country_ne": "Niger", + "country_nf": "Norfolk Island", + "country_ng": "Nigeria", + "country_ni": "Nicaragua", + "country_nl": "Netherlands", + "country_no": "Norway", + "country_np": "Nepal", + "country_nr": "Nauru", + "country_nt": "Neutral Zone", + "country_nu": "Niue", + "country_nz": "New Zealand", + "country_o1": "ostatní země", + "country_om": "Oman", + "country_pa": "Panama", + "country_pe": "Peru", + "country_pf": "French Polynesia", + "country_pg": "Papua New Guinea", + "country_ph": "Philippines", + "country_pk": "Pakistan", + "country_pl": "Poland", + "country_pm": "Saint Pierre and Miquelon", + "country_pn": "Pitcairn", + "country_pr": "Puerto Rico", + "country_ps": "Palestinian Territory", + "country_pt": "Portugal", + "country_pw": "Palau", + "country_py": "Paraguay", + "country_qa": "Qatar", + "country_re": "Reunion Island", + "country_ro": "Romania", + "country_rs": "Serbia", + "country_ru": "Russia", + "country_rw": "Rwanda", + "country_sa": "Saudi Arabia", + "country_sb": "Solomon Islands", + "country_sc": "Seychelles", + "country_sd": "Sudan", + "country_se": "Sweden", + "country_sf": "Finland", + "country_sg": "Singapore", + "country_sh": "Saint Helena", + "country_si": "Slovenia", + "country_sj": "Svalbard", + "country_sk": "Slovakia", + "country_sl": "Sierra Leone", + "country_sm": "San Marino", + "country_sn": "Senegal", + "country_so": "Somalia", + "country_sr": "Suriname", + "country_ss": "Jižní Sudán", + "country_st": "Sao Tome and Principe", + "country_su": "Old U.S.S.R", + "country_sv": "El Salvador", + "country_sx": "Sint Maarten", + "country_sy": "Syrian Arab Republic", + "country_sz": "Swaziland", + "country_ta": "Tristan da Cunha", + "country_tc": "Turks and Caicos Islands", + "country_td": "Chad", + "country_tf": "French Southern Territories", + "country_tg": "Togo", + "country_th": "Thailand", + "country_ti": "Tibet", + "country_tj": "Tajikistan", + "country_tk": "Tokelau", + "country_tl": "East Timor", + "country_tm": "Turkmenistan", + "country_tn": "Tunisia", + "country_to": "Tonga", + "country_tp": "East Timor", + "country_tr": "Turkey", + "country_tt": "Trinidad and Tobago", + "country_tv": "Tuvalu", + "country_tw": "Taiwan", + "country_tz": "Tanzania, United Republic of", + "country_ua": "Ukraine", + "country_ug": "Uganda", + "country_uk": "United Kingdom", + "country_um": "United States Minor Outlying Islands", + "country_us": "United States", + "country_uy": "Uruguay", + "country_uz": "Uzbekistan", + "country_va": "Vatican City", + "country_vc": "Saint Vincent and the Grenadines", + "country_ve": "Venezuela", + "country_vg": "Virgin Islands, British", + "country_vi": "Virgin Islands, U.S.", + "country_vn": "Vietnam", + "country_vu": "Vanuatu", + "country_wf": "Wallis and Futuna", + "country_ws": "Samoa", + "country_ye": "Yemen", + "country_yt": "Mayotte", + "country_yu": "Yugoslavia", + "country_za": "South Africa", + "country_zm": "Zambia", + "country_zr": "Zaire", + "country_zw": "Zimbabwe", + "DistinctCountries": "%s jedinečných států", + "DownloadingDb": "Stahování %s", + "FoundApacheModules": "Piwik našel tyto Apache moduly", + "GeoIPDatabases": "Databáze GeoIP", + "Geolocation": "Geolokace", + "HowToInstallApacheModule": "Jak nainstalovat GeoIP modul na Apache?", + "HowToInstallGeoIpPecl": "Jak nainstalovat GeoIP PECL rozšíření?", + "HowToInstallNginxModule": "Jak nainstalovat GeoIP modul pro Nginx?", + "ISPDatabase": "Databáze ISP", + "Latitude": "Zeměpisná šířka", + "Location": "Místo", + "Longitude": "Zeměpisná délka", + "Organization": "Společnost", + "PluginDescription": "Zobrazí státy návštěvníků", + "Region": "Region", + "SubmenuLocations": "Umístění" + }, + "UserCountryMap": { + "Cities": "Města", + "Countries": "Země", + "DaysAgo": "před %s dny", + "Hours": "hodin", + "HoursAgo": "před %s hodinami", + "map": "mapa", + "Minutes": "minut", + "MinutesAgo": "před %s minutami", + "NoVisit": "Žádná návštěva", + "RealTimeMap": "Mapa v reálném čase", + "Regions": "Regiony", + "Searches": "%s vyhledávání", + "Seconds": "sekund", + "SecondsAgo": "před %s sekundami", + "VisitorMap": "Mapa návštěvníků", + "WorldWide": "Celostvětová" + }, + "UserSettings": { + "BrowserFamilies": "Rodiny Web prohlížečů", + "Browsers": "Web prohlížeče", + "ColumnBrowser": "Web prohlížeč", + "ColumnBrowserFamily": "Rodina Web prohlížeče", + "ColumnBrowserVersion": "Verze prohlížeče", + "ColumnConfiguration": "Konfigurace", + "ColumnOperatingSystem": "Operační systém", + "ColumnResolution": "Rozlišení", + "ColumnTypeOfScreen": "Typ obrazovky", + "Configurations": "Nastavení", + "GamingConsole": "Herní konzole", + "Language_aa": "afarština", + "Language_ab": "abcházština", + "Language_ae": "avestánština", + "Language_af": "afrikánština", + "Language_ak": "akanština", + "Language_am": "amharština", + "Language_an": "aragonština", + "Language_ar": "arabština", + "Language_as": "assaméština", + "Language_av": "avarština", + "Language_ay": "aymárština", + "Language_az": "azerbajdžánština", + "Language_ba": "baskirština", + "Language_be": "běloruština", + "Language_bg": "bulharština", + "Language_bh": "biharština", + "Language_bi": "bislámština", + "Language_bm": "bambarština", + "Language_bn": "bengálština", + "Language_bo": "tibetština", + "Language_br": "bretaňština", + "Language_bs": "bosenština", + "Language_ca": "katalánština", + "Language_ce": "čečenština", + "Language_ch": "čamoro", + "Language_co": "korsičtina", + "Language_cr": "krí", + "Language_cs": "čeština", + "Language_cu": "staroslověnština", + "Language_cv": "čuvašština", + "Language_cy": "velština", + "Language_da": "dánština", + "Language_de": "němčina", + "Language_dv": "divehi", + "Language_dz": "bhútánština", + "Language_ee": "eweština", + "Language_el": "řečtina", + "Language_en": "angličtina", + "Language_eo": "esperanto", + "Language_es": "španělština", + "Language_et": "estonština", + "Language_eu": "baskičtina", + "Language_fa": "perština", + "Language_ff": "fulahština", + "Language_fi": "finština", + "Language_fj": "fidži", + "Language_fo": "faerština", + "Language_fr": "francouzština", + "Language_fy": "fríština", + "Language_ga": "irština", + "Language_gd": "skotská galština", + "Language_gl": "haličština", + "Language_gn": "guaranština", + "Language_gu": "gujaratština", + "Language_gv": "manština", + "Language_ha": "hausa", + "Language_he": "hebrejština", + "Language_hi": "hindština", + "Language_ho": "hiri motu", + "Language_hr": "chorvatština", + "Language_ht": "haitština", + "Language_hu": "maďarština", + "Language_hy": "arménština", + "Language_hz": "herero", + "Language_ia": "interlingua", + "Language_id": "indonéština", + "Language_ie": "interlingue", + "Language_ig": "igboština", + "Language_ik": "inupiakština", + "Language_io": "ido", + "Language_is": "islandština", + "Language_it": "italština", + "Language_iu": "inuktitutština", + "Language_ja": "japonština", + "Language_jv": "javánština", + "Language_ka": "gruzínština", + "Language_kg": "konžština", + "Language_ki": "kikujština", + "Language_kj": "kuaňamština", + "Language_kk": "kazachština", + "Language_kl": "grónština", + "Language_km": "kambodžština", + "Language_kn": "kannadština", + "Language_ko": "korejština", + "Language_kr": "kanuri", + "Language_ks": "kašmírština", + "Language_ku": "kurdština", + "Language_kv": "komijština", + "Language_kw": "kornština", + "Language_ky": "kirgizština", + "Language_la": "latina", + "Language_lb": "Lucemburština", + "Language_lg": "ganda", + "Language_li": "limburština", + "Language_ln": "lingalština", + "Language_lo": "laoština", + "Language_lt": "litevština", + "Language_lu": "lubu-katanžština", + "Language_lv": "lotyština", + "Language_mg": "malgaština", + "Language_mh": "maršálština", + "Language_mi": "maorština", + "Language_mk": "makedonština", + "Language_ml": "malabarština", + "Language_mn": "mongolština", + "Language_mr": "marathi", + "Language_ms": "malajština", + "Language_mt": "maltština", + "Language_my": "barmština", + "Language_na": "nauru", + "Language_nb": "norština (Bokmål)", + "Language_nd": "ndebele (Zimbabwe)", + "Language_ne": "nepálština", + "Language_ng": "ndondština", + "Language_nl": "nizozemština", + "Language_nn": "norština (nynorsk)", + "Language_no": "norština", + "Language_nr": "ndebele (Jižní Afrika)", + "Language_nv": "navažština", + "Language_ny": "ňandžština", + "Language_oc": "occitan", + "Language_oj": "odžibvejština", + "Language_om": "Oromo (Afan)", + "Language_or": "oriya", + "Language_os": "osetština", + "Language_pa": "paňdžábština", + "Language_pi": "pálí", + "Language_pl": "polština", + "Language_ps": "Pashto (Pushto)", + "Language_pt": "portugalština", + "Language_qu": "kečuánština", + "Language_rm": "rétorománština", + "Language_rn": "kirundi", + "Language_ro": "rumunština", + "Language_ru": "ruština", + "Language_rw": "kinyarwandština", + "Language_sa": "sanskrt", + "Language_sc": "sardština", + "Language_sd": "sindhi", + "Language_se": "sámština (severní)", + "Language_sg": "sangho", + "Language_si": "sinhálština", + "Language_sk": "slovenština", + "Language_sl": "slovinština", + "Language_sm": "samoyština", + "Language_sn": "shona", + "Language_so": "somálština", + "Language_sq": "albánština", + "Language_sr": "srbština", + "Language_ss": "siswatština", + "Language_st": "sesotho", + "Language_su": "sundanština", + "Language_sv": "švédština", + "Language_sw": "svahilština", + "Language_ta": "tamilština", + "Language_te": "telugština", + "Language_tg": "tádžičtina", + "Language_th": "thajština", + "Language_ti": "tigrinijština", + "Language_tk": "turkmenština", + "Language_tl": "tagalog", + "Language_tn": "setswanština", + "Language_to": "tonga", + "Language_tr": "turečtina", + "Language_ts": "tsonga", + "Language_tt": "tatarština", + "Language_tw": "twi", + "Language_ty": "tahitština", + "Language_ug": "uighurština", + "Language_uk": "ukrajinština", + "Language_ur": "urdština", + "Language_uz": "uzbečtina", + "Language_ve": "vendština", + "Language_vi": "vietnamština", + "Language_vo": "volapuk", + "Language_wa": "valonština", + "Language_wo": "wolof", + "Language_xh": "xhosa", + "Language_yi": "jidiš", + "Language_yo": "yoruba", + "Language_za": "zhuang", + "Language_zh": "čínština", + "Language_zu": "zulu", + "LanguageCode": "Jazykový kód", + "MobileVsDesktop": "Mobilní vs. Desktop", + "OperatingSystemFamily": "Operační systém", + "OperatingSystems": "Operační systémy", + "PluginDescription": "Zobrazí různá uživatelská nastavení: prohlížeč, rodinu prohlížečů, operační systém, zásuvné moduly, rozlišení, globální nastavení.", + "PluginDetectionDoesNotWorkInIE": "Poznámka: Detekce zásuvných modulů nepracuje v prohlížeči Interet Explorer. Toto hlášení je založeno na ostatních prohlížečích", + "Resolutions": "Rozlišení", + "VisitorSettings": "Nastavení návštěvníků", + "WideScreen": "Širokoúhlá obrazovka", + "WidgetBrowserFamilies": "Prohlížeče podle rodiny", + "WidgetBrowsers": "Web prohlížeče návštěvníků", + "WidgetBrowserVersion": "Verze webového prohlížeče", + "WidgetGlobalVisitors": "Hlavní nastavení návštěvníků", + "WidgetOperatingSystems": "Operační systémy", + "WidgetPlugins": "Seznam zásuvných modulů", + "WidgetResolutions": "Rozlišení obrazovky", + "WidgetWidescreen": "Normální \/ Širokoúhlá" + }, + "UsersManager": { + "AddUser": "Přidat nového uživatele", + "Alias": "Alias", + "AllWebsites": "Všechny weby", + "AnonymousUserHasViewAccess": "Poznámka: %1$s uživatel má právo k přístupu k %2$s", + "ApplyToAllWebsites": "Použít na všechny weby", + "ChangeAllConfirm": "Jste si jistí, že chcete změnit '%s' oprávnění pro všechny weby?", + "ClickHereToDeleteTheCookie": "Klinětě zde pro vymazání cookie a zaznamenávání vaších návštěv", + "ClickHereToSetTheCookieOnDomain": "Klikněte zde pro nastavení cookie, která vynechá vaše návštěvy na webech monitorováných Piwikem na %s", + "DeleteConfirm": "Jste si jistí, že chcete vymazat uživatele %s?", + "Email": "E-mail", + "ExceptionAccessValues": "Parametr přístupu musí mít jednu z následujících hodnot : [ %s ]", + "ExceptionAdminAnonymous": "Nemůžete dát 'admin' přístup 'anonymous' uživateli.", + "ExceptionDeleteDoesNotExist": "Uživatel '%s' neexistuje a proto nemůže být vymazán.", + "ExceptionEditAnonymous": "'Anonymous' uživatel nemůže být upraven, nebo vymazán. Je použit Piwikem k definici uživatele, který ještě není přihlášen. Například můžete zveřejnit vaše statistiky udělením oprávnění 'view' uživateli 'anonymous'.", + "ExceptionEmailExists": "Uživatel s emailem '%s' již existuje.", + "ExceptionInvalidEmail": "Email nemá platný formát.", + "ExceptionInvalidLoginFormat": "Přihlašovací jméno musí obsahovat mezi %1$s a %2$s znaky a obsahovat pouze písmena, čísla, nebo znaky '_' nebo '-' nebo '.'", + "ExceptionLoginExists": "Uživatelské jméno '%s' již existuje.", + "ExceptionPasswordMD5HashExpected": "UsersManager.getTokenAuth očekává heslo v podobě MD5 hashe (32 znaků dlouhého řetězce). Prosím nejprve zavoltejte metodu md5() na tomto hesle.", + "ExceptionUserDoesNotExist": "Uživatel '%s' neexistuje.", + "ExcludeVisitsViaCookie": "Vynechat vaše navštěvy pomocí cookie", + "ForAnonymousUsersReportDateToLoadByDefault": "Datum hlášení, které se má načíst jako výchozí pro anonymní uživatele", + "IfYouWouldLikeToChangeThePasswordTypeANewOne": "Pokud chcete změnit heslo, zapiště jej. Jinak jej nevyplňujte.", + "MainDescription": "Nastavení, kteří uživatelé mají přístup k Piwik statistikám na vaších stránkách. Můžete také nastavit oprávnění ke všem stránkám najednou.", + "ManageAccess": "Správa přístupu", + "MenuAnonymousUserSettings": "Nastavení anonymního uživatele", + "MenuUsers": "Uživatelé", + "MenuUserSettings": "Nastavení uživatele", + "PluginDescription": "Správa uživatelů v Piwiku: přidat nového uživatele, upravit existujícícho, aktualizace oprávnění. Všechny akce jsou k dizpozici přes API", + "PrivAdmin": "Administrátor", + "PrivNone": "Žádné oprávnění", + "PrivView": "Zobrazení", + "ReportDateToLoadByDefault": "Datum hlášení, které se má načíst jako výchozí", + "ReportToLoadByDefault": "Hlášení, které se má načíst jako výchozí", + "TheLoginScreen": "Přihlašovací obrazovka", + "TypeYourPasswordAgain": "Zapiště vaše nové heslo znova", + "User": "Uživatel", + "UsersManagement": "Správa uživatelů", + "UsersManagementMainDescription": "Vytvořte nové uživatele, nebo aktualizujte existující. Níže můžete nastavit jejich oprávnění.", + "WhenUsersAreNotLoggedInAndVisitPiwikTheyShouldAccess": "Když uživatelé nejsou přihlášení do Piwiku, můžou mít přístup k", + "YourUsernameCannotBeChanged": "Vaše uživatelské jméno nemůže být změněno", + "YourVisitsAreIgnoredOnDomain": "%sVaše návštěvy jsou vynechávány Piwikem na %s %s (cookie pro vynechání byla nalezena ve vašem prohlížeči).", + "YourVisitsAreNotIgnored": "%sVaše návštěvy nejsou vynechávány Piwikem%s (cookie pro vynechání nebyla nalezena ve vašem prohlížeči)." + }, + "VisitFrequency": { + "ColumnActionsByReturningVisits": "Akcí za opakovanou návštěvu", + "ColumnAverageVisitDurationForReturningVisitors": "Průměrná délka návštěvy pro navracejících se návštěvnících (v sekundách)", + "ColumnAvgActionsPerReturningVisit": "Proměrnů počet akcí pro navracející se návštěvníky", + "ColumnBounceRateForReturningVisits": "Frekvence odchodů pro opakované návštěvy", + "ColumnReturningVisits": "Opětovných návštěv", + "ColumnUniqueReturningVisitors": "Unikátní vracející se návštěvníci", + "PluginDescription": "Hlásí různé statistiky o navracejících se versus nových návštěvnících", + "ReturnActions": "%s akcí za opakovanou návštěvu", + "ReturnAverageVisitDuration": "%s průměrná dělka návštěvy pro navracející se návštěvníky", + "ReturnAvgActions": "%s akcí na navracejících se návštěvníka", + "ReturnBounceRate": "%s opakujících se návštěv odešlo (opustilo web po jedné stránce)", + "ReturningVisitsDocumentation": "Toto je přehled vracejících se návštěvníků.", + "ReturnVisits": "%s opakujících se návštěv", + "SubmenuFrequency": "Frekvence", + "WidgetGraphReturning": "Graf opakujících se návštěv", + "WidgetOverview": "Přehled frekvencí" + }, + "VisitorInterest": { + "BetweenXYMinutes": "%1$s-%2$s minut", + "BetweenXYSeconds": "%1$s-%2$ss", + "ColumnPagesPerVisit": "Stránek za návštěvu", + "ColumnVisitDuration": "Doba návštěvy", + "Engagement": "Zapojení", + "NPages": "%s stránek", + "OneMinute": "1 minuta", + "OnePage": "1 stránka", + "PluginDescription": "Hlášení o zájmů návštěvníků: počet otevřených stránek, čas strávený na webu", + "PlusXMin": "%s min.", + "VisitNum": "Číslo návstěvníka", + "VisitsByDaysSinceLast": "Návstěv po dnech od poslední návštěvy", + "visitsByVisitCount": "Návstěvy podle pořadí", + "VisitsPerDuration": "Návštěv za dobu návštěvy", + "VisitsPerNbOfPages": "Návštěv za počet stránek", + "WidgetLengths": "Doba návštěv", + "WidgetPages": "Stránek za návštěvu", + "WidgetVisitsByDaysSinceLastDocumentation": "V hlášení můžete vidět kolik návstěv bylo od návstěvníků, kteří již v minulosti po několika dnech stránku navstívili." + }, + "VisitsSummary": { + "AverageVisitDuration": "%s průměrná doba návštěvy", + "GenerateQueries": "%s provedeno dotazů", + "GenerateTime": "%s sekund k vygenerování stránky", + "MaxNbActions": "%s maximální počet akcí na návštěvu", + "NbActionsDescription": "%s akcí (zobrazení stránek, stažení a odchodů)", + "NbActionsPerVisit": "%s akcí za návštěvu", + "NbDownloadsDescription": "%s stažení", + "NbKeywordsDescription": "%s unikátní klíčová slova", + "NbOutlinksDescription": "%s externích odkazů", + "NbPageviewsDescription": "%s zobrazení", + "NbUniqueDownloadsDescription": "%s unikátních stražení", + "NbUniqueOutlinksDescription": "%s unikátních externích odkazů", + "NbUniquePageviewsDescription": "%s unikátních zobrazení", + "NbUniqueVisitors": "%s unikátních návštěvníků", + "NbVisitsBounced": "%s návštěvníků odešlo (opustilo web po jedné stránce)", + "PluginDescription": "Hlásí základní analytická čísla: návštěv, unikátních návštěvníků, počet akcí, poměr návratů", + "VisitsSummary": "Shrnutí návštěv", + "VisitsSummaryDocumentation": "Toto je přehled vývoj návštěv", + "WidgetLastVisits": "Graf posledních návštěv", + "WidgetOverviewGraph": "Přehled s grafem", + "WidgetVisits": "Přehled návštěv" + }, + "VisitTime": { + "ColumnLocalTime": "Lokální čas", + "ColumnServerTime": "Serverový čas", + "DayOfWeek": "Dny v týdnu", + "LocalTime": "Návštěvy podle lokálního času", + "NHour": "%sh", + "PluginDescription": "Hlásí lokální a serverový čas. Informace o serverovém času je užitečná pro naplánování odstávky webu", + "ServerTime": "Návštěvy podle serverového času", + "SubmenuTimes": "Časy", + "VisitsByDayOfWeek": "Návštěvy podle dnů v týdnu", + "WidgetLocalTime": "Návštěvy podle lokálního času", + "WidgetLocalTimeDocumentation": "Tento graf ukazuje čas v %s návštěvníkovo časové zóně %s během jěho návštěvy.", + "WidgetServerTime": "Návštěvy podle serverového času", + "WidgetServerTimeDocumentation": "Tento graf ukazuje jaký čas byl na %s serveru časové zóny %s během návstěvy." + }, + "Widgetize": { + "OpenInNewWindow": "Otevřít nové okno", + "PluginDescription": "Tento zásuvný modul zjednodušuje export widgetů Piwiku na váš blog, web nebo Igoogle a Netvibes!" + } +} \ No newline at end of file diff --git a/www/analytics/lang/cy.json b/www/analytics/lang/cy.json new file mode 100644 index 00000000..e3657329 --- /dev/null +++ b/www/analytics/lang/cy.json @@ -0,0 +1,723 @@ +{ + "CoreHome": { + "PeriodDay": "Dydd", + "PeriodMonth": "Mis", + "PeriodWeek": "Wythnos", + "PeriodYear": "Blwyddyn" + }, + "Dashboard": { + "Dashboard": "Dangosfwrdd" + }, + "General": { + "AbandonedCarts": "Troliau Siopa a Adawyd", + "AboutPiwikX": "Am dan Piwik %s", + "AllowPiwikArchivingToTriggerBrowser": "Caniatáu system archifo Piwik i sbarduno pan fydd adroddiadau yn cael eu hystyried gan y porwr", + "AllWebsitesDashboard": "Dangosfwrdd Holl Wefannau", + "API": "API", + "ApplyDateRange": "Defnyddio'r Amrediad Dyddiad", + "ArchivingInlineHelp": "Ar gyfer gwefannau sydd a traffig canolig i uchel, argymhellir i analluogi'r system archifo Piwik gael ei sbarduno gan y porwr. Yn hytrach, rydym yn argymell eich bod yn sefydlu swydd cron i brosesu adroddiadau Piwik bob awr.", + "AuthenticationMethodSmtp": "Dull dilysu ar gyfer SMTP", + "AverageOrderValue": "Gwerth yr Archeb (ar gyfartaledd)", + "AveragePrice": "Ar Gyfartaledd Pris", + "AverageQuantity": "Argyfataledd Nifer", + "BackToPiwik": "Yn ôl i", + "BrokenDownReportDocumentation": "Mae'n cael ei dorri lawr i wahanol adroddiadau, sy'n cael eu harddangos mewn sparklines ar waelod y dudalen. Gallwch chwyddo y graffiau drwy glicio ar yr adroddiad hoffech ei weld.", + "ChangeTagCloudView": "Nodwch, y gallwch weld yr adroddiad mewn ffyrdd heblaw fel cwmwl tag. Defnyddiwch y rheolaethau ar waelod yr adroddiad i wneud hynny.", + "ChooseDate": "Dewiswch ddyddiad", + "ChooseLanguage": "Dewiswch Iaith", + "ChoosePeriod": "Dewiswch gyfnod", + "ChooseWebsite": "Dewiswch wefan", + "ClickHere": "Cliciwch yma am fwy o wybodaeth", + "Close": "Cau", + "ColumnActionsPerVisit": "Nifer o gamau gweitredu fesul ymweliad", + "ColumnActionsPerVisitDocumentation": "Nifer cyfartalog o gamau gweithredu (edrych ar dudalennau, lawrlwythiadau neu cysylltiadau allanol) a berfformiwyd yn ystod yr ymweliadau.", + "ColumnAverageTimeOnPage": "Amser gyfartaledd ar dudalen", + "ColumnAverageTimeOnPageDocumentation": "Y swm cyfartalog o'r amser a dreuliwyd gan ymwelwyr ar y dudalen hon (dim ond y dudalen, nid y wefan gyfan).", + "ColumnAvgTimeOnSite": "Hyd Amser ar Wefan (cyfartaledd)", + "ColumnAvgTimeOnSiteDocumentation": "Hyd Ymweliad (cyfartaledd)", + "ColumnBounceRate": "Cyfradd Adlam", + "ColumnBounceRateDocumentation": "Canran o ymweliadau sydd ond wedi agor un dudalen. Mae hyn yn golygu, bod yr ymwelydd yn gadael y wefan yn uniongyrchol o'r dudalen fynedfa.", + "ColumnBounceRateForPageDocumentation": "Canran yr ymweliadau a ddechreuodd a ddaeth i ben ar y dudalen hon.", + "ColumnBounces": "Adlamau", + "ColumnBouncesDocumentation": "Nifer o ymweliadau a ddechreuodd a ddaeth i ben ar y dudalen hon. Mae hyn yn golygu bod yr ymwelydd wedi gadael y wefan ar ôl edrych ar y dudalen hon yn unig.", + "ColumnConversionRate": "Cyfradd Trosi", + "ColumnConversionRateDocumentation": "Canran yr ymweliadau sy'n sbarduno trosi gôl.", + "ColumnEntrances": "Mynedfeydd", + "ColumnEntrancesDocumentation": "Nifer o ymweliadau a ddechreuodd ar y dudalen hon.", + "ColumnExitRate": "Cyfradd Gadael", + "ColumnExitRateDocumentation": "Canran yr ymweliadau sy'n gadael y wefan ar ôl edrych ar y dudalen hon (golygu tudalennau unigryw wedi'i rannu â allanfeydd)", + "ColumnExits": "Allanfeydd", + "ColumnExitsDocumentation": "Nifer o ymweliadau a ddaeth i ben ar y dudalen hon.", + "ColumnLabel": "Label", + "ColumnMaxActions": "Uchafswm camau gweithredu mewn un ymweliad", + "ColumnNbActions": "Camau gweithredu", + "ColumnNbActionsDocumentation": "Nifer o gamau gweithredu a gyflawnir gan eich ymwelwyr. Gall eu bod yn olygfeydd tudalennau, lawrlwythiadau neu cysylltiadau allanol", + "ColumnNbUniqVisitors": "Ymwelwyr unigol", + "ColumnNbUniqVisitorsDocumentation": "Y nifer o ymwelwyr unigol i'ch safle. Mae pob ymwelydd yn cael ei gyfrif unwaith yn unig, hyd yn oed os yw yn ymweld â'r wefan sawl gwaith y dydd.", + "ColumnNbVisits": "Ymweliadau", + "ColumnNbVisitsDocumentation": "Os bydd ymwelydd yn dod at eich gwefan am y tro cyntaf neu os bydd yn ymweld a tudalen o fewn 30 munud ar ôl agor y dudalen olaf, bydd hyn yn cael ei gofnodi fel ymweliad newydd.", + "ColumnPageBounceRateDocumentation": "Canran yr ymweliadau a ddechreuodd ar y dudalen hon a gadawodd y wefan yn syth.", + "ColumnPageviews": "Golygu Tudalennau", + "ColumnPageviewsDocumentation": "Nifer o weithiau mae'r dudalen hon wedi ymweld â hi.", + "ColumnPercentageVisits": "% Ymweliad", + "ColumnSumVisitLength": "Cyfanswm yr amser a dreulir gan ymwelwyr (mewn eiliadau)", + "ColumnUniquePageviews": "Golygu Tudalennau Unigryw", + "ColumnUniquePageviewsDocumentation": "Nifer o ymweliadau a oedd yn cynnwys y dudalen hon. Os oedd y dudalen hon wedi ei gweld sawl gwaith yn ystod un ymweliad, mae'n cael ei chyfrif unwaith yn unig.", + "ColumnValuePerVisit": "Refeniw fesul Ymweliad", + "ColumnVisitDuration": "Hyd Ymweliad (mewn eiliadau)", + "ColumnVisitsWithConversions": "Ymweliadau â Addasiadau", + "ConfigFileIsNotWritable": "Nid yw ffeil ffurfweddu %s Piwik yn ysgrifenadwy, efallai na fydd rhai o'ch newidiadau yn cael eu cadw. %s Newidiwch caniatadau y ffeil config i'w gwneud yn ysgrifenadwy os gwelwch yn dda.", + "CurrentMonth": "Mis Cyfredol", + "CurrentWeek": "Wythnos Cyfredol", + "CurrentYear": "Blwyddyn Cyfredol", + "Daily": "Dyddiol", + "DashboardForASpecificWebsite": "Dangosfwrdd ar gyfer gwefan penodol", + "Date": "Dyddiad", + "DateRange": "Amrediad dyddiad:", + "DateRangeFrom": "Oddiwrth", + "DateRangeFromTo": "O %s i %s", + "DateRangeInPeriodList": "Amrediad dyddiad", + "DateRangeTo": "I", + "DaysHours": "%1$s diwrnodau %2$s oriau", + "DaysSinceFirstVisit": "Nifer o ddiwrnodau ers yr ymweliad cyntaf", + "DaysSinceLastEcommerceOrder": "Nifer o ddiwrnodau ers yr archeb eFasnach diwethaf", + "DaysSinceLastVisit": "Diwrnodau ers yr ymweliad diwethaf", + "Default": "Diofyn", + "Delete": "Dileu", + "Description": "Disgrifiad", + "Details": "Manylion", + "Discount": "Gostyngiad", + "DisplaySimpleTable": "Dangos tabl syml", + "DisplayTableWithGoalMetrics": "Dangos tabl gyda metrics Nodau", + "DisplayTableWithMoreMetrics": "Dangos tabl gyda mwy o metrics", + "Done": "Wedi Gorffen", + "Download": "Lawrlwytho", + "DownloadFullVersion": "%1$sLawrlwytho%2$s y fersiwn llawn! Ewch yma %3$s", + "EcommerceOrders": "Archebion eFasnach", + "EcommerceVisitStatusDesc": "Ymwelwch â statws eFasnach ar ddiwedd yr ymweliad", + "EcommerceVisitStatusEg": "Er enghraifft, i ddewis yr holl ymweliadau sydd wedi gwneud archeb Efasnach, byddai'r cais API yn cynnwys %s", + "Edit": "Golygu", + "EncryptedSmtpTransport": "Nodwch y cludiant amgryptio haen sy'n ofynnol gan eich gweinyddwr SMTP.", + "EnglishLanguageName": "Welsh", + "Error": "Gwall", + "ErrorRequest": "O diar!...roedd problem yn ystod y cais, ceisiwch eto os gwelwch yn dda.", + "ExceptionConfigurationFileNotFound": "Nid yw'r ffeil cyfluniad {%s} wedi ei darganfod.", + "ExceptionDatabaseVersion": "Rydych yn defnyddio %1$s fersiwn %2$s ond mae Piwik angen o leiaf fersiwn %3$s", + "ExceptionFileIntegrity": "Gwirio uniondeb wedi methu: %s", + "ExceptionFilesizeMismatch": "Diffyg cyfatebiaeth maint y ffeil: %1$s (disgwylir hyd o: %2$s, wedi dod o hyd i: %3$s)", + "ExceptionInvalidArchiveTimeToLive": "I drosglwyddo amser archif i amser byw rhaid fod y nifer o eiliadau fwy na sero", + "ExceptionInvalidDateFormat": "Rhaid i fformat dyddiadau fod yn: %s neu unrhyw air allweddol a gefnogir gan y gweithred %s (gweler %s am fwy o wybodaeth)", + "ExceptionInvalidDateRange": "Nid yw'r '%s' dyddiad mewn ystod dyddiad cywir. Dylai fod yn y ffurf canlynol: %s.", + "ExceptionInvalidPeriod": "Nid yw'r cyfnod '%s' yn cael ei gefnogi Rhowch gynnig ar unrhyw un o'r canlynol yn lle hynny: %s", + "ExceptionInvalidReportRendererFormat": "Nid yw '%s' yn ddilys. Rhowch gynnig ar unrhyw un o'r canlynol yn lle hynny: %s.", + "ExceptionInvalidToken": "Nid yw'r tocyn yn ddilys.", + "ExceptionLanguageFileNotFound": "Nid yw'r ffeil Iaith '%s' wedi ei chanfod.", + "ExceptionMethodNotFound": "Nid yw'r dull '%s' yn bodoli neu nid yw ar gael yn y modiwl '%s'.", + "ExceptionMissingFile": "Ffeil ar goll: %s", + "ExceptionNonceMismatch": "Doedd dim modd gwirio'r tocyn diogelwch ar y ffurflen hon.", + "ExceptionPrivilege": "Ni allwch ddefnyddio'r adnodd hwn, mae'n ofynnol cael %s i gael mynediad.", + "ExceptionPrivilegeAccessWebsite": "Ni allwch ddefnyddio'r adnodd hwn, mae'n ofynnol cael lefel %s i gael mynediad i'r wefan =%d.", + "ExceptionPrivilegeAtLeastOneWebsite": "Ni allwch ddefnyddio'r adnodd hwn, mae'n ofynnol cael lefel %s i gael mynediad ar gyfer o leiaf un wefan.", + "ExceptionUnableToStartSession": "Methu dechrau sesiwn", + "ExceptionUndeletableFile": "Wedi methu dileu %s", + "ExceptionUnreadableFileDisabledMethod": "Nid oedd modd darllen y ffeil cyfluniad {%s}. Efallai bod eich cynhaliwr gwefan wedi ei anablu %s.", + "Export": "Allforio", + "ExportAsImage": "Allforio fel llun", + "ExportThisReport": "Allforio'r set ddata hon mewn fformatau eraill", + "FileIntegrityWarningExplanation": "Mae rhai gwallau wedi eu darganfod wrth redeg gwiriad cywirdeb ar y ffeil. O ganlyniad mae'n debygol fod methiant rhannol neu llawn wedi digwydd wrth llwytho i fyny rhai o ffeiliau Piwik. Dylech ail lwytho holl ffeiliau Piwik eto yn y modd BINARY ac adnewyddu'r dudalen hon hyd nes ei fod yn dangos dim gwall.", + "First": "Cyntaf", + "ForExampleShort": "ee.", + "FromReferrer": "oddiwrth", + "GeneralSettings": "Gosodiadau Cyffredinol", + "GiveUsYourFeedback": "Gyrrwch Adborth!", + "GoTo": "Ewch i %s", + "GraphHelp": "Mwy o wybodaeth am ddangos graffiau yn Piwik.", + "HelloUser": "Helo, %s!", + "HoursMinutes": "%1$s oriau %2$s isafswm", + "Id": "Id", + "IfArchivingIsFastYouCanSetupCronRunMoreOften": "Gan dybio fod y system archifo ar gyfer eich gosodiad yn gyflym, gallwch sefydlu'r crontab i redeg yn fwy aml.", + "InvalidDateRange": "Amrediad Dyddiad Annilys, Ceisiwch Eto os Gwelwch yn Dda", + "InvalidResponse": "Mae'r data a dderbyniwyd yn annilys.", + "Language": "Iaith", + "LastDays": "Dyddiau %s diwethaf (yn cynnwys heddiw)", + "LayoutDirection": "ltr", + "Loading": "Yn llwytho...", + "LoadingData": "Wrthi'n llwytho data...", + "Locale": "cy_GB.UTF-8", + "Logout": "Allgofnodi", + "LongDay_1": "Llun", + "LongDay_2": "Mawrth", + "LongDay_3": "Mercher", + "LongDay_4": "Iau", + "LongDay_5": "Gwener", + "LongDay_6": "Sadwrn", + "LongDay_7": "Sul", + "LongMonth_1": "Ionawr", + "LongMonth_10": "Hydref", + "LongMonth_11": "Tachwedd", + "LongMonth_12": "Rhagfyr", + "LongMonth_2": "Chwefror", + "LongMonth_3": "Mawrth", + "LongMonth_4": "Ebrill", + "LongMonth_5": "Mai", + "LongMonth_6": "Mehefin", + "LongMonth_7": "Gorffenaf", + "LongMonth_8": "Awst", + "LongMonth_9": "Medi", + "MinutesSeconds": "%1$s isafswm %2$ss", + "Monthly": "Misol", + "MultiSitesSummary": "Yr Holl Wefannau", + "Name": "Enw", + "NbActions": "Nifer o gamau gweithredu", + "Never": "Byth", + "NewReportsWillBeProcessedByCron": "Os nad yw system archifo Piwik wedi ei sbarduno gan y porwr, bydd adroddiadau newydd yn cael eu prosesu gan y crontab", + "NewUpdatePiwikX": "Diweddariad Newydd: Piwik %s", + "NewVisitor": "Ymwelydd Newydd", + "Next": "Nesaf", + "No": "Na", + "NoDataForGraph": "Nid oes data ar gyfer y graff yma.", + "NoDataForTagCloud": "Dim data ar gyfer y tag cwmwl.", + "NotDefined": "%s heb ei ddiffinio", + "NotValid": "%s ddim yn ddilys", + "NSeconds": "%s eiliad", + "NumberOfVisits": "Nifer o Ymweliadau", + "Ok": "Iawn", + "OnlyEnterIfRequired": "Dim ond rhoi enw defnyddiwr os yw eich gweinyddwr SMTP yn gofyn amdano.", + "OnlyEnterIfRequiredPassword": "Dim ond rhoi cyfrinair os yw eich gweinyddwr SMTP yn gofyn amdano.", + "OnlyUsedIfUserPwdIsSet": "Yn cael ei ddefnyddio ond pan fydd enw defnyddiwr \/ cyfrinair wedi ei osod, gofynnwch i'ch darparwr os ydych yn ansicr pa ddull i'w ddefnyddio.", + "OpenSourceWebAnalytics": "Dadansoddwr Gwefannol Ffynhonnell Agored", + "OptionalSmtpPort": "Dewisol. Yn rhagosod i 25 os heb eu amgryptio a TLS SMTP, a 465 ar gyfer SSL SMTP.", + "OrCancel": "neu %s Canslo %s", + "OriginalLanguageName": "Cymraeg", + "Others": "Eraill", + "Period": "Cyfnod", + "Piechart": "Siart Cylch", + "PiwikXIsAvailablePleaseUpdateNow": "Mae Piwik %1$s ar gael. %2$sDiweddarwch nawr!%3$s (gweler %4$snewidiadau%5$s).", + "PleaseSpecifyValue": "Nodwch werth i '%s'", + "PleaseUpdatePiwik": "Diweddarwch Piwik os gwelwch yn dda", + "PoweredBy": "Pwerwyd gan", + "Previous": "Blaenorol", + "PreviousDays": "Diwrnodau %s Blaenorol (heb gynnwys heddiw)", + "Price": "Pris", + "ProductConversionRate": "Cyfradd Trosi Cynnyrch", + "ProductRevenue": "Cyfanswm Cynnyrch", + "PurchasedProducts": "Cynhyrchion a Brynwyd", + "Quantity": "Nifer", + "RefreshPage": "Adnewyddwch y dudalen", + "Report": "Adroddiad", + "Reports": "Adroddiadau", + "ReportsContainingTodayWillBeProcessedAtMostEvery": "Bydd adroddiadau ar gyfer heddiw (neu unrhyw amrediad arall o ddyddiadau gan gynnwys heddiw) yn cael eu prosesu yn y rhan fwyaf pob", + "ReportsWillBeProcessedAtMostEveryHour": "Felly bydd adroddiadau yn cael ei prosesu ar y mwyaf pob awr.", + "RequestTimedOut": "Mae cais data i %s wedi dod i ben. Rhowch gynnig arall arni.", + "Required": "%s angenrheidiol", + "ReturningVisitor": "Ymwelydd yn ail ymweld", + "Save": "Cadw", + "SaveImageOnYourComputer": "I gadw copi o'r llun ar eich cyfrifiadur, cliciwch a'r y botwm dde a dewiswch \"Save Image As...\"", + "Search": "Chwilio", + "Seconds": "%ss", + "SelectYesIfYouWantToSendEmailsViaServer": "Dewiswch \"Oes\" os ydych eisiau, neu ydych yn gorfod anfon e-bost trwy weinyddwr a enwir yn lle y defnyddio gweithred post lleol", + "Settings": "Gosodiadau", + "Shipping": "Cludiant", + "ShortDay_1": "Llun", + "ShortDay_2": "Mawrth", + "ShortDay_3": "Mercher", + "ShortDay_4": "Iau", + "ShortDay_5": "Gwener", + "ShortDay_6": "Sadwrn", + "ShortDay_7": "Sul", + "ShortMonth_1": "Ionawr", + "ShortMonth_10": "Hydref", + "ShortMonth_11": "Tachwedd", + "ShortMonth_12": "Rhagfyr", + "ShortMonth_2": "Chwefror", + "ShortMonth_3": "Mawrth", + "ShortMonth_4": "Ebrill", + "ShortMonth_5": "Mai", + "ShortMonth_6": "Mehefin", + "ShortMonth_7": "Gorffenaf", + "ShortMonth_8": "Awst", + "ShortMonth_9": "Medi", + "SmallTrafficYouCanLeaveDefault": "Ar gyfer gwefannau heb lawer o draffig, gallwch adael yr eiliadau %s diofyn, a agor pob adroddiad mewn amser real.", + "SmtpEncryption": "Amgryptio SMTP", + "SmtpPassword": "Cyfrinair SMTP", + "SmtpPort": "Porth SMTP", + "SmtpServerAddress": "Cyfeiriad y gweinyddwr SMTP", + "SmtpUsername": "Enw defnyddiwr SMTP", + "Subtotal": "Is-gyfanswm", + "Table": "Tabl", + "TagCloud": "Tag Cwmwl", + "Tax": "Treth", + "Today": "Heddiw", + "Total": "Cyfanswm", + "TotalRevenue": "Cyfanswm Refeniw", + "TranslatorEmail": "hefinw@deudraeth.net", + "TranslatorName": "Hefin Williams", + "UniquePurchases": "Pryniant Unigol", + "Unknown": "Anadnabyddus", + "Upload": "Uwchlwytho", + "UsePlusMinusIconsDocumentation": "Defnyddiwch yr eiconau mwy (+) a llai (-) ar y chwith i lywio.", + "Username": "Enw Defnyddiwr", + "UseSMTPServerForEmail": "Defnyddiwch gweinyddwr SMTP ar gyfer e-bost", + "Value": "Gwerth", + "VBarGraph": "Graff bar fertigol", + "Visit": "Ymweliad", + "VisitConvertedGoal": "Mae'r ymweliad wedi trosi o leiaf un Nod", + "VisitConvertedNGoals": "Mae'r ymweliad wedi trosi %s Nod", + "VisitDuration": "Hyd Ymweliadau Mewn Eiliadau (cyfartaledd)", + "VisitorID": "ID yr Ymwelwydd", + "VisitorIP": "IP yr Ymwelydd", + "Visitors": "Ymwelwyr", + "VisitsWith": "Ymweliadau gyda %s", + "VisitType": "Math o ymwelydd", + "VisitTypeExample": "Fel enghraifft, i ddewis pob ymwelydd sydd wedi ail ddychwelyd i'r safle, yn cynnwys y rhai hynny sydd wedi prynu rhywbeth yn ystod eu hymweliad diwethaf, bydd y cais API yn cynnwys %s", + "Warning": "Rhybudd", + "WarningFileIntegrityNoManifest": "Nid oedd yn bosib perfformio gwiriad cywirdeb ar y ffeil am fod manifest.inc.php ar goll.", + "WarningFileIntegrityNoMd5file": "Nid oedd yn bosib perfformio gwiriad cywirdeb ar y ffeil am fod gweithred md5_file() ar goll.", + "WarningPasswordStored": "%sRhybudd%:%s Bydd y cyfrinair yn cael ei storio yn y ffeil config ac yn weladwy i bawb sydd a mynediad iddo.", + "Website": "Gwefan", + "Weekly": "Wythnosol", + "Widgets": "Widgets", + "YearsDays": "%1$s blynyddoedd %2$s diwrnodau", + "Yes": "Ia", + "Yesterday": "Ddoe", + "YouAreViewingDemoShortMessage": "Rydych ar hyn o bryd yn edrych ar demo o Piwik", + "YouMustBeLoggedIn": "Rhaid i chi fewngofnodi i ddefnyddio'r gweithred hwn.", + "YourChangesHaveBeenSaved": "Mae eich newidiadau wedi eu cadw." + }, + "SitesManager": { + "Currency": "Arian Breiniol" + }, + "UserCountry": { + "country_ac": "Ynys Ascension", + "country_ad": "Andorra", + "country_ae": "Emiraethau Arabaidd Unedig", + "country_af": "Affganistan", + "country_ag": "Antigwa a Barbuda", + "country_ai": "Anguilla", + "country_al": "Albania", + "country_am": "Armenia", + "country_an": "Ynysoedd Caribî yr Iseldiroedd", + "country_ao": "Angola", + "country_aq": "Antarctica", + "country_ar": "Yr Ariannin", + "country_as": "Samoa Americanaidd", + "country_at": "Awstria", + "country_au": "Awstralia", + "country_aw": "Aruba", + "country_ax": "Ynysoedd Aland", + "country_az": "Azerbaijan", + "country_ba": "Bosnia a Herzegovina", + "country_bb": "Barbados", + "country_bd": "Bangladesh", + "country_be": "Gwlad Belg", + "country_bf": "Burkina Faso", + "country_bg": "Bwlgaria", + "country_bh": "Bahrain", + "country_bi": "Burundi", + "country_bj": "Benin", + "country_bl": "Saint Barthélemy", + "country_bm": "Bermwda", + "country_bn": "Brunei", + "country_bo": "Bolifia", + "country_bq": "Iseldiroedd y Caribî", + "country_br": "Brasil", + "country_bs": "Y Bahamas", + "country_bt": "Bhwtan", + "country_bv": "Ynys Bouvet", + "country_bw": "Botswana", + "country_by": "Belarws", + "country_bz": "Belize", + "country_ca": "Canada", + "country_cc": "Ynysoedd Cocos (Keeling)", + "country_cd": "Gweriniaeth Ddemocrataidd y Congo", + "country_cf": "Gweriniaeth Canol Affrica", + "country_cg": "Congo", + "country_ch": "Y Swistir", + "country_ci": "Côte d’Ivoire", + "country_ck": "Ynysoedd Cook", + "country_cl": "Chile", + "country_cm": "Y Camerŵn", + "country_cn": "Tseina", + "country_co": "Colombia", + "country_cp": "Ynys Clipperton", + "country_cr": "Costa Rica", + "country_cu": "Ciwba", + "country_cv": "Cape Verde", + "country_cw": "Curaçao", + "country_cx": "Ynys y Nadolig", + "country_cy": "Cyprus", + "country_cz": "Gweriniaeth Tsiec", + "country_de": "Yr Almaen", + "country_dg": "Diego Garcia", + "country_dj": "Djibouti", + "country_dk": "Denmarc", + "country_dm": "Dominica", + "country_do": "Y Weriniaeth Ddominicaidd", + "country_dz": "Algeria", + "country_ea": "Ceuta a Melilla", + "country_ec": "Ecwador", + "country_ee": "Estonia", + "country_eg": "Yr Aifft", + "country_eh": "Gorllewin Sahara", + "country_er": "Eritrea", + "country_es": "Sbaen", + "country_et": "Ethiopia", + "country_fi": "Y Ffindir", + "country_fj": "Fiji", + "country_fk": "Ynysoedd y Falkland", + "country_fm": "Micronesia", + "country_fo": "Ynysoedd Ffaröe", + "country_fr": "Ffrainc", + "country_ga": "Gabon", + "country_gb": "Prydain Fawr", + "country_gd": "Grenada", + "country_ge": "Georgia", + "country_gf": "Giana Ffrengig", + "country_gg": "Guernsey", + "country_gh": "Ghana", + "country_gi": "Gibraltar", + "country_gl": "Yr Ynys Las", + "country_gm": "Gambia", + "country_gn": "Gini", + "country_gp": "Guadeloupe", + "country_gq": "Gini Gyhydeddol", + "country_gr": "Gwlad Groeg", + "country_gs": "Ynysoedd De Georgia a De Sandwich", + "country_gt": "Guatemala", + "country_gu": "Guam", + "country_gw": "Guinea-Bissau", + "country_gy": "Guyana", + "country_hk": "Hong Kong S.A.R., Tseina", + "country_hm": "Ynys Heard ac Ynysoedd McDonald", + "country_hn": "Hondwras", + "country_hr": "Croatia", + "country_ht": "Haiti", + "country_hu": "Hwngari", + "country_ic": "yr Ynysoedd Dedwydd", + "country_id": "Indonesia", + "country_ie": "Iwerddon", + "country_il": "Israel", + "country_im": "Ynys Manaw", + "country_in": "India", + "country_io": "Tiriogaeth Cefnfor India Prydain", + "country_iq": "Irac", + "country_ir": "Iran", + "country_is": "Gwlad yr Iâ", + "country_it": "Yr Eidal", + "country_je": "Jersey", + "country_jm": "Jamaica", + "country_jo": "Yr Iorddonen", + "country_jp": "Siapan", + "country_ke": "Cenia", + "country_kg": "Cirgistan", + "country_kh": "Cambodia", + "country_ki": "Kiribati", + "country_km": "Comoros", + "country_kn": "Saint Kitts a Nevis", + "country_kp": "Gogledd Corea", + "country_kr": "De Corea", + "country_kw": "Coweit", + "country_ky": "Ynysoedd Cayman", + "country_kz": "Kazakhstan", + "country_la": "Laos", + "country_lb": "Libanus", + "country_lc": "Saint Lucia", + "country_li": "Liechtenstein", + "country_lk": "Sri Lanka", + "country_lr": "Liberia", + "country_ls": "Lesotho", + "country_lt": "Lithwania", + "country_lu": "Lwcsembwrg", + "country_lv": "Latfia", + "country_ly": "Libia", + "country_ma": "Moroco", + "country_mc": "Monaco", + "country_md": "Moldofa", + "country_me": "Montenegro", + "country_mf": "Saint Martin", + "country_mg": "Madagascar", + "country_mh": "Ynysoedd Marshall", + "country_mk": "Macedonia", + "country_ml": "Mali", + "country_mm": "Myanmar", + "country_mn": "Mongolia", + "country_mo": "Macao S.A.R., Tseina", + "country_mp": "Ynysoedd Gogledd Mariana", + "country_mq": "Martinique", + "country_mr": "Mawritania", + "country_ms": "Montserrat", + "country_mt": "Malta", + "country_mu": "Mawrisiws", + "country_mv": "Maldives", + "country_mw": "Malawi", + "country_mx": "Mecsico", + "country_my": "Malaysia", + "country_mz": "Mozambique", + "country_na": "Namibia", + "country_nc": "Caledonia Newydd", + "country_ne": "Niger", + "country_nf": "Ynys Norfolk", + "country_ng": "Nigeria", + "country_ni": "Nicaragwa", + "country_nl": "Yr Iseldiroedd", + "country_no": "Norwy", + "country_np": "Nepal", + "country_nr": "Nawrw", + "country_nu": "Niue", + "country_nz": "Seland Newydd", + "country_om": "Oman", + "country_pa": "Panama", + "country_pe": "Perw", + "country_pf": "Polynesia Ffrainc", + "country_pg": "Papua Gini Newydd", + "country_ph": "Philipinau", + "country_pk": "Pacistan", + "country_pl": "Gwlad Pwyl", + "country_pm": "Saint Pierre a Miquelon", + "country_pn": "Pitcairn", + "country_pr": "Puerto Rico", + "country_ps": "Tiriogaeth Palesteina", + "country_pt": "Portiwgal", + "country_pw": "Palau", + "country_py": "Paraguay", + "country_qa": "Qatar", + "country_re": "Réunion", + "country_ro": "Rwmania", + "country_rs": "Serbia", + "country_ru": "Rwsia", + "country_rw": "Rwanda", + "country_sa": "Sawdi-Arabia", + "country_sb": "Ynysoedd Solomon", + "country_sc": "Seychelles", + "country_sd": "Y Swdan", + "country_se": "Sweden", + "country_sg": "Singapore", + "country_sh": "Saint Helena", + "country_si": "Slofenia", + "country_sj": "Svalbard a Jan Mayen", + "country_sk": "Slofacia", + "country_sl": "Sierra Leone", + "country_sm": "San Marino", + "country_sn": "Senegal", + "country_so": "Somalia", + "country_sr": "Swrinam", + "country_ss": "De Sudan", + "country_st": "Sao Tome a Principe", + "country_sv": "El Salfador", + "country_sx": "Sint Maarten", + "country_sy": "Syria", + "country_sz": "Swaziland", + "country_ta": "Tristan da Cunha", + "country_tc": "Ynysoedd Turks a Caicos", + "country_td": "Chad", + "country_tf": "Tiriogaethau Ffrengig y De", + "country_tg": "Togo", + "country_th": "Gwlad Thai", + "country_tj": "Tajicistan", + "country_tk": "Tokelau", + "country_tl": "Timor-Leste", + "country_tm": "Tyrcmenistan", + "country_tn": "Tiwnisia", + "country_to": "Tonga", + "country_tr": "Twrci", + "country_tt": "Trinidad a Thobago", + "country_tv": "Twfalw", + "country_tw": "Taiwan", + "country_tz": "Tansanïa", + "country_ua": "Wcráin", + "country_ug": "Uganda", + "country_um": "Mân Ynysoedd Pellenig yr Unol Daleithiau", + "country_us": "Yr Unol Daleithiau", + "country_uy": "Uruguay", + "country_uz": "Wsbecistan", + "country_va": "Y Fatican", + "country_vc": "Saint Vincent a’r Grenadines", + "country_ve": "Venezuela", + "country_vg": "Ynysoedd Prydeinig y Wyryf", + "country_vi": "Ynysoedd Americanaidd y Wyryf", + "country_vn": "Fietnam", + "country_vu": "Vanuatu", + "country_wf": "Wallis a Futuna", + "country_ws": "Samoa", + "country_ye": "Yemen", + "country_yt": "Mayotte", + "country_za": "De Affrica", + "country_zm": "Sambia", + "country_zw": "Simbabwe" + }, + "UserSettings": { + "Language_aa": "Affareg", + "Language_ab": "Abchaseg", + "Language_ae": "Afestaneg", + "Language_af": "Affricaneg", + "Language_ak": "Acaneg", + "Language_am": "Amhareg", + "Language_an": "Aragoneg", + "Language_ar": "Arabeg", + "Language_as": "Asameg", + "Language_av": "Afareg", + "Language_az": "Azerbaijani", + "Language_ba": "Bashcorteg", + "Language_be": "Belarwsiyn", + "Language_bg": "Bwlgareg", + "Language_bh": "Bihari", + "Language_bi": "Bislama", + "Language_bm": "Bambareg", + "Language_bn": "Bengali; Bangla", + "Language_bo": "Tibeteg", + "Language_br": "Llydaweg", + "Language_bs": "Bosnieg", + "Language_ca": "Catalaneg", + "Language_ce": "Tsietsieneg", + "Language_ch": "Tsiamorro", + "Language_co": "Corseg", + "Language_cr": "Cri", + "Language_cs": "Tsiec", + "Language_cu": "Hen Slafoneg", + "Language_cy": "Cymraeg", + "Language_da": "Daneg", + "Language_de": "Almaeneg", + "Language_dv": "Difehi", + "Language_ee": "Ewe", + "Language_el": "Groeg", + "Language_en": "Saesneg", + "Language_eo": "Esperanto", + "Language_es": "Sbaeneg", + "Language_et": "Estoneg", + "Language_eu": "Basgeg", + "Language_fa": "Persieg", + "Language_ff": "Ffwla", + "Language_fi": "Ffineg", + "Language_fj": "Ffijïeg", + "Language_fo": "Ffaroeg", + "Language_fr": "Ffrangeg", + "Language_fy": "Ffrisieg", + "Language_ga": "Gwyddeleg", + "Language_gd": "Gaeleg yr Alban", + "Language_gl": "Galiseg", + "Language_gn": "Guarani", + "Language_gu": "Gwjarati", + "Language_gv": "Manaweg", + "Language_ha": "Hawsa", + "Language_he": "Hebraeg", + "Language_hi": "Hindi", + "Language_hr": "Croateg", + "Language_ht": "Creol Haiti", + "Language_hu": "Hwngareg", + "Language_hy": "Armeneg", + "Language_hz": "Herero", + "Language_ia": "Interlingua", + "Language_id": "Indonesieg", + "Language_ie": "Interlingue", + "Language_ig": "Igbo", + "Language_ii": "Nwosw", + "Language_ik": "Inwpiaceg", + "Language_is": "Islandeg", + "Language_it": "Eidaleg", + "Language_iu": "Inwctitwt", + "Language_ja": "Siapaneeg", + "Language_jv": "Jafanaeg", + "Language_ka": "Georgeg", + "Language_kg": "Congo", + "Language_ki": "Cicwyeg", + "Language_kk": "Casacheg", + "Language_km": "Cambodieg", + "Language_kn": "Kannada", + "Language_ko": "Corëeg", + "Language_kr": "Canwri", + "Language_ks": "Cashmireg", + "Language_ku": "Cwrdeg", + "Language_kv": "Comi", + "Language_kw": "Cernyweg", + "Language_ky": "Kyrgyz", + "Language_la": "Lladin", + "Language_lb": "Lwcsembwrgeg", + "Language_lg": "Ganda", + "Language_li": "Limbwrgeg", + "Language_ln": "Lingala", + "Language_lo": "Laoeg", + "Language_lt": "Lithwaneg", + "Language_lv": "Latfieg", + "Language_mg": "Malagaseg", + "Language_mh": "Marsialeg", + "Language_mi": "Maori", + "Language_mk": "Macedoneg", + "Language_ml": "Malayalam", + "Language_mn": "Mongoleg", + "Language_mr": "Marathi", + "Language_ms": "Malai", + "Language_mt": "Malteseg", + "Language_my": "Byrmaneg", + "Language_na": "Nawrŵeg", + "Language_nb": "Norwyeg Bokmål", + "Language_nd": "Ndebele Gogleddol", + "Language_ne": "Nepali", + "Language_nl": "Iseldireg", + "Language_nn": "Norwyeg (Nynorsk)", + "Language_no": "Norwyeg", + "Language_nr": "Ndebele Deheuol", + "Language_nv": "Nafaho", + "Language_ny": "Nianja", + "Language_oc": "Ocsitaneg", + "Language_oj": "Ojibwa", + "Language_om": "Oromo", + "Language_or": "Oriya", + "Language_os": "Oseteg", + "Language_pa": "Pwnjabi", + "Language_pl": "Pwyleg", + "Language_ps": "Pashto", + "Language_pt": "Portiwgaleg", + "Language_qu": "Quechua", + "Language_rm": "Romaunsch", + "Language_rn": "Rwndi", + "Language_ro": "Rwmaneg", + "Language_ru": "Rwsieg", + "Language_rw": "Ciniarŵandeg", + "Language_sa": "Sansgrit", + "Language_sc": "Sardeg", + "Language_sd": "Sindhi", + "Language_se": "Sami Gogleddol", + "Language_sg": "Sango", + "Language_si": "Sinhaleg", + "Language_sk": "Slofaceg", + "Language_sl": "Slofeneg", + "Language_sm": "Samöeg", + "Language_so": "Somaleg", + "Language_sq": "Albaneg", + "Language_sr": "Serbeg", + "Language_st": "Sesotheg", + "Language_su": "Sundaneg", + "Language_sv": "Swedeg", + "Language_sw": "Swahili", + "Language_ta": "Tamil", + "Language_te": "Telugu", + "Language_tg": "Tajiceg", + "Language_th": "Thai", + "Language_ti": "Tigrinya", + "Language_tk": "Tyrcmeneg", + "Language_tl": "Tagalog*", + "Language_tn": "Tswana", + "Language_to": "Tongeg", + "Language_tr": "Twrceg", + "Language_ts": "Tsongaeg", + "Language_tt": "Tatareg", + "Language_tw": "Twi", + "Language_ty": "Tahitïeg", + "Language_ug": "Uighur", + "Language_uk": "Wcreineg", + "Language_ur": "Urdu", + "Language_uz": "Wsbeceg", + "Language_ve": "Fendeg", + "Language_vi": "Fietnameg", + "Language_wa": "Walwneg", + "Language_wo": "Woloff", + "Language_xh": "Xhosa", + "Language_yi": "Iddew-Almaeneg", + "Language_yo": "Iorwba", + "Language_zh": "Tseineeg", + "Language_zu": "Zwlw" + }, + "Widgetize": { + "OpenInNewWindow": "Agor mewn ffenestr newydd" + } +} \ No newline at end of file diff --git a/www/analytics/lang/da.json b/www/analytics/lang/da.json new file mode 100644 index 00000000..69b5351f --- /dev/null +++ b/www/analytics/lang/da.json @@ -0,0 +1,2437 @@ +{ + "Actions": { + "AvgGenerationTimeTooltip": "Gennemsnit baseret på %s hits %s mellem %s og %s", + "ColumnClickedURL": "Klikkede URL", + "ColumnClicks": "Klik", + "ColumnClicksDocumentation": "Antal gange linket blev klikket på.", + "ColumnDownloadURL": "Hentet URL", + "ColumnEntryPageTitle": "Indgangssidetitel", + "ColumnEntryPageURL": "Indgangsside URL", + "ColumnExitPageTitle": "Udgangssidetitel", + "ColumnExitPageURL": "Udgangsside URL", + "ColumnNoResultKeyword": "Søgeord uden nogen resultater", + "ColumnPageName": "Sidenavn", + "ColumnPagesPerSearch": "Søgeresultatssider", + "ColumnPagesPerSearchDocumentation": "Besøgende vil søge på hjemmesiden, og vil nogle gange klikke på \"Næste\" for at se flere resultater. Dette er det gennemsnitlige antal søgeresultatsider set for dette søgeord.", + "ColumnPageURL": "Side URL", + "ColumnSearchCategory": "Søgekategori", + "ColumnSearches": "Søgninger", + "ColumnSearchesDocumentation": "Antal besøg, der søgte efter dette søgeord på hjemmesidens søgemaskine.", + "ColumnSearchExits": "% søgeafslutninger", + "ColumnSearchExitsDocumentation": "Procentdelen af ​​besøg, der forlod hjemmesiden efter en søgning på søgeordet i søgemaskinen.", + "ColumnSearchResultsCount": "Antal søgeresultater", + "ColumnSiteSearchKeywords": "Unikke søgeord", + "ColumnUniqueClicks": "Unikke klik", + "ColumnUniqueClicksDocumentation": "Antal besøg, der involverede et klik på linket. Hvis et link blev klikket flere gange under et besøg, tæller det kun én gang.", + "ColumnUniqueDownloads": "Unikke fil-hentninger", + "ColumnUniqueOutlinks": "Unikke udgående links", + "DownloadsReportDocumentation": "Rapporten viser, hvilke filer dine besøgende har hentet. %s Hvad Piwik tæller som en filhentning, er klikket på et link. Hvorvidt overførelsen er afsluttet eller ej ved Piwik ikke.", + "EntryPagesReportDocumentation": "Rapporten indeholder oplysninger om indgangs sider, der blev anvendt i den angivne periode. En indgangs side er den første side, en bruger får vist under sit besøg. %s Indgang siden vises som en mappe struktur.", + "EntryPageTitles": "Indgangssidetitler", + "EntryPageTitlesReportDocumentation": "Rapporten indeholder oplysninger om titlerne på indgangssider, der blev brugt i den angivne periode.", + "ExitPagesReportDocumentation": "Rapporten indeholder oplysninger om de udgangs sider, der blev brugt i den angivne periode. En udgangs side er den sidste side, en bruger får vist under sit besøg. %s udgangs netadresserne vises som en mappe struktur.", + "ExitPageTitles": "Udgangssidetitler", + "ExitPageTitlesReportDocumentation": "Rapporten indeholder oplysninger om titlerne på udgangssider, der blev brugt i den angivne periode.", + "LearnMoreAboutSiteSearchLink": "Lær mere om sporing af hvordan besøgende bruger din søgemaskine.", + "OneSearch": "1 søgning", + "OutlinkDocumentation": "Et udgående link er et link, der fører den besøgende væk fra hjemmesiden (til et andet domæne)", + "OutlinksReportDocumentation": "Rapporten viser en hierarkisk liste over udgående netadresser, der blev klikket på af de besøgende", + "PagesReportDocumentation": "Rapporten indeholder oplysninger om sidens netadresser, der er blevet besøgt. %s tabellen er arrangeret hierarkisk, netadresserne vises i en mappestruktur.", + "PageTitlesReportDocumentation": "Rapporten indeholder oplysninger om titlerne på de sider, der er blevet besøgt. %s side titel er HTML %s mærket, som de fleste netlæsere viser i vinduetitlen.", + "PageUrls": "Side URL'er", + "PluginDescription": "Rapporter om sidevisninger, udgående links og fil-hentninger. Udgående links og filhentninger spores automatisk!", + "SiteSearchCategories1": "Rapporten viser de kategorier, som de besøgende valgte, da de lavede en søgning på hjemmesiden.", + "SiteSearchCategories2": "For eksempel har E-handel hjemmesider typisk en \"Kategori\" vælger, så besøgende kan begrænse deres søgninger til alle produkter i en bestemt kategori.", + "SiteSearchFollowingPagesDoc": "Når besøgende søger på hjemmesiden, er de på udkig efter en bestemt side, indhold, produkt eller service. Rapporten viser de sider, der blev klikket mest på efter en intern søgning. Med andre ord, listen over sider mest søgt af besøgende allerede på hjemmesiden.", + "SiteSearchIntro": "Sporing af søgninger, som de besøgende foretager på hjemmesiden er en meget effektiv måde at lære mere om, hvad din målgruppe er på udkig efter, det kan hjælpe med til at finde idéer til nyt indhold, nye E-handel produkter, som potentielle kunder kan søge efter, og generelt forbedre de besøgendes oplever på hjemmesiden.", + "SiteSearchKeyword": "Søgeord (intern søgning)", + "SiteSearchKeywordsDocumentation": "Rapporten viser de søgeord, de besøgende søgte efter på den interne søgemaskine.", + "SiteSearchKeywordsNoResultDocumentation": "Rapporten viser de søgeord, der ikke returnerede noget søgeresultat: måske søgemaskinen algoritme kan forbedres, eller måske de besøgende er på udkig efter indhold, der (endnu) ikke er på din hjemmeside?", + "SubmenuPagesEntry": "Indgangssider", + "SubmenuPagesExit": "Udgangssider", + "SubmenuPageTitles": "Sidetitler", + "SubmenuSitesearch": "Webstedssøgning", + "WidgetEntryPageTitles": "Indgangssidetitler", + "WidgetExitPageTitles": "Udgangssidetitler", + "WidgetPagesEntry": "Indgangssider", + "WidgetPagesExit": "Udgangssider", + "WidgetPageTitles": "Sidetitler", + "WidgetPageTitlesFollowingSearch": "Sidetitler efter en webstedssøgning", + "WidgetPageUrlsFollowingSearch": "Sider efter en webstedssøgning", + "WidgetSearchCategories": "Søge kategorier", + "WidgetSearchKeywords": "Websted søgeord", + "WidgetSearchNoResultKeywords": "Søgeord uden nogen resultater" + }, + "Annotations": { + "AddAnnotationsFor": "Tilføj annotation for %s...", + "AnnotationOnDate": "Annotation den %1$s: %2$s", + "Annotations": "Anmærkninger", + "ClickToDelete": "Klik for at slette en annotation", + "ClickToEdit": "Klik for at rette en annotation", + "ClickToEditOrAdd": "Klik for at ændre eller tilføje en ny annotation.", + "ClickToStarOrUnstar": "Klik for at markere eller fjerne markering af anmærkningen.", + "CreateNewAnnotation": "Opret ny annotation", + "EnterAnnotationText": "Indtast din note...", + "HideAnnotationsFor": "Skjul annotationer for %s...", + "IconDesc": "Vis noter for dette tidsrum.", + "IconDescHideNotes": "Skjul noter for dette tidsrum.", + "InlineQuickHelp": "Du kan oprette anmærkninger for at markere specielle begivenheder (som et nyt blog-indlæg, eller hjemmeside redesign), gemme dine data analyser eller at gemme noget andet, du synes er vigtigt.", + "LoginToAnnotate": "Log på for at oprette en anmærkning", + "NoAnnotations": "Der er ingen anmærkninger for datointervallet.", + "PluginDescription": "Giver mulighed for at knytte bemærkninger til forskellige dage, så du kan huske, hvorfor data ser ud som de gør.", + "ViewAndAddAnnotations": "Vis og tilføj annotationer for %s...", + "YouCannotModifyThisNote": "Du kan ikke ændre denne annotation, da du ikke har oprettet den eller har administrator adgang." + }, + "API": { + "GenerateVisits": "Hvis du ikke har data for i dag ,kan du generere nogle data ved hjælp af %s programudvidelsen. Aktiver %s programudvidelsen, og klik derefter på 'Besøgsgenerator' i menuen under indstillinger.", + "KeepTokenSecret": "Token_auth er ligeså hemmeligt som brugernavn og adgangskode, %sdel det ikke ud%s!", + "LoadedAPIs": "%s APIs indlæst", + "MoreInformation": "Mere information om Piwik API'er, findes på %sIntroduktion til Piwik API%s og %sPiwik API Reference%s.", + "PluginDescription": "Alle data i Piwik er tilgængelig via enkle API'er. Programudvidelsen er webservice indgangen, som kan kaldes for at få Web analyse-data i xml, json, php, csv, osv.", + "QuickDocumentationTitle": "API - hurtig i gang dokumentation", + "TopLinkTooltip": "Få adgang til dine webanalyse data programmeringsmæssigt gennem en simpel API i JSON, XML, etc.", + "UserAuthentication": "Brugergodkendelse", + "UsingTokenAuth": "Hvis du ønsker at %s hente data i et script, med crontab, m.m. %s skal du tilføje parameteren %s til API-kaldes netadresse, som kræver godkendelse." + }, + "CoreAdminHome": { + "Administration": "Administration", + "ArchivingSettings": "Arkivering indstillinger", + "BrandingSettings": "Branding indstillinger", + "CheckReleaseGetVersion": "Når der kontrolleres for en ny version af Piwik, altid få", + "ClickHereToOptIn": "Klik her for at vælge.", + "ClickHereToOptOut": "Klik her for at fravælge.", + "CustomLogoFeedbackInfo": "Hvis du tilpasser Piwik logoet, kan du også være interesseret i at skjule %s linket i topmenuen. For at gøre dette, kan du deaktivere tilbagemeldingsmodulet på %sUdvidelsesmodul administration%s siden.", + "CustomLogoHelpText": "Du kan tilpasse Piwik logo, der bliver vist i brugergrænsefladen og e-mail rapporter.", + "DevelopmentProcess": "Mens vores%s udviklingsproces%s omfatter tusindvis af automatiske tests, spiller betatestere en nøglerolle i at opnå \"ingen fejl politikken\" i Piwik.", + "EmailServerSettings": "E-mail-server indstillinger", + "ForBetaTestersOnly": "Kun for beta testere", + "ImageTracking": "Sporing vha. et billede", + "ImageTrackingIntro1": "Når en besøgende har deaktiveret JavaScript, eller når JavaScript kan ikke bruges, kan sporing vha. et billede bruges til at spore besøgende.", + "ImageTrackingIntro2": "Generer nedenstående link. Kopier og sæt det genererede HTML ind på siden. Hvis du bruger det som en reserveløsning til JavaScript sporing, kan du omgive det med %1$s tags.", + "ImageTrackingIntro3": "For at se hele listen af muligheder, som kan bruge sammen med sporing vha. billede, se %1$sTracking API Documentation%2$s.", + "ImageTrackingLink": "Link til sporing af vha. et billede", + "ImportingServerLogs": "Import af serverlogfiler", + "ImportingServerLogsDesc": "Et alternativ til sporing af besøgende gennem browseren (enten via JavaScript eller et billed-link) er løbende at importere serverlogfiler. Lær mere om %1$sServerlogfiler analyse%2$s.", + "InvalidPluginsWarning": "Følgende udvidelsesmoduler er ikke kompatible med %1$s og kunne ikke indlæses: %2$s.", + "InvalidPluginsYouCanUninstall": "Du kan opdatere eller afinstallere disse udvidelsesmoduler på %1$sManage Plugins%2$s-siden.", + "JavaScriptTracking": "Sporing med JavaScript", + "JSTracking_CampaignKwdParam": "Kampagne nøgleord parameter", + "JSTracking_CampaignNameParam": "Kampagnenavn parameter", + "JSTracking_CodeNote": "Sørg for, at koden er på hver side af hjemmesiden, før %1$s tag.", + "JSTracking_CustomCampaignQueryParam": "Brug brugerdefineret forespørgsel parameternavne for kampagnenavnet & søgeord", + "JSTracking_CustomCampaignQueryParamDesc": "Note: %1$sPiwik registrerer automatisk Google Analytics parametre.%2$s", + "JSTracking_EnableDoNotTrack": "Aktiver klientside DoNotTrack detektering", + "JSTracking_EnableDoNotTrack_AlreadyEnabled": "Bemærk: Server side DoNotTrack understøttelse er blevet aktiveret, så denne indstilling har ingen virkning.", + "JSTracking_EnableDoNotTrackDesc": "Så sporing anmodninger sendes ikke, hvis de besøgende ikke vil spores.", + "JSTracking_GroupPageTitlesByDomain": "Tilføjer hjemmeside domænet til sidetitel, når sporing", + "JSTracking_GroupPageTitlesByDomainDesc1": "Så hvis nogen besøger 'Om' siden på blog.%1$s vil det blive registreret som 'blog \/ Om'. Dette er den nemmeste måde at få et overblik over trafikken på subdomæner.", + "JSTracking_MergeAliases": "I rapporten \"udgående links\", skjul klik til kendte alias URL på", + "JSTracking_MergeAliasesDesc": "Så klik på links til alias ​​URL'er (f.eks %s) vil ikke blive talt som \"Udgående link\".", + "JSTracking_MergeSubdomains": "Spor besøgende på tværs af alle underdomæner for", + "JSTracking_MergeSubdomainsDesc": "Så hvis en besøgende besøger %1$s og %2$s, vil de regnes som en unikke besøgende.", + "JSTracking_PageCustomVars": "Spor en brugerdefineret variabel for hver sidevisning", + "JSTracking_PageCustomVarsDesc": "For eksempel med variabelnavn \"Kategori\" og værdi \"Hvidbøger\".", + "JSTracking_VisitorCustomVars": "Spor brugerdefinerede variabler for denne besøgende", + "JSTracking_VisitorCustomVarsDesc": "For eksempel med variabelnavn \"Type\" og værdi \"Kunde\".", + "JSTrackingIntro1": "Du kan spore besøgende til hjemmesiden på mange forskellige måder. Den anbefalede måde at gøre det på er vha. JavaScript. For at bruge denne metode, skal du sørge for alle sider på hjemmesiden har noget JavaScript-kode, som du kan generere her.", + "JSTrackingIntro2": "Når du har JavaScript sporingskoden til hjemmesiden, kopier og indsæt den på alle de sider, der skal spores med Piwik.", + "JSTrackingIntro3": "De fleste hjemmesider, blogs, CMS, mv. kan bruge et foruddefineret modul til at gøre det tekniske arbejde for dig. (Se %1$slisten med moduler, der kan bruges til at integrere Piwik%2$s). Hvis der ikke findes et modul, kan du redigere hjemmeside skabelonen og tilføje denne kode i \"sidefoden\".", + "JSTrackingIntro4": "Hvis du ikke ønsker at bruge JavaScript til at spore besøgende,%1$sgenerere et billed sporingslink herunder%2$s.", + "JSTrackingIntro5": "Hvis du vil gøre mere end at spore sidevisninger, kan du checke %1$sPiwik Javascript sporingsdokumentation%2$s for listen over tilgængelige funktioner. Ved hjælp af disse funktioner kan du spore mål, brugerdefinerede variabler, e-handels ordrer, afbrudte ordrer og meget mere.", + "LatestBetaRelease": "Den nyeste beta release", + "LatestStableRelease": "Den seneste stabile udgave", + "LogoNotWriteableInstruction": "Hvis du vil bruge din brugerdefinerede logo i stedet for standard Piwik logoet, giver skriverettigheder til denne mappe: %1$s Piwik brug skriveadgang til dine logoer gemt i filer %2$s.", + "LogoUpload": "Vælg et logo til overførelse", + "LogoUploadHelp": "Overfør en fil i %s formater med en højde på mindst %s pixels.", + "MenuDiagnostic": "Diagnosticering", + "MenuGeneralSettings": "Generelle indstillinger", + "MenuManage": "Administrere", + "OptOutComplete": "Opt-out udført; dine besøg på hjemmesiden bliver ikke registreret af analyseværktøjet.", + "OptOutCompleteBis": "Bemærk, at hvis du sletter dine cookies, sletter opt-out-cookien, eller hvis du skifter computer eller browser, skal du udføre opt-out-proceduren igen.", + "OptOutExplanation": "Piwik er dedikeret til at værne om personlige oplysninger på internettet. For at give dine besøgende valgmulighed for at framelde Piwik Web Analyse, kan du tilføje den følgende HTML-kode på en af dine hjemmesider, f. eks. på en fortrolighedspolitik side.", + "OptOutExplanationBis": "Koden vil vise en Iframe, der indeholder et link til dine besøgende til at framelde Piwik ved at sætte en opt out-cookie i browseren. %sKlik her%s for at få vist indholdet af iFramen.", + "OptOutForYourVisitors": "Piwik opt-out for dine besøgende", + "PiwikIsInstalledAt": "Piwik er installeret på", + "PluginDescription": "Piwik administration.", + "PluginSettingChangeNotAllowed": "Du må ikke ændre værdien \"%s\" i udvidelse \"%s\"", + "PluginSettings": "Programudvidelses indstilinger", + "PluginSettingsIntro": "Her kan du ændre indstillingerne for følgende 3. parts udvidelsesmoduler:", + "PluginSettingsValueNotAllowed": "Værdien for feltet \"%s\" i udvidelsen \"%s\" er ikke tilladt", + "SendPluginUpdateCommunication": "Send mig en e-mail når der er en ny opdatering af denne plugin.", + "SendPluginUpdateCommunicationHelp": "En e-mail vil blive sendt til Superbrugere, når der er en ny version tilgængelig for dette plugin.", + "StableReleases": "Hvis Piwik er en kritisk del af virksomheden, anbefaler vi at man bruger den nyeste stabile udgave. Hvis man bruger den nyeste beta, og finder en fejl eller har et forslag, %sse her%s.", + "TrackAGoal": "Spor et mål", + "TrackingCode": "Sporingskode", + "TrustedHostConfirm": "Er du sikker på, at du vil ændre det betroede Piwik værtsnavn?", + "TrustedHostSettings": "Betroet Piwik værtsnavn", + "UpdateSettings": "Opdater indstillinger", + "UseCustomLogo": "Anvend brugerdefineret logo", + "ValidPiwikHostname": "Gyldigt Piwik værtsnavn", + "WithOptionalRevenue": "med valgfri indtægter", + "YouAreOptedIn": "Du er i øjeblikket tilmeldt.", + "YouAreOptedOut": "Du har i øjeblikket frameldt.", + "YouMayOptOut": "Du kan vælge ikke at have en unik web analyse cookie identifikationsnummer tildelt til computeren for at undgå aggregering og analyse af data indsamlet på denne hjemmesider.", + "YouMayOptOutBis": "Vælg muligheden, klik nedenfor for at modtage en opt out-cookie." + }, + "CoreHome": { + "CategoryNoData": "Ingen data i kategorien. Prøv at 'Inkluder hele populationen'.", + "CheckForUpdates": "Søg efter opdateringer", + "CheckPiwikOut": "Tjek Piwik!", + "CloseWidgetDirections": "Luk modulet ved at klikke på 'X' ikonet øverst.", + "DataForThisReportHasBeenPurged": "Data til rapporten er is mere end %s måneder gamle og er blevet ryddet op.", + "DataTableExcludeAggregateRows": "Samlede rækker vises %s Skjul dem", + "DataTableIncludeAggregateRows": "Samlede rækker er skjult %s Vis dem", + "DateFormat": "%longDay% %day% %longMonth% %longYear%", + "Default": "standard", + "DonateCall1": "Piwik vil altid ikke koste noget at bruge, men det betyder ikke, at det ikke koster os noget at lave.", + "DonateCall2": "Piwik har brug for din fortsatte støtte til at vokse og trives.", + "DonateCall3": "Synes du, at Piwik har tilføjet væsentlig merværdi til dig eller din virksomhed, %1$s overvej at donere!%2$s", + "DonateFormInstructions": "Klik på skyderen for at vælge et beløb, og klik derefter på vælg at donere.", + "ExcludeRowsWithLowPopulation": "Alle rækker vises %s Udeluk lav population", + "FlattenDataTable": "Rapporten er hierarkisk %s lav den ikke hierakisk", + "HowMuchIsPiwikWorth": "Hvor meget er Piwik værd for dig?", + "IncludeRowsWithLowPopulation": "Rækker med lav population er skjult %s Vis alle rækker", + "InjectedHostEmailBody": "Hej, jeg forsøgte at få adgang Piwik i dag og stødte på den ukendte værtsnavn advarsel.", + "InjectedHostEmailSubject": "Piwik blev åbnet fra et ukendt værtsnavn: %s", + "InjectedHostNonSuperUserWarning": "%1$sKlik her for at få adgang Piwik på en sikker måde%2$s og fjerne denne advarsel. Du kan også vælge at kontakte din Piwik administrator og underrette om problemet (%3$sKlik her for e-mail%4$s).", + "InjectedHostSuperUserWarning": "Piwik kan være konfigureret forkert (f.eks. Hvis Piwik for nylig blev flyttet til en ny server eller en URL-adresse). Du kan enten %1$sklikke her og føje %2$s som gyldigt Piwik værtsnavn (hvis du har tillid til det) %3$s eller %4$sklikke her og få %5$s adgang til Piwik sikkert %6$s.", + "InjectedHostWarningIntro": "Du tilgår Piwik fra %1$s, men Piwik er konfigureret til at køre på denne adresse: %2$s.", + "JavascriptDisabled": "JavaSript skal være aktiveret for at Piwik kan bruges i standardvisning. JavaScript er enten deaktiveret eller ikke understøttet i netlæseren.
    For at bruge standardvisning, aktiver JavaScript i indstillingerne for netlæseren, og %1$s prøv igen %2$s.
    ", + "LongMonthFormat": "%longYear%, %longMonth%", + "LongWeekFormat": "%dayFrom% %longMonthFrom% - %dayTo% %longMonthTo% %longYearTo%", + "MakeADifference": "Gør en forskel: %1$sGiv et bidrag nu%2$s og støt Piwik 2.0!", + "MakeOneTimeDonation": "Foretag engangsdonation, i stedet.", + "NoPrivilegesAskPiwikAdmin": "Du er logget på som '%s' men det ser ikke ud som du har adgang til Piwik. %s Spørg Piwik administratoren (klik for at sende e-mail)%s for at give dig 'se' adgang til et websted.", + "OnlyForSuperUserAccess": "Modulet vises kun til brugere, der har superbruger adgang.", + "PageOf": "%1$s af %2$s", + "PeriodDay": "dag", + "PeriodDays": "dage", + "PeriodMonth": "måned", + "PeriodMonths": "måneder", + "PeriodRange": "Interval", + "PeriodWeek": "Uge", + "PeriodWeeks": "uger", + "PeriodYear": "År", + "PeriodYears": "år", + "PluginDescription": "Struktur for webanalyse-rapporter.", + "ReportGeneratedOn": "Rapport genereret på %s", + "ReportGeneratedXAgo": "Rapport genereret %s siden", + "SharePiwikLong": "Hej! Jeg har lige fundet et fantastisk open source program: Piwik!\n\nPiwik kan gratis spore besøgende på din hjemmeside. Du bør helt klart undersøge det!", + "SharePiwikShort": "Piwik! Gratis og open source internet analyse. Ej dine egne data.", + "ShareThis": "Del dette", + "ShortDateFormat": "%shortDay% %day% %shortMonth%", + "ShortDateFormatWithYear": "%day% %shortMonth% %shortYear%", + "ShortMonthFormat": "%shortMonth% %longYear%", + "ShortWeekFormat": "%dayFrom% %shortMonthFrom% - %dayTo% %shortMonthTo% %shortYearTo%", + "ShowJSCode": "Vis JavaScript-koden til at indsætte.", + "SubscribeAndBecomePiwikSupporter": "Fortsæt til sikker kreditkort betalingsside (Paypal) for at blive en Piwik supporter!", + "SupportPiwik": "Støt Piwik!", + "TableNoData": "Ingen data til denne tabel.", + "ThereIsNoDataForThisReport": "Der er ingen data for denne rapport.", + "UnFlattenDataTable": "Rapporten er ikke hierarkisk %s lav den hierakisk", + "ViewAllPiwikVideoTutorials": "Vis alle Piwik videoselvstudier", + "WebAnalyticsReports": "Webanalyse-rapporter", + "YouAreUsingTheLatestVersion": "Du bruger den seneste version af Piwik!" + }, + "CorePluginsAdmin": { + "ActionActivatePlugin": "Aktiver programudvidelse", + "ActionActivateTheme": "Aktiver tema", + "ActionInstall": "Installer", + "ActionUninstall": "Afinstaller", + "Activate": "Aktiver", + "Activated": "Aktiveret", + "Active": "Aktiv", + "Activity": "Aktivitet", + "AllowedUploadFormats": "Du kan via denne side overføre en programudvidelse eller tema i .zip-format.", + "AuthorHomepage": "Forfatter hjemmeside", + "Authors": "Forfattere", + "BackToExtendPiwik": "Tilbage til markedspladsen", + "BeCarefulUsingPlugins": "Programudvidelser, der ikke er forfattet af Piwik holdet skal anvendes med forsigtighed: vi har ikke kontrolleret dem.", + "BeCarefulUsingThemes": "Temaer, der ikke er forfattet af Piwik holdet skal anvendes med forsigtighed: vi har ikke kontrolleret dem.", + "ByDesigningOwnTheme": "ved %sdesign af dit eget tema%s", + "ByInstallingNewPluginFromMarketplace": "ved at %sinstallere en ny programudvidelse fra markedspladsen%s", + "ByInstallingNewThemeFromMarketplace": "%sved at installere et nyt tema fra marketpladsen%s", + "ByWritingOwnPlugin": "ved at %sskrive din egen programudvidelse%s", + "ByXDevelopers": "af %s udviklere", + "Changelog": "Ændringslog", + "ChangeSettingsPossible": "Du kan ændre %sindstillinger%s for denne programudvidelse", + "CorePluginTooltip": "Kerne programudvidelser har ingen version, da de distribuereter med Piwik.", + "Deactivate": "Deaktiver", + "Developer": "Udvikler", + "DoMoreContactPiwikAdmins": "For at installere en ny programudvidelse eller et nyt tema, kontakt Piwik administratoren.", + "DownloadAndInstallPluginsFromMarketplace": "Du kan automatisk hente og installere nye programudvidelser fra %smarkedspladsen%s.", + "EnjoyAnotherLookAndFeelOfThemes": "Nyd et andet udseende", + "FeaturedPlugin": "Udvalgte programudvidelser", + "GetEarlyAccessForPaidPlugins": "Bemærk: alle programudvidelser er tilgængelige gratis på nuværende tidspunkt; i fremtiden vil vi sætte betalte programudvidelser på markedspladsen (%skontakt os%s for tidlig adgang).", + "GetNewFunctionality": "Få ny funktionalitet", + "History": "Historik", + "Inactive": "Inaktiv", + "InfoPluginUpdateIsRecommended": "Opdater dine programudvidelser nu og drag fordel af de seneste forbedringer.", + "InfoThemeIsUsedByOtherUsersAsWell": "Bemærk: Den anden %1$s bruger der er registreret i denne Piwik bruger også temaet %2$s.", + "InfoThemeUpdateIsRecommended": "Opdater dine temaer for at nyde den nyeste version.", + "InstallingPlugin": "Installerer %s", + "InstallNewPlugins": "Installer nye udvidelser", + "InstallNewThemes": "Installer nye temaer", + "LastCommitTime": "(last bidrag %s)", + "LastUpdated": "Sidst opdateret", + "LicenseHomepage": "Licens hjemmeside", + "MainDescription": "Udvidelsesmoduler udvider funktionaliteten i Piwik. Når et udvidelsesmodul er installeret, kan det aktiveres eller deaktiveres her.", + "Marketplace": "Markedsplads", + "MarketplaceSellPluginSubject": "Markedspladsen - Sælg programudvidelser", + "MenuPlatform": "Platform", + "MissingRequirementsNotice": "Husk at opdatere %1$s %2$s til en nyere version, %1$s %3$s er påkrævet.", + "NoPluginsFound": "Ingen udvidelsesmoduler fundet", + "NotAllowedToBrowseMarketplacePlugins": "Du kan gennemse listen over programudvidelser, der kan installeres for at tilpasse eller udvide din Piwik platform. Kontakt din administrator, hvis du har brug for nogen af ​​disse installeret.", + "NotAllowedToBrowseMarketplaceThemes": "Du kan gennemse listen over temaer, der kan installeres for at tilpasse udseendet af Piwik platformen. Kontakt din administrator for at få nogen af ​​disse installeret.", + "NoThemesFound": "Ingen temaer fundet", + "NoZipFileSelected": "Vælg venligst en ZIP-fil.", + "NumDownloadsLatestVersion": "Nyeste version: %s Overførsler", + "NumUpdatesAvailable": "%s opdateringer tilgængelige", + "OrByUploadingAPlugin": "eller ved %soverføre en programudvidelse%s", + "OrByUploadingATheme": "eller ved at %soverføre et tema%s", + "Origin": "Kilde", + "OriginCore": "Kernen", + "OriginThirdParty": "Tredjeparts", + "PluginDescription": "Programudvidelses administration.", + "PluginHomepage": "Udvidelsesmodul hjemmeside", + "PluginKeywords": "Nøgleord", + "PluginNotCompatibleWith": "%1$s udvidelsesmodul er ikke kompatibelt med %2$s.", + "PluginNotWorkingAlternative": "Hvis du har brugt programudvidelsen,kan du måske finde en nyere version på markedspladen. Hvis ikke, kan du afinstallere den.", + "PluginsManagement": "Udvidelsesmodul administration", + "PluginUpdateAvailable": "Du bruger version %s en ny version %s er tilgængelig.", + "PluginVersionInfo": "%1$s fra %2$s", + "PluginWebsite": "Programudvidelsens hjemmeside", + "Screenshots": "Skærmbilleder", + "SortByAlpha": "alpha", + "SortByNewest": "nyeste", + "SortByPopular": "populær", + "Status": "Status", + "StepDownloadingPluginFromMarketplace": "Henter programudvidelse fra markedspladsen", + "StepDownloadingThemeFromMarketplace": "Henter tema fra markedspladsen", + "StepPluginSuccessfullyInstalled": "Du har installeret temaet %1$s %2$s.", + "StepPluginSuccessfullyUpdated": "Du har opdateret temaet %1$s %2$s.", + "StepReplaceExistingPlugin": "Erstatter eksisterende programudvidelse", + "StepReplaceExistingTheme": "Erstatter eksisterende tema", + "StepThemeSuccessfullyInstalled": "Du har installeret temaet %1$s %2$s.", + "StepThemeSuccessfullyUpdated": "Du har opdateret temaet %1$s %2$s.", + "StepUnzippingPlugin": "Udpakker programudvidelse", + "StepUnzippingTheme": "Udpakker tema", + "SuccessfullyActicated": "Du har aktiveret %s<\/strong>.", + "Support": "Support", + "TeaserExtendPiwik": "Udvid Piwik med programudvidelser og temaer", + "TeaserExtendPiwikByPlugin": "Udvid Piwik ved at installere en ny programudvidelse", + "TeaserExtendPiwikByTheme": "Ændre udseendet ved at installere et nyt tema", + "TeaserExtendPiwikByUpload": "Udvid Piwik ved at overføre en ZIP-fil", + "Theme": "Tema", + "Themes": "Temaer", + "ThemesDescription": "Temaer kan ændre udseendet af Piwik brugergrænsefladen,og giver en helt ny visuel oplevelse af dine analytiske rapporter.", + "ThemesManagement": "Håndtere temaer", + "UninstallConfirm": "Du er ved at afinstallere et udvidelsesmodul %s. Udvidelsesmodulet vil blive fjernet helt fra dit system og vil ikke kunne genskabes. Er du sikker på du vil gøre det?", + "Updated": "Opdateret", + "UpdatingPlugin": "Opdaterer %s", + "UploadZipFile": "Overfør ZIP-fil", + "Version": "Version", + "ViewRepositoryChangelog": "Se ændringerne", + "Websites": "Websteder" + }, + "CoreUpdater": { + "ClickHereToViewSqlQueries": "Klik her for at se og kopiere listen over SQL-forespørgsler, der vil blive udført", + "CreatingBackupOfConfigurationFile": "Opretter sikkerhedskopi af konfigurationsfiler i %s", + "CriticalErrorDuringTheUpgradeProcess": "Kritisk fejl under opdateringen:", + "DatabaseUpgradeRequired": "Database opdatering er nødvendig", + "DownloadingUpdateFromX": "Henter opdatering fra %s", + "DownloadX": "Hent %s", + "EmptyDatabaseError": "Databasen %s er tom. Redigere eller fjern Piwiks konfigurationsfil.", + "ErrorDIYHelp": "Hvis du er en erfaren bruger og støder på en fejl i database opgraderingen:", + "ErrorDIYHelp_1": "identificere og ret kilden til problemet (f. eks. memory_limit eller max_execution_time)", + "ErrorDIYHelp_2": "udfører de resterende forespørgsler i opdateringen, som mislykkedes", + "ErrorDIYHelp_3": "opdater manuelt \"option\" tabellen i Piwik databasen, skift værdien af \"version_core\" til versionen på den mislykkede opdatering", + "ErrorDIYHelp_4": "kør opdateringsprogrammet igen (via netlæseren eller kommando-linje) for at fortsætte med de resterende opdateringer", + "ErrorDIYHelp_5": "indberet problemet (og løsning), så Piwik kan forbedres", + "ErrorDuringPluginsUpdates": "Fejl ved opdatering udvidelsesmodul :", + "ExceptionAlreadyLatestVersion": "Piwik version %s er fuld opdateret.", + "ExceptionArchiveEmpty": "Tomt arkiv.", + "ExceptionArchiveIncompatible": "Inkompatibel arkiv: %s", + "ExceptionArchiveIncomplete": "Arkiv er ikke komplet: nogle filer mangler (fx. %s).", + "FeedbackRequest": "Du er velkommen til at dele dine ideer og forslag med Piwik Teamet her:", + "HelpMessageContent": "se %1$s Piwik FAQ %2$s som forklarer de mest almindelige fejl om opdateringer. %3$s Spørg systemadministratoren - vedkommende kan måske hjælpe med fejlen som sandsynligvis skyldes server eller MySQL-opsætning.", + "HelpMessageIntroductionWhenError": "Ovenstående vises fejlmeddelelsen. Den bør hjælpe med at forklare årsagen, men hvis der behøves mere hjælp:", + "HelpMessageIntroductionWhenWarning": "Opdateringen er fuldført, men det var nogle småproblemer under processen. Læs detaljerne ovenfor. For yderligere hjælp:", + "HighTrafficPiwikServerEnableMaintenance": "Hvis du administrerer en Piwik server med høj trafik, anbefaler vi at du %s midlertidigt deaktivere sporing af besøgende og sætte Piwik brugergrænseflade i vedligeholdelsestilstand%s", + "InstallingTheLatestVersion": "Installerer den seneste version", + "MajorUpdateWarning1": "Dette er en stor opdatering! Den vil tage længere tid end normalt.", + "MajorUpdateWarning2": "Det følgende råd er især vigtigt for store installationer.", + "NoteForLargePiwikInstances": "Vigtig bemærkning til store Piwik installationer", + "NoteItIsExpectedThatQueriesFail": "Bemærk: Hvis du manuelt udfører disse forespørgsler, forventes det, at nogle af dem mislykkes. I så fald ignoreres fejlene blot, og køre den næste forespørgsel på listen.", + "NotificationClickToUpdatePlugins": "Klik her for at opdatere dit plugins nu:", + "NotificationClickToUpdateThemes": "Klik her for at opdatere dine temaer nu:", + "NotificationSubjectAvailableCoreUpdate": "Ny Piwik %s er nu tilgængelig", + "NotificationSubjectAvailablePluginUpdate": "En opdatering til tilgængelig for dine Piwik plugins", + "PiwikHasBeenSuccessfullyUpgraded": "Piwik blev opdateret!", + "PiwikUpdatedSuccessfully": "Piwik er opdateret!", + "PiwikWillBeUpgradedFromVersionXToVersionY": "Piwik vil blive opgraderet fra version %1$s til den nye version %2$s.", + "PluginDescription": "Piwik ajourføringsmekanisme", + "ReadyToGo": "Klar, parat, start?", + "TheFollowingPluginsWillBeUpgradedX": "Følgende udvidelsesmoduler vil blive opgraderet: %s.", + "ThereIsNewPluginVersionAvailableForUpdate": "Nogle plugins du bruger er blevet opdateret på markedspladsen:", + "ThereIsNewVersionAvailableForUpdate": "Ny version af Piwik er tilgængelig", + "TheUpgradeProcessMayFailExecuteCommand": "Hvis du har en stor Piwik database, kan opdateringer tage for lang tid at køre i browseren. I denne situation, kan du udføre opdateringen fra kommandolinjen: %s", + "TheUpgradeProcessMayTakeAWhilePleaseBePatient": "Database opgraderingen kan tage lidt tid, vær tålmodig.", + "UnpackingTheUpdate": "Opdatering pakkes ud", + "UpdateAutomatically": "Opdater automatisk", + "UpdateHasBeenCancelledExplanation": "Piwik ét klik-opdatering er blevet annulleret. Kan ovenstående fejlmeddelelse ikke løses, anbefales det at opdatere Piwik manuelt. %1$s Læs %2$sOpdaterings dokumentationen%3$s for at komme i gang!", + "UpdateTitle": "Opdatering", + "UpgradeComplete": "Opgradering fuldført!", + "UpgradePiwik": "Opgrader Piwik", + "VerifyingUnpackedFiles": "Kontrollerer filer", + "WarningMessages": "Advarsler:", + "WeAutomaticallyDeactivatedTheFollowingPlugins": "Vil automatisk deaktivere følgende udvidelsesmoduler: %s", + "YouCanUpgradeAutomaticallyOrDownloadPackage": "Opdater til version %s automatisk eller hent programpakken og installer manuelt:", + "YouCouldManuallyExecuteSqlQueries": "Hvis du ikke er i stand til at bruge kommandolinje opdatering og hvis Piwik opgradering fejler (på grund af timeout i databasen, netlæser timeout, eller andet), kan du manuelt udføre SQL-forespørgsler til at opdatere Piwik.", + "YouMustDownloadPackageOrFixPermissions": "Piwik er i stand til at overskrive din nuværende installation. Du kan enten rette mappe\/fil tilladelserne eller hente pakken og installere version %s manuelt:", + "YourDatabaseIsOutOfDate": "Piwik databasen er ikke opdateret, og skal opgraderes før du kan fortsættes." + }, + "CustomVariables": { + "ColumnCustomVariableName": "Brugerdefineret variabelnavn", + "ColumnCustomVariableValue": "Brugerdefineret variabelværdi", + "CustomVariables": "Brugerdefinerede variabler", + "CustomVariablesReportDocumentation": "Rapporten indeholder oplysninger om brugerdefinerede variabler. Klik på et variabelnavn se fordeling af værdier. %s Yderligere oplysninger om brugerdefinerede variabler generelt, læs %sBrugerdefinerede variabel dokumentation på piwik.org%s", + "PluginDescription": "Brugerdefinerede variabler er navne, værdipar, du kan tildele en besøgende ved hjælp af funktionen Javascript API setVisitCustomVariables(). Piwik vil derefter rapportere hvor mange besøg, sider, konverteringer for hver af disse brugerdefinerede navne og værdier.", + "ScopePage": "virkefelt side", + "ScopeVisit": "virkefelt besøg", + "TrackingHelp": "Hjælp: %1$sSporing af egne variabler i Piwik%2$s" + }, + "Dashboard": { + "AddAWidget": "Tilføj modul...", + "AddPreviewedWidget": "Tilføj forhåndsvist modul til kontrolpanelet.", + "ChangeDashboardLayout": "Skift udformning på kontrolpanel", + "CopyDashboardToUser": "Kopier kontrolpanel til bruger", + "CreateNewDashboard": "Opret nyt kontrolpanel", + "Dashboard": "Kontrolpanel", + "DashboardCopied": "Aktuelt kontrolpanel blev succesfuldt kopieret til den valgte bruger.", + "DashboardEmptyNotification": "Kontrolpanelet indeholder ikke nogen moduler. Start med at tilføje nogle moduler eller nulstille kontrolpanel til standard.", + "DashboardName": "Kontrolpanel navn:", + "DashboardOf": "Kontrolpanel af %s", + "DefaultDashboard": "Standard kontrolpanel - Bruger standard moduler og udformning", + "DeleteWidgetConfirm": "Bekræft fjernelse af modul fra kontrolpanelet?", + "EmptyDashboard": "Tomt kontrolpanel - Vælg favorit moduler", + "LoadingWidget": "Indlæser modul, vent...", + "ManageDashboard": "Administrer kontrolpanel", + "Maximise": "Maksimer", + "Minimise": "Minimer", + "NotUndo": "Handlingen kan ikke fortrydes.", + "PluginDescription": "Kontrolpanelet kan tilpasses: tilføj nye moduler, ændre rækkefølgen af dem. Hver bruger kan få adgang til sit eget brugerdefinerede kontrolpanel.", + "RemoveDashboard": "Fjern kontrolpanel", + "RemoveDashboardConfirm": "Bekræft fjernelse af kontrolpanel \"%s\"?", + "RenameDashboard": "Omdøb kontrolpanel", + "ResetDashboard": "Nulstil kontrolpanel", + "ResetDashboardConfirm": "Nulstil kontrolpanel til standard?", + "SelectDashboardLayout": "Vælg ny udformning på kontrolpanel", + "SelectWidget": "Vælg modul, som skal tilføjes kontrolpanelet.", + "SetAsDefaultWidgets": "Indstil som standard modul valg", + "SetAsDefaultWidgetsConfirm": "Skal de nuværende modul udvælgelser og kontrolpanel udformning bruges som standard kontrolpanel skabelon?", + "SetAsDefaultWidgetsConfirmHelp": "Modul udvælgelse og kontrolpanelets udformning vil blive brugt når en bruger opretter et nyt kontrolpanel, eller når \"%s\" -funktionen bruges.", + "TopLinkTooltip": "Vis webanalyse-rapporter for %s.", + "WidgetNotFound": "Fandt ikke modul", + "WidgetPreview": "Forhåndsvisning af modul", + "WidgetsAndDashboard": "Kontrolpanel & moduler" + }, + "DoNotTrack": { + "PluginDescription": "Ignorer besøg med X-Do-Not-Track eller DNT header." + }, + "Feedback": { + "ContactThePiwikTeam": "Kontakt Piwik holdet", + "DoYouHaveBugReportOrFeatureRequest": "Har du en fejlrapport eller en anmodning om en funktion?", + "GetInTouch": "Vi værdsætter din feedback og læser altid alle meddelelser uanset om du har en forretningsidé, ønsker at finde en Piwik konsulent, fortæl os en succeshistorie eller blot sige hej!", + "IWantTo": "Jeg ønsker at:", + "LearnWaysToParticipate": "Lær om alle de måder, du kan %s bidrage%s", + "ManuallySendEmailTo": "Send din besked manuelt til", + "PluginDescription": "Send tilbagemelding til Piwik Team med et enkelt klik. Del ideer og forslag med os!", + "PrivacyClaim": "Piwik respekterer din %1$sprivacy%2$s og giver dig fuld kontrol over dine data.", + "RateFeatureLeaveMessageDislike": "Vi er kede af at høre, at du ikke kan lide det! Fortæl os, hvad vi kan gøre bedre.", + "RateFeatureLeaveMessageLike": "Vi er glad for du kan lide det! Fortæl os hvad du kan lide mest eller hvis du har forslag til en ny funktion.", + "RateFeatureSendFeedbackInformation": "Piwik vil sende Piwik holdet en e-mail (med din e-mail-adresse), så vi kan komme i kontakt med dig, hvis du har nogen spørgsmål.", + "RateFeatureThankYouTitle": "Tak for din bedømmelse '%s'!", + "SendFeedback": "Send tilbagemelding", + "SpecialRequest": "Har du en speciel anmodning til Piwik holdet?", + "ThankYou": "Tak, fordi du hjælper med at gøre Piwik bedre!", + "TopLinkTooltip": "Fortæl os hvad du mener eller anmod om professionel support", + "VisitTheForums": "Besøg %s Forum%s", + "WantToThankConsiderDonating": "Synes du at Piwik er fantastisk og ønsker du at takke os?" + }, + "General": { + "AbandonedCarts": "Afbrudte Ordrer", + "AboutPiwikX": "Om Piwik %s", + "Action": "Handling", + "Actions": "Handlinger", + "Add": "Tilføj", + "AfterEntry": "efter indtastning her", + "All": "Alle", + "AllowPiwikArchivingToTriggerBrowser": "Tillad at Piwik-arkivering udføres når rapporter vises i browseren", + "AllWebsitesDashboard": "Kontrolpanel for alle hjemmesider", + "And": "og", + "API": "API", + "ApplyDateRange": "Anvend datointerval", + "ArchivingInlineHelp": "For hjemmesider med medium til høj trafik anbefales det at deaktivere Piwik-arkivering udløst fra browseren. I stedet anbefales det at køre et cron job hver time til at behandle Piwik rapporter.", + "ArchivingTriggerDescription": "Til større Piwik installationer anbefales det, at %soprette et cron job%s til at behandle rapporterne automatisk.", + "AuthenticationMethodSmtp": "Godkenselsesmetode til SMTP", + "AverageOrderValue": "Gennemsnitlig ordreværdi", + "AveragePrice": "Gennemsnitlige pris", + "AverageQuantity": "Gennemsnitlige mængder", + "BackToPiwik": "Tilbage til Piwik", + "Broken": "I stykker", + "BrokenDownReportDocumentation": "er opdelt i forskellige rapporter, som vises i minidiagrammer nederst på siden. Du kan forstørre diagrammerne ved at klikke på den rapport, du gerne se.", + "Cancel": "Fortryd", + "CannotUnzipFile": "Kan ikke udpakke filen %1$s: %2$s", + "ChangePassword": "Skift adgangskode", + "ChangeTagCloudView": "Bemærk, at du kan få vist rapporten på andre måder end som en emnesky. Brug kontrolelementerne nederst i rapporten for at gøre det.", + "ChooseDate": "Vælg dato", + "ChooseLanguage": "Vælg sprog", + "ChoosePeriod": "Vælg periode", + "ChooseWebsite": "Vælg hjemmeside", + "ClickHere": "Klik her for yderligere oplysninger.", + "ClickToChangePeriod": "Klik igen for at ændre perioden.", + "Close": "Luk", + "ColumnActionsPerVisit": "Handlinger pr. besøg", + "ColumnActionsPerVisitDocumentation": "Det gennemsnitlige antal handlinger (sidevisninger, filhentninger eller udgående links), der blev udført under besøgene.", + "ColumnAverageGenerationTime": "Gennemsnitlig genereringstid", + "ColumnAverageGenerationTimeDocumentation": "Den gennemsnitlige tid, det tog at generere siden. Målingen omfatter den tid det tog serveren at generere web-siden, plus den tid det tog for den besøgende at hente svar fra serveren. En lavere gennemsnitlig genereringstid betyder en hurtigere hjemmeside for de besøgende!", + "ColumnAverageTimeOnPage": "Gns. tid på siden", + "ColumnAverageTimeOnPageDocumentation": "Den gennemsnitlige tid, besøgende har brugt på siden (kun denne side, ikke hele hjemmesiden).", + "ColumnAvgTimeOnSite": "Gennemsnitstid på hjemmeside", + "ColumnAvgTimeOnSiteDocumentation": "Gennemsnitlig varighed af et besøg.", + "ColumnBounceRate": "Afvisnings %", + "ColumnBounceRateDocumentation": "Procentdel af besøg, der kun havde en enkelt sidevisning. Det betyder, at den besøgende forlod webstedet direkte fra indgangen side.", + "ColumnBounceRateForPageDocumentation": "Procentdel af besøg, der startede og sluttede på denne side.", + "ColumnBounces": "Afvisninger", + "ColumnBouncesDocumentation": "Antal besøg, der startede og sluttede på siden. Det betyder, at den besøgende forlod hjemmesiden efter kun at have set denne side.", + "ColumnConversionRate": "Omregningsfrekvens", + "ColumnConversionRateDocumentation": "Procentdelen af besøg, der udløste en mål konvertering.", + "ColumnDestinationPage": "Destinationsside", + "ColumnEntrances": "Indgange", + "ColumnEntrancesDocumentation": " Antal besøg, der startede på siden.", + "ColumnExitRate": "Udgangs %", + "ColumnExitRateDocumentation": "Procentdel af besøg, der forlod hjemmesiden efter at have set denne side.", + "ColumnExits": "Udgange", + "ColumnExitsDocumentation": " Antal besøg, der sluttede på siden.", + "ColumnGenerationTime": "Genereringstid", + "ColumnKeyword": "Søgeord", + "ColumnLabel": "Etiket", + "ColumnMaxActions": "Max handlinger i et enkelt besøg", + "ColumnNbActions": "Handlinger", + "ColumnNbActionsDocumentation": "Antal handlinger, der udføres af besøgende. Handlinger kan være sidevisninger, filhentninger eller udgående links.", + "ColumnNbUniqVisitors": "Unikke besøgende", + "ColumnNbUniqVisitorsDocumentation": "Antal unikke besøg på hjemmesiden. Hver besøgende tælles kun én gang, selvom hjemmesiden besøges flere gange om dagen.", + "ColumnNbVisits": "Besøg", + "ColumnNbVisitsDocumentation": "Hvis en besøgende kommer til hjemmesiden for første gang, eller hvis det sidste besøg er mere end 30 minutter efter første besøg, vil det blive registreret som et nyt besøg.", + "ColumnPageBounceRateDocumentation": "Procentdel af besøg, der startede på denne side og forlod hjemmesiden med det samme.", + "ColumnPageviews": "Sidevisninger", + "ColumnPageviewsDocumentation": "Antal gange siden blev besøgt.", + "ColumnPercentageVisits": "% Besøg", + "ColumnRevenue": "Indtægter", + "ColumnSumVisitLength": "Samlet tid brugt af besøgende (i sekunder)", + "ColumnTotalPageviews": "Totale sidevisninger", + "ColumnUniqueEntrances": "Unikke indgange", + "ColumnUniqueExits": "Unikke udgange", + "ColumnUniquePageviews": "Unikke sidevisninger", + "ColumnUniquePageviewsDocumentation": "Antal besøg, som omfattede denne side. Hvis en side er blevet vist flere gange i løbet af et besøg, tælles der kun én gang.", + "ColumnValuePerVisit": "Værdi pr. besøg", + "ColumnViewedAfterSearch": "Klikkede i søgeresultater", + "ColumnViewedAfterSearchDocumentation": "Antallet af gange denne side er blevet besøgt efter en besøgende lavede en søgning på hjemmesiden, og klikkede på denne side i søgeresultaterne.", + "ColumnVisitDuration": "Besøgsvarighed (i sekunder)", + "ColumnVisitsWithConversions": "Besøg med konverteringer", + "ConfigFileIsNotWritable": "Piwik konfigurationsfilen %s er skrivebeskyttet, nogle af ændringerne vil ikke blive gemt. %s Skift tilladelser til konfigurationsfilen for at gøre den skrivebar.", + "Continue": "Fortsæt", + "ContinueToPiwik": "Fortsæt til Piwik", + "CurrentMonth": "Denne måned", + "CurrentWeek": "Nuværende uge", + "CurrentYear": "Dette år", + "Daily": "Daglig", + "DailyReport": "daglig", + "DailyReports": "Daglige rapporter", + "DailySum": "daglige sum", + "DashboardForASpecificWebsite": "Kontrolpanel for en specifik hjemmeside", + "DataForThisGraphHasBeenPurged": "Dataene for denne graf er mere end %s måneder gamle og er blevet slettet.", + "DataForThisTagCloudHasBeenPurged": "Dataene for denne emnesky er mere end %s måneder gamle og er blevet slettet.", + "Date": "Dato", + "DateRange": "Datointerval:", + "DateRangeFrom": "Fra", + "DateRangeFromTo": "Fra %s til %s", + "DateRangeInPeriodList": "Datointerval", + "DateRangeTo": "Til", + "DayFr": "F", + "DayMo": "M", + "DaySa": "L", + "DaysHours": "%1$s dage %2$s timer", + "DaysSinceFirstVisit": "Dage siden første besøg", + "DaysSinceLastEcommerceOrder": "Dage siden sidste e-handel ordre", + "DaysSinceLastVisit": "Dage siden sidste besøg", + "DaySu": "S", + "DayTh": "T", + "DayTu": "T", + "DayWe": "O", + "Default": "Standard", + "DefaultAppended": "(standard)", + "Delete": "Slet", + "Description": "Beskrivelse", + "Desktop": "Stationær", + "Details": "Detaljer", + "Discount": "Rabat", + "DisplaySimpleTable": "Vis enkel tabel", + "DisplayTableWithGoalMetrics": "Vis tabel med mål data", + "DisplayTableWithMoreMetrics": "Vis tabel med flere målinger", + "Documentation": "Dokumentation", + "Donate": "Donér", + "Done": "Færdig", + "Download": "Fil-hentninger", + "DownloadFail_FileExists": "Filen %s findes allerede!", + "DownloadFail_FileExistsContinue": "Forsøger at fortsætte overførslen for %s, men den hentede fil findes allerede!", + "DownloadFail_HttpRequestFail": "Kunne ikke hente filen! Noget kunne være galt med det websted, du henter fra. Du kan prøve igen senere eller hente filen selv.", + "DownloadFullVersion": "%1$sHent%2$s fuld version! Tjek %3$s", + "DownloadPleaseRemoveExisting": "Hvis filen skal erstattes, skal du fjerne den eksisterende fil.", + "Downloads": "Fil-hentninger", + "EcommerceOrders": "E-handel ordre", + "EcommerceVisitStatusDesc": "E-handel status ved afslutningen af ​​besøget", + "EcommerceVisitStatusEg": "For eksempel, for at vælge alle besøg, der har lavet en E-handel ordre, vil API-anmodningen indeholde %s", + "Edit": "Rediger", + "EncryptedSmtpTransport": "Indtast tranportlag krypteringen, som kræves af SMTP serveren.", + "EnglishLanguageName": "Danish", + "Error": "Fejl", + "ErrorRequest": "Ups... problemer, prøv igen.", + "EvolutionOverPeriod": "Udvikling i perioden", + "EvolutionSummaryGeneric": "%1$s i %2$s sammenlignet med %3$s i %4$s. Udvikling: %5$s", + "ExceptionCheckUserHasSuperUserAccessOrIsTheUser": "Brugeren skal være enten en superbruger eller brugeren '%s' selv.", + "ExceptionConfigurationFileNotFound": "Konfigurationsfilen (%s) blev ikke fundet.", + "ExceptionDatabaseVersion": "%1$s version er %2$s, men Piwik behøver mindst version %3$s.", + "ExceptionFileIntegrity": "Integritetstjek mislykkedes: %s", + "ExceptionFilesizeMismatch": "Fil størrelse passer ikke: %1$s (forventet længde: %2$s, fundet: %3$s)", + "ExceptionIncompatibleClientServerVersions": "%1$s klient version er %2$s, som er uforenelig med server version %3$s.", + "ExceptionInvalidAggregateReportsFormat": "Sammenfattende rapportformat '%s' ikke gyldigt. Prøv en af følgende i stedet: %s.", + "ExceptionInvalidArchiveTimeToLive": " Levetiden på \"I dag arkiv\" skal være et antal sekunder større end nul", + "ExceptionInvalidDateFormat": "Datoformat skal være: %s eller et nøgleord, der understøttes af %s funktionen (se %s for mere information)", + "ExceptionInvalidDateRange": "Datoen '%s' er ikke et korrekt datointerval. Det skal have følgende format: %s.", + "ExceptionInvalidPeriod": "Perioden '%s' er ikke understøttet. Prøv en af følgende i stedet: %s", + "ExceptionInvalidRendererFormat": "Rendering format '%s' ikke gyldigt. Prøv et af følgende i stedet: %s.", + "ExceptionInvalidReportRendererFormat": "Rapport format '%s' er ikke gyldigt. Prøv i stedet et af følgende: %s.", + "ExceptionInvalidStaticGraphType": "Statisk diagramtype '%s' ikke gyldigt. Prøv en af følgende i stedet: %s.", + "ExceptionInvalidToken": "Token er ikke gyldig.", + "ExceptionLanguageFileNotFound": "Sprogfil '%s' blev ikke fundet.", + "ExceptionMethodNotFound": " Metoden '%s' eksisterer ikke eller er ikke tilgængelig i modulet '%s'.", + "ExceptionMissingFile": "Mangler fil: %s", + "ExceptionNonceMismatch": "Kan ikke kontrollere sikkerheden på formularen.", + "ExceptionPrivilege": "Du kan ikke få adgang til denne ressource, det kræver en %s adgang.", + "ExceptionPrivilegeAccessWebsite": "Du kan ikke få adgang til denne ressource, da det kræver en %s adgang til hjemmeside id = %d.", + "ExceptionPrivilegeAtLeastOneWebsite": "Du kan ikke få adgang til denne ressource, da det kræver en %s adgang til mindst én hjemmeside.", + "ExceptionUnableToStartSession": "Kan ikke starte sessionen.", + "ExceptionUndeletableFile": "Kunne ikke slette %s", + "ExceptionUnreadableFileDisabledMethod": "Konfigurationsfilen (%s) kunne ikke læses. Udbyderen kan have deaktiveret %s.", + "Export": "Eksporter", + "ExportAsImage": "Eksporter som billede", + "ExportThisReport": "Eksporter datasættet i andre formater", + "Faq": "OSS", + "FileIntegrityWarningExplanation": "Fil integritetstjek mislykkedes og rapporterede nogle fejl. Det skyldes mest sandsynligt en delvis eller mislykket overførelse af nogle Piwik filer. Du bør overføre alle Piwik filer igen og opdatere denne side, indtil den ingen fejl viser.", + "First": "Første", + "Flatten": "Udjævn", + "ForExampleShort": "fx.", + "Forums": "Forum", + "FromReferrer": "fra", + "GeneralInformation": "General Information", + "GeneralSettings": "Generelle indstillinger", + "GetStarted": "Kom i gang", + "GiveUsYourFeedback": "Tilbagemelding!", + "Goal": "Mål", + "GoTo": "Gå til %s", + "GraphHelp": "Flere oplysninger om visning af diagrammer i Piwik.", + "HelloUser": "Hej %s!", + "Help": "Hjælp", + "HelpTranslatePiwik": "Måske vil du gerne %1$shjæle til at forbedre Piwik oversættelser%2$s?", + "Hide": "skjul", + "HoursMinutes": "%1$s timer %2$s minutter", + "Id": "Id", + "IfArchivingIsFastYouCanSetupCronRunMoreOften": "Forudsat arkivering er hurtig på din installation, kan crontab sættes til at køre oftere.", + "InfoFor": "Info om %s", + "Installed": "Installeret", + "InvalidDateRange": "Ugyldig datointerval, prøv igen", + "InvalidResponse": "Modtagende data er ugyldige.", + "IP": "IP", + "JsTrackingTag": "JavaScript sporingskode", + "Language": "Sprog", + "LastDays": "Sidste %s dage (inklusiv i dag)", + "LastDaysShort": "Sidste %s dage", + "LayoutDirection": "ltr", + "Live": "Live", + "Loading": "Indlæser...", + "LoadingData": "Indlæser data...", + "LoadingPopover": "Indlæser %s...", + "LoadingPopoverFor": "Indlæser %s for", + "Locale": "da_DK.UTF-8", + "Logout": "Log af", + "LongDay_1": "mandag", + "LongDay_2": "tirsdag", + "LongDay_3": "onsdag", + "LongDay_4": "torsdag", + "LongDay_5": "fredag", + "LongDay_6": "lørdag", + "LongDay_7": "søndag", + "LongMonth_1": "januar", + "LongMonth_10": "oktober", + "LongMonth_11": "november", + "LongMonth_12": "december", + "LongMonth_2": "februar", + "LongMonth_3": "marts", + "LongMonth_4": "april", + "LongMonth_5": "maj", + "LongMonth_6": "juni", + "LongMonth_7": "juli", + "LongMonth_8": "august", + "LongMonth_9": "september", + "MainMetrics": "Hovedmålinger", + "Matches": "Matcher", + "MediumToHighTrafficItIsRecommendedTo": "For hjemmesider med medium til høj trafik anbefales det kun at behandle rapporter for i dag højst hver halve time (%s sekunder) eller hver time (%s sekunder).", + "Metadata": "Metadata", + "Metric": "Måling", + "Metrics": "Målinger", + "MetricsToPlot": "Målinger til afbildning", + "MetricToPlot": "Måling til afbildning", + "MinutesSeconds": "%1$s m. %2$ss.", + "Mobile": "Mobil", + "Monthly": "Månedlig", + "MonthlyReport": "månedligt", + "MonthlyReports": "Månedlige rapporter", + "More": "Mer", + "MoreDetails": "Flere detaljer", + "MoreLowerCase": "mere", + "MultiSitesSummary": "Alle hjemmesider", + "Name": "Navn", + "NbActions": "Antal handlinger", + "NbSearches": "Antal af interne søgning", + "NDays": "%s dage", + "Never": "Aldrig", + "NewReportsWillBeProcessedByCron": "Når Piwik arkivering ikke udløses af browseren, vil nye rapporter blive behandlet af crontab.", + "NewUpdatePiwikX": "Ny opdatering: Piwik %s", + "NewVisitor": "Ny besøgende", + "NewVisits": "Nye besøg", + "Next": "Næste", + "NMinutes": "%s minutter", + "No": "Nej", + "NoDataForGraph": "Ingen data for denne graf.", + "NoDataForTagCloud": "Ingen data for emnesky.", + "NotDefined": "%s ikke defineret", + "Note": "Note", + "NotInstalled": "Ikke installeret", + "NotRecommended": "(anbefales ikke)", + "NotValid": "%s er ikke gyldig", + "NSeconds": "%s sekunder", + "NumberOfVisits": "Antal besøg", + "NVisits": "%s besøg", + "Ok": "OK", + "OneAction": "1 handling", + "OneDay": "1 dag", + "OneMinute": "1 minut", + "OneVisit": "1 besøg", + "OnlyEnterIfRequired": "Indtast kun et brugernavn, hvis SMTP-serveren kræver det", + "OnlyEnterIfRequiredPassword": "Indtast kun en adgangskode, hvis SMTP-serveren kræver det", + "OnlyUsedIfUserPwdIsSet": "Anvendes kun, hvis brugernavn\/adgangskode er sat, spørg din udbyder hvis du ikke ved hvilken metode der skal bruges.", + "OpenSourceWebAnalytics": "Open Source webanalyse", + "OperationAtLeast": "Mindst", + "OperationAtMost": "Højst", + "OperationContains": "Indeholder", + "OperationDoesNotContain": "Indeholder ikke", + "OperationEquals": "Lig med", + "OperationGreaterThan": "Større end", + "OperationIs": "Er", + "OperationIsNot": "Er ikke", + "OperationLessThan": "Mindre end", + "OperationNotEquals": "Ikke lig med", + "OptionalSmtpPort": "Valgfri: Standard 25 for ukrypteret og TLS SMTP og 465 for SSL SMTP.", + "Options": "Indstillinger", + "OrCancel": "eller %s Fortryd %s", + "OriginalLanguageName": "Dansk", + "Others": "Andre", + "Outlink": "Udgående links", + "Outlinks": "Udgående links", + "OverlayRowActionTooltip": "Se analysedata direkte på hjemmesiden (åbner ny fane)", + "OverlayRowActionTooltipTitle": "Åbn sideoverlejring", + "Overview": "Oversigt", + "Pages": "Sider", + "ParameterMustIntegerBetween": "Parameter %s skal være et tal mellem %s og %s.", + "Password": "Adgangskode", + "Period": "Periode", + "Piechart": "Cirkeldiagram", + "PiwikXIsAvailablePleaseNotifyPiwikAdmin": "%1$s er tilængelig. Kontakt venligst %2$sPiwik administrator%3$s.", + "PiwikXIsAvailablePleaseUpdateNow": "Piwik %1$s er tilgængelig. %2$sOpdater nu!%3$s (se %4$sændringer%5$s).", + "PleaseSpecifyValue": "Angiv værdi for '%s'.", + "PleaseUpdatePiwik": "Opdater Piwik", + "Plugin": "Udvidelsesmodul", + "Plugins": "Udvidelsesmoduler", + "PoweredBy": "Leveret af", + "Previous": "Forrige", + "PreviousDays": "Forrige %s dage (ikke inklusive i dag)", + "PreviousDaysShort": "Forrige %s dage", + "Price": "Pris", + "ProductConversionRate": "Produkt konverteringsfrekvens", + "ProductRevenue": "Produkt Omsætning", + "PurchasedProducts": "Købte produkter", + "Quantity": "Mængde", + "RangeReports": "Brugerdefinerede datointervaller", + "ReadThisToLearnMore": "%1$sLæs dette for at lære mere.%2$s", + "Recommended": "(anbefales)", + "RecordsToPlot": "Poster at afbilde", + "Refresh": "Opdater", + "RefreshPage": "Opdater siden", + "RelatedReport": "Relateret rapport", + "RelatedReports": "Relaterede rapporter", + "Remove": "Fjern", + "Report": "Rapport", + "ReportGeneratedFrom": "Denne rapport blev genereret med data fra %s.", + "ReportRatioTooltip": "'%1$s' repræsenterer %2$s af %3$s %4$s med %5$s.", + "Reports": "Rapporter", + "ReportsContainingTodayWillBeProcessedAtMostEvery": "Rapporter for i dag (eller ethvert andet datointerval herunder i dag) vil blive udført mindst hver", + "ReportsWillBeProcessedAtMostEveryHour": "Rapporterne vil derfor blive behandlet højst hver time.", + "RequestTimedOut": "En anmodning til %s fik timeout. Prøv igen.", + "Required": "%s påkrævet", + "ReturningVisitor": "Tilbagevendende besøgende", + "ReturningVisitorAllVisits": "Vis alle besøg", + "RowEvolutionRowActionTooltip": "Se hvordan målinger for denne række har ændret sig med tiden", + "RowEvolutionRowActionTooltipTitle": "Åben rækkeudvikling", + "Rows": "Rækker", + "RowsToDisplay": "Vis antal rækker", + "Save": "Gem", + "SaveImageOnYourComputer": "Gem billedet på computer, højreklik på billede og vælg \"Gem billede som…\"", + "Search": "Søg", + "SearchNoResults": "Ingen resultater", + "Seconds": "%ss.", + "SeeAll": "se samtlige", + "SeeTheOfficialDocumentationForMoreInformation": "Se den %sofficielle dokumentation%s for mere information.", + "Segment": "Segment", + "SelectYesIfYouWantToSendEmailsViaServer": "Vælg \"Ja\", hvis du ønsker eller er nødt til at sende e-mail via en bestemt server i stedet for den lokale mail-funktionen", + "Settings": "Indstillinger", + "Shipping": "Forsendelse", + "ShortDay_1": "man", + "ShortDay_2": "tir", + "ShortDay_3": "ons", + "ShortDay_4": "tor", + "ShortDay_5": "fre", + "ShortDay_6": "lør", + "ShortDay_7": "søn", + "ShortMonth_1": "jan.", + "ShortMonth_10": "okt.", + "ShortMonth_11": "nov.", + "ShortMonth_12": "dec.", + "ShortMonth_2": "feb.", + "ShortMonth_3": "mar.", + "ShortMonth_4": "apr.", + "ShortMonth_5": "maj.", + "ShortMonth_6": "jun.", + "ShortMonth_7": "jul.", + "ShortMonth_8": "aug.", + "ShortMonth_9": "sep.", + "Show": "vis", + "SingleWebsitesDashboard": "Kontrolpanel for en hjemmeside", + "SmallTrafficYouCanLeaveDefault": "For hjemmesider med lidt trafik brug standard %s sekunder og få adgang til alle rapporter i realtid.", + "SmtpEncryption": "SMTP-kryptering", + "SmtpPassword": "SMTP-adgangskode", + "SmtpPort": "SMTP port", + "SmtpServerAddress": "SMTP server adresse", + "SmtpUsername": "SMTP brugernavn", + "Source": "Kilde", + "StatisticsAreNotRecorded": "Piwik besøgssporing er i øjeblikket deaktiveret! Genaktiver sporing ved at sætte record_statistics = 1 i config\/config.ini.php filen.", + "Subtotal": "Subtotal", + "Summary": "Sammenfatning", + "Table": "Tabel", + "TagCloud": "Emne sky", + "Tax": "Moms", + "TimeOnPage": "Tid på siden", + "Today": "I dag", + "Total": "Total", + "TotalRatioTooltip": "Dette er %1$s af alle %2$s %3$s.", + "TotalRevenue": "Indtægter i alt", + "TotalVisitsPageviewsRevenue": "(Total: %s besøgende, %s sidevisninger, %s indtjening)", + "TransitionsRowActionTooltip": "See hvad besøgende gjorde før og efter de så denne side", + "TransitionsRowActionTooltipTitle": "Åben overgange", + "TranslatorEmail": "danieljuhl@gmail.com, jsm@janz.dk", + "TranslatorName": "
    Daniel Juhl<\/a>, jan madsen", + "UniquePurchases": "Unikke køb", + "Unknown": "Ukendt", + "Upload": "Overfør", + "UsePlusMinusIconsDocumentation": "Brug plus og minus ikonerne til venstre til at navigere.", + "Username": "Brugernavn", + "UseSMTPServerForEmail": "Brug SMTP server til e-mail", + "Value": "Værdi", + "VBarGraph": "Søjlediagram", + "View": "Vis", + "ViewDocumentationFor": "Se dokumentation for %1$s", + "Visit": "Besøg", + "VisitConvertedGoal": "Besøg omregnet mindst et mål", + "VisitConvertedGoalId": "Besøg konverteres til bestemt mål-Id", + "VisitConvertedNGoals": "Besøg konverteret %s mål", + "VisitDuration": "Gns. besøgsvarighed (i sekunder)", + "Visitor": "Besøgende", + "VisitorID": "Besøgendes ID", + "VisitorIP": "Besøgendes IP", + "Visitors": "Besøgende", + "VisitsWith": "Besøg med %s", + "VisitType": "Besøgstype", + "VisitTypeExample": "F. eks., for at vælge alle besøgende, som er vendt tilbage til hjemmesiden, herunder dem, der har købt noget i deres tidligere besøg, vil API-anmodningen indeholde %s", + "Warning": "Advarsel", + "WarningFileIntegrityNoManifest": "Fil integritetstjek kunne ikke udføres på grund af manglende manifest.inc.php.", + "WarningFileIntegrityNoManifestDeployingFromGit": "Hvis du implementerer Piwik fra Git, er meddelelsen normal.", + "WarningFileIntegrityNoMd5file": "Fil integritetstjek kunne ikke gennemføres pga. manglende md5_file () funktion.", + "WarningPasswordStored": "%sAdvarsel:%s Adgangskoden bliver gemt i konfigurationsfilen synlig for alle, der har adgang til den.", + "Website": "Hjemmeside", + "Weekly": "Ugentligt", + "WeeklyReport": "ugentlig", + "WeeklyReports": "Ugentlige rapporter", + "WellDone": "Godt klaret!", + "Widgets": "Moduler", + "XComparedToY": "%1$s sammenlignet med %2$s", + "XFromY": "%1$s fra %2$s", + "YearlyReport": "årligt", + "YearlyReports": "Årlige rapporter", + "YearsDays": "%1$s år %2$s dage", + "YearShort": "år", + "Yes": "Ja", + "Yesterday": "I går", + "YouAreCurrentlyUsing": "Du bruger i øjeblikket Piwik %s.", + "YouAreViewingDemoShortMessage": "Du bruger en demo version af Piwik", + "YouMustBeLoggedIn": "Du skal være logget på for at få adgang til denne funktion.", + "YourChangesHaveBeenSaved": "Ændringer er gemt." + }, + "Goals": { + "AbandonedCart": "Afbrudt Ordre", + "AddGoal": "Tilføj mål", + "AddNewGoal": "Tilføj et nyt mål", + "AddNewGoalOrEditExistingGoal": "%sTilføj et nyt mål%s eller %srediger%s eksisterende mål", + "AllowGoalConvertedMoreThanOncePerVisit": "Tillad at mål konverteres mere end én gang pr. besøg", + "AllowMultipleConversionsPerVisit": "Tillad flere konverteringer pr. besøg", + "BestCountries": "De bedste konverteringslande er:", + "BestKeywords": "Top konverteringssøgeord er:", + "BestReferrers": "Bedste konverteringshjemmeside henvisning er:", + "CaseSensitive": "Forskel på små og store bogstaver", + "ClickOutlink": "Klik på et link til en ekstern hjemmeside", + "ColumnAverageOrderRevenueDocumentation": "Gennemsnitlig ordreværdi (GOV) er den samlede indtjening fra alle e-handel ordrer divideret med antallet af ordrer.", + "ColumnAveragePriceDocumentation": "Gennemsnitlig indtjening for denne %s.", + "ColumnAverageQuantityDocumentation": "Gennemsnitlige mængde af denne %s solgt i e-handel ordrer.", + "ColumnConversionRateDocumentation": "Procentdel af besøg, der udløste målet %s.", + "ColumnConversionRateProductDocumentation": "%s konverteringsfrekvens er antallet af ordrer, der indeholder produktet divideret med antallet af besøg på produktets side.", + "ColumnConversions": "Omregninger", + "ColumnConversionsDocumentation": "Antal besøg, der udløste målet %s.", + "ColumnOrdersDocumentation": "Det samlede antal e-handel ordrer, der indeholdt denne %s mindst én gang.", + "ColumnPurchasedProductsDocumentation": "Antallet af købte varer er summen af solgte varer i alle e-handel ordrer.", + "ColumnQuantityDocumentation": "Mængde er det samlede antal af solgte produkter for hver %s.", + "ColumnRevenueDocumentation": "Samlede indtægter fra %s konverteringer divideret med antallet af besøg.", + "ColumnRevenuePerVisitDocumentation": "Den samlede indtægt fra %s divideret med antallet af besøg.", + "ColumnVisits": "Det samlede antal besøg, uanset om et mål blev udløst eller ej.", + "ColumnVisitsProductDocumentation": "Antallet af besøg på produktet\/kategori siden. Anvendes til at behandle %s konverteringsfrekvens. Målingen er med i rapporten, hvis e-handel sporing var konfigureret på produkt\/kategori siderne.", + "Contains": "indeholder %s", + "ConversionByTypeReportDocumentation": "Rapporten indeholder detaljerede oplysninger om mål ydeevne (konverteringer, omregningskurser og indtægt pr. besøg) for hver af kategorierne tilgængelig i panelet til venstre. %s Klik på en af kategorierne for at få vist rapporten. %s For mere information læs %sSporing af mål dokumentation på piwik.org%s", + "ConversionRate": "%s konverteringsrate", + "Conversions": "%s konverteringer", + "ConversionsOverview": "Konverteringsoversigt", + "ConversionsOverviewBy": "Konverteringsoversigt efter besøgstype", + "CreateNewGOal": "Opret nyt mål", + "DaysToConv": "Dage til konvertering", + "DefaultGoalConvertedOncePerVisit": "(standard) Målet kan kun konverteres én gang pr. besøg", + "DefaultRevenue": "Målets standard indtægter er", + "DefaultRevenueHelp": "F.eks. en kontaktformular sendt af en besøgende kan være 10 kr. værd i gennemsnit. Piwik vil hjælpe dig med at forstå, hvor godt dine besøgssegmenter klarer sig.", + "DeleteGoalConfirm": "Bekræft sletning af mål %s?", + "DocumentationRevenueGeneratedByProductSales": "Produktsalg. Omfatter ikke moms, forsendelse og rabat", + "Download": "Henter en fil", + "Ecommerce": "E-handel", + "EcommerceAndGoalsMenu": "E-handel & Mål", + "EcommerceLog": "E-handel log", + "EcommerceOrder": "E-handel ordre", + "EcommerceOverview": "E-handel oversigt", + "EcommerceReports": "E-handelsrapporter", + "ExceptionInvalidMatchingString": "Hvis der vælges 'eksakt match', skal den matchende streng være en netadresse der starter med %s. F.eks., '%s'.", + "ExternalWebsiteUrl": "ekstern hjemmesideadresse", + "Filename": "filnavn", + "GoalConversion": "Mål konvertering", + "GoalConversions": "Mål konverteringer", + "GoalConversionsBy": "Mål %s konverteringer efter besøgstype", + "GoalIsTriggered": "Målet er opfyldt", + "GoalIsTriggeredWhen": "Målet er opfyldt, når", + "GoalName": "Navn", + "Goals": "Mål", + "GoalsManagement": "Mål administration", + "GoalsOverview": "Måloversigt", + "GoalsOverviewDocumentation": "Oversigt over dine målkonverteringer. I første omgang viser diagrammet summen af alle konverteringer. %s Under diagrammet, kan du se konverteringsrapporter for hvert af dine mål. Minidiagrammer kan udvides ved at klikke på dem.", + "GoalX": "Mål %s", + "HelpOneConversionPerVisit": "Hvis en side der matcher målet opdateres eller ses mere end én gang i et besøg, vil målet kun spores første gang siden bliver indlæst under besøget.", + "IsExactly": "er nøjagtig %s", + "LearnMoreAboutGoalTrackingDocumentation": "Lær mere om %s Spor mål i Piwik%s i brugerdokumentationen, eller opret et mål nu.", + "LeftInCart": "%s tilbage i kurven", + "Manually": "manuelt", + "ManuallyTriggeredUsingJavascriptFunction": "Målet udløses manuelt ved hjælp af JavaScript API trackGoal ()", + "MatchesExpression": "matcher udtrykket %s", + "NewGoalDescription": "Et mål i Piwik er din strategi, din prioritet, og kan betyde mange ting: \"Hentet brochure\", \"Registreret nyhedsbrev\", \"Besøgt side services.html\", osv.", + "NewGoalIntro": "Sporing af målkonvertering er et af de mest effektive måder at måle og forbedre dine forretningsmæssige målsætninger.", + "NewGoalYouWillBeAbleTo": "Du vil kunne se og analysere dine præstationer for hvert mål, og lære hvordan man kan øge konverteringer, konverteringsfrekvenser og indtægter pr besøg.", + "NewVisitorsConversionRateIs": "Nye besøgendes konverteringsrate er %s", + "NewWhatDoYouWantUsersToDo": "Hvad vil du have dine brugere til at gøre på din hjemmeside?", + "NoGoalsNeedAccess": "Kun en administrator eller en bruger med superbruger adgang kan tilføje mål for et bestemt websted. Spørg din Piwik administratore om at opstille et mål for dit websted.
    Sporing mål er en fantastisk måde til at forstå og maksimere dit websteds effektivitet!", + "Optional": "(valgfri)", + "OverallConversionRate": "%s samlet konverteringsrate (besøg med et færdigt mål)", + "OverallRevenue": "%s samlede indtægter", + "PageTitle": "sidetitel", + "Pattern": "Mønster", + "PluginDescription": " Opret mål og se rapporter om målkonverteringer: udvikling over tid, indtjening pr besøg, konverteringer pr referer, for hvert enkelt søgeord osv.", + "ProductCategory": "Produktkategori", + "ProductName": "Produktnavn", + "Products": "Produkter", + "ProductSKU": "Produkt SKU", + "ReturningVisitorsConversionRateIs": "Tilbagevendende besøgendes konverteringsrate er %s", + "SingleGoalOverviewDocumentation": "Oversigt over konverteringer for et enkelt mål. %s Minidiagrammer under diagrammet kan forstørres ved at klikke på dem.", + "UpdateGoal": "Opdater mål", + "URL": "Netadresse", + "ViewAndEditGoals": "Vis og rediger mål", + "ViewGoalsBy": "Vis mål efter %s", + "VisitPageTitle": "Besøger en given sidetitel", + "VisitsUntilConv": "Besøg til konvertering", + "VisitUrl": "Besøger en bestemt hjemmeside (side eller en gruppe af sider)", + "WhenVisitors": "når de besøgende", + "WhereThe": "Hvor", + "WhereVisitedPageManuallyCallsJavascriptTrackerLearnMore": "hvor den besøgte side indeholder et kald til JavaScriptet piwikTracker.trackGoal () metode (%s lær mere%s)", + "YouCanEnableEcommerceReports": "Du kan aktivere %s for hjemmesiden på %s siden." + }, + "ImageGraph": { + "ColumnOrdinateMissing": "Kolonnen '%s' blev ikke fundet i denne rapport. Prøv en af %s", + "PluginDescription": "Generere flotte statiske PNG diagrammer til Piwik rapporter." + }, + "Insights": { + "ControlFilterByDescription": "Vis alle, kun ændringer, kun nye eller kun forsvundne", + "DatePeriodCombinationNotSupported": "Det er ikke muligt at generere rapporter til disse datoer og kombination af perioder.", + "DayComparedToPreviousDay": "Foregående dag", + "DayComparedToPreviousWeek": "Samme dag i sidste uge", + "DayComparedToPreviousYear": "Samme dag sidste år", + "Filter": "Filter", + "FilterIncreaserAndDecreaser": "Forøg og mindsk", + "FilterOnlyDecreaser": "Forminsk kun", + "FilterOnlyDisappeared": "Kun forsvundne", + "FilterOnlyIncreaser": "Kun øgede", + "FilterOnlyMovers": "Kun flyttede", + "FilterOnlyNew": "Kun nye", + "IgnoredChanges": "Ændringer, der påvirker mindre end %s besøgende bliver ignoreret", + "MonthComparedToPreviousMonth": "forrige måned", + "MonthComparedToPreviousYear": "samme måned forrige år" + }, + "Installation": { + "CollaborativeProject": "Piwik er et samarbejdsprojekt, bygget med kærlighed af folk fra hele verden.", + "CommunityNewsletter": "send e-mail med opdateringer (nye udvidelsesmoduler, nye muligheder m.m.)", + "ConfigurationHelp": "Piwik konfigurationsfil synes at være konfigureret forkert. Fjern enten config\/config.ini.php og genoptage installation eller ret database forbindelsesindstillinger.", + "ConfirmDeleteExistingTables": "Bekræft sletning af tabellerne: %s fra databasen? ADVARSEL: DATA FRA DISSE TABELLER KAN IKKE GENSKABES!", + "Congratulations": "Tillykke", + "CongratulationsHelp": "

    Tillykke! Piwik installationen er færdig.<\/p>

    Sørg for at JavaScript-koden er tilføjet på dine sider, og vent på de første besøgende!<\/p>", + "DatabaseAbilities": "Database egenskaber", + "DatabaseCheck": "Database check", + "DatabaseClientVersion": "Database klient version", + "DatabaseCreation": "Database oprettet", + "DatabaseErrorConnect": "Fejl ved tilkobling til databaseserveren", + "DatabaseServerVersion": "Databaseserver version", + "DatabaseSetup": "Database oprettelse", + "DatabaseSetupAdapter": "Adapter", + "DatabaseSetupDatabaseName": "Databasenavn", + "DatabaseSetupLogin": "Brugernavn", + "DatabaseSetupServer": "Databaseserver", + "DatabaseSetupTablePrefix": "Tabel præfiks", + "Email": "E-mail", + "ErrorInvalidState": "Fejl: Det lader til at du forsøger at springe et skridt af installationen over, eller cookies er slået fra, eller Piwik konfigurationsfil allerede er oprettet. %1$sSørg for, at cookies er aktiveret%2$s og gå tilbage %3$s til den første side i installationen %4$s.", + "Extension": "udvidelse", + "Filesystem": "Filsystem", + "GetInvolved": "Hvis du kan lide hvad du ser, kan du %1$sblive involveret%2$s.", + "GoBackAndDefinePrefix": "Gå tilbage og opret præfiks for Piwik tabeller", + "HappyAnalysing": "Held og lykke med analysering!", + "Installation": "Installation", + "InstallationStatus": "Installationstatus", + "InsufficientPrivilegesHelp": "Du kan tilføje disse rettigheder ved at bruge et værktøj som phpMyAdmin eller ved at udføre de rigtige SQL-forespørgsler. Hvis du ikke ved hvordan man gør, skal du bede din sysadmin til at tildele rettighederne.", + "InsufficientPrivilegesMain": "Enten eksisterer databasen ikke (og kunne ikke oprettes) eller den angivne bruger har utilstrækkelige privilegier. Databasebrugeren skal have følgende privilegier: %s", + "JsTagArchivingHelp1": "For mellemstore og højt trafikere hjemmesider, er der visse optimeringer, der bør gøres for at hjælpe Piwik til køre hurtigere (såsom %1$soprettelse af auto-arkivering%2$s).", + "JSTracking_EndNote": "Bemærk: Efter installationen, kan du generere tilpasset sporingskode i %1$sSporingskode%2$s administrator delen.", + "JSTracking_Intro": "For at spore hjemmeside trafik med Piwik skal du sørge for nogle ekstra kode til hver af hjemmesiderne.", + "LargePiwikInstances": "Hjælp til store Piwik installationer", + "Legend": "Beskrivelse", + "LoadDataInfileRecommended": "Hvis Piwik serveren sporer trafikbelastede hjemmesider(fx. > 100.000 sider pr. måned), anbefales det for at forsøge at løse problemet.", + "LoadDataInfileUnavailableHelp": "Brug af %1$s vil i høj grad sætte Piwiks arkiveringhastighed op. For at gøre det tilgængeligt for Piwik, opdater PHP & MySQL-softwaren og sørg for at databasebruger har %2$s privilegium.", + "NfsFilesystemWarning": "Din server benytter et NFS filsystem.", + "NfsFilesystemWarningSuffixAdmin": "Det betyder at Piwik vil være ekstremt langsom når der benyttes filbaserede sessioner.", + "NfsFilesystemWarningSuffixInstall": "Brug af filbaserede sessioner på et NFS filsystem er ekstrem langsomt, så Piwik vil benytte database sessioner. Hvis du har mange samtidige kontrolpanelsbrugere, vil du muligvis være nødt til at hæve det maksimale antal af klient forbindelser til database serveren.", + "NoConfigFound": "Piwik-konfigurationsfil blev ikke fundet og du prøver at få adgang til Piwik.

    + + + it('should sanitize the html snippet by default', function() { + expect(element(by.css('#bind-html-with-sanitize div')).getInnerHtml()). + toBe('

    an html\nclick here\nsnippet

    '); + }); + + it('should inline raw snippet if bound to a trusted value', function() { + expect(element(by.css('#bind-html-with-trust div')).getInnerHtml()). + toBe("

    an html\n" + + "click here\n" + + "snippet

    "); + }); + + it('should escape snippet without any filter', function() { + expect(element(by.css('#bind-default div')).getInnerHtml()). + toBe("<p style=\"color:blue\">an html\n" + + "<em onmouseover=\"this.textContent='PWN3D!'\">click here</em>\n" + + "snippet</p>"); + }); + + it('should update', function() { + element(by.model('snippet')).clear(); + element(by.model('snippet')).sendKeys('new text'); + expect(element(by.css('#bind-html-with-sanitize div')).getInnerHtml()). + toBe('new text'); + expect(element(by.css('#bind-html-with-trust div')).getInnerHtml()).toBe( + 'new text'); + expect(element(by.css('#bind-default div')).getInnerHtml()).toBe( + "new <b onclick=\"alert(1)\">text</b>"); + }); +
    + + */ +function $SanitizeProvider() { + this.$get = ['$$sanitizeUri', function($$sanitizeUri) { + return function(html) { + var buf = []; + htmlParser(html, htmlSanitizeWriter(buf, function(uri, isImage) { + return !/^unsafe/.test($$sanitizeUri(uri, isImage)); + })); + return buf.join(''); + }; + }]; +} + +function sanitizeText(chars) { + var buf = []; + var writer = htmlSanitizeWriter(buf, angular.noop); + writer.chars(chars); + return buf.join(''); +} + + +// Regular Expressions for parsing tags and attributes +var START_TAG_REGEXP = + /^<\s*([\w:-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*>/, + END_TAG_REGEXP = /^<\s*\/\s*([\w:-]+)[^>]*>/, + ATTR_REGEXP = /([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g, + BEGIN_TAG_REGEXP = /^/g, + DOCTYPE_REGEXP = /]*?)>/i, + CDATA_REGEXP = //g, + // Match everything outside of normal chars and " (quote character) + NON_ALPHANUMERIC_REGEXP = /([^\#-~| |!])/g; + + +// Good source of info about elements and attributes +// http://dev.w3.org/html5/spec/Overview.html#semantics +// http://simon.html5.org/html-elements + +// Safe Void Elements - HTML5 +// http://dev.w3.org/html5/spec/Overview.html#void-elements +var voidElements = makeMap("area,br,col,hr,img,wbr"); + +// Elements that you can, intentionally, leave open (and which close themselves) +// http://dev.w3.org/html5/spec/Overview.html#optional-tags +var optionalEndTagBlockElements = makeMap("colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr"), + optionalEndTagInlineElements = makeMap("rp,rt"), + optionalEndTagElements = angular.extend({}, + optionalEndTagInlineElements, + optionalEndTagBlockElements); + +// Safe Block Elements - HTML5 +var blockElements = angular.extend({}, optionalEndTagBlockElements, makeMap("address,article," + + "aside,blockquote,caption,center,del,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5," + + "h6,header,hgroup,hr,ins,map,menu,nav,ol,pre,script,section,table,ul")); + +// Inline Elements - HTML5 +var inlineElements = angular.extend({}, optionalEndTagInlineElements, makeMap("a,abbr,acronym,b," + + "bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,q,ruby,rp,rt,s," + + "samp,small,span,strike,strong,sub,sup,time,tt,u,var")); + + +// Special Elements (can contain anything) +var specialElements = makeMap("script,style"); + +var validElements = angular.extend({}, + voidElements, + blockElements, + inlineElements, + optionalEndTagElements); + +//Attributes that have href and hence need to be sanitized +var uriAttrs = makeMap("background,cite,href,longdesc,src,usemap"); +var validAttrs = angular.extend({}, uriAttrs, makeMap( + 'abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,'+ + 'color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,'+ + 'ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,'+ + 'scope,scrolling,shape,size,span,start,summary,target,title,type,'+ + 'valign,value,vspace,width')); + +function makeMap(str) { + var obj = {}, items = str.split(','), i; + for (i = 0; i < items.length; i++) obj[items[i]] = true; + return obj; +} + + +/** + * @example + * htmlParser(htmlString, { + * start: function(tag, attrs, unary) {}, + * end: function(tag) {}, + * chars: function(text) {}, + * comment: function(text) {} + * }); + * + * @param {string} html string + * @param {object} handler + */ +function htmlParser( html, handler ) { + var index, chars, match, stack = [], last = html; + stack.last = function() { return stack[ stack.length - 1 ]; }; + + while ( html ) { + chars = true; + + // Make sure we're not in a script or style element + if ( !stack.last() || !specialElements[ stack.last() ] ) { + + // Comment + if ( html.indexOf("", index) === index) { + if (handler.comment) handler.comment( html.substring( 4, index ) ); + html = html.substring( index + 3 ); + chars = false; + } + // DOCTYPE + } else if ( DOCTYPE_REGEXP.test(html) ) { + match = html.match( DOCTYPE_REGEXP ); + + if ( match ) { + html = html.replace( match[0] , ''); + chars = false; + } + // end tag + } else if ( BEGING_END_TAGE_REGEXP.test(html) ) { + match = html.match( END_TAG_REGEXP ); + + if ( match ) { + html = html.substring( match[0].length ); + match[0].replace( END_TAG_REGEXP, parseEndTag ); + chars = false; + } + + // start tag + } else if ( BEGIN_TAG_REGEXP.test(html) ) { + match = html.match( START_TAG_REGEXP ); + + if ( match ) { + html = html.substring( match[0].length ); + match[0].replace( START_TAG_REGEXP, parseStartTag ); + chars = false; + } + } + + if ( chars ) { + index = html.indexOf("<"); + + var text = index < 0 ? html : html.substring( 0, index ); + html = index < 0 ? "" : html.substring( index ); + + if (handler.chars) handler.chars( decodeEntities(text) ); + } + + } else { + html = html.replace(new RegExp("(.*)<\\s*\\/\\s*" + stack.last() + "[^>]*>", 'i'), + function(all, text){ + text = text.replace(COMMENT_REGEXP, "$1").replace(CDATA_REGEXP, "$1"); + + if (handler.chars) handler.chars( decodeEntities(text) ); + + return ""; + }); + + parseEndTag( "", stack.last() ); + } + + if ( html == last ) { + throw $sanitizeMinErr('badparse', "The sanitizer was unable to parse the following block " + + "of html: {0}", html); + } + last = html; + } + + // Clean up any remaining tags + parseEndTag(); + + function parseStartTag( tag, tagName, rest, unary ) { + tagName = angular.lowercase(tagName); + if ( blockElements[ tagName ] ) { + while ( stack.last() && inlineElements[ stack.last() ] ) { + parseEndTag( "", stack.last() ); + } + } + + if ( optionalEndTagElements[ tagName ] && stack.last() == tagName ) { + parseEndTag( "", tagName ); + } + + unary = voidElements[ tagName ] || !!unary; + + if ( !unary ) + stack.push( tagName ); + + var attrs = {}; + + rest.replace(ATTR_REGEXP, + function(match, name, doubleQuotedValue, singleQuotedValue, unquotedValue) { + var value = doubleQuotedValue + || singleQuotedValue + || unquotedValue + || ''; + + attrs[name] = decodeEntities(value); + }); + if (handler.start) handler.start( tagName, attrs, unary ); + } + + function parseEndTag( tag, tagName ) { + var pos = 0, i; + tagName = angular.lowercase(tagName); + if ( tagName ) + // Find the closest opened tag of the same type + for ( pos = stack.length - 1; pos >= 0; pos-- ) + if ( stack[ pos ] == tagName ) + break; + + if ( pos >= 0 ) { + // Close all the open elements, up the stack + for ( i = stack.length - 1; i >= pos; i-- ) + if (handler.end) handler.end( stack[ i ] ); + + // Remove the open elements from the stack + stack.length = pos; + } + } +} + +var hiddenPre=document.createElement("pre"); +var spaceRe = /^(\s*)([\s\S]*?)(\s*)$/; +/** + * decodes all entities into regular string + * @param value + * @returns {string} A string with decoded entities. + */ +function decodeEntities(value) { + if (!value) { return ''; } + + // Note: IE8 does not preserve spaces at the start/end of innerHTML + // so we must capture them and reattach them afterward + var parts = spaceRe.exec(value); + var spaceBefore = parts[1]; + var spaceAfter = parts[3]; + var content = parts[2]; + if (content) { + hiddenPre.innerHTML=content.replace(//g, '>'); +} + +/** + * create an HTML/XML writer which writes to buffer + * @param {Array} buf use buf.jain('') to get out sanitized html string + * @returns {object} in the form of { + * start: function(tag, attrs, unary) {}, + * end: function(tag) {}, + * chars: function(text) {}, + * comment: function(text) {} + * } + */ +function htmlSanitizeWriter(buf, uriValidator){ + var ignore = false; + var out = angular.bind(buf, buf.push); + return { + start: function(tag, attrs, unary){ + tag = angular.lowercase(tag); + if (!ignore && specialElements[tag]) { + ignore = tag; + } + if (!ignore && validElements[tag] === true) { + out('<'); + out(tag); + angular.forEach(attrs, function(value, key){ + var lkey=angular.lowercase(key); + var isImage = (tag === 'img' && lkey === 'src') || (lkey === 'background'); + if (validAttrs[lkey] === true && + (uriAttrs[lkey] !== true || uriValidator(value, isImage))) { + out(' '); + out(key); + out('="'); + out(encodeEntities(value)); + out('"'); + } + }); + out(unary ? '/>' : '>'); + } + }, + end: function(tag){ + tag = angular.lowercase(tag); + if (!ignore && validElements[tag] === true) { + out(''); + } + if (tag == ignore) { + ignore = false; + } + }, + chars: function(chars){ + if (!ignore) { + out(encodeEntities(chars)); + } + } + }; +} + + +// define ngSanitize module and register $sanitize service +angular.module('ngSanitize', []).provider('$sanitize', $SanitizeProvider); + +/* global sanitizeText: false */ + +/** + * @ngdoc filter + * @name ngSanitize.filter:linky + * @function + * + * @description + * Finds links in text input and turns them into html links. Supports http/https/ftp/mailto and + * plain email address links. + * + * Requires the {@link ngSanitize `ngSanitize`} module to be installed. + * + * @param {string} text Input text. + * @param {string} target Window (_blank|_self|_parent|_top) or named frame to open links in. + * @returns {string} Html-linkified text. + * + * @usage + + * + * @example + + + +
    + Snippet: + + + + + + + + + + + + + + + + + + + + + +
    FilterSourceRendered
    linky filter +
    <div ng-bind-html="snippet | linky">
    </div>
    +
    +
    +
    linky target +
    <div ng-bind-html="snippetWithTarget | linky:'_blank'">
    </div>
    +
    +
    +
    no filter
    <div ng-bind="snippet">
    </div>
    + + + it('should linkify the snippet with urls', function() { + expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). + toBe('Pretty text with some links: http://angularjs.org/, us@somewhere.org, ' + + 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); + expect(element.all(by.css('#linky-filter a')).count()).toEqual(4); + }); + + it('should not linkify snippet without the linky filter', function() { + expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()). + toBe('Pretty text with some links: http://angularjs.org/, mailto:us@somewhere.org, ' + + 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); + expect(element.all(by.css('#escaped-html a')).count()).toEqual(0); + }); + + it('should update', function() { + element(by.model('snippet')).clear(); + element(by.model('snippet')).sendKeys('new http://link.'); + expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). + toBe('new http://link.'); + expect(element.all(by.css('#linky-filter a')).count()).toEqual(1); + expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()) + .toBe('new http://link.'); + }); + + it('should work with the target property', function() { + expect(element(by.id('linky-target')). + element(by.binding("snippetWithTarget | linky:'_blank'")).getText()). + toBe('http://angularjs.org/'); + expect(element(by.css('#linky-target a')).getAttribute('target')).toEqual('_blank'); + }); + + + */ +angular.module('ngSanitize').filter('linky', ['$sanitize', function($sanitize) { + var LINKY_URL_REGEXP = + /((ftp|https?):\/\/|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>]/, + MAILTO_REGEXP = /^mailto:/; + + return function(text, target) { + if (!text) return text; + var match; + var raw = text; + var html = []; + var url; + var i; + while ((match = raw.match(LINKY_URL_REGEXP))) { + // We can not end in these as they are sometimes found at the end of the sentence + url = match[0]; + // if we did not match ftp/http/mailto then assume mailto + if (match[2] == match[3]) url = 'mailto:' + url; + i = match.index; + addText(raw.substr(0, i)); + addLink(url, match[0].replace(MAILTO_REGEXP, '')); + raw = raw.substring(i + match[0].length); + } + addText(raw); + return $sanitize(html.join('')); + + function addText(text) { + if (!text) { + return; + } + html.push(sanitizeText(text)); + } + + function addLink(url, text) { + html.push('
    '); + addText(text); + html.push(''); + } + }; +}]); + + +})(window, window.angular); diff --git a/www/analytics/libs/angularjs/angular-sanitize.min.js b/www/analytics/libs/angularjs/angular-sanitize.min.js new file mode 100755 index 00000000..387c564d --- /dev/null +++ b/www/analytics/libs/angularjs/angular-sanitize.min.js @@ -0,0 +1,13 @@ +/* + AngularJS v1.2.13 + (c) 2010-2014 Google, Inc. http://angularjs.org + License: MIT +*/ +(function(p,h,q){'use strict';function E(a){var e=[];s(e,h.noop).chars(a);return e.join("")}function k(a){var e={};a=a.split(",");var d;for(d=0;d=c;d--)e.end&&e.end(f[d]);f.length=c}}var b,g,f=[],l=a;for(f.last=function(){return f[f.length-1]};a;){g=!0;if(f.last()&&x[f.last()])a=a.replace(RegExp("(.*)<\\s*\\/\\s*"+f.last()+"[^>]*>","i"),function(b,a){a=a.replace(H,"$1").replace(I,"$1");e.chars&&e.chars(r(a));return""}),c("",f.last());else{if(0===a.indexOf("\x3c!--"))b=a.indexOf("--",4),0<=b&&a.lastIndexOf("--\x3e",b)===b&&(e.comment&&e.comment(a.substring(4,b)),a=a.substring(b+3),g=!1);else if(y.test(a)){if(b=a.match(y))a= +a.replace(b[0],""),g=!1}else if(J.test(a)){if(b=a.match(z))a=a.substring(b[0].length),b[0].replace(z,c),g=!1}else K.test(a)&&(b=a.match(A))&&(a=a.substring(b[0].length),b[0].replace(A,d),g=!1);g&&(b=a.indexOf("<"),g=0>b?a:a.substring(0,b),a=0>b?"":a.substring(b),e.chars&&e.chars(r(g)))}if(a==l)throw L("badparse",a);l=a}c()}function r(a){if(!a)return"";var e=M.exec(a);a=e[1];var d=e[3];if(e=e[2])n.innerHTML=e.replace(//g,">")}function s(a,e){var d=!1,c=h.bind(a,a.push);return{start:function(a,g,f){a=h.lowercase(a);!d&&x[a]&&(d=a);d||!0!==C[a]||(c("<"),c(a),h.forEach(g,function(d,f){var g=h.lowercase(f),k="img"===a&&"src"===g||"background"===g;!0!==O[g]||!0===D[g]&&!e(d,k)||(c(" "),c(f),c('="'),c(B(d)),c('"'))}),c(f?"/>":">"))},end:function(a){a=h.lowercase(a);d||!0!==C[a]||(c(""));a==d&&(d=!1)},chars:function(a){d|| +c(B(a))}}}var L=h.$$minErr("$sanitize"),A=/^<\s*([\w:-]+)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*>/,z=/^<\s*\/\s*([\w:-]+)[^>]*>/,G=/([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g,K=/^]*?)>/i,I=/]/,d=/^mailto:/;return function(c,b){function g(a){a&&m.push(E(a))}function f(a,c){m.push("');g(c);m.push("")}if(!c)return c;for(var l,k=c,m=[],n,p;l=k.match(e);)n=l[0],l[2]==l[3]&&(n="mailto:"+n),p=l.index,g(k.substr(0,p)),f(n,l[0].replace(d,"")),k=k.substring(p+l[0].length);g(k);return a(m.join(""))}}])})(window,window.angular); diff --git a/www/analytics/libs/angularjs/angular-scenario.js b/www/analytics/libs/angularjs/angular-scenario.js new file mode 100755 index 00000000..752d85ef --- /dev/null +++ b/www/analytics/libs/angularjs/angular-scenario.js @@ -0,0 +1,32880 @@ +/*! + * jQuery JavaScript Library v1.10.2 + * http://jquery.com/ + * + * Includes Sizzle.js + * http://sizzlejs.com/ + * + * Copyright 2005, 2013 jQuery Foundation, Inc. and other contributors + * Released under the MIT license + * http://jquery.org/license + * + * Date: 2013-07-03T13:48Z + */ +(function( window, undefined ) {'use strict'; + +// Can't do this because several apps including ASP.NET trace +// the stack via arguments.caller.callee and Firefox dies if +// you try to trace through "use strict" call chains. (#13335) +// Support: Firefox 18+ +// + +var + // The deferred used on DOM ready + readyList, + + // A central reference to the root jQuery(document) + rootjQuery, + + // Support: IE<10 + // For `typeof xmlNode.method` instead of `xmlNode.method !== undefined` + core_strundefined = typeof undefined, + + // Use the correct document accordingly with window argument (sandbox) + location = window.location, + document = window.document, + docElem = document.documentElement, + + // Map over jQuery in case of overwrite + _jQuery = window.jQuery, + + // Map over the $ in case of overwrite + _$ = window.$, + + // [[Class]] -> type pairs + class2type = {}, + + // List of deleted data cache ids, so we can reuse them + core_deletedIds = [], + + core_version = "1.10.2", + + // Save a reference to some core methods + core_concat = core_deletedIds.concat, + core_push = core_deletedIds.push, + core_slice = core_deletedIds.slice, + core_indexOf = core_deletedIds.indexOf, + core_toString = class2type.toString, + core_hasOwn = class2type.hasOwnProperty, + core_trim = core_version.trim, + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' + return new jQuery.fn.init( selector, context, rootjQuery ); + }, + + // Used for matching numbers + core_pnum = /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source, + + // Used for splitting on whitespace + core_rnotwhite = /\S+/g, + + // Make sure we trim BOM and NBSP (here's looking at you, Safari 5.0 and IE) + rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, + + // A simple way to check for HTML strings + // Prioritize #id over to avoid XSS via location.hash (#9521) + // Strict HTML recognition (#11290: must start with <) + rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/, + + // Match a standalone tag + rsingleTag = /^<(\w+)\s*\/?>(?:<\/\1>|)$/, + + // JSON RegExp + rvalidchars = /^[\],:{}\s]*$/, + rvalidbraces = /(?:^|:|,)(?:\s*\[)+/g, + rvalidescape = /\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g, + rvalidtokens = /"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g, + + // Matches dashed string for camelizing + rmsPrefix = /^-ms-/, + rdashAlpha = /-([\da-z])/gi, + + // Used by jQuery.camelCase as callback to replace() + fcamelCase = function( all, letter ) { + return letter.toUpperCase(); + }, + + // The ready event handler + completed = function( event ) { + + // readyState === "complete" is good enough for us to call the dom ready in oldIE + if ( document.addEventListener || event.type === "load" || document.readyState === "complete" ) { + detach(); + jQuery.ready(); + } + }, + // Clean-up method for dom ready events + detach = function() { + if ( document.addEventListener ) { + document.removeEventListener( "DOMContentLoaded", completed, false ); + window.removeEventListener( "load", completed, false ); + + } else { + document.detachEvent( "onreadystatechange", completed ); + window.detachEvent( "onload", completed ); + } + }; + +jQuery.fn = jQuery.prototype = { + // The current version of jQuery being used + jquery: core_version, + + constructor: jQuery, + init: function( selector, context, rootjQuery ) { + var match, elem; + + // HANDLE: $(""), $(null), $(undefined), $(false) + if ( !selector ) { + return this; + } + + // Handle HTML strings + if ( typeof selector === "string" ) { + if ( selector.charAt(0) === "<" && selector.charAt( selector.length - 1 ) === ">" && selector.length >= 3 ) { + // Assume that strings that start and end with <> are HTML and skip the regex check + match = [ null, selector, null ]; + + } else { + match = rquickExpr.exec( selector ); + } + + // Match html or make sure no context is specified for #id + if ( match && (match[1] || !context) ) { + + // HANDLE: $(html) -> $(array) + if ( match[1] ) { + context = context instanceof jQuery ? context[0] : context; + + // scripts is true for back-compat + jQuery.merge( this, jQuery.parseHTML( + match[1], + context && context.nodeType ? context.ownerDocument || context : document, + true + ) ); + + // HANDLE: $(html, props) + if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) { + for ( match in context ) { + // Properties of context are called as methods if possible + if ( jQuery.isFunction( this[ match ] ) ) { + this[ match ]( context[ match ] ); + + // ...and otherwise set as attributes + } else { + this.attr( match, context[ match ] ); + } + } + } + + return this; + + // HANDLE: $(#id) + } else { + elem = document.getElementById( match[2] ); + + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document #6963 + if ( elem && elem.parentNode ) { + // Handle the case where IE and Opera return items + // by name instead of ID + if ( elem.id !== match[2] ) { + return rootjQuery.find( selector ); + } + + // Otherwise, we inject the element directly into the jQuery object + this.length = 1; + this[0] = elem; + } + + this.context = document; + this.selector = selector; + return this; + } + + // HANDLE: $(expr, $(...)) + } else if ( !context || context.jquery ) { + return ( context || rootjQuery ).find( selector ); + + // HANDLE: $(expr, context) + // (which is just equivalent to: $(context).find(expr) + } else { + return this.constructor( context ).find( selector ); + } + + // HANDLE: $(DOMElement) + } else if ( selector.nodeType ) { + this.context = this[0] = selector; + this.length = 1; + return this; + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) { + return rootjQuery.ready( selector ); + } + + if ( selector.selector !== undefined ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return jQuery.makeArray( selector, this ); + }, + + // Start with an empty selector + selector: "", + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return core_slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + return num == null ? + + // Return a 'clean' array + this.toArray() : + + // Return just the object + ( num < 0 ? this[ this.length + num ] : this[ num ] ); + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + ret.context = this.context; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + // (You can seed the arguments with an array of args, but this is + // only used internally.) + each: function( callback, args ) { + return jQuery.each( this, callback, args ); + }, + + ready: function( fn ) { + // Add the callback + jQuery.ready.promise().done( fn ); + + return this; + }, + + slice: function() { + return this.pushStack( core_slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[j] ] : [] ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map(this, function( elem, i ) { + return callback.call( elem, i, elem ); + })); + }, + + end: function() { + return this.prevObject || this.constructor(null); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: core_push, + sort: [].sort, + splice: [].splice +}; + +// Give the init function the jQuery prototype for later instantiation +jQuery.fn.init.prototype = jQuery.fn; + +jQuery.extend = jQuery.fn.extend = function() { + var src, copyIsArray, copy, name, options, clone, + target = arguments[0] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + target = arguments[1] || {}; + // skip the boolean and the target + i = 2; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !jQuery.isFunction(target) ) { + target = {}; + } + + // extend jQuery itself if only one argument is passed + if ( length === i ) { + target = this; + --i; + } + + for ( ; i < length; i++ ) { + // Only deal with non-null/undefined values + if ( (options = arguments[ i ]) != null ) { + // Extend the base object + for ( name in options ) { + src = target[ name ]; + copy = options[ name ]; + + // Prevent never-ending loop + if ( target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { + if ( copyIsArray ) { + copyIsArray = false; + clone = src && jQuery.isArray(src) ? src : []; + + } else { + clone = src && jQuery.isPlainObject(src) ? src : {}; + } + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend({ + // Unique for each copy of jQuery on the page + // Non-digits removed to match rinlinejQuery + expando: "jQuery" + ( core_version + Math.random() ).replace( /\D/g, "" ), + + noConflict: function( deep ) { + if ( window.$ === jQuery ) { + window.$ = _$; + } + + if ( deep && window.jQuery === jQuery ) { + window.jQuery = _jQuery; + } + + return jQuery; + }, + + // Is the DOM ready to be used? Set to true once it occurs. + isReady: false, + + // A counter to track how many items to wait for before + // the ready event fires. See #6781 + readyWait: 1, + + // Hold (or release) the ready event + holdReady: function( hold ) { + if ( hold ) { + jQuery.readyWait++; + } else { + jQuery.ready( true ); + } + }, + + // Handle when the DOM is ready + ready: function( wait ) { + + // Abort if there are pending holds or we're already ready + if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { + return; + } + + // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). + if ( !document.body ) { + return setTimeout( jQuery.ready ); + } + + // Remember that the DOM is ready + jQuery.isReady = true; + + // If a normal DOM Ready event fired, decrement, and wait if need be + if ( wait !== true && --jQuery.readyWait > 0 ) { + return; + } + + // If there are functions bound, to execute + readyList.resolveWith( document, [ jQuery ] ); + + // Trigger any bound ready events + if ( jQuery.fn.trigger ) { + jQuery( document ).trigger("ready").off("ready"); + } + }, + + // See test/unit/core.js for details concerning isFunction. + // Since version 1.3, DOM methods and functions like alert + // aren't supported. They return false on IE (#2968). + isFunction: function( obj ) { + return jQuery.type(obj) === "function"; + }, + + isArray: Array.isArray || function( obj ) { + return jQuery.type(obj) === "array"; + }, + + isWindow: function( obj ) { + /* jshint eqeqeq: false */ + return obj != null && obj == obj.window; + }, + + isNumeric: function( obj ) { + return !isNaN( parseFloat(obj) ) && isFinite( obj ); + }, + + type: function( obj ) { + if ( obj == null ) { + return String( obj ); + } + return typeof obj === "object" || typeof obj === "function" ? + class2type[ core_toString.call(obj) ] || "object" : + typeof obj; + }, + + isPlainObject: function( obj ) { + var key; + + // Must be an Object. + // Because of IE, we also have to check the presence of the constructor property. + // Make sure that DOM nodes and window objects don't pass through, as well + if ( !obj || jQuery.type(obj) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { + return false; + } + + try { + // Not own constructor property must be Object + if ( obj.constructor && + !core_hasOwn.call(obj, "constructor") && + !core_hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) { + return false; + } + } catch ( e ) { + // IE8,9 Will throw exceptions on certain host objects #9897 + return false; + } + + // Support: IE<9 + // Handle iteration over inherited properties before own properties. + if ( jQuery.support.ownLast ) { + for ( key in obj ) { + return core_hasOwn.call( obj, key ); + } + } + + // Own properties are enumerated firstly, so to speed up, + // if last one is own, then all properties are own. + for ( key in obj ) {} + + return key === undefined || core_hasOwn.call( obj, key ); + }, + + isEmptyObject: function( obj ) { + var name; + for ( name in obj ) { + return false; + } + return true; + }, + + error: function( msg ) { + throw new Error( msg ); + }, + + // data: string of html + // context (optional): If specified, the fragment will be created in this context, defaults to document + // keepScripts (optional): If true, will include scripts passed in the html string + parseHTML: function( data, context, keepScripts ) { + if ( !data || typeof data !== "string" ) { + return null; + } + if ( typeof context === "boolean" ) { + keepScripts = context; + context = false; + } + context = context || document; + + var parsed = rsingleTag.exec( data ), + scripts = !keepScripts && []; + + // Single tag + if ( parsed ) { + return [ context.createElement( parsed[1] ) ]; + } + + parsed = jQuery.buildFragment( [ data ], context, scripts ); + if ( scripts ) { + jQuery( scripts ).remove(); + } + return jQuery.merge( [], parsed.childNodes ); + }, + + parseJSON: function( data ) { + // Attempt to parse using the native JSON parser first + if ( window.JSON && window.JSON.parse ) { + return window.JSON.parse( data ); + } + + if ( data === null ) { + return data; + } + + if ( typeof data === "string" ) { + + // Make sure leading/trailing whitespace is removed (IE can't handle it) + data = jQuery.trim( data ); + + if ( data ) { + // Make sure the incoming data is actual JSON + // Logic borrowed from http://json.org/json2.js + if ( rvalidchars.test( data.replace( rvalidescape, "@" ) + .replace( rvalidtokens, "]" ) + .replace( rvalidbraces, "")) ) { + + return ( new Function( "return " + data ) )(); + } + } + } + + jQuery.error( "Invalid JSON: " + data ); + }, + + // Cross-browser xml parsing + parseXML: function( data ) { + var xml, tmp; + if ( !data || typeof data !== "string" ) { + return null; + } + try { + if ( window.DOMParser ) { // Standard + tmp = new DOMParser(); + xml = tmp.parseFromString( data , "text/xml" ); + } else { // IE + xml = new ActiveXObject( "Microsoft.XMLDOM" ); + xml.async = "false"; + xml.loadXML( data ); + } + } catch( e ) { + xml = undefined; + } + if ( !xml || !xml.documentElement || xml.getElementsByTagName( "parsererror" ).length ) { + jQuery.error( "Invalid XML: " + data ); + } + return xml; + }, + + noop: function() {}, + + // Evaluates a script in a global context + // Workarounds based on findings by Jim Driscoll + // http://weblogs.java.net/blog/driscoll/archive/2009/09/08/eval-javascript-global-context + globalEval: function( data ) { + if ( data && jQuery.trim( data ) ) { + // We use execScript on Internet Explorer + // We use an anonymous function so that context is window + // rather than jQuery in Firefox + ( window.execScript || function( data ) { + window[ "eval" ].call( window, data ); + } )( data ); + } + }, + + // Convert dashed to camelCase; used by the css and data modules + // Microsoft forgot to hump their vendor prefix (#9572) + camelCase: function( string ) { + return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); + }, + + nodeName: function( elem, name ) { + return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); + }, + + // args is for internal usage only + each: function( obj, callback, args ) { + var value, + i = 0, + length = obj.length, + isArray = isArraylike( obj ); + + if ( args ) { + if ( isArray ) { + for ( ; i < length; i++ ) { + value = callback.apply( obj[ i ], args ); + + if ( value === false ) { + break; + } + } + } else { + for ( i in obj ) { + value = callback.apply( obj[ i ], args ); + + if ( value === false ) { + break; + } + } + } + + // A special, fast, case for the most common use of each + } else { + if ( isArray ) { + for ( ; i < length; i++ ) { + value = callback.call( obj[ i ], i, obj[ i ] ); + + if ( value === false ) { + break; + } + } + } else { + for ( i in obj ) { + value = callback.call( obj[ i ], i, obj[ i ] ); + + if ( value === false ) { + break; + } + } + } + } + + return obj; + }, + + // Use native String.trim function wherever possible + trim: core_trim && !core_trim.call("\uFEFF\xA0") ? + function( text ) { + return text == null ? + "" : + core_trim.call( text ); + } : + + // Otherwise use our own trimming functionality + function( text ) { + return text == null ? + "" : + ( text + "" ).replace( rtrim, "" ); + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArraylike( Object(arr) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + core_push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + var len; + + if ( arr ) { + if ( core_indexOf ) { + return core_indexOf.call( arr, elem, i ); + } + + len = arr.length; + i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0; + + for ( ; i < len; i++ ) { + // Skip accessing in sparse arrays + if ( i in arr && arr[ i ] === elem ) { + return i; + } + } + } + + return -1; + }, + + merge: function( first, second ) { + var l = second.length, + i = first.length, + j = 0; + + if ( typeof l === "number" ) { + for ( ; j < l; j++ ) { + first[ i++ ] = second[ j ]; + } + } else { + while ( second[j] !== undefined ) { + first[ i++ ] = second[ j++ ]; + } + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, inv ) { + var retVal, + ret = [], + i = 0, + length = elems.length; + inv = !!inv; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + retVal = !!callback( elems[ i ], i ); + if ( inv !== retVal ) { + ret.push( elems[ i ] ); + } + } + + return ret; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var value, + i = 0, + length = elems.length, + isArray = isArraylike( elems ), + ret = []; + + // Go through the array, translating each of the items to their + if ( isArray ) { + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret[ ret.length ] = value; + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret[ ret.length ] = value; + } + } + } + + // Flatten any nested arrays + return core_concat.apply( [], ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // Bind a function to a context, optionally partially applying any + // arguments. + proxy: function( fn, context ) { + var args, proxy, tmp; + + if ( typeof context === "string" ) { + tmp = fn[ context ]; + context = fn; + fn = tmp; + } + + // Quick check to determine if target is callable, in the spec + // this throws a TypeError, but we will just return undefined. + if ( !jQuery.isFunction( fn ) ) { + return undefined; + } + + // Simulated bind + args = core_slice.call( arguments, 2 ); + proxy = function() { + return fn.apply( context || this, args.concat( core_slice.call( arguments ) ) ); + }; + + // Set the guid of unique handler to the same of original handler, so it can be removed + proxy.guid = fn.guid = fn.guid || jQuery.guid++; + + return proxy; + }, + + // Multifunctional method to get and set values of a collection + // The value/s can optionally be executed if it's a function + access: function( elems, fn, key, value, chainable, emptyGet, raw ) { + var i = 0, + length = elems.length, + bulk = key == null; + + // Sets many values + if ( jQuery.type( key ) === "object" ) { + chainable = true; + for ( i in key ) { + jQuery.access( elems, fn, i, key[i], true, emptyGet, raw ); + } + + // Sets one value + } else if ( value !== undefined ) { + chainable = true; + + if ( !jQuery.isFunction( value ) ) { + raw = true; + } + + if ( bulk ) { + // Bulk operations run against the entire set + if ( raw ) { + fn.call( elems, value ); + fn = null; + + // ...except when executing function values + } else { + bulk = fn; + fn = function( elem, key, value ) { + return bulk.call( jQuery( elem ), value ); + }; + } + } + + if ( fn ) { + for ( ; i < length; i++ ) { + fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) ); + } + } + } + + return chainable ? + elems : + + // Gets + bulk ? + fn.call( elems ) : + length ? fn( elems[0], key ) : emptyGet; + }, + + now: function() { + return ( new Date() ).getTime(); + }, + + // A method for quickly swapping in/out CSS properties to get correct calculations. + // Note: this method belongs to the css module but it's needed here for the support module. + // If support gets modularized, this method should be moved back to the css module. + swap: function( elem, options, callback, args ) { + var ret, name, + old = {}; + + // Remember the old values, and insert the new ones + for ( name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + ret = callback.apply( elem, args || [] ); + + // Revert the old values + for ( name in options ) { + elem.style[ name ] = old[ name ]; + } + + return ret; + } +}); + +jQuery.ready.promise = function( obj ) { + if ( !readyList ) { + + readyList = jQuery.Deferred(); + + // Catch cases where $(document).ready() is called after the browser event has already occurred. + // we once tried to use readyState "interactive" here, but it caused issues like the one + // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15 + if ( document.readyState === "complete" ) { + // Handle it asynchronously to allow scripts the opportunity to delay ready + setTimeout( jQuery.ready ); + + // Standards-based browsers support DOMContentLoaded + } else if ( document.addEventListener ) { + // Use the handy event callback + document.addEventListener( "DOMContentLoaded", completed, false ); + + // A fallback to window.onload, that will always work + window.addEventListener( "load", completed, false ); + + // If IE event model is used + } else { + // Ensure firing before onload, maybe late but safe also for iframes + document.attachEvent( "onreadystatechange", completed ); + + // A fallback to window.onload, that will always work + window.attachEvent( "onload", completed ); + + // If IE and not a frame + // continually check to see if the document is ready + var top = false; + + try { + top = window.frameElement == null && document.documentElement; + } catch(e) {} + + if ( top && top.doScroll ) { + (function doScrollCheck() { + if ( !jQuery.isReady ) { + + try { + // Use the trick by Diego Perini + // http://javascript.nwbox.com/IEContentLoaded/ + top.doScroll("left"); + } catch(e) { + return setTimeout( doScrollCheck, 50 ); + } + + // detach all dom ready events + detach(); + + // and execute any waiting functions + jQuery.ready(); + } + })(); + } + } + } + return readyList.promise( obj ); +}; + +// Populate the class2type map +jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); +}); + +function isArraylike( obj ) { + var length = obj.length, + type = jQuery.type( obj ); + + if ( jQuery.isWindow( obj ) ) { + return false; + } + + if ( obj.nodeType === 1 && length ) { + return true; + } + + return type === "array" || type !== "function" && + ( length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj ); +} + +// All jQuery objects should point back to these +rootjQuery = jQuery(document); +/*! + * Sizzle CSS Selector Engine v1.10.2 + * http://sizzlejs.com/ + * + * Copyright 2013 jQuery Foundation, Inc. and other contributors + * Released under the MIT license + * http://jquery.org/license + * + * Date: 2013-07-03 + */ +(function( window, undefined ) { + +var i, + support, + cachedruns, + Expr, + getText, + isXML, + compile, + outermostContext, + sortInput, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + -(new Date()), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + hasDuplicate = false, + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + return 0; + } + return 0; + }, + + // General-purpose constants + strundefined = typeof undefined, + MAX_NEGATIVE = 1 << 31, + + // Instance methods + hasOwn = ({}).hasOwnProperty, + arr = [], + pop = arr.pop, + push_native = arr.push, + push = arr.push, + slice = arr.slice, + // Use a stripped-down indexOf if we can't use a native one + indexOf = arr.indexOf || function( elem ) { + var i = 0, + len = this.length; + for ( ; i < len; i++ ) { + if ( this[i] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + // http://www.w3.org/TR/css3-syntax/#characters + characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", + + // Loosely modeled on CSS identifier characters + // An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors + // Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + identifier = characterEncoding.replace( "w", "w#" ), + + // Acceptable operators http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + characterEncoding + ")" + whitespace + + "*(?:([*^$|!~]?=)" + whitespace + "*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|(" + identifier + ")|)|)" + whitespace + "*\\]", + + // Prefer arguments quoted, + // then not containing pseudos/brackets, + // then attribute selectors/non-parenthetical expressions, + // then anything else + // These preferences are here to reduce the number of selectors + // needing tokenize in the PSEUDO preFilter + pseudos = ":(" + characterEncoding + ")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|" + attributes.replace( 3, 8 ) + ")*)|.*)\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), + + rsibling = new RegExp( whitespace + "*[+~]" ), + rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*)" + whitespace + "*\\]", "g" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + characterEncoding + ")" ), + "CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ), + "TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + + whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rescape = /'|\\/g, + + // CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ), + funescape = function( _, escaped, escapedWhitespace ) { + var high = "0x" + escaped - 0x10000; + // NaN means non-codepoint + // Support: Firefox + // Workaround erroneous numeric interpretation of +"0x" + return high !== high || escapedWhitespace ? + escaped : + // BMP codepoint + high < 0 ? + String.fromCharCode( high + 0x10000 ) : + // Supplemental Plane codepoint (surrogate pair) + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }; + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + (arr = slice.call( preferredDoc.childNodes )), + preferredDoc.childNodes + ); + // Support: Android<4.0 + // Detect silently failing push.apply + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + push_native.apply( target, slice.call(els) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + // Can't trust NodeList.length + while ( (target[j++] = els[i++]) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var match, elem, m, nodeType, + // QSA vars + i, groups, old, nid, newContext, newSelector; + + if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { + setDocument( context ); + } + + context = context || document; + results = results || []; + + if ( !selector || typeof selector !== "string" ) { + return results; + } + + if ( (nodeType = context.nodeType) !== 1 && nodeType !== 9 ) { + return []; + } + + if ( documentIsHTML && !seed ) { + + // Shortcuts + if ( (match = rquickExpr.exec( selector )) ) { + // Speed-up: Sizzle("#ID") + if ( (m = match[1]) ) { + if ( nodeType === 9 ) { + elem = context.getElementById( m ); + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document #6963 + if ( elem && elem.parentNode ) { + // Handle the case where IE, Opera, and Webkit return items + // by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + } else { + // Context is not a document + if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) && + contains( context, elem ) && elem.id === m ) { + results.push( elem ); + return results; + } + } + + // Speed-up: Sizzle("TAG") + } else if ( match[2] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Speed-up: Sizzle(".CLASS") + } else if ( (m = match[3]) && support.getElementsByClassName && context.getElementsByClassName ) { + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // QSA path + if ( support.qsa && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { + nid = old = expando; + newContext = context; + newSelector = nodeType === 9 && selector; + + // qSA works strangely on Element-rooted queries + // We can work around this by specifying an extra ID on the root + // and working up from there (Thanks to Andrew Dupont for the technique) + // IE 8 doesn't work on object elements + if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { + groups = tokenize( selector ); + + if ( (old = context.getAttribute("id")) ) { + nid = old.replace( rescape, "\\$&" ); + } else { + context.setAttribute( "id", nid ); + } + nid = "[id='" + nid + "'] "; + + i = groups.length; + while ( i-- ) { + groups[i] = nid + toSelector( groups[i] ); + } + newContext = rsibling.test( selector ) && context.parentNode || context; + newSelector = groups.join(","); + } + + if ( newSelector ) { + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch(qsaError) { + } finally { + if ( !old ) { + context.removeAttribute("id"); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {Function(string, Object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key += " " ) > Expr.cacheLength ) { + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return (cache[ key ] = value); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created div and expects a boolean result + */ +function assert( fn ) { + var div = document.createElement("div"); + + try { + return !!fn( div ); + } catch (e) { + return false; + } finally { + // Remove from its parent by default + if ( div.parentNode ) { + div.parentNode.removeChild( div ); + } + // release memory in IE + div = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split("|"), + i = attrs.length; + + while ( i-- ) { + Expr.attrHandle[ arr[i] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + ( ~b.sourceIndex || MAX_NEGATIVE ) - + ( ~a.sourceIndex || MAX_NEGATIVE ); + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( (cur = cur.nextSibling) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return (name === "input" || name === "button") && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction(function( argument ) { + argument = +argument; + return markFunction(function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ (j = matchIndexes[i]) ] ) { + seed[j] = !(matches[j] = seed[j]); + } + } + }); + }); +} + +/** + * Detect xml + * @param {Element|Object} elem An element or a document + */ +isXML = Sizzle.isXML = function( elem ) { + // documentElement is verified for cases where it doesn't yet exist + // (such as loading iframes in IE - #4833) + var documentElement = elem && (elem.ownerDocument || elem).documentElement; + return documentElement ? documentElement.nodeName !== "HTML" : false; +}; + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var doc = node ? node.ownerDocument || node : preferredDoc, + parent = doc.defaultView; + + // If no document and documentElement is available, return + if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Set our document + document = doc; + docElem = doc.documentElement; + + // Support tests + documentIsHTML = !isXML( doc ); + + // Support: IE>8 + // If iframe document is assigned to "document" variable and if iframe has been reloaded, + // IE will throw "permission denied" error when accessing "document" variable, see jQuery #13936 + // IE6-8 do not support the defaultView property so parent will be undefined + if ( parent && parent.attachEvent && parent !== parent.top ) { + parent.attachEvent( "onbeforeunload", function() { + setDocument(); + }); + } + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties (excepting IE8 booleans) + support.attributes = assert(function( div ) { + div.className = "i"; + return !div.getAttribute("className"); + }); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert(function( div ) { + div.appendChild( doc.createComment("") ); + return !div.getElementsByTagName("*").length; + }); + + // Check if getElementsByClassName can be trusted + support.getElementsByClassName = assert(function( div ) { + div.innerHTML = "
    "; + + // Support: Safari<4 + // Catch class over-caching + div.firstChild.className = "i"; + // Support: Opera<10 + // Catch gEBCN failure to find non-leading classes + return div.getElementsByClassName("i").length === 2; + }); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert(function( div ) { + docElem.appendChild( div ).id = expando; + return !doc.getElementsByName || !doc.getElementsByName( expando ).length; + }); + + // ID find and filter + if ( support.getById ) { + Expr.find["ID"] = function( id, context ) { + if ( typeof context.getElementById !== strundefined && documentIsHTML ) { + var m = context.getElementById( id ); + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document #6963 + return m && m.parentNode ? [m] : []; + } + }; + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute("id") === attrId; + }; + }; + } else { + // Support: IE6/7 + // getElementById is not reliable as a find shortcut + delete Expr.find["ID"]; + + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== strundefined && elem.getAttributeNode("id"); + return node && node.value === attrId; + }; + }; + } + + // Tag + Expr.find["TAG"] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== strundefined ) { + return context.getElementsByTagName( tag ); + } + } : + function( tag, context ) { + var elem, + tmp = [], + i = 0, + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( (elem = results[i++]) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== strundefined && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See http://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( (support.qsa = rnative.test( doc.querySelectorAll )) ) { + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert(function( div ) { + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // http://bugs.jquery.com/ticket/12359 + div.innerHTML = ""; + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !div.querySelectorAll("[selected]").length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !div.querySelectorAll(":checked").length ) { + rbuggyQSA.push(":checked"); + } + }); + + assert(function( div ) { + + // Support: Opera 10-12/IE8 + // ^= $= *= and empty values + // Should not select anything + // Support: Windows 8 Native Apps + // The type attribute is restricted during .innerHTML assignment + var input = doc.createElement("input"); + input.setAttribute( "type", "hidden" ); + div.appendChild( input ).setAttribute( "t", "" ); + + if ( div.querySelectorAll("[t^='']").length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( !div.querySelectorAll(":enabled").length ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Opera 10-11 does not throw on post-comma invalid pseudos + div.querySelectorAll("*,:x"); + rbuggyQSA.push(",.*:"); + }); + } + + if ( (support.matchesSelector = rnative.test( (matches = docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector) )) ) { + + assert(function( div ) { + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( div, "div" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( div, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + }); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") ); + + /* Contains + ---------------------------------------------------------------------- */ + + // Element contains another + // Purposefully does not implement inclusive descendent + // As in, an element does not contain itself + contains = rnative.test( docElem.contains ) || docElem.compareDocumentPosition ? + function( a, b ) { + var adown = a.nodeType === 9 ? a.documentElement : a, + bup = b && b.parentNode; + return a === bup || !!( bup && bup.nodeType === 1 && ( + adown.contains ? + adown.contains( bup ) : + a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 + )); + } : + function( a, b ) { + if ( b ) { + while ( (b = b.parentNode) ) { + if ( b === a ) { + return true; + } + } + } + return false; + }; + + /* Sorting + ---------------------------------------------------------------------- */ + + // Document order sorting + sortOrder = docElem.compareDocumentPosition ? + function( a, b ) { + + // Flag for duplicate removal + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + var compare = b.compareDocumentPosition && a.compareDocumentPosition && a.compareDocumentPosition( b ); + + if ( compare ) { + // Disconnected nodes + if ( compare & 1 || + (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { + + // Choose the first element that is related to our preferred document + if ( a === doc || contains(preferredDoc, a) ) { + return -1; + } + if ( b === doc || contains(preferredDoc, b) ) { + return 1; + } + + // Maintain original order + return sortInput ? + ( indexOf.call( sortInput, a ) - indexOf.call( sortInput, b ) ) : + 0; + } + + return compare & 4 ? -1 : 1; + } + + // Not directly comparable, sort on existence of method + return a.compareDocumentPosition ? -1 : 1; + } : + function( a, b ) { + var cur, + i = 0, + aup = a.parentNode, + bup = b.parentNode, + ap = [ a ], + bp = [ b ]; + + // Exit early if the nodes are identical + if ( a === b ) { + hasDuplicate = true; + return 0; + + // Parentless nodes are either documents or disconnected + } else if ( !aup || !bup ) { + return a === doc ? -1 : + b === doc ? 1 : + aup ? -1 : + bup ? 1 : + sortInput ? + ( indexOf.call( sortInput, a ) - indexOf.call( sortInput, b ) ) : + 0; + + // If the nodes are siblings, we can do a quick check + } else if ( aup === bup ) { + return siblingCheck( a, b ); + } + + // Otherwise we need full lists of their ancestors for comparison + cur = a; + while ( (cur = cur.parentNode) ) { + ap.unshift( cur ); + } + cur = b; + while ( (cur = cur.parentNode) ) { + bp.unshift( cur ); + } + + // Walk down the tree looking for a discrepancy + while ( ap[i] === bp[i] ) { + i++; + } + + return i ? + // Do a sibling check if the nodes have a common ancestor + siblingCheck( ap[i], bp[i] ) : + + // Otherwise nodes in our document sort first + ap[i] === preferredDoc ? -1 : + bp[i] === preferredDoc ? 1 : + 0; + }; + + return doc; +}; + +Sizzle.matches = function( expr, elements ) { + return Sizzle( expr, null, null, elements ); +}; + +Sizzle.matchesSelector = function( elem, expr ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + // Make sure that attribute selectors are quoted + expr = expr.replace( rattributeQuotes, "='$1']" ); + + if ( support.matchesSelector && documentIsHTML && + ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && + ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { + + try { + var ret = matches.call( elem, expr ); + + // IE 9's matchesSelector returns false on disconnected nodes + if ( ret || support.disconnectedMatch || + // As well, disconnected nodes are said to be in a document + // fragment in IE 9 + elem.document && elem.document.nodeType !== 11 ) { + return ret; + } + } catch(e) {} + } + + return Sizzle( expr, document, null, [elem] ).length > 0; +}; + +Sizzle.contains = function( context, elem ) { + // Set document vars if needed + if ( ( context.ownerDocument || context ) !== document ) { + setDocument( context ); + } + return contains( context, elem ); +}; + +Sizzle.attr = function( elem, name ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + var fn = Expr.attrHandle[ name.toLowerCase() ], + // Don't get fooled by Object.prototype properties (jQuery #13807) + val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ? + fn( elem, name, !documentIsHTML ) : + undefined; + + return val === undefined ? + support.attributes || !documentIsHTML ? + elem.getAttribute( name ) : + (val = elem.getAttributeNode(name)) && val.specified ? + val.value : + null : + val; +}; + +Sizzle.error = function( msg ) { + throw new Error( "Syntax error, unrecognized expression: " + msg ); +}; + +/** + * Document sorting and removing duplicates + * @param {ArrayLike} results + */ +Sizzle.uniqueSort = function( results ) { + var elem, + duplicates = [], + j = 0, + i = 0; + + // Unless we *know* we can detect duplicates, assume their presence + hasDuplicate = !support.detectDuplicates; + sortInput = !support.sortStable && results.slice( 0 ); + results.sort( sortOrder ); + + if ( hasDuplicate ) { + while ( (elem = results[i++]) ) { + if ( elem === results[ i ] ) { + j = duplicates.push( i ); + } + } + while ( j-- ) { + results.splice( duplicates[ j ], 1 ); + } + } + + return results; +}; + +/** + * Utility function for retrieving the text value of an array of DOM nodes + * @param {Array|Element} elem + */ +getText = Sizzle.getText = function( elem ) { + var node, + ret = "", + i = 0, + nodeType = elem.nodeType; + + if ( !nodeType ) { + // If no nodeType, this is expected to be an array + for ( ; (node = elem[i]); i++ ) { + // Do not traverse comment nodes + ret += getText( node ); + } + } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { + // Use textContent for elements + // innerText usage removed for consistency of new lines (see #11153) + if ( typeof elem.textContent === "string" ) { + return elem.textContent; + } else { + // Traverse its children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + ret += getText( elem ); + } + } + } else if ( nodeType === 3 || nodeType === 4 ) { + return elem.nodeValue; + } + // Do not include comment or processing instruction nodes + + return ret; +}; + +Expr = Sizzle.selectors = { + + // Can be adjusted by the user + cacheLength: 50, + + createPseudo: markFunction, + + match: matchExpr, + + attrHandle: {}, + + find: {}, + + relative: { + ">": { dir: "parentNode", first: true }, + " ": { dir: "parentNode" }, + "+": { dir: "previousSibling", first: true }, + "~": { dir: "previousSibling" } + }, + + preFilter: { + "ATTR": function( match ) { + match[1] = match[1].replace( runescape, funescape ); + + // Move the given value to match[3] whether quoted or unquoted + match[3] = ( match[4] || match[5] || "" ).replace( runescape, funescape ); + + if ( match[2] === "~=" ) { + match[3] = " " + match[3] + " "; + } + + return match.slice( 0, 4 ); + }, + + "CHILD": function( match ) { + /* matches from matchExpr["CHILD"] + 1 type (only|nth|...) + 2 what (child|of-type) + 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...) + 4 xn-component of xn+y argument ([+-]?\d*n|) + 5 sign of xn-component + 6 x of xn-component + 7 sign of y-component + 8 y of y-component + */ + match[1] = match[1].toLowerCase(); + + if ( match[1].slice( 0, 3 ) === "nth" ) { + // nth-* requires argument + if ( !match[3] ) { + Sizzle.error( match[0] ); + } + + // numeric x and y parameters for Expr.filter.CHILD + // remember that false/true cast respectively to 0/1 + match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) ); + match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" ); + + // other types prohibit arguments + } else if ( match[3] ) { + Sizzle.error( match[0] ); + } + + return match; + }, + + "PSEUDO": function( match ) { + var excess, + unquoted = !match[5] && match[2]; + + if ( matchExpr["CHILD"].test( match[0] ) ) { + return null; + } + + // Accept quoted arguments as-is + if ( match[3] && match[4] !== undefined ) { + match[2] = match[4]; + + // Strip excess characters from unquoted arguments + } else if ( unquoted && rpseudo.test( unquoted ) && + // Get excess from tokenize (recursively) + (excess = tokenize( unquoted, true )) && + // advance to the next closing parenthesis + (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) { + + // excess is a negative index + match[0] = match[0].slice( 0, excess ); + match[2] = unquoted.slice( 0, excess ); + } + + // Return only captures needed by the pseudo filter method (type and argument) + return match.slice( 0, 3 ); + } + }, + + filter: { + + "TAG": function( nodeNameSelector ) { + var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase(); + return nodeNameSelector === "*" ? + function() { return true; } : + function( elem ) { + return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; + }; + }, + + "CLASS": function( className ) { + var pattern = classCache[ className + " " ]; + + return pattern || + (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) && + classCache( className, function( elem ) { + return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== strundefined && elem.getAttribute("class") || "" ); + }); + }, + + "ATTR": function( name, operator, check ) { + return function( elem ) { + var result = Sizzle.attr( elem, name ); + + if ( result == null ) { + return operator === "!="; + } + if ( !operator ) { + return true; + } + + result += ""; + + return operator === "=" ? result === check : + operator === "!=" ? result !== check : + operator === "^=" ? check && result.indexOf( check ) === 0 : + operator === "*=" ? check && result.indexOf( check ) > -1 : + operator === "$=" ? check && result.slice( -check.length ) === check : + operator === "~=" ? ( " " + result + " " ).indexOf( check ) > -1 : + operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" : + false; + }; + }, + + "CHILD": function( type, what, argument, first, last ) { + var simple = type.slice( 0, 3 ) !== "nth", + forward = type.slice( -4 ) !== "last", + ofType = what === "of-type"; + + return first === 1 && last === 0 ? + + // Shortcut for :nth-*(n) + function( elem ) { + return !!elem.parentNode; + } : + + function( elem, context, xml ) { + var cache, outerCache, node, diff, nodeIndex, start, + dir = simple !== forward ? "nextSibling" : "previousSibling", + parent = elem.parentNode, + name = ofType && elem.nodeName.toLowerCase(), + useCache = !xml && !ofType; + + if ( parent ) { + + // :(first|last|only)-(child|of-type) + if ( simple ) { + while ( dir ) { + node = elem; + while ( (node = node[ dir ]) ) { + if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) { + return false; + } + } + // Reverse direction for :only-* (if we haven't yet done so) + start = dir = type === "only" && !start && "nextSibling"; + } + return true; + } + + start = [ forward ? parent.firstChild : parent.lastChild ]; + + // non-xml :nth-child(...) stores cache data on `parent` + if ( forward && useCache ) { + // Seek `elem` from a previously-cached index + outerCache = parent[ expando ] || (parent[ expando ] = {}); + cache = outerCache[ type ] || []; + nodeIndex = cache[0] === dirruns && cache[1]; + diff = cache[0] === dirruns && cache[2]; + node = nodeIndex && parent.childNodes[ nodeIndex ]; + + while ( (node = ++nodeIndex && node && node[ dir ] || + + // Fallback to seeking `elem` from the start + (diff = nodeIndex = 0) || start.pop()) ) { + + // When found, cache indexes on `parent` and break + if ( node.nodeType === 1 && ++diff && node === elem ) { + outerCache[ type ] = [ dirruns, nodeIndex, diff ]; + break; + } + } + + // Use previously-cached element index if available + } else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) { + diff = cache[1]; + + // xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...) + } else { + // Use the same loop as above to seek `elem` from the start + while ( (node = ++nodeIndex && node && node[ dir ] || + (diff = nodeIndex = 0) || start.pop()) ) { + + if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) { + // Cache the index of each encountered element + if ( useCache ) { + (node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ]; + } + + if ( node === elem ) { + break; + } + } + } + } + + // Incorporate the offset, then check against cycle size + diff -= last; + return diff === first || ( diff % first === 0 && diff / first >= 0 ); + } + }; + }, + + "PSEUDO": function( pseudo, argument ) { + // pseudo-class names are case-insensitive + // http://www.w3.org/TR/selectors/#pseudo-classes + // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters + // Remember that setFilters inherits from pseudos + var args, + fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] || + Sizzle.error( "unsupported pseudo: " + pseudo ); + + // The user may use createPseudo to indicate that + // arguments are needed to create the filter function + // just as Sizzle does + if ( fn[ expando ] ) { + return fn( argument ); + } + + // But maintain support for old signatures + if ( fn.length > 1 ) { + args = [ pseudo, pseudo, "", argument ]; + return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ? + markFunction(function( seed, matches ) { + var idx, + matched = fn( seed, argument ), + i = matched.length; + while ( i-- ) { + idx = indexOf.call( seed, matched[i] ); + seed[ idx ] = !( matches[ idx ] = matched[i] ); + } + }) : + function( elem ) { + return fn( elem, 0, args ); + }; + } + + return fn; + } + }, + + pseudos: { + // Potentially complex pseudos + "not": markFunction(function( selector ) { + // Trim the selector passed to compile + // to avoid treating leading and trailing + // spaces as combinators + var input = [], + results = [], + matcher = compile( selector.replace( rtrim, "$1" ) ); + + return matcher[ expando ] ? + markFunction(function( seed, matches, context, xml ) { + var elem, + unmatched = matcher( seed, null, xml, [] ), + i = seed.length; + + // Match elements unmatched by `matcher` + while ( i-- ) { + if ( (elem = unmatched[i]) ) { + seed[i] = !(matches[i] = elem); + } + } + }) : + function( elem, context, xml ) { + input[0] = elem; + matcher( input, null, xml, results ); + return !results.pop(); + }; + }), + + "has": markFunction(function( selector ) { + return function( elem ) { + return Sizzle( selector, elem ).length > 0; + }; + }), + + "contains": markFunction(function( text ) { + return function( elem ) { + return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1; + }; + }), + + // "Whether an element is represented by a :lang() selector + // is based solely on the element's language value + // being equal to the identifier C, + // or beginning with the identifier C immediately followed by "-". + // The matching of C against the element's language value is performed case-insensitively. + // The identifier C does not have to be a valid language name." + // http://www.w3.org/TR/selectors/#lang-pseudo + "lang": markFunction( function( lang ) { + // lang value must be a valid identifier + if ( !ridentifier.test(lang || "") ) { + Sizzle.error( "unsupported lang: " + lang ); + } + lang = lang.replace( runescape, funescape ).toLowerCase(); + return function( elem ) { + var elemLang; + do { + if ( (elemLang = documentIsHTML ? + elem.lang : + elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) { + + elemLang = elemLang.toLowerCase(); + return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0; + } + } while ( (elem = elem.parentNode) && elem.nodeType === 1 ); + return false; + }; + }), + + // Miscellaneous + "target": function( elem ) { + var hash = window.location && window.location.hash; + return hash && hash.slice( 1 ) === elem.id; + }, + + "root": function( elem ) { + return elem === docElem; + }, + + "focus": function( elem ) { + return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex); + }, + + // Boolean properties + "enabled": function( elem ) { + return elem.disabled === false; + }, + + "disabled": function( elem ) { + return elem.disabled === true; + }, + + "checked": function( elem ) { + // In CSS3, :checked should return both checked and selected elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + var nodeName = elem.nodeName.toLowerCase(); + return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected); + }, + + "selected": function( elem ) { + // Accessing this property makes selected-by-default + // options in Safari work properly + if ( elem.parentNode ) { + elem.parentNode.selectedIndex; + } + + return elem.selected === true; + }, + + // Contents + "empty": function( elem ) { + // http://www.w3.org/TR/selectors/#empty-pseudo + // :empty is only affected by element nodes and content nodes(including text(3), cdata(4)), + // not comment, processing instructions, or others + // Thanks to Diego Perini for the nodeName shortcut + // Greater than "@" means alpha characters (specifically not starting with "#" or "?") + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + if ( elem.nodeName > "@" || elem.nodeType === 3 || elem.nodeType === 4 ) { + return false; + } + } + return true; + }, + + "parent": function( elem ) { + return !Expr.pseudos["empty"]( elem ); + }, + + // Element/input types + "header": function( elem ) { + return rheader.test( elem.nodeName ); + }, + + "input": function( elem ) { + return rinputs.test( elem.nodeName ); + }, + + "button": function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === "button" || name === "button"; + }, + + "text": function( elem ) { + var attr; + // IE6 and 7 will map elem.type to 'text' for new HTML5 types (search, etc) + // use getAttribute instead to test this case + return elem.nodeName.toLowerCase() === "input" && + elem.type === "text" && + ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === elem.type ); + }, + + // Position-in-collection + "first": createPositionalPseudo(function() { + return [ 0 ]; + }), + + "last": createPositionalPseudo(function( matchIndexes, length ) { + return [ length - 1 ]; + }), + + "eq": createPositionalPseudo(function( matchIndexes, length, argument ) { + return [ argument < 0 ? argument + length : argument ]; + }), + + "even": createPositionalPseudo(function( matchIndexes, length ) { + var i = 0; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "odd": createPositionalPseudo(function( matchIndexes, length ) { + var i = 1; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? argument + length : argument; + for ( ; --i >= 0; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "gt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? argument + length : argument; + for ( ; ++i < length; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }) + } +}; + +Expr.pseudos["nth"] = Expr.pseudos["eq"]; + +// Add button/input type pseudos +for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) { + Expr.pseudos[ i ] = createInputPseudo( i ); +} +for ( i in { submit: true, reset: true } ) { + Expr.pseudos[ i ] = createButtonPseudo( i ); +} + +// Easy API for creating new setFilters +function setFilters() {} +setFilters.prototype = Expr.filters = Expr.pseudos; +Expr.setFilters = new setFilters(); + +function tokenize( selector, parseOnly ) { + var matched, match, tokens, type, + soFar, groups, preFilters, + cached = tokenCache[ selector + " " ]; + + if ( cached ) { + return parseOnly ? 0 : cached.slice( 0 ); + } + + soFar = selector; + groups = []; + preFilters = Expr.preFilter; + + while ( soFar ) { + + // Comma and first run + if ( !matched || (match = rcomma.exec( soFar )) ) { + if ( match ) { + // Don't consume trailing commas as valid + soFar = soFar.slice( match[0].length ) || soFar; + } + groups.push( tokens = [] ); + } + + matched = false; + + // Combinators + if ( (match = rcombinators.exec( soFar )) ) { + matched = match.shift(); + tokens.push({ + value: matched, + // Cast descendant combinators to space + type: match[0].replace( rtrim, " " ) + }); + soFar = soFar.slice( matched.length ); + } + + // Filters + for ( type in Expr.filter ) { + if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || + (match = preFilters[ type ]( match ))) ) { + matched = match.shift(); + tokens.push({ + value: matched, + type: type, + matches: match + }); + soFar = soFar.slice( matched.length ); + } + } + + if ( !matched ) { + break; + } + } + + // Return the length of the invalid excess + // if we're just parsing + // Otherwise, throw an error or return tokens + return parseOnly ? + soFar.length : + soFar ? + Sizzle.error( selector ) : + // Cache the tokens + tokenCache( selector, groups ).slice( 0 ); +} + +function toSelector( tokens ) { + var i = 0, + len = tokens.length, + selector = ""; + for ( ; i < len; i++ ) { + selector += tokens[i].value; + } + return selector; +} + +function addCombinator( matcher, combinator, base ) { + var dir = combinator.dir, + checkNonElements = base && dir === "parentNode", + doneName = done++; + + return combinator.first ? + // Check against closest ancestor/preceding element + function( elem, context, xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + return matcher( elem, context, xml ); + } + } + } : + + // Check against all ancestor/preceding elements + function( elem, context, xml ) { + var data, cache, outerCache, + dirkey = dirruns + " " + doneName; + + // We can't set arbitrary data on XML nodes, so they don't benefit from dir caching + if ( xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + if ( matcher( elem, context, xml ) ) { + return true; + } + } + } + } else { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + outerCache = elem[ expando ] || (elem[ expando ] = {}); + if ( (cache = outerCache[ dir ]) && cache[0] === dirkey ) { + if ( (data = cache[1]) === true || data === cachedruns ) { + return data === true; + } + } else { + cache = outerCache[ dir ] = [ dirkey ]; + cache[1] = matcher( elem, context, xml ) || cachedruns; + if ( cache[1] === true ) { + return true; + } + } + } + } + } + }; +} + +function elementMatcher( matchers ) { + return matchers.length > 1 ? + function( elem, context, xml ) { + var i = matchers.length; + while ( i-- ) { + if ( !matchers[i]( elem, context, xml ) ) { + return false; + } + } + return true; + } : + matchers[0]; +} + +function condense( unmatched, map, filter, context, xml ) { + var elem, + newUnmatched = [], + i = 0, + len = unmatched.length, + mapped = map != null; + + for ( ; i < len; i++ ) { + if ( (elem = unmatched[i]) ) { + if ( !filter || filter( elem, context, xml ) ) { + newUnmatched.push( elem ); + if ( mapped ) { + map.push( i ); + } + } + } + } + + return newUnmatched; +} + +function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) { + if ( postFilter && !postFilter[ expando ] ) { + postFilter = setMatcher( postFilter ); + } + if ( postFinder && !postFinder[ expando ] ) { + postFinder = setMatcher( postFinder, postSelector ); + } + return markFunction(function( seed, results, context, xml ) { + var temp, i, elem, + preMap = [], + postMap = [], + preexisting = results.length, + + // Get initial elements from seed or context + elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ), + + // Prefilter to get matcher input, preserving a map for seed-results synchronization + matcherIn = preFilter && ( seed || !selector ) ? + condense( elems, preMap, preFilter, context, xml ) : + elems, + + matcherOut = matcher ? + // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results, + postFinder || ( seed ? preFilter : preexisting || postFilter ) ? + + // ...intermediate processing is necessary + [] : + + // ...otherwise use results directly + results : + matcherIn; + + // Find primary matches + if ( matcher ) { + matcher( matcherIn, matcherOut, context, xml ); + } + + // Apply postFilter + if ( postFilter ) { + temp = condense( matcherOut, postMap ); + postFilter( temp, [], context, xml ); + + // Un-match failing elements by moving them back to matcherIn + i = temp.length; + while ( i-- ) { + if ( (elem = temp[i]) ) { + matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem); + } + } + } + + if ( seed ) { + if ( postFinder || preFilter ) { + if ( postFinder ) { + // Get the final matcherOut by condensing this intermediate into postFinder contexts + temp = []; + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) ) { + // Restore matcherIn since elem is not yet a final match + temp.push( (matcherIn[i] = elem) ); + } + } + postFinder( null, (matcherOut = []), temp, xml ); + } + + // Move matched elements from seed to results to keep them synchronized + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) && + (temp = postFinder ? indexOf.call( seed, elem ) : preMap[i]) > -1 ) { + + seed[temp] = !(results[temp] = elem); + } + } + } + + // Add elements to results, through postFinder if defined + } else { + matcherOut = condense( + matcherOut === results ? + matcherOut.splice( preexisting, matcherOut.length ) : + matcherOut + ); + if ( postFinder ) { + postFinder( null, results, matcherOut, xml ); + } else { + push.apply( results, matcherOut ); + } + } + }); +} + +function matcherFromTokens( tokens ) { + var checkContext, matcher, j, + len = tokens.length, + leadingRelative = Expr.relative[ tokens[0].type ], + implicitRelative = leadingRelative || Expr.relative[" "], + i = leadingRelative ? 1 : 0, + + // The foundational matcher ensures that elements are reachable from top-level context(s) + matchContext = addCombinator( function( elem ) { + return elem === checkContext; + }, implicitRelative, true ), + matchAnyContext = addCombinator( function( elem ) { + return indexOf.call( checkContext, elem ) > -1; + }, implicitRelative, true ), + matchers = [ function( elem, context, xml ) { + return ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( + (checkContext = context).nodeType ? + matchContext( elem, context, xml ) : + matchAnyContext( elem, context, xml ) ); + } ]; + + for ( ; i < len; i++ ) { + if ( (matcher = Expr.relative[ tokens[i].type ]) ) { + matchers = [ addCombinator(elementMatcher( matchers ), matcher) ]; + } else { + matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches ); + + // Return special upon seeing a positional matcher + if ( matcher[ expando ] ) { + // Find the next relative operator (if any) for proper handling + j = ++i; + for ( ; j < len; j++ ) { + if ( Expr.relative[ tokens[j].type ] ) { + break; + } + } + return setMatcher( + i > 1 && elementMatcher( matchers ), + i > 1 && toSelector( + // If the preceding token was a descendant combinator, insert an implicit any-element `*` + tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" }) + ).replace( rtrim, "$1" ), + matcher, + i < j && matcherFromTokens( tokens.slice( i, j ) ), + j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), + j < len && toSelector( tokens ) + ); + } + matchers.push( matcher ); + } + } + + return elementMatcher( matchers ); +} + +function matcherFromGroupMatchers( elementMatchers, setMatchers ) { + // A counter to specify which element is currently being matched + var matcherCachedRuns = 0, + bySet = setMatchers.length > 0, + byElement = elementMatchers.length > 0, + superMatcher = function( seed, context, xml, results, expandContext ) { + var elem, j, matcher, + setMatched = [], + matchedCount = 0, + i = "0", + unmatched = seed && [], + outermost = expandContext != null, + contextBackup = outermostContext, + // We must always have either seed elements or context + elems = seed || byElement && Expr.find["TAG"]( "*", expandContext && context.parentNode || context ), + // Use integer dirruns iff this is the outermost matcher + dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1); + + if ( outermost ) { + outermostContext = context !== document && context; + cachedruns = matcherCachedRuns; + } + + // Add elements passing elementMatchers directly to results + // Keep `i` a string if there are no elements so `matchedCount` will be "00" below + for ( ; (elem = elems[i]) != null; i++ ) { + if ( byElement && elem ) { + j = 0; + while ( (matcher = elementMatchers[j++]) ) { + if ( matcher( elem, context, xml ) ) { + results.push( elem ); + break; + } + } + if ( outermost ) { + dirruns = dirrunsUnique; + cachedruns = ++matcherCachedRuns; + } + } + + // Track unmatched elements for set filters + if ( bySet ) { + // They will have gone through all possible matchers + if ( (elem = !matcher && elem) ) { + matchedCount--; + } + + // Lengthen the array for every element, matched or not + if ( seed ) { + unmatched.push( elem ); + } + } + } + + // Apply set filters to unmatched elements + matchedCount += i; + if ( bySet && i !== matchedCount ) { + j = 0; + while ( (matcher = setMatchers[j++]) ) { + matcher( unmatched, setMatched, context, xml ); + } + + if ( seed ) { + // Reintegrate element matches to eliminate the need for sorting + if ( matchedCount > 0 ) { + while ( i-- ) { + if ( !(unmatched[i] || setMatched[i]) ) { + setMatched[i] = pop.call( results ); + } + } + } + + // Discard index placeholder values to get only actual matches + setMatched = condense( setMatched ); + } + + // Add matches to results + push.apply( results, setMatched ); + + // Seedless set matches succeeding multiple successful matchers stipulate sorting + if ( outermost && !seed && setMatched.length > 0 && + ( matchedCount + setMatchers.length ) > 1 ) { + + Sizzle.uniqueSort( results ); + } + } + + // Override manipulation of globals by nested matchers + if ( outermost ) { + dirruns = dirrunsUnique; + outermostContext = contextBackup; + } + + return unmatched; + }; + + return bySet ? + markFunction( superMatcher ) : + superMatcher; +} + +compile = Sizzle.compile = function( selector, group /* Internal Use Only */ ) { + var i, + setMatchers = [], + elementMatchers = [], + cached = compilerCache[ selector + " " ]; + + if ( !cached ) { + // Generate a function of recursive functions that can be used to check each element + if ( !group ) { + group = tokenize( selector ); + } + i = group.length; + while ( i-- ) { + cached = matcherFromTokens( group[i] ); + if ( cached[ expando ] ) { + setMatchers.push( cached ); + } else { + elementMatchers.push( cached ); + } + } + + // Cache the compiled function + cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); + } + return cached; +}; + +function multipleContexts( selector, contexts, results ) { + var i = 0, + len = contexts.length; + for ( ; i < len; i++ ) { + Sizzle( selector, contexts[i], results ); + } + return results; +} + +function select( selector, context, results, seed ) { + var i, tokens, token, type, find, + match = tokenize( selector ); + + if ( !seed ) { + // Try to minimize operations if there is only one group + if ( match.length === 1 ) { + + // Take a shortcut and set the context if the root selector is an ID + tokens = match[0] = match[0].slice( 0 ); + if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && + support.getById && context.nodeType === 9 && documentIsHTML && + Expr.relative[ tokens[1].type ] ) { + + context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0]; + if ( !context ) { + return results; + } + selector = selector.slice( tokens.shift().value.length ); + } + + // Fetch a seed set for right-to-left matching + i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; + while ( i-- ) { + token = tokens[i]; + + // Abort if we hit a combinator + if ( Expr.relative[ (type = token.type) ] ) { + break; + } + if ( (find = Expr.find[ type ]) ) { + // Search, expanding context for leading sibling combinators + if ( (seed = find( + token.matches[0].replace( runescape, funescape ), + rsibling.test( tokens[0].type ) && context.parentNode || context + )) ) { + + // If seed is empty or no tokens remain, we can return early + tokens.splice( i, 1 ); + selector = seed.length && toSelector( tokens ); + if ( !selector ) { + push.apply( results, seed ); + return results; + } + + break; + } + } + } + } + } + + // Compile and execute a filtering function + // Provide `match` to avoid retokenization if we modified the selector above + compile( selector, match )( + seed, + context, + !documentIsHTML, + results, + rsibling.test( selector ) + ); + return results; +} + +// One-time assignments + +// Sort stability +support.sortStable = expando.split("").sort( sortOrder ).join("") === expando; + +// Support: Chrome<14 +// Always assume duplicates if they aren't passed to the comparison function +support.detectDuplicates = hasDuplicate; + +// Initialize against the default document +setDocument(); + +// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27) +// Detached nodes confoundingly follow *each other* +support.sortDetached = assert(function( div1 ) { + // Should return 1, but returns 4 (following) + return div1.compareDocumentPosition( document.createElement("div") ) & 1; +}); + +// Support: IE<8 +// Prevent attribute/property "interpolation" +// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx +if ( !assert(function( div ) { + div.innerHTML = ""; + return div.firstChild.getAttribute("href") === "#" ; +}) ) { + addHandle( "type|href|height|width", function( elem, name, isXML ) { + if ( !isXML ) { + return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 ); + } + }); +} + +// Support: IE<9 +// Use defaultValue in place of getAttribute("value") +if ( !support.attributes || !assert(function( div ) { + div.innerHTML = ""; + div.firstChild.setAttribute( "value", "" ); + return div.firstChild.getAttribute( "value" ) === ""; +}) ) { + addHandle( "value", function( elem, name, isXML ) { + if ( !isXML && elem.nodeName.toLowerCase() === "input" ) { + return elem.defaultValue; + } + }); +} + +// Support: IE<9 +// Use getAttributeNode to fetch booleans when getAttribute lies +if ( !assert(function( div ) { + return div.getAttribute("disabled") == null; +}) ) { + addHandle( booleans, function( elem, name, isXML ) { + var val; + if ( !isXML ) { + return (val = elem.getAttributeNode( name )) && val.specified ? + val.value : + elem[ name ] === true ? name.toLowerCase() : null; + } + }); +} + +jQuery.find = Sizzle; +jQuery.expr = Sizzle.selectors; +jQuery.expr[":"] = jQuery.expr.pseudos; +jQuery.unique = Sizzle.uniqueSort; +jQuery.text = Sizzle.getText; +jQuery.isXMLDoc = Sizzle.isXML; +jQuery.contains = Sizzle.contains; + + +})( window ); +// String to Object options format cache +var optionsCache = {}; + +// Convert String-formatted options into Object-formatted ones and store in cache +function createOptions( options ) { + var object = optionsCache[ options ] = {}; + jQuery.each( options.match( core_rnotwhite ) || [], function( _, flag ) { + object[ flag ] = true; + }); + return object; +} + +/* + * Create a callback list using the following parameters: + * + * options: an optional list of space-separated options that will change how + * the callback list behaves or a more traditional option object + * + * By default a callback list will act like an event callback list and can be + * "fired" multiple times. + * + * Possible options: + * + * once: will ensure the callback list can only be fired once (like a Deferred) + * + * memory: will keep track of previous values and will call any callback added + * after the list has been fired right away with the latest "memorized" + * values (like a Deferred) + * + * unique: will ensure a callback can only be added once (no duplicate in the list) + * + * stopOnFalse: interrupt callings when a callback returns false + * + */ +jQuery.Callbacks = function( options ) { + + // Convert options from String-formatted to Object-formatted if needed + // (we check in cache first) + options = typeof options === "string" ? + ( optionsCache[ options ] || createOptions( options ) ) : + jQuery.extend( {}, options ); + + var // Flag to know if list is currently firing + firing, + // Last fire value (for non-forgettable lists) + memory, + // Flag to know if list was already fired + fired, + // End of the loop when firing + firingLength, + // Index of currently firing callback (modified by remove if needed) + firingIndex, + // First callback to fire (used internally by add and fireWith) + firingStart, + // Actual callback list + list = [], + // Stack of fire calls for repeatable lists + stack = !options.once && [], + // Fire callbacks + fire = function( data ) { + memory = options.memory && data; + fired = true; + firingIndex = firingStart || 0; + firingStart = 0; + firingLength = list.length; + firing = true; + for ( ; list && firingIndex < firingLength; firingIndex++ ) { + if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) { + memory = false; // To prevent further calls using add + break; + } + } + firing = false; + if ( list ) { + if ( stack ) { + if ( stack.length ) { + fire( stack.shift() ); + } + } else if ( memory ) { + list = []; + } else { + self.disable(); + } + } + }, + // Actual Callbacks object + self = { + // Add a callback or a collection of callbacks to the list + add: function() { + if ( list ) { + // First, we save the current length + var start = list.length; + (function add( args ) { + jQuery.each( args, function( _, arg ) { + var type = jQuery.type( arg ); + if ( type === "function" ) { + if ( !options.unique || !self.has( arg ) ) { + list.push( arg ); + } + } else if ( arg && arg.length && type !== "string" ) { + // Inspect recursively + add( arg ); + } + }); + })( arguments ); + // Do we need to add the callbacks to the + // current firing batch? + if ( firing ) { + firingLength = list.length; + // With memory, if we're not firing then + // we should call right away + } else if ( memory ) { + firingStart = start; + fire( memory ); + } + } + return this; + }, + // Remove a callback from the list + remove: function() { + if ( list ) { + jQuery.each( arguments, function( _, arg ) { + var index; + while( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { + list.splice( index, 1 ); + // Handle firing indexes + if ( firing ) { + if ( index <= firingLength ) { + firingLength--; + } + if ( index <= firingIndex ) { + firingIndex--; + } + } + } + }); + } + return this; + }, + // Check if a given callback is in the list. + // If no argument is given, return whether or not list has callbacks attached. + has: function( fn ) { + return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length ); + }, + // Remove all callbacks from the list + empty: function() { + list = []; + firingLength = 0; + return this; + }, + // Have the list do nothing anymore + disable: function() { + list = stack = memory = undefined; + return this; + }, + // Is it disabled? + disabled: function() { + return !list; + }, + // Lock the list in its current state + lock: function() { + stack = undefined; + if ( !memory ) { + self.disable(); + } + return this; + }, + // Is it locked? + locked: function() { + return !stack; + }, + // Call all callbacks with the given context and arguments + fireWith: function( context, args ) { + if ( list && ( !fired || stack ) ) { + args = args || []; + args = [ context, args.slice ? args.slice() : args ]; + if ( firing ) { + stack.push( args ); + } else { + fire( args ); + } + } + return this; + }, + // Call all the callbacks with the given arguments + fire: function() { + self.fireWith( this, arguments ); + return this; + }, + // To know if the callbacks have already been called at least once + fired: function() { + return !!fired; + } + }; + + return self; +}; +jQuery.extend({ + + Deferred: function( func ) { + var tuples = [ + // action, add listener, listener list, final state + [ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ], + [ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ], + [ "notify", "progress", jQuery.Callbacks("memory") ] + ], + state = "pending", + promise = { + state: function() { + return state; + }, + always: function() { + deferred.done( arguments ).fail( arguments ); + return this; + }, + then: function( /* fnDone, fnFail, fnProgress */ ) { + var fns = arguments; + return jQuery.Deferred(function( newDefer ) { + jQuery.each( tuples, function( i, tuple ) { + var action = tuple[ 0 ], + fn = jQuery.isFunction( fns[ i ] ) && fns[ i ]; + // deferred[ done | fail | progress ] for forwarding actions to newDefer + deferred[ tuple[1] ](function() { + var returned = fn && fn.apply( this, arguments ); + if ( returned && jQuery.isFunction( returned.promise ) ) { + returned.promise() + .done( newDefer.resolve ) + .fail( newDefer.reject ) + .progress( newDefer.notify ); + } else { + newDefer[ action + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments ); + } + }); + }); + fns = null; + }).promise(); + }, + // Get a promise for this deferred + // If obj is provided, the promise aspect is added to the object + promise: function( obj ) { + return obj != null ? jQuery.extend( obj, promise ) : promise; + } + }, + deferred = {}; + + // Keep pipe for back-compat + promise.pipe = promise.then; + + // Add list-specific methods + jQuery.each( tuples, function( i, tuple ) { + var list = tuple[ 2 ], + stateString = tuple[ 3 ]; + + // promise[ done | fail | progress ] = list.add + promise[ tuple[1] ] = list.add; + + // Handle state + if ( stateString ) { + list.add(function() { + // state = [ resolved | rejected ] + state = stateString; + + // [ reject_list | resolve_list ].disable; progress_list.lock + }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock ); + } + + // deferred[ resolve | reject | notify ] + deferred[ tuple[0] ] = function() { + deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments ); + return this; + }; + deferred[ tuple[0] + "With" ] = list.fireWith; + }); + + // Make the deferred a promise + promise.promise( deferred ); + + // Call given func if any + if ( func ) { + func.call( deferred, deferred ); + } + + // All done! + return deferred; + }, + + // Deferred helper + when: function( subordinate /* , ..., subordinateN */ ) { + var i = 0, + resolveValues = core_slice.call( arguments ), + length = resolveValues.length, + + // the count of uncompleted subordinates + remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, + + // the master Deferred. If resolveValues consist of only a single Deferred, just use that. + deferred = remaining === 1 ? subordinate : jQuery.Deferred(), + + // Update function for both resolve and progress values + updateFunc = function( i, contexts, values ) { + return function( value ) { + contexts[ i ] = this; + values[ i ] = arguments.length > 1 ? core_slice.call( arguments ) : value; + if( values === progressValues ) { + deferred.notifyWith( contexts, values ); + } else if ( !( --remaining ) ) { + deferred.resolveWith( contexts, values ); + } + }; + }, + + progressValues, progressContexts, resolveContexts; + + // add listeners to Deferred subordinates; treat others as resolved + if ( length > 1 ) { + progressValues = new Array( length ); + progressContexts = new Array( length ); + resolveContexts = new Array( length ); + for ( ; i < length; i++ ) { + if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { + resolveValues[ i ].promise() + .done( updateFunc( i, resolveContexts, resolveValues ) ) + .fail( deferred.reject ) + .progress( updateFunc( i, progressContexts, progressValues ) ); + } else { + --remaining; + } + } + } + + // if we're not waiting on anything, resolve the master + if ( !remaining ) { + deferred.resolveWith( resolveContexts, resolveValues ); + } + + return deferred.promise(); + } +}); +jQuery.support = (function( support ) { + + var all, a, input, select, fragment, opt, eventName, isSupported, i, + div = document.createElement("div"); + + // Setup + div.setAttribute( "className", "t" ); + div.innerHTML = "
    a"; + + // Finish early in limited (non-browser) environments + all = div.getElementsByTagName("*") || []; + a = div.getElementsByTagName("a")[ 0 ]; + if ( !a || !a.style || !all.length ) { + return support; + } + + // First batch of tests + select = document.createElement("select"); + opt = select.appendChild( document.createElement("option") ); + input = div.getElementsByTagName("input")[ 0 ]; + + a.style.cssText = "top:1px;float:left;opacity:.5"; + + // Test setAttribute on camelCase class. If it works, we need attrFixes when doing get/setAttribute (ie6/7) + support.getSetAttribute = div.className !== "t"; + + // IE strips leading whitespace when .innerHTML is used + support.leadingWhitespace = div.firstChild.nodeType === 3; + + // Make sure that tbody elements aren't automatically inserted + // IE will insert them into empty tables + support.tbody = !div.getElementsByTagName("tbody").length; + + // Make sure that link elements get serialized correctly by innerHTML + // This requires a wrapper element in IE + support.htmlSerialize = !!div.getElementsByTagName("link").length; + + // Get the style information from getAttribute + // (IE uses .cssText instead) + support.style = /top/.test( a.getAttribute("style") ); + + // Make sure that URLs aren't manipulated + // (IE normalizes it by default) + support.hrefNormalized = a.getAttribute("href") === "/a"; + + // Make sure that element opacity exists + // (IE uses filter instead) + // Use a regex to work around a WebKit issue. See #5145 + support.opacity = /^0.5/.test( a.style.opacity ); + + // Verify style float existence + // (IE uses styleFloat instead of cssFloat) + support.cssFloat = !!a.style.cssFloat; + + // Check the default checkbox/radio value ("" on WebKit; "on" elsewhere) + support.checkOn = !!input.value; + + // Make sure that a selected-by-default option has a working selected property. + // (WebKit defaults to false instead of true, IE too, if it's in an optgroup) + support.optSelected = opt.selected; + + // Tests for enctype support on a form (#6743) + support.enctype = !!document.createElement("form").enctype; + + // Makes sure cloning an html5 element does not cause problems + // Where outerHTML is undefined, this still works + support.html5Clone = document.createElement("nav").cloneNode( true ).outerHTML !== "<:nav>"; + + // Will be defined later + support.inlineBlockNeedsLayout = false; + support.shrinkWrapBlocks = false; + support.pixelPosition = false; + support.deleteExpando = true; + support.noCloneEvent = true; + support.reliableMarginRight = true; + support.boxSizingReliable = true; + + // Make sure checked status is properly cloned + input.checked = true; + support.noCloneChecked = input.cloneNode( true ).checked; + + // Make sure that the options inside disabled selects aren't marked as disabled + // (WebKit marks them as disabled) + select.disabled = true; + support.optDisabled = !opt.disabled; + + // Support: IE<9 + try { + delete div.test; + } catch( e ) { + support.deleteExpando = false; + } + + // Check if we can trust getAttribute("value") + input = document.createElement("input"); + input.setAttribute( "value", "" ); + support.input = input.getAttribute( "value" ) === ""; + + // Check if an input maintains its value after becoming a radio + input.value = "t"; + input.setAttribute( "type", "radio" ); + support.radioValue = input.value === "t"; + + // #11217 - WebKit loses check when the name is after the checked attribute + input.setAttribute( "checked", "t" ); + input.setAttribute( "name", "t" ); + + fragment = document.createDocumentFragment(); + fragment.appendChild( input ); + + // Check if a disconnected checkbox will retain its checked + // value of true after appended to the DOM (IE6/7) + support.appendChecked = input.checked; + + // WebKit doesn't clone checked state correctly in fragments + support.checkClone = fragment.cloneNode( true ).cloneNode( true ).lastChild.checked; + + // Support: IE<9 + // Opera does not clone events (and typeof div.attachEvent === undefined). + // IE9-10 clones events bound via attachEvent, but they don't trigger with .click() + if ( div.attachEvent ) { + div.attachEvent( "onclick", function() { + support.noCloneEvent = false; + }); + + div.cloneNode( true ).click(); + } + + // Support: IE<9 (lack submit/change bubble), Firefox 17+ (lack focusin event) + // Beware of CSP restrictions (https://developer.mozilla.org/en/Security/CSP) + for ( i in { submit: true, change: true, focusin: true }) { + div.setAttribute( eventName = "on" + i, "t" ); + + support[ i + "Bubbles" ] = eventName in window || div.attributes[ eventName ].expando === false; + } + + div.style.backgroundClip = "content-box"; + div.cloneNode( true ).style.backgroundClip = ""; + support.clearCloneStyle = div.style.backgroundClip === "content-box"; + + // Support: IE<9 + // Iteration over object's inherited properties before its own. + for ( i in jQuery( support ) ) { + break; + } + support.ownLast = i !== "0"; + + // Run tests that need a body at doc ready + jQuery(function() { + var container, marginDiv, tds, + divReset = "padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;", + body = document.getElementsByTagName("body")[0]; + + if ( !body ) { + // Return for frameset docs that don't have a body + return; + } + + container = document.createElement("div"); + container.style.cssText = "border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px"; + + body.appendChild( container ).appendChild( div ); + + // Support: IE8 + // Check if table cells still have offsetWidth/Height when they are set + // to display:none and there are still other visible table cells in a + // table row; if so, offsetWidth/Height are not reliable for use when + // determining if an element has been hidden directly using + // display:none (it is still safe to use offsets if a parent element is + // hidden; don safety goggles and see bug #4512 for more information). + div.innerHTML = "
    t
    "; + tds = div.getElementsByTagName("td"); + tds[ 0 ].style.cssText = "padding:0;margin:0;border:0;display:none"; + isSupported = ( tds[ 0 ].offsetHeight === 0 ); + + tds[ 0 ].style.display = ""; + tds[ 1 ].style.display = "none"; + + // Support: IE8 + // Check if empty table cells still have offsetWidth/Height + support.reliableHiddenOffsets = isSupported && ( tds[ 0 ].offsetHeight === 0 ); + + // Check box-sizing and margin behavior. + div.innerHTML = ""; + div.style.cssText = "box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;"; + + // Workaround failing boxSizing test due to offsetWidth returning wrong value + // with some non-1 values of body zoom, ticket #13543 + jQuery.swap( body, body.style.zoom != null ? { zoom: 1 } : {}, function() { + support.boxSizing = div.offsetWidth === 4; + }); + + // Use window.getComputedStyle because jsdom on node.js will break without it. + if ( window.getComputedStyle ) { + support.pixelPosition = ( window.getComputedStyle( div, null ) || {} ).top !== "1%"; + support.boxSizingReliable = ( window.getComputedStyle( div, null ) || { width: "4px" } ).width === "4px"; + + // Check if div with explicit width and no margin-right incorrectly + // gets computed margin-right based on width of container. (#3333) + // Fails in WebKit before Feb 2011 nightlies + // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right + marginDiv = div.appendChild( document.createElement("div") ); + marginDiv.style.cssText = div.style.cssText = divReset; + marginDiv.style.marginRight = marginDiv.style.width = "0"; + div.style.width = "1px"; + + support.reliableMarginRight = + !parseFloat( ( window.getComputedStyle( marginDiv, null ) || {} ).marginRight ); + } + + if ( typeof div.style.zoom !== core_strundefined ) { + // Support: IE<8 + // Check if natively block-level elements act like inline-block + // elements when setting their display to 'inline' and giving + // them layout + div.innerHTML = ""; + div.style.cssText = divReset + "width:1px;padding:1px;display:inline;zoom:1"; + support.inlineBlockNeedsLayout = ( div.offsetWidth === 3 ); + + // Support: IE6 + // Check if elements with layout shrink-wrap their children + div.style.display = "block"; + div.innerHTML = "
    "; + div.firstChild.style.width = "5px"; + support.shrinkWrapBlocks = ( div.offsetWidth !== 3 ); + + if ( support.inlineBlockNeedsLayout ) { + // Prevent IE 6 from affecting layout for positioned elements #11048 + // Prevent IE from shrinking the body in IE 7 mode #12869 + // Support: IE<8 + body.style.zoom = 1; + } + } + + body.removeChild( container ); + + // Null elements to avoid leaks in IE + container = div = tds = marginDiv = null; + }); + + // Null elements to avoid leaks in IE + all = select = fragment = opt = a = input = null; + + return support; +})({}); + +var rbrace = /(?:\{[\s\S]*\}|\[[\s\S]*\])$/, + rmultiDash = /([A-Z])/g; + +function internalData( elem, name, data, pvt /* Internal Use Only */ ){ + if ( !jQuery.acceptData( elem ) ) { + return; + } + + var ret, thisCache, + internalKey = jQuery.expando, + + // We have to handle DOM nodes and JS objects differently because IE6-7 + // can't GC object references properly across the DOM-JS boundary + isNode = elem.nodeType, + + // Only DOM nodes need the global jQuery cache; JS object data is + // attached directly to the object so GC can occur automatically + cache = isNode ? jQuery.cache : elem, + + // Only defining an ID for JS objects if its cache already exists allows + // the code to shortcut on the same path as a DOM node with no cache + id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey; + + // Avoid doing any more work than we need to when trying to get data on an + // object that has no data at all + if ( (!id || !cache[id] || (!pvt && !cache[id].data)) && data === undefined && typeof name === "string" ) { + return; + } + + if ( !id ) { + // Only DOM nodes need a new unique ID for each element since their data + // ends up in the global cache + if ( isNode ) { + id = elem[ internalKey ] = core_deletedIds.pop() || jQuery.guid++; + } else { + id = internalKey; + } + } + + if ( !cache[ id ] ) { + // Avoid exposing jQuery metadata on plain JS objects when the object + // is serialized using JSON.stringify + cache[ id ] = isNode ? {} : { toJSON: jQuery.noop }; + } + + // An object can be passed to jQuery.data instead of a key/value pair; this gets + // shallow copied over onto the existing cache + if ( typeof name === "object" || typeof name === "function" ) { + if ( pvt ) { + cache[ id ] = jQuery.extend( cache[ id ], name ); + } else { + cache[ id ].data = jQuery.extend( cache[ id ].data, name ); + } + } + + thisCache = cache[ id ]; + + // jQuery data() is stored in a separate object inside the object's internal data + // cache in order to avoid key collisions between internal data and user-defined + // data. + if ( !pvt ) { + if ( !thisCache.data ) { + thisCache.data = {}; + } + + thisCache = thisCache.data; + } + + if ( data !== undefined ) { + thisCache[ jQuery.camelCase( name ) ] = data; + } + + // Check for both converted-to-camel and non-converted data property names + // If a data property was specified + if ( typeof name === "string" ) { + + // First Try to find as-is property data + ret = thisCache[ name ]; + + // Test for null|undefined property data + if ( ret == null ) { + + // Try to find the camelCased property + ret = thisCache[ jQuery.camelCase( name ) ]; + } + } else { + ret = thisCache; + } + + return ret; +} + +function internalRemoveData( elem, name, pvt ) { + if ( !jQuery.acceptData( elem ) ) { + return; + } + + var thisCache, i, + isNode = elem.nodeType, + + // See jQuery.data for more information + cache = isNode ? jQuery.cache : elem, + id = isNode ? elem[ jQuery.expando ] : jQuery.expando; + + // If there is already no cache entry for this object, there is no + // purpose in continuing + if ( !cache[ id ] ) { + return; + } + + if ( name ) { + + thisCache = pvt ? cache[ id ] : cache[ id ].data; + + if ( thisCache ) { + + // Support array or space separated string names for data keys + if ( !jQuery.isArray( name ) ) { + + // try the string as a key before any manipulation + if ( name in thisCache ) { + name = [ name ]; + } else { + + // split the camel cased version by spaces unless a key with the spaces exists + name = jQuery.camelCase( name ); + if ( name in thisCache ) { + name = [ name ]; + } else { + name = name.split(" "); + } + } + } else { + // If "name" is an array of keys... + // When data is initially created, via ("key", "val") signature, + // keys will be converted to camelCase. + // Since there is no way to tell _how_ a key was added, remove + // both plain key and camelCase key. #12786 + // This will only penalize the array argument path. + name = name.concat( jQuery.map( name, jQuery.camelCase ) ); + } + + i = name.length; + while ( i-- ) { + delete thisCache[ name[i] ]; + } + + // If there is no data left in the cache, we want to continue + // and let the cache object itself get destroyed + if ( pvt ? !isEmptyDataObject(thisCache) : !jQuery.isEmptyObject(thisCache) ) { + return; + } + } + } + + // See jQuery.data for more information + if ( !pvt ) { + delete cache[ id ].data; + + // Don't destroy the parent cache unless the internal data object + // had been the only thing left in it + if ( !isEmptyDataObject( cache[ id ] ) ) { + return; + } + } + + // Destroy the cache + if ( isNode ) { + jQuery.cleanData( [ elem ], true ); + + // Use delete when supported for expandos or `cache` is not a window per isWindow (#10080) + /* jshint eqeqeq: false */ + } else if ( jQuery.support.deleteExpando || cache != cache.window ) { + /* jshint eqeqeq: true */ + delete cache[ id ]; + + // When all else fails, null + } else { + cache[ id ] = null; + } +} + +jQuery.extend({ + cache: {}, + + // The following elements throw uncatchable exceptions if you + // attempt to add expando properties to them. + noData: { + "applet": true, + "embed": true, + // Ban all objects except for Flash (which handle expandos) + "object": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" + }, + + hasData: function( elem ) { + elem = elem.nodeType ? jQuery.cache[ elem[jQuery.expando] ] : elem[ jQuery.expando ]; + return !!elem && !isEmptyDataObject( elem ); + }, + + data: function( elem, name, data ) { + return internalData( elem, name, data ); + }, + + removeData: function( elem, name ) { + return internalRemoveData( elem, name ); + }, + + // For internal use only. + _data: function( elem, name, data ) { + return internalData( elem, name, data, true ); + }, + + _removeData: function( elem, name ) { + return internalRemoveData( elem, name, true ); + }, + + // A method for determining if a DOM node can handle the data expando + acceptData: function( elem ) { + // Do not set data on non-element because it will not be cleared (#8335). + if ( elem.nodeType && elem.nodeType !== 1 && elem.nodeType !== 9 ) { + return false; + } + + var noData = elem.nodeName && jQuery.noData[ elem.nodeName.toLowerCase() ]; + + // nodes accept data unless otherwise specified; rejection can be conditional + return !noData || noData !== true && elem.getAttribute("classid") === noData; + } +}); + +jQuery.fn.extend({ + data: function( key, value ) { + var attrs, name, + data = null, + i = 0, + elem = this[0]; + + // Special expections of .data basically thwart jQuery.access, + // so implement the relevant behavior ourselves + + // Gets all values + if ( key === undefined ) { + if ( this.length ) { + data = jQuery.data( elem ); + + if ( elem.nodeType === 1 && !jQuery._data( elem, "parsedAttrs" ) ) { + attrs = elem.attributes; + for ( ; i < attrs.length; i++ ) { + name = attrs[i].name; + + if ( name.indexOf("data-") === 0 ) { + name = jQuery.camelCase( name.slice(5) ); + + dataAttr( elem, name, data[ name ] ); + } + } + jQuery._data( elem, "parsedAttrs", true ); + } + } + + return data; + } + + // Sets multiple values + if ( typeof key === "object" ) { + return this.each(function() { + jQuery.data( this, key ); + }); + } + + return arguments.length > 1 ? + + // Sets one value + this.each(function() { + jQuery.data( this, key, value ); + }) : + + // Gets one value + // Try to fetch any internally stored data first + elem ? dataAttr( elem, key, jQuery.data( elem, key ) ) : null; + }, + + removeData: function( key ) { + return this.each(function() { + jQuery.removeData( this, key ); + }); + } +}); + +function dataAttr( elem, key, data ) { + // If nothing was found internally, try to fetch any + // data from the HTML5 data-* attribute + if ( data === undefined && elem.nodeType === 1 ) { + + var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); + + data = elem.getAttribute( name ); + + if ( typeof data === "string" ) { + try { + data = data === "true" ? true : + data === "false" ? false : + data === "null" ? null : + // Only convert to a number if it doesn't change the string + +data + "" === data ? +data : + rbrace.test( data ) ? jQuery.parseJSON( data ) : + data; + } catch( e ) {} + + // Make sure we set the data so it isn't changed later + jQuery.data( elem, key, data ); + + } else { + data = undefined; + } + } + + return data; +} + +// checks a cache object for emptiness +function isEmptyDataObject( obj ) { + var name; + for ( name in obj ) { + + // if the public data object is empty, the private is still empty + if ( name === "data" && jQuery.isEmptyObject( obj[name] ) ) { + continue; + } + if ( name !== "toJSON" ) { + return false; + } + } + + return true; +} +jQuery.extend({ + queue: function( elem, type, data ) { + var queue; + + if ( elem ) { + type = ( type || "fx" ) + "queue"; + queue = jQuery._data( elem, type ); + + // Speed up dequeue by getting out quickly if this is just a lookup + if ( data ) { + if ( !queue || jQuery.isArray(data) ) { + queue = jQuery._data( elem, type, jQuery.makeArray(data) ); + } else { + queue.push( data ); + } + } + return queue || []; + } + }, + + dequeue: function( elem, type ) { + type = type || "fx"; + + var queue = jQuery.queue( elem, type ), + startLength = queue.length, + fn = queue.shift(), + hooks = jQuery._queueHooks( elem, type ), + next = function() { + jQuery.dequeue( elem, type ); + }; + + // If the fx queue is dequeued, always remove the progress sentinel + if ( fn === "inprogress" ) { + fn = queue.shift(); + startLength--; + } + + if ( fn ) { + + // Add a progress sentinel to prevent the fx queue from being + // automatically dequeued + if ( type === "fx" ) { + queue.unshift( "inprogress" ); + } + + // clear up the last queue stop function + delete hooks.stop; + fn.call( elem, next, hooks ); + } + + if ( !startLength && hooks ) { + hooks.empty.fire(); + } + }, + + // not intended for public consumption - generates a queueHooks object, or returns the current one + _queueHooks: function( elem, type ) { + var key = type + "queueHooks"; + return jQuery._data( elem, key ) || jQuery._data( elem, key, { + empty: jQuery.Callbacks("once memory").add(function() { + jQuery._removeData( elem, type + "queue" ); + jQuery._removeData( elem, key ); + }) + }); + } +}); + +jQuery.fn.extend({ + queue: function( type, data ) { + var setter = 2; + + if ( typeof type !== "string" ) { + data = type; + type = "fx"; + setter--; + } + + if ( arguments.length < setter ) { + return jQuery.queue( this[0], type ); + } + + return data === undefined ? + this : + this.each(function() { + var queue = jQuery.queue( this, type, data ); + + // ensure a hooks for this queue + jQuery._queueHooks( this, type ); + + if ( type === "fx" && queue[0] !== "inprogress" ) { + jQuery.dequeue( this, type ); + } + }); + }, + dequeue: function( type ) { + return this.each(function() { + jQuery.dequeue( this, type ); + }); + }, + // Based off of the plugin by Clint Helfers, with permission. + // http://blindsignals.com/index.php/2009/07/jquery-delay/ + delay: function( time, type ) { + time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; + type = type || "fx"; + + return this.queue( type, function( next, hooks ) { + var timeout = setTimeout( next, time ); + hooks.stop = function() { + clearTimeout( timeout ); + }; + }); + }, + clearQueue: function( type ) { + return this.queue( type || "fx", [] ); + }, + // Get a promise resolved when queues of a certain type + // are emptied (fx is the type by default) + promise: function( type, obj ) { + var tmp, + count = 1, + defer = jQuery.Deferred(), + elements = this, + i = this.length, + resolve = function() { + if ( !( --count ) ) { + defer.resolveWith( elements, [ elements ] ); + } + }; + + if ( typeof type !== "string" ) { + obj = type; + type = undefined; + } + type = type || "fx"; + + while( i-- ) { + tmp = jQuery._data( elements[ i ], type + "queueHooks" ); + if ( tmp && tmp.empty ) { + count++; + tmp.empty.add( resolve ); + } + } + resolve(); + return defer.promise( obj ); + } +}); +var nodeHook, boolHook, + rclass = /[\t\r\n\f]/g, + rreturn = /\r/g, + rfocusable = /^(?:input|select|textarea|button|object)$/i, + rclickable = /^(?:a|area)$/i, + ruseDefault = /^(?:checked|selected)$/i, + getSetAttribute = jQuery.support.getSetAttribute, + getSetInput = jQuery.support.input; + +jQuery.fn.extend({ + attr: function( name, value ) { + return jQuery.access( this, jQuery.attr, name, value, arguments.length > 1 ); + }, + + removeAttr: function( name ) { + return this.each(function() { + jQuery.removeAttr( this, name ); + }); + }, + + prop: function( name, value ) { + return jQuery.access( this, jQuery.prop, name, value, arguments.length > 1 ); + }, + + removeProp: function( name ) { + name = jQuery.propFix[ name ] || name; + return this.each(function() { + // try/catch handles cases where IE balks (such as removing a property on window) + try { + this[ name ] = undefined; + delete this[ name ]; + } catch( e ) {} + }); + }, + + addClass: function( value ) { + var classes, elem, cur, clazz, j, + i = 0, + len = this.length, + proceed = typeof value === "string" && value; + + if ( jQuery.isFunction( value ) ) { + return this.each(function( j ) { + jQuery( this ).addClass( value.call( this, j, this.className ) ); + }); + } + + if ( proceed ) { + // The disjunction here is for better compressibility (see removeClass) + classes = ( value || "" ).match( core_rnotwhite ) || []; + + for ( ; i < len; i++ ) { + elem = this[ i ]; + cur = elem.nodeType === 1 && ( elem.className ? + ( " " + elem.className + " " ).replace( rclass, " " ) : + " " + ); + + if ( cur ) { + j = 0; + while ( (clazz = classes[j++]) ) { + if ( cur.indexOf( " " + clazz + " " ) < 0 ) { + cur += clazz + " "; + } + } + elem.className = jQuery.trim( cur ); + + } + } + } + + return this; + }, + + removeClass: function( value ) { + var classes, elem, cur, clazz, j, + i = 0, + len = this.length, + proceed = arguments.length === 0 || typeof value === "string" && value; + + if ( jQuery.isFunction( value ) ) { + return this.each(function( j ) { + jQuery( this ).removeClass( value.call( this, j, this.className ) ); + }); + } + if ( proceed ) { + classes = ( value || "" ).match( core_rnotwhite ) || []; + + for ( ; i < len; i++ ) { + elem = this[ i ]; + // This expression is here for better compressibility (see addClass) + cur = elem.nodeType === 1 && ( elem.className ? + ( " " + elem.className + " " ).replace( rclass, " " ) : + "" + ); + + if ( cur ) { + j = 0; + while ( (clazz = classes[j++]) ) { + // Remove *all* instances + while ( cur.indexOf( " " + clazz + " " ) >= 0 ) { + cur = cur.replace( " " + clazz + " ", " " ); + } + } + elem.className = value ? jQuery.trim( cur ) : ""; + } + } + } + + return this; + }, + + toggleClass: function( value, stateVal ) { + var type = typeof value; + + if ( typeof stateVal === "boolean" && type === "string" ) { + return stateVal ? this.addClass( value ) : this.removeClass( value ); + } + + if ( jQuery.isFunction( value ) ) { + return this.each(function( i ) { + jQuery( this ).toggleClass( value.call(this, i, this.className, stateVal), stateVal ); + }); + } + + return this.each(function() { + if ( type === "string" ) { + // toggle individual class names + var className, + i = 0, + self = jQuery( this ), + classNames = value.match( core_rnotwhite ) || []; + + while ( (className = classNames[ i++ ]) ) { + // check each className given, space separated list + if ( self.hasClass( className ) ) { + self.removeClass( className ); + } else { + self.addClass( className ); + } + } + + // Toggle whole class name + } else if ( type === core_strundefined || type === "boolean" ) { + if ( this.className ) { + // store className if set + jQuery._data( this, "__className__", this.className ); + } + + // If the element has a class name or if we're passed "false", + // then remove the whole classname (if there was one, the above saved it). + // Otherwise bring back whatever was previously saved (if anything), + // falling back to the empty string if nothing was stored. + this.className = this.className || value === false ? "" : jQuery._data( this, "__className__" ) || ""; + } + }); + }, + + hasClass: function( selector ) { + var className = " " + selector + " ", + i = 0, + l = this.length; + for ( ; i < l; i++ ) { + if ( this[i].nodeType === 1 && (" " + this[i].className + " ").replace(rclass, " ").indexOf( className ) >= 0 ) { + return true; + } + } + + return false; + }, + + val: function( value ) { + var ret, hooks, isFunction, + elem = this[0]; + + if ( !arguments.length ) { + if ( elem ) { + hooks = jQuery.valHooks[ elem.type ] || jQuery.valHooks[ elem.nodeName.toLowerCase() ]; + + if ( hooks && "get" in hooks && (ret = hooks.get( elem, "value" )) !== undefined ) { + return ret; + } + + ret = elem.value; + + return typeof ret === "string" ? + // handle most common string cases + ret.replace(rreturn, "") : + // handle cases where value is null/undef or number + ret == null ? "" : ret; + } + + return; + } + + isFunction = jQuery.isFunction( value ); + + return this.each(function( i ) { + var val; + + if ( this.nodeType !== 1 ) { + return; + } + + if ( isFunction ) { + val = value.call( this, i, jQuery( this ).val() ); + } else { + val = value; + } + + // Treat null/undefined as ""; convert numbers to string + if ( val == null ) { + val = ""; + } else if ( typeof val === "number" ) { + val += ""; + } else if ( jQuery.isArray( val ) ) { + val = jQuery.map(val, function ( value ) { + return value == null ? "" : value + ""; + }); + } + + hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ]; + + // If set returns undefined, fall back to normal setting + if ( !hooks || !("set" in hooks) || hooks.set( this, val, "value" ) === undefined ) { + this.value = val; + } + }); + } +}); + +jQuery.extend({ + valHooks: { + option: { + get: function( elem ) { + // Use proper attribute retrieval(#6932, #12072) + var val = jQuery.find.attr( elem, "value" ); + return val != null ? + val : + elem.text; + } + }, + select: { + get: function( elem ) { + var value, option, + options = elem.options, + index = elem.selectedIndex, + one = elem.type === "select-one" || index < 0, + values = one ? null : [], + max = one ? index + 1 : options.length, + i = index < 0 ? + max : + one ? index : 0; + + // Loop through all the selected options + for ( ; i < max; i++ ) { + option = options[ i ]; + + // oldIE doesn't update selected after form reset (#2551) + if ( ( option.selected || i === index ) && + // Don't return options that are disabled or in a disabled optgroup + ( jQuery.support.optDisabled ? !option.disabled : option.getAttribute("disabled") === null ) && + ( !option.parentNode.disabled || !jQuery.nodeName( option.parentNode, "optgroup" ) ) ) { + + // Get the specific value for the option + value = jQuery( option ).val(); + + // We don't need an array for one selects + if ( one ) { + return value; + } + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + }, + + set: function( elem, value ) { + var optionSet, option, + options = elem.options, + values = jQuery.makeArray( value ), + i = options.length; + + while ( i-- ) { + option = options[ i ]; + if ( (option.selected = jQuery.inArray( jQuery(option).val(), values ) >= 0) ) { + optionSet = true; + } + } + + // force browsers to behave consistently when non-matching value is set + if ( !optionSet ) { + elem.selectedIndex = -1; + } + return values; + } + } + }, + + attr: function( elem, name, value ) { + var hooks, ret, + nType = elem.nodeType; + + // don't get/set attributes on text, comment and attribute nodes + if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + // Fallback to prop when attributes are not supported + if ( typeof elem.getAttribute === core_strundefined ) { + return jQuery.prop( elem, name, value ); + } + + // All attributes are lowercase + // Grab necessary hook if one is defined + if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { + name = name.toLowerCase(); + hooks = jQuery.attrHooks[ name ] || + ( jQuery.expr.match.bool.test( name ) ? boolHook : nodeHook ); + } + + if ( value !== undefined ) { + + if ( value === null ) { + jQuery.removeAttr( elem, name ); + + } else if ( hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) { + return ret; + + } else { + elem.setAttribute( name, value + "" ); + return value; + } + + } else if ( hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) { + return ret; + + } else { + ret = jQuery.find.attr( elem, name ); + + // Non-existent attributes return null, we normalize to undefined + return ret == null ? + undefined : + ret; + } + }, + + removeAttr: function( elem, value ) { + var name, propName, + i = 0, + attrNames = value && value.match( core_rnotwhite ); + + if ( attrNames && elem.nodeType === 1 ) { + while ( (name = attrNames[i++]) ) { + propName = jQuery.propFix[ name ] || name; + + // Boolean attributes get special treatment (#10870) + if ( jQuery.expr.match.bool.test( name ) ) { + // Set corresponding property to false + if ( getSetInput && getSetAttribute || !ruseDefault.test( name ) ) { + elem[ propName ] = false; + // Support: IE<9 + // Also clear defaultChecked/defaultSelected (if appropriate) + } else { + elem[ jQuery.camelCase( "default-" + name ) ] = + elem[ propName ] = false; + } + + // See #9699 for explanation of this approach (setting first, then removal) + } else { + jQuery.attr( elem, name, "" ); + } + + elem.removeAttribute( getSetAttribute ? name : propName ); + } + } + }, + + attrHooks: { + type: { + set: function( elem, value ) { + if ( !jQuery.support.radioValue && value === "radio" && jQuery.nodeName(elem, "input") ) { + // Setting the type on a radio button after the value resets the value in IE6-9 + // Reset value to default in case type is set after value during creation + var val = elem.value; + elem.setAttribute( "type", value ); + if ( val ) { + elem.value = val; + } + return value; + } + } + } + }, + + propFix: { + "for": "htmlFor", + "class": "className" + }, + + prop: function( elem, name, value ) { + var ret, hooks, notxml, + nType = elem.nodeType; + + // don't get/set properties on text, comment and attribute nodes + if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); + + if ( notxml ) { + // Fix name and attach hooks + name = jQuery.propFix[ name ] || name; + hooks = jQuery.propHooks[ name ]; + } + + if ( value !== undefined ) { + return hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ? + ret : + ( elem[ name ] = value ); + + } else { + return hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ? + ret : + elem[ name ]; + } + }, + + propHooks: { + tabIndex: { + get: function( elem ) { + // elem.tabIndex doesn't always return the correct value when it hasn't been explicitly set + // http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ + // Use proper attribute retrieval(#12072) + var tabindex = jQuery.find.attr( elem, "tabindex" ); + + return tabindex ? + parseInt( tabindex, 10 ) : + rfocusable.test( elem.nodeName ) || rclickable.test( elem.nodeName ) && elem.href ? + 0 : + -1; + } + } + } +}); + +// Hooks for boolean attributes +boolHook = { + set: function( elem, value, name ) { + if ( value === false ) { + // Remove boolean attributes when set to false + jQuery.removeAttr( elem, name ); + } else if ( getSetInput && getSetAttribute || !ruseDefault.test( name ) ) { + // IE<8 needs the *property* name + elem.setAttribute( !getSetAttribute && jQuery.propFix[ name ] || name, name ); + + // Use defaultChecked and defaultSelected for oldIE + } else { + elem[ jQuery.camelCase( "default-" + name ) ] = elem[ name ] = true; + } + + return name; + } +}; +jQuery.each( jQuery.expr.match.bool.source.match( /\w+/g ), function( i, name ) { + var getter = jQuery.expr.attrHandle[ name ] || jQuery.find.attr; + + jQuery.expr.attrHandle[ name ] = getSetInput && getSetAttribute || !ruseDefault.test( name ) ? + function( elem, name, isXML ) { + var fn = jQuery.expr.attrHandle[ name ], + ret = isXML ? + undefined : + /* jshint eqeqeq: false */ + (jQuery.expr.attrHandle[ name ] = undefined) != + getter( elem, name, isXML ) ? + + name.toLowerCase() : + null; + jQuery.expr.attrHandle[ name ] = fn; + return ret; + } : + function( elem, name, isXML ) { + return isXML ? + undefined : + elem[ jQuery.camelCase( "default-" + name ) ] ? + name.toLowerCase() : + null; + }; +}); + +// fix oldIE attroperties +if ( !getSetInput || !getSetAttribute ) { + jQuery.attrHooks.value = { + set: function( elem, value, name ) { + if ( jQuery.nodeName( elem, "input" ) ) { + // Does not return so that setAttribute is also used + elem.defaultValue = value; + } else { + // Use nodeHook if defined (#1954); otherwise setAttribute is fine + return nodeHook && nodeHook.set( elem, value, name ); + } + } + }; +} + +// IE6/7 do not support getting/setting some attributes with get/setAttribute +if ( !getSetAttribute ) { + + // Use this for any attribute in IE6/7 + // This fixes almost every IE6/7 issue + nodeHook = { + set: function( elem, value, name ) { + // Set the existing or create a new attribute node + var ret = elem.getAttributeNode( name ); + if ( !ret ) { + elem.setAttributeNode( + (ret = elem.ownerDocument.createAttribute( name )) + ); + } + + ret.value = value += ""; + + // Break association with cloned elements by also using setAttribute (#9646) + return name === "value" || value === elem.getAttribute( name ) ? + value : + undefined; + } + }; + jQuery.expr.attrHandle.id = jQuery.expr.attrHandle.name = jQuery.expr.attrHandle.coords = + // Some attributes are constructed with empty-string values when not defined + function( elem, name, isXML ) { + var ret; + return isXML ? + undefined : + (ret = elem.getAttributeNode( name )) && ret.value !== "" ? + ret.value : + null; + }; + jQuery.valHooks.button = { + get: function( elem, name ) { + var ret = elem.getAttributeNode( name ); + return ret && ret.specified ? + ret.value : + undefined; + }, + set: nodeHook.set + }; + + // Set contenteditable to false on removals(#10429) + // Setting to empty string throws an error as an invalid value + jQuery.attrHooks.contenteditable = { + set: function( elem, value, name ) { + nodeHook.set( elem, value === "" ? false : value, name ); + } + }; + + // Set width and height to auto instead of 0 on empty string( Bug #8150 ) + // This is for removals + jQuery.each([ "width", "height" ], function( i, name ) { + jQuery.attrHooks[ name ] = { + set: function( elem, value ) { + if ( value === "" ) { + elem.setAttribute( name, "auto" ); + return value; + } + } + }; + }); +} + + +// Some attributes require a special call on IE +// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx +if ( !jQuery.support.hrefNormalized ) { + // href/src property should get the full normalized URL (#10299/#12915) + jQuery.each([ "href", "src" ], function( i, name ) { + jQuery.propHooks[ name ] = { + get: function( elem ) { + return elem.getAttribute( name, 4 ); + } + }; + }); +} + +if ( !jQuery.support.style ) { + jQuery.attrHooks.style = { + get: function( elem ) { + // Return undefined in the case of empty string + // Note: IE uppercases css property names, but if we were to .toLowerCase() + // .cssText, that would destroy case senstitivity in URL's, like in "background" + return elem.style.cssText || undefined; + }, + set: function( elem, value ) { + return ( elem.style.cssText = value + "" ); + } + }; +} + +// Safari mis-reports the default selected property of an option +// Accessing the parent's selectedIndex property fixes it +if ( !jQuery.support.optSelected ) { + jQuery.propHooks.selected = { + get: function( elem ) { + var parent = elem.parentNode; + + if ( parent ) { + parent.selectedIndex; + + // Make sure that it also works with optgroups, see #5701 + if ( parent.parentNode ) { + parent.parentNode.selectedIndex; + } + } + return null; + } + }; +} + +jQuery.each([ + "tabIndex", + "readOnly", + "maxLength", + "cellSpacing", + "cellPadding", + "rowSpan", + "colSpan", + "useMap", + "frameBorder", + "contentEditable" +], function() { + jQuery.propFix[ this.toLowerCase() ] = this; +}); + +// IE6/7 call enctype encoding +if ( !jQuery.support.enctype ) { + jQuery.propFix.enctype = "encoding"; +} + +// Radios and checkboxes getter/setter +jQuery.each([ "radio", "checkbox" ], function() { + jQuery.valHooks[ this ] = { + set: function( elem, value ) { + if ( jQuery.isArray( value ) ) { + return ( elem.checked = jQuery.inArray( jQuery(elem).val(), value ) >= 0 ); + } + } + }; + if ( !jQuery.support.checkOn ) { + jQuery.valHooks[ this ].get = function( elem ) { + // Support: Webkit + // "" is returned instead of "on" if a value isn't specified + return elem.getAttribute("value") === null ? "on" : elem.value; + }; + } +}); +var rformElems = /^(?:input|select|textarea)$/i, + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|contextmenu)|click/, + rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)$/; + +function returnTrue() { + return true; +} + +function returnFalse() { + return false; +} + +function safeActiveElement() { + try { + return document.activeElement; + } catch ( err ) { } +} + +/* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ +jQuery.event = { + + global: {}, + + add: function( elem, types, handler, data, selector ) { + var tmp, events, t, handleObjIn, + special, eventHandle, handleObj, + handlers, type, namespaces, origType, + elemData = jQuery._data( elem ); + + // Don't attach events to noData or text/comment nodes (but allow plain objects) + if ( !elemData ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + if ( !(events = elemData.events) ) { + events = elemData.events = {}; + } + if ( !(eventHandle = elemData.handle) ) { + eventHandle = elemData.handle = function( e ) { + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== core_strundefined && (!e || jQuery.event.triggered !== e.type) ? + jQuery.event.dispatch.apply( eventHandle.elem, arguments ) : + undefined; + }; + // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events + eventHandle.elem = elem; + } + + // Handle multiple events separated by a space + types = ( types || "" ).match( core_rnotwhite ) || [""]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[t] ) || []; + type = origType = tmp[1]; + namespaces = ( tmp[2] || "" ).split( "." ).sort(); + + // There *must* be a type, no attaching namespace-only handlers + if ( !type ) { + continue; + } + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend({ + type: type, + origType: origType, + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join(".") + }, handleObjIn ); + + // Init the event handler queue if we're the first + if ( !(handlers = events[ type ]) ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener/attachEvent if the special events handler returns false + if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + // Bind the global event handler to the element + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle, false ); + + } else if ( elem.attachEvent ) { + elem.attachEvent( "on" + type, eventHandle ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + // Nullify elem to prevent memory leaks in IE + elem = null; + }, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + var j, handleObj, tmp, + origCount, t, events, + special, handlers, type, + namespaces, origType, + elemData = jQuery.hasData( elem ) && jQuery._data( elem ); + + if ( !elemData || !(events = elemData.events) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = ( types || "" ).match( core_rnotwhite ) || [""]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[t] ) || []; + type = origType = tmp[1]; + namespaces = ( tmp[2] || "" ).split( "." ).sort(); + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector ? special.delegateType : special.bindType ) || type; + handlers = events[ type ] || []; + tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ); + + // Remove matching events + origCount = j = handlers.length; + while ( j-- ) { + handleObj = handlers[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !tmp || tmp.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { + handlers.splice( j, 1 ); + + if ( handleObj.selector ) { + handlers.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( origCount && !handlers.length ) { + if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + delete elemData.handle; + + // removeData also checks for emptiness and clears the expando if empty + // so use it instead of delete + jQuery._removeData( elem, "events" ); + } + }, + + trigger: function( event, data, elem, onlyHandlers ) { + var handle, ontype, cur, + bubbleType, special, tmp, i, + eventPath = [ elem || document ], + type = core_hasOwn.call( event, "type" ) ? event.type : event, + namespaces = core_hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : []; + + cur = tmp = elem = elem || document; + + // Don't do events on text and comment nodes + if ( elem.nodeType === 3 || elem.nodeType === 8 ) { + return; + } + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf(".") >= 0 ) { + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split("."); + type = namespaces.shift(); + namespaces.sort(); + } + ontype = type.indexOf(":") < 0 && "on" + type; + + // Caller can pass in a jQuery.Event object, Object, or just an event type string + event = event[ jQuery.expando ] ? + event : + new jQuery.Event( type, typeof event === "object" && event ); + + // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) + event.isTrigger = onlyHandlers ? 2 : 3; + event.namespace = namespaces.join("."); + event.namespace_re = event.namespace ? + new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) : + null; + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data == null ? + [ event ] : + jQuery.makeArray( data, [ event ] ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + if ( !rfocusMorph.test( bubbleType + type ) ) { + cur = cur.parentNode; + } + for ( ; cur; cur = cur.parentNode ) { + eventPath.push( cur ); + tmp = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( tmp === (elem.ownerDocument || document) ) { + eventPath.push( tmp.defaultView || tmp.parentWindow || window ); + } + } + + // Fire handlers on the event path + i = 0; + while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) { + + event.type = i > 1 ? + bubbleType : + special.bindType || type; + + // jQuery handler + handle = ( jQuery._data( cur, "events" ) || {} )[ event.type ] && jQuery._data( cur, "handle" ); + if ( handle ) { + handle.apply( cur, data ); + } + + // Native handler + handle = ontype && cur[ ontype ]; + if ( handle && jQuery.acceptData( cur ) && handle.apply && handle.apply( cur, data ) === false ) { + event.preventDefault(); + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) && + jQuery.acceptData( elem ) ) { + + // Call a native DOM method on the target with the same name name as the event. + // Can't use an .isFunction() check here because IE6/7 fails that test. + // Don't do default actions on window, that's where global variables be (#6170) + if ( ontype && elem[ type ] && !jQuery.isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + tmp = elem[ ontype ]; + + if ( tmp ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + try { + elem[ type ](); + } catch ( e ) { + // IE<9 dies on focus/blur to hidden element (#1486,#12518) + // only reproducible on winXP IE8 native, not IE9 in IE8 mode + } + jQuery.event.triggered = undefined; + + if ( tmp ) { + elem[ ontype ] = tmp; + } + } + } + } + + return event.result; + }, + + dispatch: function( event ) { + + // Make a writable jQuery.Event from the native event object + event = jQuery.event.fix( event ); + + var i, ret, handleObj, matched, j, + handlerQueue = [], + args = core_slice.call( arguments ), + handlers = ( jQuery._data( this, "events" ) || {} )[ event.type ] || [], + special = jQuery.event.special[ event.type ] || {}; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[0] = event; + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers + handlerQueue = jQuery.event.handlers.call( this, event, handlers ); + + // Run delegates first; they may want to stop propagation beneath us + i = 0; + while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) { + event.currentTarget = matched.elem; + + j = 0; + while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) { + + // Triggered event must either 1) have no namespace, or + // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace). + if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) { + + event.handleObj = handleObj; + event.data = handleObj.data; + + ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) + .apply( matched.elem, args ); + + if ( ret !== undefined ) { + if ( (event.result = ret) === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + handlers: function( event, handlers ) { + var sel, handleObj, matches, i, + handlerQueue = [], + delegateCount = handlers.delegateCount, + cur = event.target; + + // Find delegate handlers + // Black-hole SVG instance trees (#13180) + // Avoid non-left-click bubbling in Firefox (#3861) + if ( delegateCount && cur.nodeType && (!event.button || event.type !== "click") ) { + + /* jshint eqeqeq: false */ + for ( ; cur != this; cur = cur.parentNode || this ) { + /* jshint eqeqeq: true */ + + // Don't check non-elements (#13208) + // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.nodeType === 1 && (cur.disabled !== true || event.type !== "click") ) { + matches = []; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + + // Don't conflict with Object.prototype properties (#13203) + sel = handleObj.selector + " "; + + if ( matches[ sel ] === undefined ) { + matches[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) >= 0 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( matches[ sel ] ) { + matches.push( handleObj ); + } + } + if ( matches.length ) { + handlerQueue.push({ elem: cur, handlers: matches }); + } + } + } + } + + // Add the remaining (directly-bound) handlers + if ( delegateCount < handlers.length ) { + handlerQueue.push({ elem: this, handlers: handlers.slice( delegateCount ) }); + } + + return handlerQueue; + }, + + fix: function( event ) { + if ( event[ jQuery.expando ] ) { + return event; + } + + // Create a writable copy of the event object and normalize some properties + var i, prop, copy, + type = event.type, + originalEvent = event, + fixHook = this.fixHooks[ type ]; + + if ( !fixHook ) { + this.fixHooks[ type ] = fixHook = + rmouseEvent.test( type ) ? this.mouseHooks : + rkeyEvent.test( type ) ? this.keyHooks : + {}; + } + copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; + + event = new jQuery.Event( originalEvent ); + + i = copy.length; + while ( i-- ) { + prop = copy[ i ]; + event[ prop ] = originalEvent[ prop ]; + } + + // Support: IE<9 + // Fix target property (#1925) + if ( !event.target ) { + event.target = originalEvent.srcElement || document; + } + + // Support: Chrome 23+, Safari? + // Target should not be a text node (#504, #13143) + if ( event.target.nodeType === 3 ) { + event.target = event.target.parentNode; + } + + // Support: IE<9 + // For mouse/key events, metaKey==false if it's undefined (#3368, #11328) + event.metaKey = !!event.metaKey; + + return fixHook.filter ? fixHook.filter( event, originalEvent ) : event; + }, + + // Includes some event props shared by KeyEvent and MouseEvent + props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), + + fixHooks: {}, + + keyHooks: { + props: "char charCode key keyCode".split(" "), + filter: function( event, original ) { + + // Add which for key events + if ( event.which == null ) { + event.which = original.charCode != null ? original.charCode : original.keyCode; + } + + return event; + } + }, + + mouseHooks: { + props: "button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "), + filter: function( event, original ) { + var body, eventDoc, doc, + button = original.button, + fromElement = original.fromElement; + + // Calculate pageX/Y if missing and clientX/Y available + if ( event.pageX == null && original.clientX != null ) { + eventDoc = event.target.ownerDocument || document; + doc = eventDoc.documentElement; + body = eventDoc.body; + + event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); + event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); + } + + // Add relatedTarget, if necessary + if ( !event.relatedTarget && fromElement ) { + event.relatedTarget = fromElement === event.target ? original.toElement : fromElement; + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + // Note: button is not normalized, so don't use it + if ( !event.which && button !== undefined ) { + event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); + } + + return event; + } + }, + + special: { + load: { + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + focus: { + // Fire native event if possible so blur/focus sequence is correct + trigger: function() { + if ( this !== safeActiveElement() && this.focus ) { + try { + this.focus(); + return false; + } catch ( e ) { + // Support: IE<9 + // If we error on focus to hidden element (#1486, #12518), + // let .trigger() run the handlers + } + } + }, + delegateType: "focusin" + }, + blur: { + trigger: function() { + if ( this === safeActiveElement() && this.blur ) { + this.blur(); + return false; + } + }, + delegateType: "focusout" + }, + click: { + // For checkbox, fire native event so checked state will be right + trigger: function() { + if ( jQuery.nodeName( this, "input" ) && this.type === "checkbox" && this.click ) { + this.click(); + return false; + } + }, + + // For cross-browser consistency, don't fire native .click() on links + _default: function( event ) { + return jQuery.nodeName( event.target, "a" ); + } + }, + + beforeunload: { + postDispatch: function( event ) { + + // Even when returnValue equals to undefined Firefox will still show alert + if ( event.result !== undefined ) { + event.originalEvent.returnValue = event.result; + } + } + } + }, + + simulate: function( type, elem, event, bubble ) { + // Piggyback on a donor event to simulate a different one. + // Fake originalEvent to avoid donor's stopPropagation, but if the + // simulated event prevents default then we do the same on the donor. + var e = jQuery.extend( + new jQuery.Event(), + event, + { + type: type, + isSimulated: true, + originalEvent: {} + } + ); + if ( bubble ) { + jQuery.event.trigger( e, null, elem ); + } else { + jQuery.event.dispatch.call( elem, e ); + } + if ( e.isDefaultPrevented() ) { + event.preventDefault(); + } + } +}; + +jQuery.removeEvent = document.removeEventListener ? + function( elem, type, handle ) { + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle, false ); + } + } : + function( elem, type, handle ) { + var name = "on" + type; + + if ( elem.detachEvent ) { + + // #8545, #7054, preventing memory leaks for custom events in IE6-8 + // detachEvent needed property on element, by name of that event, to properly expose it to GC + if ( typeof elem[ name ] === core_strundefined ) { + elem[ name ] = null; + } + + elem.detachEvent( name, handle ); + } + }; + +jQuery.Event = function( src, props ) { + // Allow instantiation without the 'new' keyword + if ( !(this instanceof jQuery.Event) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = ( src.defaultPrevented || src.returnValue === false || + src.getPreventDefault && src.getPreventDefault() ) ? returnTrue : returnFalse; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || jQuery.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; +}; + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html +jQuery.Event.prototype = { + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse, + + preventDefault: function() { + var e = this.originalEvent; + + this.isDefaultPrevented = returnTrue; + if ( !e ) { + return; + } + + // If preventDefault exists, run it on the original event + if ( e.preventDefault ) { + e.preventDefault(); + + // Support: IE + // Otherwise set the returnValue property of the original event to false + } else { + e.returnValue = false; + } + }, + stopPropagation: function() { + var e = this.originalEvent; + + this.isPropagationStopped = returnTrue; + if ( !e ) { + return; + } + // If stopPropagation exists, run it on the original event + if ( e.stopPropagation ) { + e.stopPropagation(); + } + + // Support: IE + // Set the cancelBubble property of the original event to true + e.cancelBubble = true; + }, + stopImmediatePropagation: function() { + this.isImmediatePropagationStopped = returnTrue; + this.stopPropagation(); + } +}; + +// Create mouseenter/leave events using mouseover/out and event-time checks +jQuery.each({ + mouseenter: "mouseover", + mouseleave: "mouseout" +}, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj; + + // For mousenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || (related !== target && !jQuery.contains( target, related )) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; +}); + +// IE submit delegation +if ( !jQuery.support.submitBubbles ) { + + jQuery.event.special.submit = { + setup: function() { + // Only need this for delegated form submit events + if ( jQuery.nodeName( this, "form" ) ) { + return false; + } + + // Lazy-add a submit handler when a descendant form may potentially be submitted + jQuery.event.add( this, "click._submit keypress._submit", function( e ) { + // Node name check avoids a VML-related crash in IE (#9807) + var elem = e.target, + form = jQuery.nodeName( elem, "input" ) || jQuery.nodeName( elem, "button" ) ? elem.form : undefined; + if ( form && !jQuery._data( form, "submitBubbles" ) ) { + jQuery.event.add( form, "submit._submit", function( event ) { + event._submit_bubble = true; + }); + jQuery._data( form, "submitBubbles", true ); + } + }); + // return undefined since we don't need an event listener + }, + + postDispatch: function( event ) { + // If form was submitted by the user, bubble the event up the tree + if ( event._submit_bubble ) { + delete event._submit_bubble; + if ( this.parentNode && !event.isTrigger ) { + jQuery.event.simulate( "submit", this.parentNode, event, true ); + } + } + }, + + teardown: function() { + // Only need this for delegated form submit events + if ( jQuery.nodeName( this, "form" ) ) { + return false; + } + + // Remove delegated handlers; cleanData eventually reaps submit handlers attached above + jQuery.event.remove( this, "._submit" ); + } + }; +} + +// IE change delegation and checkbox/radio fix +if ( !jQuery.support.changeBubbles ) { + + jQuery.event.special.change = { + + setup: function() { + + if ( rformElems.test( this.nodeName ) ) { + // IE doesn't fire change on a check/radio until blur; trigger it on click + // after a propertychange. Eat the blur-change in special.change.handle. + // This still fires onchange a second time for check/radio after blur. + if ( this.type === "checkbox" || this.type === "radio" ) { + jQuery.event.add( this, "propertychange._change", function( event ) { + if ( event.originalEvent.propertyName === "checked" ) { + this._just_changed = true; + } + }); + jQuery.event.add( this, "click._change", function( event ) { + if ( this._just_changed && !event.isTrigger ) { + this._just_changed = false; + } + // Allow triggered, simulated change events (#11500) + jQuery.event.simulate( "change", this, event, true ); + }); + } + return false; + } + // Delegated event; lazy-add a change handler on descendant inputs + jQuery.event.add( this, "beforeactivate._change", function( e ) { + var elem = e.target; + + if ( rformElems.test( elem.nodeName ) && !jQuery._data( elem, "changeBubbles" ) ) { + jQuery.event.add( elem, "change._change", function( event ) { + if ( this.parentNode && !event.isSimulated && !event.isTrigger ) { + jQuery.event.simulate( "change", this.parentNode, event, true ); + } + }); + jQuery._data( elem, "changeBubbles", true ); + } + }); + }, + + handle: function( event ) { + var elem = event.target; + + // Swallow native change events from checkbox/radio, we already triggered them above + if ( this !== elem || event.isSimulated || event.isTrigger || (elem.type !== "radio" && elem.type !== "checkbox") ) { + return event.handleObj.handler.apply( this, arguments ); + } + }, + + teardown: function() { + jQuery.event.remove( this, "._change" ); + + return !rformElems.test( this.nodeName ); + } + }; +} + +// Create "bubbling" focus and blur events +if ( !jQuery.support.focusinBubbles ) { + jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler while someone wants focusin/focusout + var attaches = 0, + handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + if ( attaches++ === 0 ) { + document.addEventListener( orig, handler, true ); + } + }, + teardown: function() { + if ( --attaches === 0 ) { + document.removeEventListener( orig, handler, true ); + } + } + }; + }); +} + +jQuery.fn.extend({ + + on: function( types, selector, data, fn, /*INTERNAL*/ one ) { + var type, origFn; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + this.on( type, selector, data, types[ type ], one ); + } + return this; + } + + if ( data == null && fn == null ) { + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return this; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return this.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + }); + }, + one: function( types, selector, data, fn ) { + return this.on( types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each(function() { + jQuery.event.remove( this, types, fn, selector ); + }); + }, + + trigger: function( type, data ) { + return this.each(function() { + jQuery.event.trigger( type, data, this ); + }); + }, + triggerHandler: function( type, data ) { + var elem = this[0]; + if ( elem ) { + return jQuery.event.trigger( type, data, elem, true ); + } + } +}); +var isSimple = /^.[^:#\[\.,]*$/, + rparentsprev = /^(?:parents|prev(?:Until|All))/, + rneedsContext = jQuery.expr.match.needsContext, + // methods guaranteed to produce a unique set when starting from a unique set + guaranteedUnique = { + children: true, + contents: true, + next: true, + prev: true + }; + +jQuery.fn.extend({ + find: function( selector ) { + var i, + ret = [], + self = this, + len = self.length; + + if ( typeof selector !== "string" ) { + return this.pushStack( jQuery( selector ).filter(function() { + for ( i = 0; i < len; i++ ) { + if ( jQuery.contains( self[ i ], this ) ) { + return true; + } + } + }) ); + } + + for ( i = 0; i < len; i++ ) { + jQuery.find( selector, self[ i ], ret ); + } + + // Needed because $( selector, context ) becomes $( context ).find( selector ) + ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret ); + ret.selector = this.selector ? this.selector + " " + selector : selector; + return ret; + }, + + has: function( target ) { + var i, + targets = jQuery( target, this ), + len = targets.length; + + return this.filter(function() { + for ( i = 0; i < len; i++ ) { + if ( jQuery.contains( this, targets[i] ) ) { + return true; + } + } + }); + }, + + not: function( selector ) { + return this.pushStack( winnow(this, selector || [], true) ); + }, + + filter: function( selector ) { + return this.pushStack( winnow(this, selector || [], false) ); + }, + + is: function( selector ) { + return !!winnow( + this, + + // If this is a positional/relative selector, check membership in the returned set + // so $("p:first").is("p:last") won't return true for a doc with two "p". + typeof selector === "string" && rneedsContext.test( selector ) ? + jQuery( selector ) : + selector || [], + false + ).length; + }, + + closest: function( selectors, context ) { + var cur, + i = 0, + l = this.length, + ret = [], + pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ? + jQuery( selectors, context || this.context ) : + 0; + + for ( ; i < l; i++ ) { + for ( cur = this[i]; cur && cur !== context; cur = cur.parentNode ) { + // Always skip document fragments + if ( cur.nodeType < 11 && (pos ? + pos.index(cur) > -1 : + + // Don't pass non-elements to Sizzle + cur.nodeType === 1 && + jQuery.find.matchesSelector(cur, selectors)) ) { + + cur = ret.push( cur ); + break; + } + } + } + + return this.pushStack( ret.length > 1 ? jQuery.unique( ret ) : ret ); + }, + + // Determine the position of an element within + // the matched set of elements + index: function( elem ) { + + // No argument, return index in parent + if ( !elem ) { + return ( this[0] && this[0].parentNode ) ? this.first().prevAll().length : -1; + } + + // index in selector + if ( typeof elem === "string" ) { + return jQuery.inArray( this[0], jQuery( elem ) ); + } + + // Locate the position of the desired element + return jQuery.inArray( + // If it receives a jQuery object, the first element is used + elem.jquery ? elem[0] : elem, this ); + }, + + add: function( selector, context ) { + var set = typeof selector === "string" ? + jQuery( selector, context ) : + jQuery.makeArray( selector && selector.nodeType ? [ selector ] : selector ), + all = jQuery.merge( this.get(), set ); + + return this.pushStack( jQuery.unique(all) ); + }, + + addBack: function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter(selector) + ); + } +}); + +function sibling( cur, dir ) { + do { + cur = cur[ dir ]; + } while ( cur && cur.nodeType !== 1 ); + + return cur; +} + +jQuery.each({ + parent: function( elem ) { + var parent = elem.parentNode; + return parent && parent.nodeType !== 11 ? parent : null; + }, + parents: function( elem ) { + return jQuery.dir( elem, "parentNode" ); + }, + parentsUntil: function( elem, i, until ) { + return jQuery.dir( elem, "parentNode", until ); + }, + next: function( elem ) { + return sibling( elem, "nextSibling" ); + }, + prev: function( elem ) { + return sibling( elem, "previousSibling" ); + }, + nextAll: function( elem ) { + return jQuery.dir( elem, "nextSibling" ); + }, + prevAll: function( elem ) { + return jQuery.dir( elem, "previousSibling" ); + }, + nextUntil: function( elem, i, until ) { + return jQuery.dir( elem, "nextSibling", until ); + }, + prevUntil: function( elem, i, until ) { + return jQuery.dir( elem, "previousSibling", until ); + }, + siblings: function( elem ) { + return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem ); + }, + children: function( elem ) { + return jQuery.sibling( elem.firstChild ); + }, + contents: function( elem ) { + return jQuery.nodeName( elem, "iframe" ) ? + elem.contentDocument || elem.contentWindow.document : + jQuery.merge( [], elem.childNodes ); + } +}, function( name, fn ) { + jQuery.fn[ name ] = function( until, selector ) { + var ret = jQuery.map( this, fn, until ); + + if ( name.slice( -5 ) !== "Until" ) { + selector = until; + } + + if ( selector && typeof selector === "string" ) { + ret = jQuery.filter( selector, ret ); + } + + if ( this.length > 1 ) { + // Remove duplicates + if ( !guaranteedUnique[ name ] ) { + ret = jQuery.unique( ret ); + } + + // Reverse order for parents* and prev-derivatives + if ( rparentsprev.test( name ) ) { + ret = ret.reverse(); + } + } + + return this.pushStack( ret ); + }; +}); + +jQuery.extend({ + filter: function( expr, elems, not ) { + var elem = elems[ 0 ]; + + if ( not ) { + expr = ":not(" + expr + ")"; + } + + return elems.length === 1 && elem.nodeType === 1 ? + jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] : + jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { + return elem.nodeType === 1; + })); + }, + + dir: function( elem, dir, until ) { + var matched = [], + cur = elem[ dir ]; + + while ( cur && cur.nodeType !== 9 && (until === undefined || cur.nodeType !== 1 || !jQuery( cur ).is( until )) ) { + if ( cur.nodeType === 1 ) { + matched.push( cur ); + } + cur = cur[dir]; + } + return matched; + }, + + sibling: function( n, elem ) { + var r = []; + + for ( ; n; n = n.nextSibling ) { + if ( n.nodeType === 1 && n !== elem ) { + r.push( n ); + } + } + + return r; + } +}); + +// Implement the identical functionality for filter and not +function winnow( elements, qualifier, not ) { + if ( jQuery.isFunction( qualifier ) ) { + return jQuery.grep( elements, function( elem, i ) { + /* jshint -W018 */ + return !!qualifier.call( elem, i, elem ) !== not; + }); + + } + + if ( qualifier.nodeType ) { + return jQuery.grep( elements, function( elem ) { + return ( elem === qualifier ) !== not; + }); + + } + + if ( typeof qualifier === "string" ) { + if ( isSimple.test( qualifier ) ) { + return jQuery.filter( qualifier, elements, not ); + } + + qualifier = jQuery.filter( qualifier, elements ); + } + + return jQuery.grep( elements, function( elem ) { + return ( jQuery.inArray( elem, qualifier ) >= 0 ) !== not; + }); +} +function createSafeFragment( document ) { + var list = nodeNames.split( "|" ), + safeFrag = document.createDocumentFragment(); + + if ( safeFrag.createElement ) { + while ( list.length ) { + safeFrag.createElement( + list.pop() + ); + } + } + return safeFrag; +} + +var nodeNames = "abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|" + + "header|hgroup|mark|meter|nav|output|progress|section|summary|time|video", + rinlinejQuery = / jQuery\d+="(?:null|\d+)"/g, + rnoshimcache = new RegExp("<(?:" + nodeNames + ")[\\s/>]", "i"), + rleadingWhitespace = /^\s+/, + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, + rtagName = /<([\w:]+)/, + rtbody = /\s*$/g, + + // We have to close these tags to support XHTML (#13200) + wrapMap = { + option: [ 1, "" ], + legend: [ 1, "
    ", "
    " ], + area: [ 1, "", "" ], + param: [ 1, "", "" ], + thead: [ 1, "", "
    " ], + tr: [ 2, "", "
    " ], + col: [ 2, "", "
    " ], + td: [ 3, "", "
    " ], + + // IE6-8 can't serialize link, script, style, or any html5 (NoScope) tags, + // unless wrapped in a div with non-breaking characters in front of it. + _default: jQuery.support.htmlSerialize ? [ 0, "", "" ] : [ 1, "X
    ", "
    " ] + }, + safeFragment = createSafeFragment( document ), + fragmentDiv = safeFragment.appendChild( document.createElement("div") ); + +wrapMap.optgroup = wrapMap.option; +wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; +wrapMap.th = wrapMap.td; + +jQuery.fn.extend({ + text: function( value ) { + return jQuery.access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().append( ( this[0] && this[0].ownerDocument || document ).createTextNode( value ) ); + }, null, value, arguments.length ); + }, + + append: function() { + return this.domManip( arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.appendChild( elem ); + } + }); + }, + + prepend: function() { + return this.domManip( arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.insertBefore( elem, target.firstChild ); + } + }); + }, + + before: function() { + return this.domManip( arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this ); + } + }); + }, + + after: function() { + return this.domManip( arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + } + }); + }, + + // keepData is for internal use only--do not document + remove: function( selector, keepData ) { + var elem, + elems = selector ? jQuery.filter( selector, this ) : this, + i = 0; + + for ( ; (elem = elems[i]) != null; i++ ) { + + if ( !keepData && elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem ) ); + } + + if ( elem.parentNode ) { + if ( keepData && jQuery.contains( elem.ownerDocument, elem ) ) { + setGlobalEval( getAll( elem, "script" ) ); + } + elem.parentNode.removeChild( elem ); + } + } + + return this; + }, + + empty: function() { + var elem, + i = 0; + + for ( ; (elem = this[i]) != null; i++ ) { + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + } + + // Remove any remaining nodes + while ( elem.firstChild ) { + elem.removeChild( elem.firstChild ); + } + + // If this is a select, ensure that it displays empty (#12336) + // Support: IE<9 + if ( elem.options && jQuery.nodeName( elem, "select" ) ) { + elem.options.length = 0; + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map( function () { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + }); + }, + + html: function( value ) { + return jQuery.access( this, function( value ) { + var elem = this[0] || {}, + i = 0, + l = this.length; + + if ( value === undefined ) { + return elem.nodeType === 1 ? + elem.innerHTML.replace( rinlinejQuery, "" ) : + undefined; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + ( jQuery.support.htmlSerialize || !rnoshimcache.test( value ) ) && + ( jQuery.support.leadingWhitespace || !rleadingWhitespace.test( value ) ) && + !wrapMap[ ( rtagName.exec( value ) || ["", ""] )[1].toLowerCase() ] ) { + + value = value.replace( rxhtmlTag, "<$1>" ); + + try { + for (; i < l; i++ ) { + // Remove element nodes and prevent memory leaks + elem = this[i] || {}; + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch(e) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function() { + var + // Snapshot the DOM in case .domManip sweeps something relevant into its fragment + args = jQuery.map( this, function( elem ) { + return [ elem.nextSibling, elem.parentNode ]; + }), + i = 0; + + // Make the changes, replacing each context element with the new content + this.domManip( arguments, function( elem ) { + var next = args[ i++ ], + parent = args[ i++ ]; + + if ( parent ) { + // Don't use the snapshot next if it has moved (#13810) + if ( next && next.parentNode !== parent ) { + next = this.nextSibling; + } + jQuery( this ).remove(); + parent.insertBefore( elem, next ); + } + // Allow new content to include elements from the context set + }, true ); + + // Force removal if there was no new content (e.g., from empty arguments) + return i ? this : this.remove(); + }, + + detach: function( selector ) { + return this.remove( selector, true ); + }, + + domManip: function( args, callback, allowIntersection ) { + + // Flatten any nested arrays + args = core_concat.apply( [], args ); + + var first, node, hasScripts, + scripts, doc, fragment, + i = 0, + l = this.length, + set = this, + iNoClone = l - 1, + value = args[0], + isFunction = jQuery.isFunction( value ); + + // We can't cloneNode fragments that contain checked, in WebKit + if ( isFunction || !( l <= 1 || typeof value !== "string" || jQuery.support.checkClone || !rchecked.test( value ) ) ) { + return this.each(function( index ) { + var self = set.eq( index ); + if ( isFunction ) { + args[0] = value.call( this, index, self.html() ); + } + self.domManip( args, callback, allowIntersection ); + }); + } + + if ( l ) { + fragment = jQuery.buildFragment( args, this[ 0 ].ownerDocument, false, !allowIntersection && this ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + if ( first ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( this[i], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !jQuery._data( node, "globalEval" ) && jQuery.contains( doc, node ) ) { + + if ( node.src ) { + // Hope ajax is available... + jQuery._evalUrl( node.src ); + } else { + jQuery.globalEval( ( node.text || node.textContent || node.innerHTML || "" ).replace( rcleanScript, "" ) ); + } + } + } + } + + // Fix #11809: Avoid leaking memory + fragment = first = null; + } + } + + return this; + } +}); + +// Support: IE<8 +// Manipulating tables requires a tbody +function manipulationTarget( elem, content ) { + return jQuery.nodeName( elem, "table" ) && + jQuery.nodeName( content.nodeType === 1 ? content : content.firstChild, "tr" ) ? + + elem.getElementsByTagName("tbody")[0] || + elem.appendChild( elem.ownerDocument.createElement("tbody") ) : + elem; +} + +// Replace/restore the type attribute of script elements for safe DOM manipulation +function disableScript( elem ) { + elem.type = (jQuery.find.attr( elem, "type" ) !== null) + "/" + elem.type; + return elem; +} +function restoreScript( elem ) { + var match = rscriptTypeMasked.exec( elem.type ); + if ( match ) { + elem.type = match[1]; + } else { + elem.removeAttribute("type"); + } + return elem; +} + +// Mark scripts as having already been evaluated +function setGlobalEval( elems, refElements ) { + var elem, + i = 0; + for ( ; (elem = elems[i]) != null; i++ ) { + jQuery._data( elem, "globalEval", !refElements || jQuery._data( refElements[i], "globalEval" ) ); + } +} + +function cloneCopyEvent( src, dest ) { + + if ( dest.nodeType !== 1 || !jQuery.hasData( src ) ) { + return; + } + + var type, i, l, + oldData = jQuery._data( src ), + curData = jQuery._data( dest, oldData ), + events = oldData.events; + + if ( events ) { + delete curData.handle; + curData.events = {}; + + for ( type in events ) { + for ( i = 0, l = events[ type ].length; i < l; i++ ) { + jQuery.event.add( dest, type, events[ type ][ i ] ); + } + } + } + + // make the cloned public data object a copy from the original + if ( curData.data ) { + curData.data = jQuery.extend( {}, curData.data ); + } +} + +function fixCloneNodeIssues( src, dest ) { + var nodeName, e, data; + + // We do not need to do anything for non-Elements + if ( dest.nodeType !== 1 ) { + return; + } + + nodeName = dest.nodeName.toLowerCase(); + + // IE6-8 copies events bound via attachEvent when using cloneNode. + if ( !jQuery.support.noCloneEvent && dest[ jQuery.expando ] ) { + data = jQuery._data( dest ); + + for ( e in data.events ) { + jQuery.removeEvent( dest, e, data.handle ); + } + + // Event data gets referenced instead of copied if the expando gets copied too + dest.removeAttribute( jQuery.expando ); + } + + // IE blanks contents when cloning scripts, and tries to evaluate newly-set text + if ( nodeName === "script" && dest.text !== src.text ) { + disableScript( dest ).text = src.text; + restoreScript( dest ); + + // IE6-10 improperly clones children of object elements using classid. + // IE10 throws NoModificationAllowedError if parent is null, #12132. + } else if ( nodeName === "object" ) { + if ( dest.parentNode ) { + dest.outerHTML = src.outerHTML; + } + + // This path appears unavoidable for IE9. When cloning an object + // element in IE9, the outerHTML strategy above is not sufficient. + // If the src has innerHTML and the destination does not, + // copy the src.innerHTML into the dest.innerHTML. #10324 + if ( jQuery.support.html5Clone && ( src.innerHTML && !jQuery.trim(dest.innerHTML) ) ) { + dest.innerHTML = src.innerHTML; + } + + } else if ( nodeName === "input" && manipulation_rcheckableType.test( src.type ) ) { + // IE6-8 fails to persist the checked state of a cloned checkbox + // or radio button. Worse, IE6-7 fail to give the cloned element + // a checked appearance if the defaultChecked value isn't also set + + dest.defaultChecked = dest.checked = src.checked; + + // IE6-7 get confused and end up setting the value of a cloned + // checkbox/radio button to an empty string instead of "on" + if ( dest.value !== src.value ) { + dest.value = src.value; + } + + // IE6-8 fails to return the selected option to the default selected + // state when cloning options + } else if ( nodeName === "option" ) { + dest.defaultSelected = dest.selected = src.defaultSelected; + + // IE6-8 fails to set the defaultValue to the correct value when + // cloning other types of input fields + } else if ( nodeName === "input" || nodeName === "textarea" ) { + dest.defaultValue = src.defaultValue; + } +} + +jQuery.each({ + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" +}, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + i = 0, + ret = [], + insert = jQuery( selector ), + last = insert.length - 1; + + for ( ; i <= last; i++ ) { + elems = i === last ? this : this.clone(true); + jQuery( insert[i] )[ original ]( elems ); + + // Modern browsers can apply jQuery collections as arrays, but oldIE needs a .get() + core_push.apply( ret, elems.get() ); + } + + return this.pushStack( ret ); + }; +}); + +function getAll( context, tag ) { + var elems, elem, + i = 0, + found = typeof context.getElementsByTagName !== core_strundefined ? context.getElementsByTagName( tag || "*" ) : + typeof context.querySelectorAll !== core_strundefined ? context.querySelectorAll( tag || "*" ) : + undefined; + + if ( !found ) { + for ( found = [], elems = context.childNodes || context; (elem = elems[i]) != null; i++ ) { + if ( !tag || jQuery.nodeName( elem, tag ) ) { + found.push( elem ); + } else { + jQuery.merge( found, getAll( elem, tag ) ); + } + } + } + + return tag === undefined || tag && jQuery.nodeName( context, tag ) ? + jQuery.merge( [ context ], found ) : + found; +} + +// Used in buildFragment, fixes the defaultChecked property +function fixDefaultChecked( elem ) { + if ( manipulation_rcheckableType.test( elem.type ) ) { + elem.defaultChecked = elem.checked; + } +} + +jQuery.extend({ + clone: function( elem, dataAndEvents, deepDataAndEvents ) { + var destElements, node, clone, i, srcElements, + inPage = jQuery.contains( elem.ownerDocument, elem ); + + if ( jQuery.support.html5Clone || jQuery.isXMLDoc(elem) || !rnoshimcache.test( "<" + elem.nodeName + ">" ) ) { + clone = elem.cloneNode( true ); + + // IE<=8 does not properly clone detached, unknown element nodes + } else { + fragmentDiv.innerHTML = elem.outerHTML; + fragmentDiv.removeChild( clone = fragmentDiv.firstChild ); + } + + if ( (!jQuery.support.noCloneEvent || !jQuery.support.noCloneChecked) && + (elem.nodeType === 1 || elem.nodeType === 11) && !jQuery.isXMLDoc(elem) ) { + + // We eschew Sizzle here for performance reasons: http://jsperf.com/getall-vs-sizzle/2 + destElements = getAll( clone ); + srcElements = getAll( elem ); + + // Fix all IE cloning issues + for ( i = 0; (node = srcElements[i]) != null; ++i ) { + // Ensure that the destination node is not null; Fixes #9587 + if ( destElements[i] ) { + fixCloneNodeIssues( node, destElements[i] ); + } + } + } + + // Copy the events from the original to the clone + if ( dataAndEvents ) { + if ( deepDataAndEvents ) { + srcElements = srcElements || getAll( elem ); + destElements = destElements || getAll( clone ); + + for ( i = 0; (node = srcElements[i]) != null; i++ ) { + cloneCopyEvent( node, destElements[i] ); + } + } else { + cloneCopyEvent( elem, clone ); + } + } + + // Preserve script evaluation history + destElements = getAll( clone, "script" ); + if ( destElements.length > 0 ) { + setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); + } + + destElements = srcElements = node = null; + + // Return the cloned set + return clone; + }, + + buildFragment: function( elems, context, scripts, selection ) { + var j, elem, contains, + tmp, tag, tbody, wrap, + l = elems.length, + + // Ensure a safe fragment + safe = createSafeFragment( context ), + + nodes = [], + i = 0; + + for ( ; i < l; i++ ) { + elem = elems[ i ]; + + if ( elem || elem === 0 ) { + + // Add nodes directly + if ( jQuery.type( elem ) === "object" ) { + jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); + + // Convert non-html into a text node + } else if ( !rhtml.test( elem ) ) { + nodes.push( context.createTextNode( elem ) ); + + // Convert html into DOM nodes + } else { + tmp = tmp || safe.appendChild( context.createElement("div") ); + + // Deserialize a standard representation + tag = ( rtagName.exec( elem ) || ["", ""] )[1].toLowerCase(); + wrap = wrapMap[ tag ] || wrapMap._default; + + tmp.innerHTML = wrap[1] + elem.replace( rxhtmlTag, "<$1>" ) + wrap[2]; + + // Descend through wrappers to the right content + j = wrap[0]; + while ( j-- ) { + tmp = tmp.lastChild; + } + + // Manually add leading whitespace removed by IE + if ( !jQuery.support.leadingWhitespace && rleadingWhitespace.test( elem ) ) { + nodes.push( context.createTextNode( rleadingWhitespace.exec( elem )[0] ) ); + } + + // Remove IE's autoinserted from table fragments + if ( !jQuery.support.tbody ) { + + // String was a , *may* have spurious + elem = tag === "table" && !rtbody.test( elem ) ? + tmp.firstChild : + + // String was a bare or + wrap[1] === "
    " && !rtbody.test( elem ) ? + tmp : + 0; + + j = elem && elem.childNodes.length; + while ( j-- ) { + if ( jQuery.nodeName( (tbody = elem.childNodes[j]), "tbody" ) && !tbody.childNodes.length ) { + elem.removeChild( tbody ); + } + } + } + + jQuery.merge( nodes, tmp.childNodes ); + + // Fix #12392 for WebKit and IE > 9 + tmp.textContent = ""; + + // Fix #12392 for oldIE + while ( tmp.firstChild ) { + tmp.removeChild( tmp.firstChild ); + } + + // Remember the top-level container for proper cleanup + tmp = safe.lastChild; + } + } + } + + // Fix #11356: Clear elements from fragment + if ( tmp ) { + safe.removeChild( tmp ); + } + + // Reset defaultChecked for any radios and checkboxes + // about to be appended to the DOM in IE 6/7 (#8060) + if ( !jQuery.support.appendChecked ) { + jQuery.grep( getAll( nodes, "input" ), fixDefaultChecked ); + } + + i = 0; + while ( (elem = nodes[ i++ ]) ) { + + // #4087 - If origin and destination elements are the same, and this is + // that element, do not do anything + if ( selection && jQuery.inArray( elem, selection ) !== -1 ) { + continue; + } + + contains = jQuery.contains( elem.ownerDocument, elem ); + + // Append to fragment + tmp = getAll( safe.appendChild( elem ), "script" ); + + // Preserve script evaluation history + if ( contains ) { + setGlobalEval( tmp ); + } + + // Capture executables + if ( scripts ) { + j = 0; + while ( (elem = tmp[ j++ ]) ) { + if ( rscriptType.test( elem.type || "" ) ) { + scripts.push( elem ); + } + } + } + } + + tmp = null; + + return safe; + }, + + cleanData: function( elems, /* internal */ acceptData ) { + var elem, type, id, data, + i = 0, + internalKey = jQuery.expando, + cache = jQuery.cache, + deleteExpando = jQuery.support.deleteExpando, + special = jQuery.event.special; + + for ( ; (elem = elems[i]) != null; i++ ) { + + if ( acceptData || jQuery.acceptData( elem ) ) { + + id = elem[ internalKey ]; + data = id && cache[ id ]; + + if ( data ) { + if ( data.events ) { + for ( type in data.events ) { + if ( special[ type ] ) { + jQuery.event.remove( elem, type ); + + // This is a shortcut to avoid jQuery.event.remove's overhead + } else { + jQuery.removeEvent( elem, type, data.handle ); + } + } + } + + // Remove cache only if it was not already removed by jQuery.event.remove + if ( cache[ id ] ) { + + delete cache[ id ]; + + // IE does not allow us to delete expando properties from nodes, + // nor does it have a removeAttribute function on Document nodes; + // we must handle all of these cases + if ( deleteExpando ) { + delete elem[ internalKey ]; + + } else if ( typeof elem.removeAttribute !== core_strundefined ) { + elem.removeAttribute( internalKey ); + + } else { + elem[ internalKey ] = null; + } + + core_deletedIds.push( id ); + } + } + } + } + }, + + _evalUrl: function( url ) { + return jQuery.ajax({ + url: url, + type: "GET", + dataType: "script", + async: false, + global: false, + "throws": true + }); + } +}); +jQuery.fn.extend({ + wrapAll: function( html ) { + if ( jQuery.isFunction( html ) ) { + return this.each(function(i) { + jQuery(this).wrapAll( html.call(this, i) ); + }); + } + + if ( this[0] ) { + // The elements to wrap the target around + var wrap = jQuery( html, this[0].ownerDocument ).eq(0).clone(true); + + if ( this[0].parentNode ) { + wrap.insertBefore( this[0] ); + } + + wrap.map(function() { + var elem = this; + + while ( elem.firstChild && elem.firstChild.nodeType === 1 ) { + elem = elem.firstChild; + } + + return elem; + }).append( this ); + } + + return this; + }, + + wrapInner: function( html ) { + if ( jQuery.isFunction( html ) ) { + return this.each(function(i) { + jQuery(this).wrapInner( html.call(this, i) ); + }); + } + + return this.each(function() { + var self = jQuery( this ), + contents = self.contents(); + + if ( contents.length ) { + contents.wrapAll( html ); + + } else { + self.append( html ); + } + }); + }, + + wrap: function( html ) { + var isFunction = jQuery.isFunction( html ); + + return this.each(function(i) { + jQuery( this ).wrapAll( isFunction ? html.call(this, i) : html ); + }); + }, + + unwrap: function() { + return this.parent().each(function() { + if ( !jQuery.nodeName( this, "body" ) ) { + jQuery( this ).replaceWith( this.childNodes ); + } + }).end(); + } +}); +var iframe, getStyles, curCSS, + ralpha = /alpha\([^)]*\)/i, + ropacity = /opacity\s*=\s*([^)]*)/, + rposition = /^(top|right|bottom|left)$/, + // swappable if display is none or starts with table except "table", "table-cell", or "table-caption" + // see here for display values: https://developer.mozilla.org/en-US/docs/CSS/display + rdisplayswap = /^(none|table(?!-c[ea]).+)/, + rmargin = /^margin/, + rnumsplit = new RegExp( "^(" + core_pnum + ")(.*)$", "i" ), + rnumnonpx = new RegExp( "^(" + core_pnum + ")(?!px)[a-z%]+$", "i" ), + rrelNum = new RegExp( "^([+-])=(" + core_pnum + ")", "i" ), + elemdisplay = { BODY: "block" }, + + cssShow = { position: "absolute", visibility: "hidden", display: "block" }, + cssNormalTransform = { + letterSpacing: 0, + fontWeight: 400 + }, + + cssExpand = [ "Top", "Right", "Bottom", "Left" ], + cssPrefixes = [ "Webkit", "O", "Moz", "ms" ]; + +// return a css property mapped to a potentially vendor prefixed property +function vendorPropName( style, name ) { + + // shortcut for names that are not vendor prefixed + if ( name in style ) { + return name; + } + + // check for vendor prefixed names + var capName = name.charAt(0).toUpperCase() + name.slice(1), + origName = name, + i = cssPrefixes.length; + + while ( i-- ) { + name = cssPrefixes[ i ] + capName; + if ( name in style ) { + return name; + } + } + + return origName; +} + +function isHidden( elem, el ) { + // isHidden might be called from jQuery#filter function; + // in that case, element will be second argument + elem = el || elem; + return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem ); +} + +function showHide( elements, show ) { + var display, elem, hidden, + values = [], + index = 0, + length = elements.length; + + for ( ; index < length; index++ ) { + elem = elements[ index ]; + if ( !elem.style ) { + continue; + } + + values[ index ] = jQuery._data( elem, "olddisplay" ); + display = elem.style.display; + if ( show ) { + // Reset the inline display of this element to learn if it is + // being hidden by cascaded rules or not + if ( !values[ index ] && display === "none" ) { + elem.style.display = ""; + } + + // Set elements which have been overridden with display: none + // in a stylesheet to whatever the default browser style is + // for such an element + if ( elem.style.display === "" && isHidden( elem ) ) { + values[ index ] = jQuery._data( elem, "olddisplay", css_defaultDisplay(elem.nodeName) ); + } + } else { + + if ( !values[ index ] ) { + hidden = isHidden( elem ); + + if ( display && display !== "none" || !hidden ) { + jQuery._data( elem, "olddisplay", hidden ? display : jQuery.css( elem, "display" ) ); + } + } + } + } + + // Set the display of most of the elements in a second loop + // to avoid the constant reflow + for ( index = 0; index < length; index++ ) { + elem = elements[ index ]; + if ( !elem.style ) { + continue; + } + if ( !show || elem.style.display === "none" || elem.style.display === "" ) { + elem.style.display = show ? values[ index ] || "" : "none"; + } + } + + return elements; +} + +jQuery.fn.extend({ + css: function( name, value ) { + return jQuery.access( this, function( elem, name, value ) { + var len, styles, + map = {}, + i = 0; + + if ( jQuery.isArray( name ) ) { + styles = getStyles( elem ); + len = name.length; + + for ( ; i < len; i++ ) { + map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles ); + } + + return map; + } + + return value !== undefined ? + jQuery.style( elem, name, value ) : + jQuery.css( elem, name ); + }, name, value, arguments.length > 1 ); + }, + show: function() { + return showHide( this, true ); + }, + hide: function() { + return showHide( this ); + }, + toggle: function( state ) { + if ( typeof state === "boolean" ) { + return state ? this.show() : this.hide(); + } + + return this.each(function() { + if ( isHidden( this ) ) { + jQuery( this ).show(); + } else { + jQuery( this ).hide(); + } + }); + } +}); + +jQuery.extend({ + // Add in style property hooks for overriding the default + // behavior of getting and setting a style property + cssHooks: { + opacity: { + get: function( elem, computed ) { + if ( computed ) { + // We should always get a number back from opacity + var ret = curCSS( elem, "opacity" ); + return ret === "" ? "1" : ret; + } + } + } + }, + + // Don't automatically add "px" to these possibly-unitless properties + cssNumber: { + "columnCount": true, + "fillOpacity": true, + "fontWeight": true, + "lineHeight": true, + "opacity": true, + "order": true, + "orphans": true, + "widows": true, + "zIndex": true, + "zoom": true + }, + + // Add in properties whose names you wish to fix before + // setting or getting the value + cssProps: { + // normalize float css property + "float": jQuery.support.cssFloat ? "cssFloat" : "styleFloat" + }, + + // Get and set the style property on a DOM Node + style: function( elem, name, value, extra ) { + // Don't set styles on text and comment nodes + if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { + return; + } + + // Make sure that we're working with the right name + var ret, type, hooks, + origName = jQuery.camelCase( name ), + style = elem.style; + + name = jQuery.cssProps[ origName ] || ( jQuery.cssProps[ origName ] = vendorPropName( style, origName ) ); + + // gets hook for the prefixed version + // followed by the unprefixed version + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // Check if we're setting a value + if ( value !== undefined ) { + type = typeof value; + + // convert relative number strings (+= or -=) to relative numbers. #7345 + if ( type === "string" && (ret = rrelNum.exec( value )) ) { + value = ( ret[1] + 1 ) * ret[2] + parseFloat( jQuery.css( elem, name ) ); + // Fixes bug #9237 + type = "number"; + } + + // Make sure that NaN and null values aren't set. See: #7116 + if ( value == null || type === "number" && isNaN( value ) ) { + return; + } + + // If a number was passed in, add 'px' to the (except for certain CSS properties) + if ( type === "number" && !jQuery.cssNumber[ origName ] ) { + value += "px"; + } + + // Fixes #8908, it can be done more correctly by specifing setters in cssHooks, + // but it would mean to define eight (for every problematic property) identical functions + if ( !jQuery.support.clearCloneStyle && value === "" && name.indexOf("background") === 0 ) { + style[ name ] = "inherit"; + } + + // If a hook was provided, use that value, otherwise just set the specified value + if ( !hooks || !("set" in hooks) || (value = hooks.set( elem, value, extra )) !== undefined ) { + + // Wrapped to prevent IE from throwing errors when 'invalid' values are provided + // Fixes bug #5509 + try { + style[ name ] = value; + } catch(e) {} + } + + } else { + // If a hook was provided get the non-computed value from there + if ( hooks && "get" in hooks && (ret = hooks.get( elem, false, extra )) !== undefined ) { + return ret; + } + + // Otherwise just get the value from the style object + return style[ name ]; + } + }, + + css: function( elem, name, extra, styles ) { + var num, val, hooks, + origName = jQuery.camelCase( name ); + + // Make sure that we're working with the right name + name = jQuery.cssProps[ origName ] || ( jQuery.cssProps[ origName ] = vendorPropName( elem.style, origName ) ); + + // gets hook for the prefixed version + // followed by the unprefixed version + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // If a hook was provided get the computed value from there + if ( hooks && "get" in hooks ) { + val = hooks.get( elem, true, extra ); + } + + // Otherwise, if a way to get the computed value exists, use that + if ( val === undefined ) { + val = curCSS( elem, name, styles ); + } + + //convert "normal" to computed value + if ( val === "normal" && name in cssNormalTransform ) { + val = cssNormalTransform[ name ]; + } + + // Return, converting to number if forced or a qualifier was provided and val looks numeric + if ( extra === "" || extra ) { + num = parseFloat( val ); + return extra === true || jQuery.isNumeric( num ) ? num || 0 : val; + } + return val; + } +}); + +// NOTE: we've included the "window" in window.getComputedStyle +// because jsdom on node.js will break without it. +if ( window.getComputedStyle ) { + getStyles = function( elem ) { + return window.getComputedStyle( elem, null ); + }; + + curCSS = function( elem, name, _computed ) { + var width, minWidth, maxWidth, + computed = _computed || getStyles( elem ), + + // getPropertyValue is only needed for .css('filter') in IE9, see #12537 + ret = computed ? computed.getPropertyValue( name ) || computed[ name ] : undefined, + style = elem.style; + + if ( computed ) { + + if ( ret === "" && !jQuery.contains( elem.ownerDocument, elem ) ) { + ret = jQuery.style( elem, name ); + } + + // A tribute to the "awesome hack by Dean Edwards" + // Chrome < 17 and Safari 5.0 uses "computed value" instead of "used value" for margin-right + // Safari 5.1.7 (at least) returns percentage for a larger set of values, but width seems to be reliably pixels + // this is against the CSSOM draft spec: http://dev.w3.org/csswg/cssom/#resolved-values + if ( rnumnonpx.test( ret ) && rmargin.test( name ) ) { + + // Remember the original values + width = style.width; + minWidth = style.minWidth; + maxWidth = style.maxWidth; + + // Put in the new values to get a computed value out + style.minWidth = style.maxWidth = style.width = ret; + ret = computed.width; + + // Revert the changed values + style.width = width; + style.minWidth = minWidth; + style.maxWidth = maxWidth; + } + } + + return ret; + }; +} else if ( document.documentElement.currentStyle ) { + getStyles = function( elem ) { + return elem.currentStyle; + }; + + curCSS = function( elem, name, _computed ) { + var left, rs, rsLeft, + computed = _computed || getStyles( elem ), + ret = computed ? computed[ name ] : undefined, + style = elem.style; + + // Avoid setting ret to empty string here + // so we don't default to auto + if ( ret == null && style && style[ name ] ) { + ret = style[ name ]; + } + + // From the awesome hack by Dean Edwards + // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 + + // If we're not dealing with a regular pixel number + // but a number that has a weird ending, we need to convert it to pixels + // but not position css attributes, as those are proportional to the parent element instead + // and we can't measure the parent instead because it might trigger a "stacking dolls" problem + if ( rnumnonpx.test( ret ) && !rposition.test( name ) ) { + + // Remember the original values + left = style.left; + rs = elem.runtimeStyle; + rsLeft = rs && rs.left; + + // Put in the new values to get a computed value out + if ( rsLeft ) { + rs.left = elem.currentStyle.left; + } + style.left = name === "fontSize" ? "1em" : ret; + ret = style.pixelLeft + "px"; + + // Revert the changed values + style.left = left; + if ( rsLeft ) { + rs.left = rsLeft; + } + } + + return ret === "" ? "auto" : ret; + }; +} + +function setPositiveNumber( elem, value, subtract ) { + var matches = rnumsplit.exec( value ); + return matches ? + // Guard against undefined "subtract", e.g., when used as in cssHooks + Math.max( 0, matches[ 1 ] - ( subtract || 0 ) ) + ( matches[ 2 ] || "px" ) : + value; +} + +function augmentWidthOrHeight( elem, name, extra, isBorderBox, styles ) { + var i = extra === ( isBorderBox ? "border" : "content" ) ? + // If we already have the right measurement, avoid augmentation + 4 : + // Otherwise initialize for horizontal or vertical properties + name === "width" ? 1 : 0, + + val = 0; + + for ( ; i < 4; i += 2 ) { + // both box models exclude margin, so add it if we want it + if ( extra === "margin" ) { + val += jQuery.css( elem, extra + cssExpand[ i ], true, styles ); + } + + if ( isBorderBox ) { + // border-box includes padding, so remove it if we want content + if ( extra === "content" ) { + val -= jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + } + + // at this point, extra isn't border nor margin, so remove border + if ( extra !== "margin" ) { + val -= jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + } else { + // at this point, extra isn't content, so add padding + val += jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); + + // at this point, extra isn't content nor padding, so add border + if ( extra !== "padding" ) { + val += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); + } + } + } + + return val; +} + +function getWidthOrHeight( elem, name, extra ) { + + // Start with offset property, which is equivalent to the border-box value + var valueIsBorderBox = true, + val = name === "width" ? elem.offsetWidth : elem.offsetHeight, + styles = getStyles( elem ), + isBorderBox = jQuery.support.boxSizing && jQuery.css( elem, "boxSizing", false, styles ) === "border-box"; + + // some non-html elements return undefined for offsetWidth, so check for null/undefined + // svg - https://bugzilla.mozilla.org/show_bug.cgi?id=649285 + // MathML - https://bugzilla.mozilla.org/show_bug.cgi?id=491668 + if ( val <= 0 || val == null ) { + // Fall back to computed then uncomputed css if necessary + val = curCSS( elem, name, styles ); + if ( val < 0 || val == null ) { + val = elem.style[ name ]; + } + + // Computed unit is not pixels. Stop here and return. + if ( rnumnonpx.test(val) ) { + return val; + } + + // we need the check for style in case a browser which returns unreliable values + // for getComputedStyle silently falls back to the reliable elem.style + valueIsBorderBox = isBorderBox && ( jQuery.support.boxSizingReliable || val === elem.style[ name ] ); + + // Normalize "", auto, and prepare for extra + val = parseFloat( val ) || 0; + } + + // use the active box-sizing model to add/subtract irrelevant styles + return ( val + + augmentWidthOrHeight( + elem, + name, + extra || ( isBorderBox ? "border" : "content" ), + valueIsBorderBox, + styles + ) + ) + "px"; +} + +// Try to determine the default display value of an element +function css_defaultDisplay( nodeName ) { + var doc = document, + display = elemdisplay[ nodeName ]; + + if ( !display ) { + display = actualDisplay( nodeName, doc ); + + // If the simple way fails, read from inside an iframe + if ( display === "none" || !display ) { + // Use the already-created iframe if possible + iframe = ( iframe || + jQuery(" + + + + diff --git a/www/analytics/misc/others/iframeWidget_localhost.php b/www/analytics/misc/others/iframeWidget_localhost.php new file mode 100644 index 00000000..73bfc5c5 --- /dev/null +++ b/www/analytics/misc/others/iframeWidget_localhost.php @@ -0,0 +1,63 @@ + + + +

    Embedding the Piwik Country widget in an Iframe

    + +

    Loads a widget from localhost/trunk/ with login=root, pwd=test. Widget URL

    + +
    + +
    + +
    + +init(); +$widgets = WidgetsList::get(); +foreach ($widgets as $category => $widgetsInCategory) { + echo '

    ' . $category . '

    '; + foreach ($widgetsInCategory as $widget) { + echo '

    ' . $widget['name'] . '

    '; + $widgetUrl = UrlHelper::getArrayFromQueryString($url); + $widgetUrl['moduleToWidgetize'] = $widget['parameters']['module']; + $widgetUrl['actionToWidgetize'] = $widget['parameters']['action']; + $parameters = $widget['parameters']; + unset($parameters['module']); + unset($parameters['action']); + foreach ($parameters as $name => $value) { + if (is_array($value)) { + $value = current($value); + } + $widgetUrl[$name] = $value; + } + $widgetUrl = Url::getQueryStringFromParameters($widgetUrl); + + echo '
    '; + + } +} +?> + + diff --git a/www/analytics/misc/others/phpstorm-codestyles/Piwik_codestyle.xml b/www/analytics/misc/others/phpstorm-codestyles/Piwik_codestyle.xml new file mode 100644 index 00000000..0fd9a921 --- /dev/null +++ b/www/analytics/misc/others/phpstorm-codestyles/Piwik_codestyle.xml @@ -0,0 +1,159 @@ + + + + diff --git a/www/analytics/misc/others/phpstorm-codestyles/README.md b/www/analytics/misc/others/phpstorm-codestyles/README.md new file mode 100644 index 00000000..5f910b0a --- /dev/null +++ b/www/analytics/misc/others/phpstorm-codestyles/README.md @@ -0,0 +1,21 @@ +Phpstorm has an awesome feature called "Reformat code" which reformats all PHP code to follow a particular selected coding style. + +Piwik uses PSR coding standard for php source code. We use a slightly customized PSR style +(because the default PSR style in Phpstorm results in some unwanted changes). + +Steps: + * Use latest Phpstorm + * Copy this Piwik_codestyle.xml file in your ~/.WebIde60/config/codestyles/ + * If you use Windows or Mac see which path to copy at: http://intellij-support.jetbrains.com/entries/23358108 + * To automatically link to the file in Piwik: + `$ ln -s ~/dev/piwik-master/misc/others/phpstorm-codestyles/Piwik_codestyle.xml ~/.WebIde70/config/codestyles/Piwik_codestyle.xml` + + * Restart PhpStorm. + * Select this coding in Settings>Code style. + +Phpstorm can also be configured to apply the style automatically before commit. + +You are now writing code that respects Piwik coding standards. Enjoy! + +Reference: http://piwik.org/participate/coding-standards/ + diff --git a/www/analytics/misc/others/stress.sh b/www/analytics/misc/others/stress.sh new file mode 100644 index 00000000..1482fba9 --- /dev/null +++ b/www/analytics/misc/others/stress.sh @@ -0,0 +1,5 @@ +echo " +Stress testing piwik.php +======================== +" +ab -n5000 -c50 "http://localhost/dev/piwiktrunk/piwik.php?url=http%3A%2F%2Flocalhost%2Fdev%2Fpiwiktrunk%2F&action_name=&idsite=1&res=1280x1024&col=24&h=18&m=46&s=59&fla=1&dir=0&qt=1&realp=1&pdf=0&wma=1&java=1&cookie=1&title=&urlref=" diff --git a/www/analytics/misc/others/test_cookies_GenerateHundredsWebsitesAndVisits.php b/www/analytics/misc/others/test_cookies_GenerateHundredsWebsitesAndVisits.php new file mode 100644 index 00000000..70040f49 --- /dev/null +++ b/www/analytics/misc/others/test_cookies_GenerateHundredsWebsitesAndVisits.php @@ -0,0 +1,27 @@ +init(); +Piwik::setUserHasSuperUserAccess(); +$count = 100; +for ($i = 0; $i <= $count; $i++) { + $id = API::getInstance()->addSite(Common::getRandomString(), 'http://piwik.org'); + $t = new PiwikTracker($id, 'http://localhost/trunk/piwik.php'); + echo $id . "
    "; +} + diff --git a/www/analytics/misc/others/test_generateLotsVisitsWebsites.php b/www/analytics/misc/others/test_generateLotsVisitsWebsites.php new file mode 100644 index 00000000..4dd23c5f --- /dev/null +++ b/www/analytics/misc/others/test_generateLotsVisitsWebsites.php @@ -0,0 +1,154 @@ +init(); + +// SECURITY: DO NOT DELETE THIS LINE! +if (!Common::isPhpCliMode()) { + die("ERROR: Must be executed in CLI"); +} + +$process = new Piwik_StressTests_CopyLogs; +$process->init(); +$process->run(); +//$process->delete(); + +class Piwik_StressTests_CopyLogs +{ + function init() + { + $config = Config::getInstance(); + $config->log['log_only_when_debug_parameter'] = 0; + $config->log['log_writers'] = array('screen'); + $config->log['log_level'] = 'VERBOSE'; + } + + function run() + { + // Copy all visits in date range into TODAY + $startDate = '2011-08-12'; + $endDate = '2011-08-12'; + + $this->log("Starting..."); + $db = \Zend_Registry::get('db'); + + $initial = $this->getVisitsToday(); + $this->log(" Visits today so far: " . $initial); + $initialActions = $this->getActionsToday(); + $this->log(" Actions today: " . $initialActions); + $initialPurchasedItems = $this->getConversionItemsToday(); + $this->log(" Purchased items today: " . $initialPurchasedItems); + $initialConversions = $this->getConversionsToday(); + $this->log(" Conversions today: " . $initialConversions); + + $this->log(" Now copying visits between '$startDate' and '$endDate'..."); + $sql = "INSERT INTO " . Common::prefixTable('log_visit') . " (`idsite`, `idvisitor`, `visitor_localtime`, `visitor_returning`, `visitor_count_visits`, `visit_first_action_time`, `visit_last_action_time`, `visit_exit_idaction_url`, `visit_exit_idaction_name`, `visit_entry_idaction_url`, `visit_entry_idaction_name`, `visit_total_actions`, `visit_total_time`, `visit_goal_converted`, `visit_goal_buyer`, `referer_type`, `referer_name`, `referer_url`, `referer_keyword`, `config_id`, `config_os`, `config_browser_name`, `config_browser_version`, `config_resolution`, `config_pdf`, `config_flash`, `config_java`, `config_director`, `config_quicktime`, `config_realplayer`, `config_windowsmedia`, `config_gears`, `config_silverlight`, `config_cookie`, `location_ip`, `location_browser_lang`, `location_country`, `location_provider`, `custom_var_k1`, `custom_var_v1`, `custom_var_k2`, `custom_var_v2`, `custom_var_k3`, `custom_var_v3`, `custom_var_k4`, `custom_var_v4`, `custom_var_k5`, `custom_var_v5`, `visitor_days_since_last`, `visitor_days_since_order`, `visitor_days_since_first`) + SELECT `idsite`, `idvisitor`, `visitor_localtime`, `visitor_returning`, `visitor_count_visits`, CONCAT(CURRENT_DATE() , \" \", FLOOR(RAND()*24) , \":\",FLOOR(RAND()*60),\":\",FLOOR(RAND()*60)), CONCAT(CURRENT_DATE() , \" \", FLOOR(RAND()*24) , \":\",FLOOR(RAND()*60),\":\",FLOOR(RAND()*60)), `visit_exit_idaction_url`, `visit_exit_idaction_name`, `visit_entry_idaction_url`, `visit_entry_idaction_name`, `visit_total_actions`, `visit_total_time`, `visit_goal_converted`, `visit_goal_buyer`, `referer_type`, `referer_name`, `referer_url`, `referer_keyword`, `config_id`, `config_os`, `config_browser_name`, `config_browser_version`, `config_resolution`, `config_pdf`, `config_flash`, `config_java`, `config_director`, `config_quicktime`, `config_realplayer`, `config_windowsmedia`, `config_gears`, `config_silverlight`, `config_cookie`, `location_ip`, `location_browser_lang`, `location_country`, `location_provider`, `custom_var_k1`, `custom_var_v1`, `custom_var_k2`, `custom_var_v2`, `custom_var_k3`, `custom_var_v3`, `custom_var_k4`, `custom_var_v4`, `custom_var_k5`, `custom_var_v5`, `visitor_days_since_last`, `visitor_days_since_order`, `visitor_days_since_first` + FROM `" . Common::prefixTable('log_visit') . "` + WHERE idsite >= 1 AND date(visit_last_action_time) between '$startDate' and '$endDate' ;"; + $result = $db->query($sql); + + $this->log(" Copying actions..."); + $sql = "INSERT INTO " . Common::prefixTable('log_link_visit_action') . " (`idsite`, `idvisitor`, `server_time`, `idvisit`, `idaction_url`, `idaction_url_ref`, `idaction_name`, `idaction_name_ref`, `time_spent_ref_action`, `custom_var_k1`, `custom_var_v1`, `custom_var_k2`, `custom_var_v2`, `custom_var_k3`, `custom_var_v3`, `custom_var_k4`, `custom_var_v4`, `custom_var_k5`, `custom_var_v5`) + SELECT `idsite`, `idvisitor`, CONCAT(CURRENT_DATE() , \" \", FLOOR(RAND()*24) , \":\",FLOOR(RAND()*60),\":\",FLOOR(RAND()*60)), `idvisit`, `idaction_url`, `idaction_url_ref`, `idaction_name`, `idaction_name_ref`, `time_spent_ref_action`, `custom_var_k1`, `custom_var_v1`, `custom_var_k2`, `custom_var_v2`, `custom_var_k3`, `custom_var_v3`, `custom_var_k4`, `custom_var_v4`, `custom_var_k5`, `custom_var_v5` + FROM `" . Common::prefixTable('log_link_visit_action') . "` + WHERE idsite >= 1 AND date(server_time) between '$startDate' and '$endDate' + + ;"; // LIMIT 1000000 + $result = $db->query($sql); + + $this->log(" Copying conversions..."); + $sql = "INSERT IGNORE INTO `" . Common::prefixTable('log_conversion') . "` (`idvisit`, `idsite`, `visitor_days_since_first`, `visitor_days_since_order`, `visitor_count_visits`, `idvisitor`, `server_time`, `idaction_url`, `idlink_va`, `referer_visit_server_date`, `referer_type`, `referer_name`, `referer_keyword`, `visitor_returning`, `location_country`, `url`, `idgoal`, `revenue`, `buster`, `idorder`, `custom_var_k1`, `custom_var_v1`, `custom_var_k2`, `custom_var_v2`, `custom_var_k3`, `custom_var_v3`, `custom_var_k4`, `custom_var_v4`, `custom_var_k5`, `custom_var_v5`, `items`, `revenue_subtotal`, `revenue_tax`, `revenue_shipping`, `revenue_discount`) + SELECT `idvisit`, `idsite`, `visitor_days_since_first`, `visitor_days_since_order`, `visitor_count_visits`, `idvisitor`, CONCAT(CURRENT_DATE() , \" \", FLOOR(RAND()*24) , \":\",FLOOR(RAND()*60),\":\",FLOOR(RAND()*60)), `idaction_url`, `idlink_va`, `referer_visit_server_date`, `referer_type`, `referer_name`, `referer_keyword`, `visitor_returning`, `location_country`, `url`, `idgoal`, `revenue`, FLOOR(`buster` * RAND()), CONCAT(`idorder`,SUBSTRING(MD5(RAND()) FROM 1 FOR 9)) , `custom_var_k1`, `custom_var_v1`, `custom_var_k2`, `custom_var_v2`, `custom_var_k3`, `custom_var_v3`, `custom_var_k4`, `custom_var_v4`, `custom_var_k5`, `custom_var_v5`, `items`, `revenue_subtotal`, `revenue_tax`, `revenue_shipping`, `revenue_discount` + FROM `" . Common::prefixTable('log_conversion') . "` + WHERE idsite >= 1 AND date(server_time) between '$startDate' and '$endDate' ;"; + $result = $db->query($sql); + + $this->log(" Copying purchased items..."); + $sql = "INSERT INTO `" . Common::prefixTable('log_conversion_item') . "` (`idsite`, `idvisitor`, `server_time`, `idvisit`, `idorder`, `idaction_sku`, `idaction_name`, `idaction_category`, `price`, `quantity`, `deleted`) + SELECT `idsite`, `idvisitor`, CONCAT(CURRENT_DATE() , \" \", TIME(`server_time`)), `idvisit`, CONCAT(`idorder`,SUBSTRING(MD5(RAND()) FROM 1 FOR 9)) , `idaction_sku`, `idaction_name`, `idaction_category`, `price`, `quantity`, `deleted` + FROM `" . Common::prefixTable('log_conversion_item') . "` + WHERE idsite >= 1 AND date(server_time) between '$startDate' and '$endDate' ;"; + $result = $db->query($sql); + + $now = $this->getVisitsToday(); + $actions = $this->getActionsToday(); + $purchasedItems = $this->getConversionItemsToday(); + $conversions = $this->getConversionsToday(); + + $this->log(" -------------------------------------"); + $this->log(" Today visits after import: " . $now); + $this->log(" Actions: " . $actions); + $this->log(" Purchased items: " . $purchasedItems); + $this->log(" Conversions: " . $conversions); + $this->log(" - New visits created: " . ($now - $initial)); + $this->log(" - Actions created: " . ($actions - $initialActions)); + $this->log(" - New conversions created: " . ($conversions - $initialConversions)); + $this->log(" - New purchased items created: " . ($purchasedItems - $initialPurchasedItems)); + $this->log("done"); + } + + function delete() + { + $this->log("Deleting logs for today..."); + $db = \Zend_Registry::get('db'); + $sql = "DELETE FROM " . Common::prefixTable('log_visit') . " + WHERE date(visit_last_action_time) = CURRENT_DATE();"; + $db->query($sql); + foreach (array('log_link_visit_action', 'log_conversion', 'log_conversion_item') as $table) { + $sql = "DELETE FROM " . Common::prefixTable($table) . " + WHERE date(server_time) = CURRENT_DATE();"; + $db->query($sql); + } + + $tablesToOptimize = array( + Common::prefixTable('log_link_visit_action'), + Common::prefixTable('log_conversion'), + Common::prefixTable('log_conversion_item'), + Common::prefixTable('log_visit') + ); + \Piwik\Db::optimizeTables($tablesToOptimize); + + $this->log("done"); + } + + function log($m) + { + Log::info($m); + } + + function getVisitsToday() + { + $sql = "SELECT count(*) FROM `" . Common::prefixTable('log_visit') . "` WHERE idsite >= 1 AND DATE(`visit_last_action_time`) = CURRENT_DATE;"; + return \Zend_Registry::get('db')->fetchOne($sql); + } + + function getConversionItemsToday($table = 'log_conversion_item') + { + $sql = "SELECT count(*) FROM `" . Common::prefixTable($table) . "` WHERE idsite >= 1 AND DATE(`server_time`) = CURRENT_DATE;"; + return \Zend_Registry::get('db')->fetchOne($sql); + } + + function getConversionsToday() + { + return $this->getConversionItemsToday($table = "log_conversion"); + } + + function getActionsToday() + { + $sql = "SELECT count(*) FROM `" . Common::prefixTable('log_link_visit_action') . "` WHERE idsite >= 1 AND DATE(`server_time`) = CURRENT_DATE;"; + return \Zend_Registry::get('db')->fetchOne($sql); + } +} \ No newline at end of file diff --git a/www/analytics/misc/others/tracker_simpleImageTracker.php b/www/analytics/misc/others/tracker_simpleImageTracker.php new file mode 100644 index 00000000..30f6c2b2 --- /dev/null +++ b/www/analytics/misc/others/tracker_simpleImageTracker.php @@ -0,0 +1,30 @@ + Page titles'); + +?> + + + + + + +This page loads a Simple Tracker request to Piwik website id=1 + +'; +?> + + \ No newline at end of file diff --git a/www/analytics/misc/others/uninstall-delete-piwik-directory.php b/www/analytics/misc/others/uninstall-delete-piwik-directory.php new file mode 100644 index 00000000..e05f7025 --- /dev/null +++ b/www/analytics/misc/others/uninstall-delete-piwik-directory.php @@ -0,0 +1,26 @@ + + +

    Number of visits per week for the last 52 weeks

    + +
    + +
    + + diff --git a/www/analytics/misc/proxy-hide-piwik-url/README.md b/www/analytics/misc/proxy-hide-piwik-url/README.md new file mode 100644 index 00000000..016bbe15 --- /dev/null +++ b/www/analytics/misc/proxy-hide-piwik-url/README.md @@ -0,0 +1,55 @@ +## Piwik Proxy Hide URL +This script allows to track statistics using Piwik, without revealing the +Piwik Server URL. This is useful for users who track multiple websites +on the same Piwik server, but don't want to show the Piwik server URL in +the source code of all tracked websites. + +### Requirements +To run this properly you will need + + * Piwik server latest version + * One or several website(s) to track with this Piwik server, for example http://trackedsite.com + * The website to track must run on a server with PHP5 support + * In your php.ini you must check that the following is set: `allow_url_fopen = On` + +### How to track trackedsite.com in your Piwik without revealing the Piwik server URL? + +1. In your Piwik server, login as Super user +2. create a user, set the login for example: "UserTrackingAPI" +3. Assign this user "admin" permission on all websites you wish to track without showing the Piwik URL +4. Copy the "token_auth" for this user, and paste it below in this file, in `$TOKEN_AUTH = "xyz"` +5. In this file, below this help test, edit $PIWIK_URL variable and change http://your-piwik-domain.example.org/piwik/ with the URL to your Piwik server. +6. Upload this modified piwik.php file in the website root directory, for example at: http://trackedsite.com/piwik.php + This file (http://trackedsite.com/piwik.php) will be called by the Piwik Javascript, + instead of calling directly the (secret) Piwik Server URL (http://your-piwik-domain.example.org/piwik/). +7. You now need to add the modified Piwik Javascript Code to the footer of your pages at http://trackedsite.com/ + Go to Piwik > Settings > Websites > Show Javascript Tracking Code. + Copy the Javascript snippet. Then, edit this code and change the last lines to the following: + + ``` + [...] + (function() { + var u=(("https:" == document.location.protocol) ? "https" : "http") + "://trackedsite.com/"; + _paq.push(["setTrackerUrl", u+"piwik.php"]); + _paq.push(["setSiteId", "trackedsite-id"]); + var d=document, g=d.createElement("script"), s=d.getElementsByTagName("script")[0]; g.type="text/javascript"; + g.defer=true; g.async=true; g.src=u+"piwik.php"; s.parentNode.insertBefore(g,s); + })(); + + + ``` + + What's changed in this code snippet compared to the normal Piwik code? + + * the (secret) Piwik URL is now replaced by your website URL + * the "piwik.js" becomes "piwik.php" because this piwik.php proxy script will also display and proxy the Javascript file + * the `'; + } + + $lastCategory[$segment['type']] = $thisCategory; + + $exampleValues = isset($segment['acceptedValues']) + ? 'Example values: ' . $segment['acceptedValues'] . '' + : ''; + $restrictedToAdmin = isset($segment['permission']) ? '
    Note: This segment can only be used by an Admin user' : ''; + $output .= '
    + + + '; + + // Show only 2 custom variables and display message for rest + if ($customVariableWillBeDisplayed) { + $customVariables++; + if ($customVariables == count($onlyDisplay)) { + $output .= ''; + } + } + + if ($segment['type'] == 'dimension') { + $tableDimensions .= $output; + } else { + $tableMetrics .= $output; + } + } + + return " + Dimensions +
    ' . $thisCategory . '
    ' . $segment['segment'] . '' . $segment['name'] . $restrictedToAdmin . '
    ' . $exampleValues . '
    There are 5 custom variables available, so you can segment across any segment name and value range. +
    For example, customVariableName1==Type;customVariableValue1==Customer +
    Returns all visitors that have the Custom Variable "Type" set to "Customer". +
    Custom Variables of scope "page" can be queried separately. For example, to query the Custom Variable of scope "page", +
    stored in index 1, you would use the segment customVariablePageName1==ArticleLanguage;customVariablePageValue1==FR +
    + $tableDimensions +
    +
    + Metrics + + $tableMetrics +
    + "; + } +} diff --git a/www/analytics/plugins/API/ProcessedReport.php b/www/analytics/plugins/API/ProcessedReport.php new file mode 100644 index 00000000..cf0908e3 --- /dev/null +++ b/www/analytics/plugins/API/ProcessedReport.php @@ -0,0 +1,718 @@ +getReportMetadata($idSite, $period, $date, $hideMetricsDoc, $showSubtableReports); + + foreach ($reportsMetadata as $report) { + // See ArchiveProcessor/Aggregator.php - unique visitors are not processed for period != day + if (($period && $period != 'day') && !($apiModule == 'VisitsSummary' && $apiAction == 'get')) { + unset($report['metrics']['nb_uniq_visitors']); + } + if ($report['module'] == $apiModule + && $report['action'] == $apiAction + ) { + // No custom parameters + if (empty($apiParameters) + && empty($report['parameters']) + ) { + return array($report); + } + if (empty($report['parameters'])) { + continue; + } + $diff = array_diff($report['parameters'], $apiParameters); + if (empty($diff)) { + return array($report); + } + } + } + return false; + } + + /** + * Verfies whether the given report exists for the given site. + * + * @param int $idSite + * @param string $apiMethodUniqueId For example 'MultiSites_getAll' + * + * @return bool + */ + public function isValidReportForSite($idSite, $apiMethodUniqueId) + { + $report = $this->getReportMetadataByUniqueId($idSite, $apiMethodUniqueId); + + return !empty($report); + } + + /** + * Verfies whether the given metric belongs to the given report. + * + * @param int $idSite + * @param string $metric For example 'nb_visits' + * @param string $apiMethodUniqueId For example 'MultiSites_getAll' + * + * @return bool + */ + public function isValidMetricForReport($metric, $idSite, $apiMethodUniqueId) + { + $translation = $this->translateMetric($metric, $idSite, $apiMethodUniqueId); + + return !empty($translation); + } + + public function getReportMetadataByUniqueId($idSite, $apiMethodUniqueId) + { + $metadata = $this->getReportMetadata(array($idSite)); + + foreach ($metadata as $report) { + if ($report['uniqueId'] == $apiMethodUniqueId) { + return $report; + } + } + } + + /** + * Translates the given metric in case the report exists and in case the metric acutally belongs to the report. + * + * @param string $metric For example 'nb_visits' + * @param int $idSite + * @param string $apiMethodUniqueId For example 'MultiSites_getAll' + * + * @return null|string + */ + public function translateMetric($metric, $idSite, $apiMethodUniqueId) + { + $report = $this->getReportMetadataByUniqueId($idSite, $apiMethodUniqueId); + + if (empty($report)) { + return; + } + + $properties = array('metrics', 'processedMetrics', 'processedMetricsGoal'); + + foreach ($properties as $prop) { + if (!empty($report[$prop]) && is_array($report[$prop]) && array_key_exists($metric, $report[$prop])) { + return $report[$prop][$metric]; + } + } + } + + /** + * Triggers a hook to ask plugins for available Reports. + * Returns metadata information about each report (category, name, dimension, metrics, etc.) + * + * @param string $idSites Comma separated list of website Ids + * @param bool|string $period + * @param bool|Date $date + * @param bool $hideMetricsDoc + * @param bool $showSubtableReports + * @return array + */ + public function getReportMetadata($idSites, $period = false, $date = false, $hideMetricsDoc = false, $showSubtableReports = false) + { + $idSites = Site::getIdSitesFromIdSitesString($idSites); + if (!empty($idSites)) { + Piwik::checkUserHasViewAccess($idSites); + } + + $parameters = array('idSites' => $idSites, 'period' => $period, 'date' => $date); + + $availableReports = array(); + + /** + * Triggered when gathering metadata for all available reports. + * + * Plugins that define new reports should use this event to make them available in via + * the metadata API. By doing so, the report will become available in scheduled reports + * as well as in the Piwik Mobile App. In fact, any third party app that uses the metadata + * API will automatically have access to the new report. + * + * @param string &$availableReports The list of available reports. Append to this list + * to make a report available. + * + * Every element of this array must contain the following + * information: + * + * - **category**: A translated string describing the report's category. + * - **name**: The translated display title of the report. + * - **module**: The plugin of the report. + * - **action**: The API method that serves the report. + * + * The following information is optional: + * + * - **dimension**: The report's [dimension](/guides/all-about-analytics-data#dimensions) if any. + * - **metrics**: An array mapping metric names with their display names. + * - **metricsDocumentation**: An array mapping metric names with their + * translated documentation. + * - **processedMetrics**: The array of metrics in the report that are + * calculated using existing metrics. Can be set to + * `false` if the report contains no processed + * metrics. + * - **order**: The order of the report in the list of reports + * with the same category. + * + * @param array $parameters Contains the values of the sites and period we are + * getting reports for. Some reports depend on this data. + * For example, Goals reports depend on the site IDs being + * requested. Contains the following information: + * + * - **idSites**: The array of site IDs we are getting reports for. + * - **period**: The period type, eg, `'day'`, `'week'`, `'month'`, + * `'year'`, `'range'`. + * - **date**: A string date within the period or a date range, eg, + * `'2013-01-01'` or `'2012-01-01,2013-01-01'`. + * + * TODO: put dimensions section in all about analytics data + */ + Piwik::postEvent('API.getReportMetadata', array(&$availableReports, $parameters)); + foreach ($availableReports as &$availableReport) { + if (!isset($availableReport['metrics'])) { + $availableReport['metrics'] = Metrics::getDefaultMetrics(); + } + if (!isset($availableReport['processedMetrics'])) { + $availableReport['processedMetrics'] = Metrics::getDefaultProcessedMetrics(); + } + + if ($hideMetricsDoc) // remove metric documentation if it's not wanted + { + unset($availableReport['metricsDocumentation']); + } else if (!isset($availableReport['metricsDocumentation'])) { + // set metric documentation to default if it's not set + $availableReport['metricsDocumentation'] = Metrics::getDefaultMetricsDocumentation(); + } + } + + /** + * Triggered after all available reports are collected. + * + * This event can be used to modify the report metadata of reports in other plugins. You + * could, for example, add custom metrics to every report or remove reports from the list + * of available reports. + * + * @param array &$availableReports List of all report metadata. Read the {@hook API.getReportMetadata} + * docs to see what this array contains. + * @param array $parameters Contains the values of the sites and period we are + * getting reports for. Some report depend on this data. + * For example, Goals reports depend on the site IDs being + * request. Contains the following information: + * + * - **idSites**: The array of site IDs we are getting reports for. + * - **period**: The period type, eg, `'day'`, `'week'`, `'month'`, + * `'year'`, `'range'`. + * - **date**: A string date within the period or a date range, eg, + * `'2013-01-01'` or `'2012-01-01,2013-01-01'`. + */ + Piwik::postEvent('API.getReportMetadata.end', array(&$availableReports, $parameters)); + + // Sort results to ensure consistent order + usort($availableReports, array($this, 'sort')); + + // Add the magic API.get report metadata aggregating all plugins API.get API calls automatically + $this->addApiGetMetdata($availableReports); + + $knownMetrics = array_merge(Metrics::getDefaultMetrics(), Metrics::getDefaultProcessedMetrics()); + foreach ($availableReports as &$availableReport) { + // Ensure all metrics have a translation + $metrics = $availableReport['metrics']; + $cleanedMetrics = array(); + foreach ($metrics as $metricId => $metricTranslation) { + // When simply the column name was given, ie 'metric' => array( 'nb_visits' ) + // $metricTranslation is in this case nb_visits. We look for a known translation. + if (is_numeric($metricId) + && isset($knownMetrics[$metricTranslation]) + ) { + $metricId = $metricTranslation; + $metricTranslation = $knownMetrics[$metricTranslation]; + } + $cleanedMetrics[$metricId] = $metricTranslation; + } + $availableReport['metrics'] = $cleanedMetrics; + + // if hide/show columns specified, hide/show metrics & docs + $availableReport['metrics'] = $this->hideShowMetrics($availableReport['metrics']); + if (isset($availableReport['processedMetrics'])) { + $availableReport['processedMetrics'] = $this->hideShowMetrics($availableReport['processedMetrics']); + } + if (isset($availableReport['metricsDocumentation'])) { + $availableReport['metricsDocumentation'] = + $this->hideShowMetrics($availableReport['metricsDocumentation']); + } + + // Remove array elements that are false (to clean up API output) + foreach ($availableReport as $attributeName => $attributeValue) { + if (empty($attributeValue)) { + unset($availableReport[$attributeName]); + } + } + // when there are per goal metrics, don't display conversion_rate since it can differ from per goal sum + if (isset($availableReport['metricsGoal'])) { + unset($availableReport['processedMetrics']['conversion_rate']); + unset($availableReport['metricsGoal']['conversion_rate']); + } + + // Processing a uniqueId for each report, + // can be used by UIs as a key to match a given report + $uniqueId = $availableReport['module'] . '_' . $availableReport['action']; + if (!empty($availableReport['parameters'])) { + foreach ($availableReport['parameters'] as $key => $value) { + $uniqueId .= '_' . $key . '--' . $value; + } + } + $availableReport['uniqueId'] = $uniqueId; + + // Order is used to order reports internally, but not meant to be used outside + unset($availableReport['order']); + } + + // remove subtable reports + if (!$showSubtableReports) { + foreach ($availableReports as $idx => $report) { + if (isset($report['isSubtableReport']) && $report['isSubtableReport']) { + unset($availableReports[$idx]); + } + } + } + + return array_values($availableReports); // make sure array has contiguous key values + } + + /** + * API metadata are sorted by category/name, + * with a little tweak to replicate the standard Piwik category ordering + * + * @param string $a + * @param string $b + * @return int + */ + private function sort($a, $b) + { + static $order = null; + if (is_null($order)) { + $order = array( + Piwik::translate('General_MultiSitesSummary'), + Piwik::translate('VisitsSummary_VisitsSummary'), + Piwik::translate('Goals_Ecommerce'), + Piwik::translate('General_Actions'), + Piwik::translate('Events_Events'), + Piwik::translate('Actions_SubmenuSitesearch'), + Piwik::translate('Referrers_Referrers'), + Piwik::translate('Goals_Goals'), + Piwik::translate('General_Visitors'), + Piwik::translate('DevicesDetection_DevicesDetection'), + Piwik::translate('UserSettings_VisitorSettings'), + ); + } + return ($category = strcmp(array_search($a['category'], $order), array_search($b['category'], $order))) == 0 + ? (@$a['order'] < @$b['order'] ? -1 : 1) + : $category; + } + + /** + * Add the metadata for the API.get report + * In other plugins, this would hook on 'API.getReportMetadata' + */ + private function addApiGetMetdata(&$availableReports) + { + $metadata = array( + 'category' => Piwik::translate('General_API'), + 'name' => Piwik::translate('General_MainMetrics'), + 'module' => 'API', + 'action' => 'get', + 'metrics' => array(), + 'processedMetrics' => array(), + 'metricsDocumentation' => array(), + 'order' => 1 + ); + + $indexesToMerge = array('metrics', 'processedMetrics', 'metricsDocumentation'); + + foreach ($availableReports as $report) { + if ($report['action'] == 'get') { + foreach ($indexesToMerge as $index) { + if (isset($report[$index]) + && is_array($report[$index]) + ) { + $metadata[$index] = array_merge($metadata[$index], $report[$index]); + } + } + } + } + + $availableReports[] = $metadata; + } + + public function getProcessedReport($idSite, $period, $date, $apiModule, $apiAction, $segment = false, + $apiParameters = false, $idGoal = false, $language = false, + $showTimer = true, $hideMetricsDoc = false, $idSubtable = false, $showRawMetrics = false) + { + $timer = new Timer(); + if (empty($apiParameters)) { + $apiParameters = array(); + } + if (!empty($idGoal) + && empty($apiParameters['idGoal']) + ) { + $apiParameters['idGoal'] = $idGoal; + } + // Is this report found in the Metadata available reports? + $reportMetadata = $this->getMetadata($idSite, $apiModule, $apiAction, $apiParameters, $language, + $period, $date, $hideMetricsDoc, $showSubtableReports = true); + if (empty($reportMetadata)) { + throw new Exception("Requested report $apiModule.$apiAction for Website id=$idSite not found in the list of available reports. \n"); + } + $reportMetadata = reset($reportMetadata); + + // Generate Api call URL passing custom parameters + $parameters = array_merge($apiParameters, array( + 'method' => $apiModule . '.' . $apiAction, + 'idSite' => $idSite, + 'period' => $period, + 'date' => $date, + 'format' => 'original', + 'serialize' => '0', + 'language' => $language, + 'idSubtable' => $idSubtable, + )); + if (!empty($segment)) $parameters['segment'] = $segment; + + $url = Url::getQueryStringFromParameters($parameters); + $request = new Request($url); + try { + /** @var DataTable */ + $dataTable = $request->process(); + } catch (Exception $e) { + throw new Exception("API returned an error: " . $e->getMessage() . " at " . basename($e->getFile()) . ":" . $e->getLine() . "\n"); + } + + list($newReport, $columns, $rowsMetadata, $totals) = $this->handleTableReport($idSite, $dataTable, $reportMetadata, $showRawMetrics); + foreach ($columns as $columnId => &$name) { + $name = ucfirst($name); + } + $website = new Site($idSite); + + $period = Period::factory($period, $date); + $period = $period->getLocalizedLongString(); + + $return = array( + 'website' => $website->getName(), + 'prettyDate' => $period, + 'metadata' => $reportMetadata, + 'columns' => $columns, + 'reportData' => $newReport, + 'reportMetadata' => $rowsMetadata, + 'reportTotal' => $totals + ); + if ($showTimer) { + $return['timerMillis'] = $timer->getTimeMs(0); + } + return $return; + } + + /** + * Enhance a $dataTable using metadata : + * + * - remove metrics based on $reportMetadata['metrics'] + * - add 0 valued metrics if $dataTable doesn't provide all $reportMetadata['metrics'] + * - format metric values to a 'human readable' format + * - extract row metadata to a separate Simple|Set : $rowsMetadata + * - translate metric names to a separate array : $columns + * + * @param int $idSite enables monetary value formatting based on site currency + * @param \Piwik\DataTable\Map|\Piwik\DataTable\Simple $dataTable + * @param array $reportMetadata + * @param bool $showRawMetrics + * @return array Simple|Set $newReport with human readable format & array $columns list of translated column names & Simple|Set $rowsMetadata + */ + private function handleTableReport($idSite, $dataTable, &$reportMetadata, $showRawMetrics = false) + { + $hasDimension = isset($reportMetadata['dimension']); + $columns = $reportMetadata['metrics']; + + if ($hasDimension) { + $columns = array_merge( + array('label' => $reportMetadata['dimension']), + $columns + ); + + if (isset($reportMetadata['processedMetrics'])) { + $processedMetricsAdded = Metrics::getDefaultProcessedMetrics(); + foreach ($processedMetricsAdded as $processedMetricId => $processedMetricTranslation) { + // this processed metric can be displayed for this report + if (isset($reportMetadata['processedMetrics'][$processedMetricId])) { + $columns[$processedMetricId] = $processedMetricTranslation; + } + } + } + + // Display the global Goal metrics + if (isset($reportMetadata['metricsGoal'])) { + $metricsGoalDisplay = array('revenue'); + // Add processed metrics to be displayed for this report + foreach ($metricsGoalDisplay as $goalMetricId) { + if (isset($reportMetadata['metricsGoal'][$goalMetricId])) { + $columns[$goalMetricId] = $reportMetadata['metricsGoal'][$goalMetricId]; + } + } + } + + if (isset($reportMetadata['processedMetrics'])) { + // Add processed metrics + $dataTable->filter('AddColumnsProcessedMetrics', array($deleteRowsWithNoVisit = false)); + } + } + + $columns = $this->hideShowMetrics($columns); + $totals = array(); + + // $dataTable is an instance of Set when multiple periods requested + if ($dataTable instanceof DataTable\Map) { + // Need a new Set to store the 'human readable' values + $newReport = new DataTable\Map(); + $newReport->setKeyName("prettyDate"); + + // Need a new Set to store report metadata + $rowsMetadata = new DataTable\Map(); + $rowsMetadata->setKeyName("prettyDate"); + + // Process each Simple entry + foreach ($dataTable->getDataTables() as $label => $simpleDataTable) { + $this->removeEmptyColumns($columns, $reportMetadata, $simpleDataTable); + + list($enhancedSimpleDataTable, $rowMetadata) = $this->handleSimpleDataTable($idSite, $simpleDataTable, $columns, $hasDimension, $showRawMetrics); + $enhancedSimpleDataTable->setAllTableMetadata($simpleDataTable->getAllTableMetadata()); + + $period = $simpleDataTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getLocalizedLongString(); + $newReport->addTable($enhancedSimpleDataTable, $period); + $rowsMetadata->addTable($rowMetadata, $period); + + $totals = $this->aggregateReportTotalValues($simpleDataTable, $totals); + } + } else { + $this->removeEmptyColumns($columns, $reportMetadata, $dataTable); + list($newReport, $rowsMetadata) = $this->handleSimpleDataTable($idSite, $dataTable, $columns, $hasDimension, $showRawMetrics); + + $totals = $this->aggregateReportTotalValues($dataTable, $totals); + } + + return array( + $newReport, + $columns, + $rowsMetadata, + $totals + ); + } + + /** + * Removes metrics from the list of columns and the report meta data if they are marked empty + * in the data table meta data. + */ + private function removeEmptyColumns(&$columns, &$reportMetadata, $dataTable) + { + $emptyColumns = $dataTable->getMetadata(DataTable::EMPTY_COLUMNS_METADATA_NAME); + + if (!is_array($emptyColumns)) { + return; + } + + $columns = $this->hideShowMetrics($columns, $emptyColumns); + + if (isset($reportMetadata['metrics'])) { + $reportMetadata['metrics'] = $this->hideShowMetrics($reportMetadata['metrics'], $emptyColumns); + } + + if (isset($reportMetadata['metricsDocumentation'])) { + $reportMetadata['metricsDocumentation'] = $this->hideShowMetrics($reportMetadata['metricsDocumentation'], $emptyColumns); + } + } + + /** + * Removes column names from an array based on the values in the hideColumns, + * showColumns query parameters. This is a hack that provides the ColumnDelete + * filter functionality in processed reports. + * + * @param array $columns List of metrics shown in a processed report. + * @param array $emptyColumns Empty columns from the data table meta data. + * @return array Filtered list of metrics. + */ + private function hideShowMetrics($columns, $emptyColumns = array()) + { + if (!is_array($columns)) { + return $columns; + } + + // remove columns if hideColumns query parameters exist + $columnsToRemove = Common::getRequestVar('hideColumns', ''); + if ($columnsToRemove != '') { + $columnsToRemove = explode(',', $columnsToRemove); + foreach ($columnsToRemove as $name) { + // if a column to remove is in the column list, remove it + if (isset($columns[$name])) { + unset($columns[$name]); + } + } + } + + // remove columns if showColumns query parameters exist + $columnsToKeep = Common::getRequestVar('showColumns', ''); + if ($columnsToKeep != '') { + $columnsToKeep = explode(',', $columnsToKeep); + $columnsToKeep[] = 'label'; + + foreach ($columns as $name => $ignore) { + // if the current column should not be kept, remove it + $idx = array_search($name, $columnsToKeep); + if ($idx === false) // if $name is not in $columnsToKeep + { + unset($columns[$name]); + } + } + } + + // remove empty columns + if (is_array($emptyColumns)) { + foreach ($emptyColumns as $column) { + if (isset($columns[$column])) { + unset($columns[$column]); + } + } + } + + return $columns; + } + + /** + * Enhance $simpleDataTable using metadata : + * + * - remove metrics based on $reportMetadata['metrics'] + * - add 0 valued metrics if $simpleDataTable doesn't provide all $reportMetadata['metrics'] + * - format metric values to a 'human readable' format + * - extract row metadata to a separate Simple $rowsMetadata + * + * @param int $idSite enables monetary value formatting based on site currency + * @param Simple $simpleDataTable + * @param array $metadataColumns + * @param boolean $hasDimension + * @param bool $returnRawMetrics If set to true, the original metrics will be returned + * + * @return array DataTable $enhancedDataTable filtered metrics with human readable format & Simple $rowsMetadata + */ + private function handleSimpleDataTable($idSite, $simpleDataTable, $metadataColumns, $hasDimension, $returnRawMetrics = false) + { + // new DataTable to store metadata + $rowsMetadata = new DataTable(); + + // new DataTable to store 'human readable' values + if ($hasDimension) { + $enhancedDataTable = new DataTable(); + } else { + $enhancedDataTable = new Simple(); + } + + // add missing metrics + foreach ($simpleDataTable->getRows() as $row) { + $rowMetrics = $row->getColumns(); + foreach ($metadataColumns as $id => $name) { + if (!isset($rowMetrics[$id])) { + $row->addColumn($id, 0); + } + } + } + + foreach ($simpleDataTable->getRows() as $row) { + $enhancedRow = new Row(); + $enhancedDataTable->addRow($enhancedRow); + $rowMetrics = $row->getColumns(); + foreach ($rowMetrics as $columnName => $columnValue) { + // filter metrics according to metadata definition + if (isset($metadataColumns[$columnName])) { + // generate 'human readable' metric values + $prettyValue = MetricsFormatter::getPrettyValue($idSite, $columnName, $columnValue, $htmlAllowed = false); + $enhancedRow->addColumn($columnName, $prettyValue); + } // For example the Maps Widget requires the raw metrics to do advanced datavis + elseif ($returnRawMetrics) { + $enhancedRow->addColumn($columnName, $columnValue); + } + } + + // If report has a dimension, extract metadata into a distinct DataTable + if ($hasDimension) { + $rowMetadata = $row->getMetadata(); + $idSubDataTable = $row->getIdSubDataTable(); + + // Create a row metadata only if there are metadata to insert + if (count($rowMetadata) > 0 || !is_null($idSubDataTable)) { + $metadataRow = new Row(); + $rowsMetadata->addRow($metadataRow); + + foreach ($rowMetadata as $metadataKey => $metadataValue) { + $metadataRow->addColumn($metadataKey, $metadataValue); + } + + if (!is_null($idSubDataTable)) { + $metadataRow->addColumn('idsubdatatable', $idSubDataTable); + } + } + } + } + + return array( + $enhancedDataTable, + $rowsMetadata + ); + } + + private function aggregateReportTotalValues($simpleDataTable, $totals) + { + $metadataTotals = $simpleDataTable->getMetadata('totals'); + + if (empty($metadataTotals)) { + + return $totals; + } + + $simpleTotals = $this->hideShowMetrics($metadataTotals); + + foreach ($simpleTotals as $metric => $value) { + if (!array_key_exists($metric, $totals)) { + $totals[$metric] = $value; + } else { + $totals[$metric] += $value; + } + } + + return $totals; + } +} diff --git a/www/analytics/plugins/API/RowEvolution.php b/www/analytics/plugins/API/RowEvolution.php new file mode 100644 index 00000000..b4ebe24d --- /dev/null +++ b/www/analytics/plugins/API/RowEvolution.php @@ -0,0 +1,529 @@ +getRowEvolutionMetaData($idSite, $period, $date, $apiModule, $apiAction, $language, $idGoal); + + $dataTable = $this->loadRowEvolutionDataFromAPI($metadata, $idSite, $period, $date, $apiModule, $apiAction, $labels, $segment, $idGoal); + + if (empty($labels)) { + $labels = $this->getLabelsFromDataTable($dataTable, $labels); + $dataTable = $this->enrichRowAddMetadataLabelIndex($labels, $dataTable); + } + if (count($labels) != 1) { + $data = $this->getMultiRowEvolution( + $dataTable, + $metadata, + $apiModule, + $apiAction, + $labels, + $column, + $legendAppendMetric, + $labelUseAbsoluteUrl + ); + } else { + $data = $this->getSingleRowEvolution( + $idSite, + $dataTable, + $metadata, + $apiModule, + $apiAction, + $labels[0], + $labelUseAbsoluteUrl + ); + } + return $data; + } + + /** + * @param array $labels + * @param DataTable\Map $dataTable + * @return mixed + */ + protected function enrichRowAddMetadataLabelIndex($labels, $dataTable) + { + // set label index metadata + $labelsToIndex = array_flip($labels); + foreach ($dataTable->getDataTables() as $table) { + foreach ($table->getRows() as $row) { + $label = $row->getColumn('label'); + if (isset($labelsToIndex[$label])) { + $row->setMetadata(LabelFilter::FLAG_IS_ROW_EVOLUTION, $labelsToIndex[$label]); + } + } + } + return $dataTable; + } + + /** + * @param DataTable\Map $dataTable + * @param array $labels + * @return array + */ + protected function getLabelsFromDataTable($dataTable, $labels) + { + // if no labels specified, use all possible labels as list + foreach ($dataTable->getDataTables() as $table) { + $labels = array_merge($labels, $table->getColumn('label')); + } + $labels = array_values(array_unique($labels)); + + // if the filter_limit query param is set, treat it as a request to limit + // the number of labels used + $limit = Common::getRequestVar('filter_limit', false); + if ($limit != false + && $limit >= 0 + ) { + $labels = array_slice($labels, 0, $limit); + } + return $labels; + } + + /** + * Get row evolution for a single label + * @param DataTable\Map $dataTable + * @param array $metadata + * @param string $apiModule + * @param string $apiAction + * @param string $label + * @param bool $labelUseAbsoluteUrl + * @return array containing report data, metadata, label, logo + */ + private function getSingleRowEvolution($idSite, $dataTable, $metadata, $apiModule, $apiAction, $label, $labelUseAbsoluteUrl = true) + { + $metricNames = array_keys($metadata['metrics']); + + $logo = $actualLabel = false; + $urlFound = false; + foreach ($dataTable->getDataTables() as $date => $subTable) { + /** @var $subTable DataTable */ + $subTable->applyQueuedFilters(); + if ($subTable->getRowsCount() > 0) { + /** @var $row Row */ + $row = $subTable->getFirstRow(); + + if (!$actualLabel) { + $logo = $row->getMetadata('logo'); + + $actualLabel = $this->getRowUrlForEvolutionLabel($row, $apiModule, $apiAction, $labelUseAbsoluteUrl); + $urlFound = $actualLabel !== false; + if (empty($actualLabel)) { + $actualLabel = $row->getColumn('label'); + } + } + + // remove all columns that are not in the available metrics. + // this removes the label as well (which is desired for two reasons: (1) it was passed + // in the request, (2) it would cause the evolution graph to show the label in the legend). + foreach ($row->getColumns() as $column => $value) { + if (!in_array($column, $metricNames) && $column != 'label_html') { + $row->deleteColumn($column); + } + } + $row->deleteMetadata(); + } + } + + $this->enhanceRowEvolutionMetaData($metadata, $dataTable); + + // if we have a recursive label and no url, use the path + if (!$urlFound) { + $actualLabel = $this->formatQueryLabelForDisplay($idSite, $apiModule, $apiAction, $label); + } + + $return = array( + 'label' => SafeDecodeLabel::decodeLabelSafe($actualLabel), + 'reportData' => $dataTable, + 'metadata' => $metadata + ); + if (!empty($logo)) { + $return['logo'] = $logo; + } + return $return; + } + + private function formatQueryLabelForDisplay($idSite, $apiModule, $apiAction, $label) + { + // rows with subtables do not contain URL metadata. this hack makes sure the label titles in row + // evolution popovers look like URLs. + if ($apiModule == 'Actions' + && in_array($apiAction, self::$actionsUrlReports) + ) { + $mainUrl = Site::getMainUrlFor($idSite); + $mainUrlHost = @parse_url($mainUrl, PHP_URL_HOST); + + $replaceRegex = "/\\s*" . preg_quote(LabelFilter::SEPARATOR_RECURSIVE_LABEL) . "\\s*/"; + $cleanLabel = preg_replace($replaceRegex, '/', $label); + + return $mainUrlHost . '/' . $cleanLabel . '/'; + } else { + return str_replace(LabelFilter::SEPARATOR_RECURSIVE_LABEL, ' - ', $label); + } + } + + /** + * @param Row $row + * @param string $apiModule + * @param string $apiAction + * @param bool $labelUseAbsoluteUrl + * @return bool|string + */ + private function getRowUrlForEvolutionLabel($row, $apiModule, $apiAction, $labelUseAbsoluteUrl) + { + $url = $row->getMetadata('url'); + if ($url + && ($apiModule == 'Actions' + || ($apiModule == 'Referrers' + && $apiAction == 'getWebsites')) + && $labelUseAbsoluteUrl + ) { + $actualLabel = preg_replace(';^http(s)?://(www.)?;i', '', $url); + return $actualLabel; + } + return false; + } + + /** + * @param array $metadata see getRowEvolutionMetaData() + * @param int $idSite + * @param string $period + * @param string $date + * @param string $apiModule + * @param string $apiAction + * @param string|bool $label + * @param string|bool $segment + * @param int|bool $idGoal + * @throws Exception + * @return DataTable\Map|DataTable + */ + private function loadRowEvolutionDataFromAPI($metadata, $idSite, $period, $date, $apiModule, $apiAction, $label = false, $segment = false, $idGoal = false) + { + if (!is_array($label)) { + $label = array($label); + } + $label = array_map('rawurlencode', $label); + + $parameters = array( + 'method' => $apiModule . '.' . $apiAction, + 'label' => $label, + 'idSite' => $idSite, + 'period' => $period, + 'date' => $date, + 'format' => 'original', + 'serialize' => '0', + 'segment' => $segment, + 'idGoal' => $idGoal, + + // data for row evolution should NOT be limited + 'filter_limit' => -1, + + // if more than one label is used, we add metadata to ensure we know which + // row corresponds with which label (since the labels can change, and rows + // can be sorted in a different order) + 'labelFilterAddLabelIndex' => count($label) > 1 ? 1 : 0, + ); + + // add "processed metrics" like actions per visit or bounce rate + // note: some reports should not be filtered with AddColumnProcessedMetrics + // specifically, reports without the Metrics::INDEX_NB_VISITS metric such as Goals.getVisitsUntilConversion & Goal.getDaysToConversion + // this is because the AddColumnProcessedMetrics filter removes all datable rows lacking this metric + if( isset($metadata['metrics']['nb_visits']) + && !empty($label)) { + $parameters['filter_add_columns_when_show_all_columns'] = '1'; + } + + $url = Url::getQueryStringFromParameters($parameters); + + $request = new Request($url); + + try { + $dataTable = $request->process(); + } catch (Exception $e) { + throw new Exception("API returned an error: " . $e->getMessage() . "\n"); + } + + return $dataTable; + } + + /** + * For a given API report, returns a simpler version + * of the metadata (will return only the metrics and the dimension name) + * @param $idSite + * @param $period + * @param $date + * @param $apiModule + * @param $apiAction + * @param $language + * @param $idGoal + * @throws Exception + * @return array + */ + private function getRowEvolutionMetaData($idSite, $period, $date, $apiModule, $apiAction, $language, $idGoal = false) + { + $apiParameters = array(); + if (!empty($idGoal) && $idGoal > 0) { + $apiParameters = array('idGoal' => $idGoal); + } + $reportMetadata = API::getInstance()->getMetadata($idSite, $apiModule, $apiAction, $apiParameters, $language, + $period, $date, $hideMetricsDoc = false, $showSubtableReports = true); + + if (empty($reportMetadata)) { + throw new Exception("Requested report $apiModule.$apiAction for Website id=$idSite " + . "not found in the list of available reports. \n"); + } + + $reportMetadata = reset($reportMetadata); + + $metrics = $reportMetadata['metrics']; + if (isset($reportMetadata['processedMetrics']) && is_array($reportMetadata['processedMetrics'])) { + $metrics = $metrics + $reportMetadata['processedMetrics']; + } + + $dimension = $reportMetadata['dimension']; + + return compact('metrics', 'dimension'); + } + + /** + * Given the Row evolution dataTable, and the associated metadata, + * enriches the metadata with min/max values, and % change between the first period and the last one + * @param array $metadata + * @param DataTable\Map $dataTable + */ + private function enhanceRowEvolutionMetaData(&$metadata, $dataTable) + { + // prepare result array for metrics + $metricsResult = array(); + foreach ($metadata['metrics'] as $metric => $name) { + $metricsResult[$metric] = array('name' => $name); + + if (!empty($metadata['logos'][$metric])) { + $metricsResult[$metric]['logo'] = $metadata['logos'][$metric]; + } + } + unset($metadata['logos']); + + $subDataTables = $dataTable->getDataTables(); + $firstDataTable = reset($subDataTables); + $firstDataTableRow = $firstDataTable->getFirstRow(); + $lastDataTable = end($subDataTables); + $lastDataTableRow = $lastDataTable->getFirstRow(); + + // Process min/max values + $firstNonZeroFound = array(); + foreach ($subDataTables as $subDataTable) { + // $subDataTable is the report for one period, it has only one row + $firstRow = $subDataTable->getFirstRow(); + foreach ($metadata['metrics'] as $metric => $label) { + $value = $firstRow ? floatval($firstRow->getColumn($metric)) : 0; + if ($value > 0) { + $firstNonZeroFound[$metric] = true; + } else if (!isset($firstNonZeroFound[$metric])) { + continue; + } + if (!isset($metricsResult[$metric]['min']) + || $metricsResult[$metric]['min'] > $value + ) { + $metricsResult[$metric]['min'] = $value; + } + if (!isset($metricsResult[$metric]['max']) + || $metricsResult[$metric]['max'] < $value + ) { + $metricsResult[$metric]['max'] = $value; + } + } + } + + // Process % change between first/last values + foreach ($metadata['metrics'] as $metric => $label) { + $first = $firstDataTableRow ? floatval($firstDataTableRow->getColumn($metric)) : 0; + $last = $lastDataTableRow ? floatval($lastDataTableRow->getColumn($metric)) : 0; + + // do not calculate evolution if the first value is 0 (to avoid divide-by-zero) + if ($first == 0) { + continue; + } + + $change = CalculateEvolutionFilter::calculate($last, $first, $quotientPrecision = 0); + $change = CalculateEvolutionFilter::prependPlusSignToNumber($change); + $metricsResult[$metric]['change'] = $change; + } + + $metadata['metrics'] = $metricsResult; + } + + /** Get row evolution for a multiple labels */ + private function getMultiRowEvolution(DataTable\Map $dataTable, $metadata, $apiModule, $apiAction, $labels, $column, + $legendAppendMetric = true, + $labelUseAbsoluteUrl = true) + { + if (!isset($metadata['metrics'][$column])) { + // invalid column => use the first one that's available + $metrics = array_keys($metadata['metrics']); + $column = reset($metrics); + } + + // get the processed label and logo (if any) for every requested label + $actualLabels = $logos = array(); + foreach ($labels as $labelIdx => $label) { + foreach ($dataTable->getDataTables() as $table) { + $labelRow = $this->getRowEvolutionRowFromLabelIdx($table, $labelIdx); + + if ($labelRow) { + $actualLabels[$labelIdx] = $this->getRowUrlForEvolutionLabel( + $labelRow, $apiModule, $apiAction, $labelUseAbsoluteUrl); + + $prettyLabel = $labelRow->getColumn('label_html'); + if($prettyLabel !== false) { + $actualLabels[$labelIdx] = $prettyLabel; + } + + $logos[$labelIdx] = $labelRow->getMetadata('logo'); + + if (!empty($actualLabels[$labelIdx])) { + break; + } + } + } + + if (empty($actualLabels[$labelIdx])) { + $cleanLabel = $this->cleanOriginalLabel($label); + $actualLabels[$labelIdx] = $cleanLabel; + } + } + + // convert rows to be array($column.'_'.$labelIdx => $value) as opposed to + // array('label' => $label, 'column' => $value). + $dataTableMulti = $dataTable->getEmptyClone(); + foreach ($dataTable->getDataTables() as $tableLabel => $table) { + $newRow = new Row(); + + foreach ($labels as $labelIdx => $label) { + $row = $this->getRowEvolutionRowFromLabelIdx($table, $labelIdx); + + $value = 0; + if ($row) { + $value = $row->getColumn($column); + $value = floatVal(str_replace(',', '.', $value)); + } + + if ($value == '') { + $value = 0; + } + + $newLabel = $column . '_' . (int)$labelIdx; + $newRow->addColumn($newLabel, $value); + } + + $newTable = $table->getEmptyClone(); + if (!empty($labels)) { // only add a row if the row has data (no labels === no data) + $newTable->addRow($newRow); + } + + $dataTableMulti->addTable($newTable, $tableLabel); + } + + // the available metrics for the report are returned as metadata / columns + $metadata['columns'] = $metadata['metrics']; + + // metadata / metrics should document the rows that are compared + // this way, UI code can be reused + $metadata['metrics'] = array(); + foreach ($actualLabels as $labelIndex => $label) { + if ($legendAppendMetric) { + $label .= ' (' . $metadata['columns'][$column] . ')'; + } + $metricName = $column . '_' . $labelIndex; + $metadata['metrics'][$metricName] = $label; + + if (!empty($logos[$labelIndex])) { + $metadata['logos'][$metricName] = $logos[$labelIndex]; + } + } + + $this->enhanceRowEvolutionMetaData($metadata, $dataTableMulti); + + return array( + 'column' => $column, + 'reportData' => $dataTableMulti, + 'metadata' => $metadata + ); + } + + /** + * Returns the row in a datatable by its LabelFilter::FLAG_IS_ROW_EVOLUTION metadata. + * + * @param DataTable $table + * @param int $labelIdx + * @return Row|false + */ + private function getRowEvolutionRowFromLabelIdx($table, $labelIdx) + { + $labelIdx = (int)$labelIdx; + foreach ($table->getRows() as $row) { + if ($row->getMetadata(LabelFilter::FLAG_IS_ROW_EVOLUTION) === $labelIdx) { + return $row; + } + } + return false; + } + + /** + * Returns a prettier, more comprehensible version of a row evolution label for display. + */ + private function cleanOriginalLabel($label) + { + $label = str_replace(LabelFilter::SEPARATOR_RECURSIVE_LABEL, ' - ', $label); + $label = SafeDecodeLabel::decodeLabelSafe($label); + return $label; + } +} diff --git a/www/analytics/plugins/API/stylesheets/listAllAPI.less b/www/analytics/plugins/API/stylesheets/listAllAPI.less new file mode 100644 index 00000000..a01161b6 --- /dev/null +++ b/www/analytics/plugins/API/stylesheets/listAllAPI.less @@ -0,0 +1,48 @@ +#token_auth { + background-color: #E8FFE9; + border: 1px solid #00CC3A; + margin: 0 0 16px 8px; + padding: 12px; + line-height: 4em; +} + +.example, .example A { + color: #9E9E9E; +} + +.page_api { + padding: 0 15px 0 15px; +} + +.page_api h2 { + border-bottom: 1px solid #DADADA; + margin: 10px -15px 15px 0; + padding: 0 0 5px 0; + font-size: 24px; + width:100%; +} + +.page_api p { + line-height: 140%; + padding-bottom: 20px; +} + +.apiFirstLine { + font-weight: bold; + padding-bottom: 10px; +} + +.page_api ul { + list-style: disc outside none; + margin-left: 25px; +} + +.apiDescription { + line-height: 1.5em; + padding-bottom: 1em; +} + +.apiMethod { + margin-bottom: 5px; + margin-left: 20px; +} \ No newline at end of file diff --git a/www/analytics/plugins/API/templates/listAllAPI.twig b/www/analytics/plugins/API/templates/listAllAPI.twig new file mode 100644 index 00000000..23d1bf9a --- /dev/null +++ b/www/analytics/plugins/API/templates/listAllAPI.twig @@ -0,0 +1,32 @@ +{% extends 'dashboard.twig' %} +{% set showMenu=false %} + +{% block content %} + +{% include "@CoreHome/_siteSelectHeader.twig" %} + +
    + +
    + {% include "@CoreHome/_periodSelect.twig" %} +
    + +

    {{ 'API_QuickDocumentationTitle'|translate }}

    + +

    {{ 'API_PluginDescription'|translate }}

    + + +

    + {{ 'API_MoreInformation'|translate("","","","")|raw }} +

    + +

    {{ 'API_UserAuthentication'|translate }}

    + +

    + {{ 'API_UsingTokenAuth'|translate('','',"")|raw }}
    + &token_auth={{ token_auth }}
    + {{ 'API_KeepTokenSecret'|translate('','')|raw }} + {{ list_api_methods_with_links|raw }} +
    +

    +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Actions/API.php b/www/analytics/plugins/Actions/API.php new file mode 100644 index 00000000..9ced0fe3 --- /dev/null +++ b/www/analytics/plugins/Actions/API.php @@ -0,0 +1,596 @@ +Actions metrics for each row. + * + * It is also possible to request data for a specific Page Title with "getPageTitle" + * and setting the parameter pageName to the page title you wish to request. + * Similarly, you can request metrics for a given Page URL via "getPageUrl", a Download file via "getDownload" + * and an outlink via "getOutlink". + * + * Note: pageName, pageUrl, outlinkUrl, downloadUrl parameters must be URL encoded before you call the API. + * @method static \Piwik\Plugins\Actions\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + /** + * Returns the list of metrics (pages, downloads, outlinks) + * + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * @param bool|array $columns + * @return DataTable + */ + public function get($idSite, $period, $date, $segment = false, $columns = false) + { + Piwik::checkUserHasViewAccess($idSite); + $archive = Archive::build($idSite, $period, $date, $segment); + + $metrics = Archiver::$actionsAggregateMetrics; + $metrics['Actions_avg_time_generation'] = 'avg_time_generation'; + + // get requested columns + $columns = Piwik::getArrayFromApiParameter($columns); + if (!empty($columns)) { + // get the columns that are available and requested + $columns = array_intersect($columns, array_values($metrics)); + $columns = array_values($columns); // make sure indexes are right + $nameReplace = array(); + foreach ($columns as $i => $column) { + $fullColumn = array_search($column, $metrics); + $columns[$i] = $fullColumn; + $nameReplace[$fullColumn] = $column; + } + + if (false !== ($avgGenerationTimeRequested = array_search('Actions_avg_time_generation', $columns))) { + unset($columns[$avgGenerationTimeRequested]); + $avgGenerationTimeRequested = true; + } + } else { + // get all columns + unset($metrics['Actions_avg_time_generation']); + $columns = array_keys($metrics); + $nameReplace = & $metrics; + $avgGenerationTimeRequested = true; + } + + if ($avgGenerationTimeRequested) { + $tempColumns[] = Archiver::METRIC_SUM_TIME_RECORD_NAME; + $tempColumns[] = Archiver::METRIC_HITS_TIMED_RECORD_NAME; + $columns = array_merge($columns, $tempColumns); + $columns = array_unique($columns); + + $nameReplace[Archiver::METRIC_SUM_TIME_RECORD_NAME] = 'sum_time_generation'; + $nameReplace[Archiver::METRIC_HITS_TIMED_RECORD_NAME] = 'nb_hits_with_time_generation'; + } + + $table = $archive->getDataTableFromNumeric($columns); + + // replace labels (remove Actions_) + $table->filter('ReplaceColumnNames', array($nameReplace)); + + // compute avg generation time + if ($avgGenerationTimeRequested) { + $table->filter('ColumnCallbackAddColumnQuotient', array('avg_time_generation', 'sum_time_generation', 'nb_hits_with_time_generation', 3)); + $table->deleteColumns(array('sum_time_generation', 'nb_hits_with_time_generation')); + } + + return $table; + } + + /** + * @param int $idSite + * @param string $period + * @param Date $date + * @param bool $segment + * @param bool $expanded + * @param bool|int $idSubtable + * @param bool|int $depth + * + * @return DataTable|DataTable\Map + */ + public function getPageUrls($idSite, $period, $date, $segment = false, $expanded = false, $idSubtable = false, + $depth = false) + { + $dataTable = $this->getDataTableFromArchive('Actions_actions_url', $idSite, $period, $date, $segment, $expanded, $idSubtable, $depth); + $this->filterPageDatatable($dataTable); + $this->filterActionsDataTable($dataTable, $expanded); + return $dataTable; + } + + /** + * @param int $idSite + * @param string $period + * @param Date $date + * @param bool $segment + * @param bool $expanded + * @param bool $idSubtable + * + * @return DataTable|DataTable\Map + */ + public function getPageUrlsFollowingSiteSearch($idSite, $period, $date, $segment = false, $expanded = false, $idSubtable = false) + { + $dataTable = $this->getPageUrls($idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->keepPagesFollowingSearch($dataTable); + return $dataTable; + } + + /** + * @param int $idSite + * @param string $period + * @param Date $date + * @param bool $segment + * @param bool $expanded + * @param bool $idSubtable + * + * @return DataTable|DataTable\Map + */ + public function getPageTitlesFollowingSiteSearch($idSite, $period, $date, $segment = false, $expanded = false, $idSubtable = false) + { + $dataTable = $this->getPageTitles($idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->keepPagesFollowingSearch($dataTable); + return $dataTable; + } + + /** + * @param DataTable $dataTable + */ + protected function keepPagesFollowingSearch($dataTable) + { + // Keep only pages which are following site search + $dataTable->filter('ColumnCallbackDeleteRow', array( + 'nb_hits_following_search', + function ($value) { + return $value <= 0; + } + )); + } + + /** + * Returns a DataTable with analytics information for every unique entry page URL, for + * the specified site, period & segment. + */ + public function getEntryPageUrls($idSite, $period, $date, $segment = false, $expanded = false, $idSubtable = false) + { + $dataTable = $this->getPageUrls($idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->filterNonEntryActions($dataTable); + return $dataTable; + } + + /** + * Returns a DataTable with analytics information for every unique exit page URL, for + * the specified site, period & segment. + */ + public function getExitPageUrls($idSite, $period, $date, $segment = false, $expanded = false, $idSubtable = false) + { + $dataTable = $this->getPageUrls($idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->filterNonExitActions($dataTable); + return $dataTable; + } + + public function getPageUrl($pageUrl, $idSite, $period, $date, $segment = false) + { + $callBackParameters = array('Actions_actions_url', $idSite, $period, $date, $segment, $expanded = false, $idSubtable = false); + $dataTable = $this->getFilterPageDatatableSearch($callBackParameters, $pageUrl, Action::TYPE_PAGE_URL); + $this->filterPageDatatable($dataTable); + $this->filterActionsDataTable($dataTable); + return $dataTable; + } + + public function getPageTitles($idSite, $period, $date, $segment = false, $expanded = false, $idSubtable = false) + { + $dataTable = $this->getDataTableFromArchive('Actions_actions', $idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->filterPageDatatable($dataTable); + $this->filterActionsDataTable($dataTable, $expanded); + return $dataTable; + } + + /** + * Returns a DataTable with analytics information for every unique entry page title + * for the given site, time period & segment. + */ + public function getEntryPageTitles($idSite, $period, $date, $segment = false, $expanded = false, + $idSubtable = false) + { + $dataTable = $this->getPageTitles($idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->filterNonEntryActions($dataTable); + return $dataTable; + } + + /** + * Returns a DataTable with analytics information for every unique exit page title + * for the given site, time period & segment. + */ + public function getExitPageTitles($idSite, $period, $date, $segment = false, $expanded = false, + $idSubtable = false) + { + $dataTable = $this->getPageTitles($idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->filterNonExitActions($dataTable); + return $dataTable; + } + + public function getPageTitle($pageName, $idSite, $period, $date, $segment = false) + { + $callBackParameters = array('Actions_actions', $idSite, $period, $date, $segment, $expanded = false, $idSubtable = false); + $dataTable = $this->getFilterPageDatatableSearch($callBackParameters, $pageName, Action::TYPE_PAGE_TITLE); + $this->filterPageDatatable($dataTable); + $this->filterActionsDataTable($dataTable); + return $dataTable; + } + + public function getDownloads($idSite, $period, $date, $segment = false, $expanded = false, $idSubtable = false) + { + $dataTable = $this->getDataTableFromArchive('Actions_downloads', $idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->filterActionsDataTable($dataTable, $expanded); + return $dataTable; + } + + public function getDownload($downloadUrl, $idSite, $period, $date, $segment = false) + { + $callBackParameters = array('Actions_downloads', $idSite, $period, $date, $segment, $expanded = false, $idSubtable = false); + $dataTable = $this->getFilterPageDatatableSearch($callBackParameters, $downloadUrl, Action::TYPE_DOWNLOAD); + $this->filterActionsDataTable($dataTable); + return $dataTable; + } + + public function getOutlinks($idSite, $period, $date, $segment = false, $expanded = false, $idSubtable = false) + { + $dataTable = $this->getDataTableFromArchive('Actions_outlink', $idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->filterActionsDataTable($dataTable, $expanded); + return $dataTable; + } + + public function getOutlink($outlinkUrl, $idSite, $period, $date, $segment = false) + { + $callBackParameters = array('Actions_outlink', $idSite, $period, $date, $segment, $expanded = false, $idSubtable = false); + $dataTable = $this->getFilterPageDatatableSearch($callBackParameters, $outlinkUrl, Action::TYPE_OUTLINK); + $this->filterActionsDataTable($dataTable); + return $dataTable; + } + + public function getSiteSearchKeywords($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getSiteSearchKeywordsRaw($idSite, $period, $date, $segment); + $dataTable->deleteColumn(Metrics::INDEX_SITE_SEARCH_HAS_NO_RESULT); + $this->filterPageDatatable($dataTable); + $this->filterActionsDataTable($dataTable); + $this->addPagesPerSearchColumn($dataTable); + return $dataTable; + } + + /** + * Visitors can search, and then click "next" to view more results. This is the average number of search results pages viewed for this keyword. + * + * @param DataTable|DataTable\Simple|DataTable\Map $dataTable + * @param string $columnToRead + */ + protected function addPagesPerSearchColumn($dataTable, $columnToRead = 'nb_hits') + { + $dataTable->filter('ColumnCallbackAddColumnQuotient', array('nb_pages_per_search', $columnToRead, 'nb_visits', $precision = 1)); + } + + protected function getSiteSearchKeywordsRaw($idSite, $period, $date, $segment) + { + $dataTable = $this->getDataTableFromArchive('Actions_sitesearch', $idSite, $period, $date, $segment, $expanded = false); + return $dataTable; + } + + public function getSiteSearchNoResultKeywords($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getSiteSearchKeywordsRaw($idSite, $period, $date, $segment); + // Delete all rows that have some results + $dataTable->filter('ColumnCallbackDeleteRow', + array( + Metrics::INDEX_SITE_SEARCH_HAS_NO_RESULT, + function ($value) { + return $value < 1; + } + )); + $dataTable->deleteRow(DataTable::ID_SUMMARY_ROW); + $dataTable->deleteColumn(Metrics::INDEX_SITE_SEARCH_HAS_NO_RESULT); + $this->filterPageDatatable($dataTable); + $this->filterActionsDataTable($dataTable); + $this->addPagesPerSearchColumn($dataTable); + return $dataTable; + } + + /** + * @param int $idSite + * @param string $period + * @param Date $date + * @param bool $segment + * + * @return DataTable|DataTable\Map + */ + public function getSiteSearchCategories($idSite, $period, $date, $segment = false) + { + Actions::checkCustomVariablesPluginEnabled(); + $customVariables = APICustomVariables::getInstance()->getCustomVariables($idSite, $period, $date, $segment, $expanded = false, $_leavePiwikCoreVariables = true); + + $customVarNameToLookFor = ActionSiteSearch::CVAR_KEY_SEARCH_CATEGORY; + + $dataTable = new DataTable(); + // Handle case where date=last30&period=day + // FIXMEA: this logic should really be refactored somewhere, this is ugly! + if ($customVariables instanceof DataTable\Map) { + $dataTable = $customVariables->getEmptyClone(); + + $customVariableDatatables = $customVariables->getDataTables(); + $dataTables = $dataTable->getDataTables(); + foreach ($customVariableDatatables as $key => $customVariableTableForDate) { + // we do not enter the IF, in the case idSite=1,3 AND period=day&date=datefrom,dateto, + if ($customVariableTableForDate instanceof DataTable + && $customVariableTableForDate->getMetadata(Archive\DataTableFactory::TABLE_METADATA_PERIOD_INDEX) + ) { + $row = $customVariableTableForDate->getRowFromLabel($customVarNameToLookFor); + if ($row) { + $dateRewrite = $customVariableTableForDate->getMetadata(Archive\DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getDateStart()->toString(); + $idSubtable = $row->getIdSubDataTable(); + $categories = APICustomVariables::getInstance()->getCustomVariablesValuesFromNameId($idSite, $period, $dateRewrite, $idSubtable, $segment); + $dataTable->addTable($categories, $key); + } + } + } + } elseif ($customVariables instanceof DataTable) { + $row = $customVariables->getRowFromLabel($customVarNameToLookFor); + if ($row) { + $idSubtable = $row->getIdSubDataTable(); + $dataTable = APICustomVariables::getInstance()->getCustomVariablesValuesFromNameId($idSite, $period, $date, $idSubtable, $segment); + } + } + $this->filterActionsDataTable($dataTable); + $this->addPagesPerSearchColumn($dataTable, $columnToRead = 'nb_actions'); + return $dataTable; + } + + /** + * Will search in the DataTable for a Label matching the searched string + * and return only the matching row, or an empty datatable + */ + protected function getFilterPageDatatableSearch($callBackParameters, $search, $actionType, $table = false, + $searchTree = false) + { + if ($searchTree === false) { + // build the query parts that are searched inside the tree + if ($actionType == Action::TYPE_PAGE_TITLE) { + $searchedString = Common::unsanitizeInputValue($search); + } else { + $idSite = $callBackParameters[1]; + try { + $searchedString = PageUrl::excludeQueryParametersFromUrl($search, $idSite); + } catch (Exception $e) { + $searchedString = $search; + } + } + ArchivingHelper::reloadConfig(); + $searchTree = ArchivingHelper::getActionExplodedNames($searchedString, $actionType); + } + + if ($table === false) { + // fetch the data table + $table = call_user_func_array(array($this, 'getDataTableFromArchive'), $callBackParameters); + + if ($table instanceof DataTable\Map) { + // search an array of tables, e.g. when using date=last30 + // note that if the root is an array, we filter all children + // if an array occurs inside the nested table, we only look for the first match (see below) + $dataTableMap = $table->getEmptyClone(); + + foreach ($table->getDataTables() as $label => $subTable) { + $newSubTable = $this->doFilterPageDatatableSearch($callBackParameters, $subTable, $searchTree); + + $dataTableMap->addTable($newSubTable, $label); + } + + return $dataTableMap; + } + } + + return $this->doFilterPageDatatableSearch($callBackParameters, $table, $searchTree); + } + + /** + * This looks very similar to LabelFilter.php should it be refactored somehow? FIXME + */ + protected function doFilterPageDatatableSearch($callBackParameters, $table, $searchTree) + { + // filter a data table array + if ($table instanceof DataTable\Map) { + foreach ($table->getDataTables() as $subTable) { + $filteredSubTable = $this->doFilterPageDatatableSearch($callBackParameters, $subTable, $searchTree); + + if ($filteredSubTable->getRowsCount() > 0) { + // match found in a sub table, return and stop searching the others + return $filteredSubTable; + } + } + + // nothing found in all sub tables + return new DataTable; + } + + // filter regular data table + if ($table instanceof DataTable) { + // search for the first part of the tree search + $search = array_shift($searchTree); + $row = $table->getRowFromLabel($search); + if ($row === false) { + // not found + $result = new DataTable; + $result->setAllTableMetadata($table->getAllTableMetadata()); + return $result; + } + + // end of tree search reached + if (count($searchTree) == 0) { + $result = new DataTable(); + $result->addRow($row); + $result->setAllTableMetadata($table->getAllTableMetadata()); + return $result; + } + + // match found on this level and more levels remaining: go deeper + $idSubTable = $row->getIdSubDataTable(); + $callBackParameters[6] = $idSubTable; + $table = call_user_func_array(array($this, 'getDataTableFromArchive'), $callBackParameters); + return $this->doFilterPageDatatableSearch($callBackParameters, $table, $searchTree); + } + + throw new Exception("For this API function, DataTable " . get_class($table) . " is not supported"); + } + + /** + * Common filters for Page URLs and Page Titles + * + * @param DataTable|DataTable\Simple|DataTable\Map $dataTable + */ + protected function filterPageDatatable($dataTable) + { + $columnsToRemove = array('bounce_rate'); + $dataTable->queueFilter('ColumnDelete', array($columnsToRemove)); + + // Average time on page = total time on page / number visits on that page + $dataTable->queueFilter('ColumnCallbackAddColumnQuotient', + array('avg_time_on_page', + 'sum_time_spent', + 'nb_visits', + 0) + ); + + // Bounce rate = single page visits on this page / visits started on this page + $dataTable->queueFilter('ColumnCallbackAddColumnPercentage', + array('bounce_rate', + 'entry_bounce_count', + 'entry_nb_visits', + 0)); + + // % Exit = Number of visits that finished on this page / visits on this page + $dataTable->queueFilter('ColumnCallbackAddColumnPercentage', + array('exit_rate', + 'exit_nb_visits', + 'nb_visits', + 0) + ); + + // Handle performance analytics + $hasTimeGeneration = (array_sum($dataTable->getColumn(Metrics::INDEX_PAGE_SUM_TIME_GENERATION)) > 0); + if ($hasTimeGeneration) { + // Average generation time = total generation time / number of pageviews + $precisionAvgTimeGeneration = 3; + $dataTable->queueFilter('ColumnCallbackAddColumnQuotient', + array('avg_time_generation', + 'sum_time_generation', + 'nb_hits_with_time_generation', + $precisionAvgTimeGeneration) + ); + $dataTable->queueFilter('ColumnDelete', array(array('sum_time_generation'))); + } else { + // No generation time: remove it from the API output and add it to empty_columns metadata, so that + // the columns can also be removed from the view + $dataTable->filter('ColumnDelete', array(array( + Metrics::INDEX_PAGE_SUM_TIME_GENERATION, + Metrics::INDEX_PAGE_NB_HITS_WITH_TIME_GENERATION, + Metrics::INDEX_PAGE_MIN_TIME_GENERATION, + Metrics::INDEX_PAGE_MAX_TIME_GENERATION + ))); + + if ($dataTable instanceof DataTable) { + $emptyColumns = $dataTable->getMetadata(DataTable::EMPTY_COLUMNS_METADATA_NAME); + if (!is_array($emptyColumns)) { + $emptyColumns = array(); + } + $emptyColumns[] = 'sum_time_generation'; + $emptyColumns[] = 'avg_time_generation'; + $emptyColumns[] = 'min_time_generation'; + $emptyColumns[] = 'max_time_generation'; + $dataTable->setMetadata(DataTable::EMPTY_COLUMNS_METADATA_NAME, $emptyColumns); + } + } + } + + /** + * Common filters for all Actions API + * + * @param DataTable|DataTable\Simple|DataTable\Map $dataTable + * @param bool $expanded + */ + protected function filterActionsDataTable($dataTable, $expanded = false) + { + // Must be applied before Sort in this case, since the DataTable can contain both int and strings indexes + // (in the transition period between pre 1.2 and post 1.2 datatable structure) + $dataTable->filter('ReplaceColumnNames'); + $dataTable->filter('Sort', array('nb_visits', 'desc', $naturalSort = false, $expanded)); + + $dataTable->queueFilter('ReplaceSummaryRowLabel'); + } + + /** + * Removes DataTable rows referencing actions that were never the first action of a visit. + * + * @param DataTable $dataTable + */ + private function filterNonEntryActions($dataTable) + { + $dataTable->filter('ColumnCallbackDeleteRow', + array('entry_nb_visits', + function ($visits) { + return !strlen($visits); + } + ) + ); + } + + /** + * Removes DataTable rows referencing actions that were never the last action of a visit. + * + * @param DataTable $dataTable + */ + private function filterNonExitActions($dataTable) + { + $dataTable->filter('ColumnCallbackDeleteRow', + array('exit_nb_visits', + function ($visits) { + return !strlen($visits); + }) + ); + } + + protected function getDataTableFromArchive($name, $idSite, $period, $date, $segment, $expanded = false, $idSubtable = null, $depth = null) + { + $skipAggregationOfSubTables = false; + if ($period == 'range' + && empty($idSubtable) + && empty($expanded) + && !Request::shouldLoadFlatten() + ) { + $skipAggregationOfSubTables = false; + } + return Archive::getDataTableFromArchive($name, $idSite, $period, $date, $segment, $expanded, $idSubtable, $skipAggregationOfSubTables, $depth); + } +} diff --git a/www/analytics/plugins/Actions/Actions.php b/www/analytics/plugins/Actions/Actions.php new file mode 100644 index 00000000..30da4b51 --- /dev/null +++ b/www/analytics/plugins/Actions/Actions.php @@ -0,0 +1,937 @@ + 'addWidgets', + 'Menu.Reporting.addItems' => 'addMenus', + 'API.getReportMetadata' => 'getReportMetadata', + 'API.getSegmentDimensionMetadata' => 'getSegmentsMetadata', + 'ViewDataTable.configure' => 'configureViewDataTable', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'Insights.addReportToOverview' => 'addReportToInsightsOverview' + ); + return $hooks; + } + + public function addReportToInsightsOverview(&$reports) + { + $reports['Actions_getPageUrls'] = array(); + $reports['Actions_getPageTitles'] = array(); + $reports['Actions_getDownloads'] = array('flat' => 1); + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/Actions/stylesheets/dataTableActions.less"; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/Actions/javascripts/actionsDataTable.js"; + } + + public function getSegmentsMetadata(&$segments) + { + $sqlFilter = '\\Piwik\\Tracker\\TableLogAction::getIdActionFromSegment'; + + // entry and exit pages of visit + $segments[] = array( + 'type' => 'dimension', + 'category' => 'General_Actions', + 'name' => 'Actions_ColumnEntryPageURL', + 'segment' => 'entryPageUrl', + 'sqlSegment' => 'log_visit.visit_entry_idaction_url', + 'sqlFilter' => $sqlFilter, + ); + $segments[] = array( + 'type' => 'dimension', + 'category' => 'General_Actions', + 'name' => 'Actions_ColumnEntryPageTitle', + 'segment' => 'entryPageTitle', + 'sqlSegment' => 'log_visit.visit_entry_idaction_name', + 'sqlFilter' => $sqlFilter, + ); + $segments[] = array( + 'type' => 'dimension', + 'category' => 'General_Actions', + 'name' => 'Actions_ColumnExitPageURL', + 'segment' => 'exitPageUrl', + 'sqlSegment' => 'log_visit.visit_exit_idaction_url', + 'sqlFilter' => $sqlFilter, + ); + $segments[] = array( + 'type' => 'dimension', + 'category' => 'General_Actions', + 'name' => 'Actions_ColumnExitPageTitle', + 'segment' => 'exitPageTitle', + 'sqlSegment' => 'log_visit.visit_exit_idaction_name', + 'sqlFilter' => $sqlFilter, + ); + + // single pages + $segments[] = array( + 'type' => 'dimension', + 'category' => 'General_Actions', + 'name' => 'Actions_ColumnPageURL', + 'segment' => 'pageUrl', + 'sqlSegment' => 'log_link_visit_action.idaction_url', + 'sqlFilter' => $sqlFilter, + 'acceptedValues' => "All these segments must be URL encoded, for example: " . urlencode('http://example.com/path/page?query'), + ); + $segments[] = array( + 'type' => 'dimension', + 'category' => 'General_Actions', + 'name' => 'Actions_ColumnPageName', + 'segment' => 'pageTitle', + 'sqlSegment' => 'log_link_visit_action.idaction_name', + 'sqlFilter' => $sqlFilter, + ); + $segments[] = array( + 'type' => 'dimension', + 'category' => 'General_Actions', + 'name' => 'Actions_SiteSearchKeyword', + 'segment' => 'siteSearchKeyword', + 'sqlSegment' => 'log_link_visit_action.idaction_name', + 'sqlFilter' => $sqlFilter, + ); + } + + public function getReportMetadata(&$reports) + { + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('General_Actions') . ' - ' . Piwik::translate('General_MainMetrics'), + 'module' => 'Actions', + 'action' => 'get', + 'metrics' => array( + 'nb_pageviews' => Piwik::translate('General_ColumnPageviews'), + 'nb_uniq_pageviews' => Piwik::translate('General_ColumnUniquePageviews'), + 'nb_downloads' => Piwik::translate('General_Downloads'), + 'nb_uniq_downloads' => Piwik::translate('Actions_ColumnUniqueDownloads'), + 'nb_outlinks' => Piwik::translate('General_Outlinks'), + 'nb_uniq_outlinks' => Piwik::translate('Actions_ColumnUniqueOutlinks'), + 'nb_searches' => Piwik::translate('Actions_ColumnSearches'), + 'nb_keywords' => Piwik::translate('Actions_ColumnSiteSearchKeywords'), + 'avg_time_generation' => Piwik::translate('General_ColumnAverageGenerationTime'), + ), + 'metricsDocumentation' => array( + 'nb_pageviews' => Piwik::translate('General_ColumnPageviewsDocumentation'), + 'nb_uniq_pageviews' => Piwik::translate('General_ColumnUniquePageviewsDocumentation'), + 'nb_downloads' => Piwik::translate('Actions_ColumnClicksDocumentation'), + 'nb_uniq_downloads' => Piwik::translate('Actions_ColumnUniqueClicksDocumentation'), + 'nb_outlinks' => Piwik::translate('Actions_ColumnClicksDocumentation'), + 'nb_uniq_outlinks' => Piwik::translate('Actions_ColumnUniqueClicksDocumentation'), + 'nb_searches' => Piwik::translate('Actions_ColumnSearchesDocumentation'), + 'avg_time_generation' => Piwik::translate('General_ColumnAverageGenerationTimeDocumentation'), +// 'nb_keywords' => Piwik::translate('Actions_ColumnSiteSearchKeywords'), + ), + 'processedMetrics' => false, + 'order' => 1 + ); + + $metrics = array( + 'nb_hits' => Piwik::translate('General_ColumnPageviews'), + 'nb_visits' => Piwik::translate('General_ColumnUniquePageviews'), + 'bounce_rate' => Piwik::translate('General_ColumnBounceRate'), + 'avg_time_on_page' => Piwik::translate('General_ColumnAverageTimeOnPage'), + 'exit_rate' => Piwik::translate('General_ColumnExitRate'), + 'avg_time_generation' => Piwik::translate('General_ColumnAverageGenerationTime') + ); + + $documentation = array( + 'nb_hits' => Piwik::translate('General_ColumnPageviewsDocumentation'), + 'nb_visits' => Piwik::translate('General_ColumnUniquePageviewsDocumentation'), + 'bounce_rate' => Piwik::translate('General_ColumnPageBounceRateDocumentation'), + 'avg_time_on_page' => Piwik::translate('General_ColumnAverageTimeOnPageDocumentation'), + 'exit_rate' => Piwik::translate('General_ColumnExitRateDocumentation'), + 'avg_time_generation' => Piwik::translate('General_ColumnAverageGenerationTimeDocumentation'), + ); + + // pages report + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('Actions_PageUrls'), + 'module' => 'Actions', + 'action' => 'getPageUrls', + 'dimension' => Piwik::translate('Actions_ColumnPageURL'), + 'metrics' => $metrics, + 'metricsDocumentation' => $documentation, + 'documentation' => Piwik::translate('Actions_PagesReportDocumentation', '
    ') + . '
    ' . Piwik::translate('General_UsePlusMinusIconsDocumentation'), + 'processedMetrics' => false, + 'actionToLoadSubTables' => 'getPageUrls', + 'order' => 2 + ); + + // entry pages report + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('Actions_SubmenuPagesEntry'), + 'module' => 'Actions', + 'action' => 'getEntryPageUrls', + 'dimension' => Piwik::translate('Actions_ColumnPageURL'), + 'metrics' => array( + 'entry_nb_visits' => Piwik::translate('General_ColumnEntrances'), + 'entry_bounce_count' => Piwik::translate('General_ColumnBounces'), + 'bounce_rate' => Piwik::translate('General_ColumnBounceRate'), + ), + 'metricsDocumentation' => array( + 'entry_nb_visits' => Piwik::translate('General_ColumnEntrancesDocumentation'), + 'entry_bounce_count' => Piwik::translate('General_ColumnBouncesDocumentation'), + 'bounce_rate' => Piwik::translate('General_ColumnBounceRateForPageDocumentation') + ), + 'documentation' => Piwik::translate('Actions_EntryPagesReportDocumentation', '
    ') + . ' ' . Piwik::translate('General_UsePlusMinusIconsDocumentation'), + 'processedMetrics' => false, + 'actionToLoadSubTables' => 'getEntryPageUrls', + 'order' => 3 + ); + + // exit pages report + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('Actions_SubmenuPagesExit'), + 'module' => 'Actions', + 'action' => 'getExitPageUrls', + 'dimension' => Piwik::translate('Actions_ColumnPageURL'), + 'metrics' => array( + 'exit_nb_visits' => Piwik::translate('General_ColumnExits'), + 'nb_visits' => Piwik::translate('General_ColumnUniquePageviews'), + 'exit_rate' => Piwik::translate('General_ColumnExitRate') + ), + 'metricsDocumentation' => array( + 'exit_nb_visits' => Piwik::translate('General_ColumnExitsDocumentation'), + 'nb_visits' => Piwik::translate('General_ColumnUniquePageviewsDocumentation'), + 'exit_rate' => Piwik::translate('General_ColumnExitRateDocumentation') + ), + 'documentation' => Piwik::translate('Actions_ExitPagesReportDocumentation', '
    ') + . ' ' . Piwik::translate('General_UsePlusMinusIconsDocumentation'), + 'processedMetrics' => false, + 'actionToLoadSubTables' => 'getExitPageUrls', + 'order' => 4 + ); + + // page titles report + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('Actions_SubmenuPageTitles'), + 'module' => 'Actions', + 'action' => 'getPageTitles', + 'dimension' => Piwik::translate('Actions_ColumnPageName'), + 'metrics' => $metrics, + 'metricsDocumentation' => $documentation, + 'documentation' => Piwik::translate('Actions_PageTitlesReportDocumentation', array('
    ', htmlentities(''))), + 'processedMetrics' => false, + 'actionToLoadSubTables' => 'getPageTitles', + 'order' => 5, + + ); + + // entry page titles report + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('Actions_EntryPageTitles'), + 'module' => 'Actions', + 'action' => 'getEntryPageTitles', + 'dimension' => Piwik::translate('Actions_ColumnPageName'), + 'metrics' => array( + 'entry_nb_visits' => Piwik::translate('General_ColumnEntrances'), + 'entry_bounce_count' => Piwik::translate('General_ColumnBounces'), + 'bounce_rate' => Piwik::translate('General_ColumnBounceRate'), + ), + 'metricsDocumentation' => array( + 'entry_nb_visits' => Piwik::translate('General_ColumnEntrancesDocumentation'), + 'entry_bounce_count' => Piwik::translate('General_ColumnBouncesDocumentation'), + 'bounce_rate' => Piwik::translate('General_ColumnBounceRateForPageDocumentation') + ), + 'documentation' => Piwik::translate('Actions_ExitPageTitlesReportDocumentation', '<br />') + . ' ' . Piwik::translate('General_UsePlusMinusIconsDocumentation'), + 'processedMetrics' => false, + 'actionToLoadSubTables' => 'getEntryPageTitles', + 'order' => 6 + ); + + // exit page titles report + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('Actions_ExitPageTitles'), + 'module' => 'Actions', + 'action' => 'getExitPageTitles', + 'dimension' => Piwik::translate('Actions_ColumnPageName'), + 'metrics' => array( + 'exit_nb_visits' => Piwik::translate('General_ColumnExits'), + 'nb_visits' => Piwik::translate('General_ColumnUniquePageviews'), + 'exit_rate' => Piwik::translate('General_ColumnExitRate') + ), + 'metricsDocumentation' => array( + 'exit_nb_visits' => Piwik::translate('General_ColumnExitsDocumentation'), + 'nb_visits' => Piwik::translate('General_ColumnUniquePageviewsDocumentation'), + 'exit_rate' => Piwik::translate('General_ColumnExitRateDocumentation') + ), + 'documentation' => Piwik::translate('Actions_EntryPageTitlesReportDocumentation', '<br />') + . ' ' . Piwik::translate('General_UsePlusMinusIconsDocumentation'), + 'processedMetrics' => false, + 'actionToLoadSubTables' => 'getExitPageTitles', + 'order' => 7 + ); + + $documentation = array( + 'nb_visits' => Piwik::translate('Actions_ColumnUniqueClicksDocumentation'), + 'nb_hits' => Piwik::translate('Actions_ColumnClicksDocumentation') + ); + + // outlinks report + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('General_Outlinks'), + 'module' => 'Actions', + 'action' => 'getOutlinks', + 'dimension' => Piwik::translate('Actions_ColumnClickedURL'), + 'metrics' => array( + 'nb_visits' => Piwik::translate('Actions_ColumnUniqueClicks'), + 'nb_hits' => Piwik::translate('Actions_ColumnClicks') + ), + 'metricsDocumentation' => $documentation, + 'documentation' => Piwik::translate('Actions_OutlinksReportDocumentation') . ' ' + . Piwik::translate('Actions_OutlinkDocumentation') . '<br />' + . Piwik::translate('General_UsePlusMinusIconsDocumentation'), + 'processedMetrics' => false, + 'actionToLoadSubTables' => 'getOutlinks', + 'order' => 8, + ); + + // downloads report + $reports[] = array( + 'category' => Piwik::translate('General_Actions'), + 'name' => Piwik::translate('General_Downloads'), + 'module' => 'Actions', + 'action' => 'getDownloads', + 'dimension' => Piwik::translate('Actions_ColumnDownloadURL'), + 'metrics' => array( + 'nb_visits' => Piwik::translate('Actions_ColumnUniqueDownloads'), + 'nb_hits' => Piwik::translate('General_Downloads') + ), + 'metricsDocumentation' => $documentation, + 'documentation' => Piwik::translate('Actions_DownloadsReportDocumentation', '<br />'), + 'processedMetrics' => false, + 'actionToLoadSubTables' => 'getDownloads', + 'order' => 9, + ); + + if ($this->isSiteSearchEnabled()) { + // Search Keywords + $reports[] = array( + 'category' => Piwik::translate('Actions_SubmenuSitesearch'), + 'name' => Piwik::translate('Actions_WidgetSearchKeywords'), + 'module' => 'Actions', + 'action' => 'getSiteSearchKeywords', + 'dimension' => Piwik::translate('General_ColumnKeyword'), + 'metrics' => array( + 'nb_visits' => Piwik::translate('Actions_ColumnSearches'), + 'nb_pages_per_search' => Piwik::translate('Actions_ColumnPagesPerSearch'), + 'exit_rate' => Piwik::translate('Actions_ColumnSearchExits'), + ), + 'metricsDocumentation' => array( + 'nb_visits' => Piwik::translate('Actions_ColumnSearchesDocumentation'), + 'nb_pages_per_search' => Piwik::translate('Actions_ColumnPagesPerSearchDocumentation'), + 'exit_rate' => Piwik::translate('Actions_ColumnSearchExitsDocumentation'), + ), + 'documentation' => Piwik::translate('Actions_SiteSearchKeywordsDocumentation') . '<br/><br/>' . Piwik::translate('Actions_SiteSearchIntro') . '<br/><br/>' + . '<a href="http://piwik.org/docs/site-search/" target="_blank">' . Piwik::translate('Actions_LearnMoreAboutSiteSearchLink') . '</a>', + 'processedMetrics' => false, + 'order' => 15 + ); + // No Result Search Keywords + $reports[] = array( + 'category' => Piwik::translate('Actions_SubmenuSitesearch'), + 'name' => Piwik::translate('Actions_WidgetSearchNoResultKeywords'), + 'module' => 'Actions', + 'action' => 'getSiteSearchNoResultKeywords', + 'dimension' => Piwik::translate('Actions_ColumnNoResultKeyword'), + 'metrics' => array( + 'nb_visits' => Piwik::translate('Actions_ColumnSearches'), + 'exit_rate' => Piwik::translate('Actions_ColumnSearchExits'), + ), + 'metricsDocumentation' => array( + 'nb_visits' => Piwik::translate('Actions_ColumnSearchesDocumentation'), + 'exit_rate' => Piwik::translate('Actions_ColumnSearchExitsDocumentation'), + ), + 'documentation' => Piwik::translate('Actions_SiteSearchIntro') . '<br /><br />' . Piwik::translate('Actions_SiteSearchKeywordsNoResultDocumentation'), + 'processedMetrics' => false, + 'order' => 16 + ); + + if (self::isCustomVariablesPluginsEnabled()) { + // Search Categories + $reports[] = array( + 'category' => Piwik::translate('Actions_SubmenuSitesearch'), + 'name' => Piwik::translate('Actions_WidgetSearchCategories'), + 'module' => 'Actions', + 'action' => 'getSiteSearchCategories', + 'dimension' => Piwik::translate('Actions_ColumnSearchCategory'), + 'metrics' => array( + 'nb_visits' => Piwik::translate('Actions_ColumnSearches'), + 'nb_pages_per_search' => Piwik::translate('Actions_ColumnPagesPerSearch'), + 'exit_rate' => Piwik::translate('Actions_ColumnSearchExits'), + ), + 'metricsDocumentation' => array( + 'nb_visits' => Piwik::translate('Actions_ColumnSearchesDocumentation'), + 'nb_pages_per_search' => Piwik::translate('Actions_ColumnPagesPerSearchDocumentation'), + 'exit_rate' => Piwik::translate('Actions_ColumnSearchExitsDocumentation'), + ), + 'documentation' => Piwik::translate('Actions_SiteSearchCategories1') . '<br/>' . Piwik::translate('Actions_SiteSearchCategories2'), + 'processedMetrics' => false, + 'order' => 17 + ); + } + + $documentation = Piwik::translate('Actions_SiteSearchFollowingPagesDoc') . '<br/>' . Piwik::translate('General_UsePlusMinusIconsDocumentation'); + // Pages URLs following Search + $reports[] = array( + 'category' => Piwik::translate('Actions_SubmenuSitesearch'), + 'name' => Piwik::translate('Actions_WidgetPageUrlsFollowingSearch'), + 'module' => 'Actions', + 'action' => 'getPageUrlsFollowingSiteSearch', + 'dimension' => Piwik::translate('General_ColumnDestinationPage'), + 'metrics' => array( + 'nb_hits_following_search' => Piwik::translate('General_ColumnViewedAfterSearch'), + 'nb_hits' => Piwik::translate('General_ColumnTotalPageviews'), + ), + 'metricsDocumentation' => array( + 'nb_hits_following_search' => Piwik::translate('General_ColumnViewedAfterSearchDocumentation'), + 'nb_hits' => Piwik::translate('General_ColumnPageviewsDocumentation'), + ), + 'documentation' => $documentation, + 'processedMetrics' => false, + 'order' => 18 + ); + // Pages Titles following Search + $reports[] = array( + 'category' => Piwik::translate('Actions_SubmenuSitesearch'), + 'name' => Piwik::translate('Actions_WidgetPageTitlesFollowingSearch'), + 'module' => 'Actions', + 'action' => 'getPageTitlesFollowingSiteSearch', + 'dimension' => Piwik::translate('General_ColumnDestinationPage'), + 'metrics' => array( + 'nb_hits_following_search' => Piwik::translate('General_ColumnViewedAfterSearch'), + 'nb_hits' => Piwik::translate('General_ColumnTotalPageviews'), + ), + 'metricsDocumentation' => array( + 'nb_hits_following_search' => Piwik::translate('General_ColumnViewedAfterSearchDocumentation'), + 'nb_hits' => Piwik::translate('General_ColumnPageviewsDocumentation'), + ), + 'documentation' => $documentation, + 'processedMetrics' => false, + 'order' => 19 + ); + } + } + + function addWidgets() + { + WidgetsList::add('General_Actions', 'General_Pages', 'Actions', 'getPageUrls'); + WidgetsList::add('General_Actions', 'Actions_WidgetPageTitles', 'Actions', 'getPageTitles'); + WidgetsList::add('General_Actions', 'General_Outlinks', 'Actions', 'getOutlinks'); + WidgetsList::add('General_Actions', 'General_Downloads', 'Actions', 'getDownloads'); + WidgetsList::add('General_Actions', 'Actions_WidgetPagesEntry', 'Actions', 'getEntryPageUrls'); + WidgetsList::add('General_Actions', 'Actions_WidgetPagesExit', 'Actions', 'getExitPageUrls'); + WidgetsList::add('General_Actions', 'Actions_WidgetEntryPageTitles', 'Actions', 'getEntryPageTitles'); + WidgetsList::add('General_Actions', 'Actions_WidgetExitPageTitles', 'Actions', 'getExitPageTitles'); + + if ($this->isSiteSearchEnabled()) { + WidgetsList::add('Actions_SubmenuSitesearch', 'Actions_WidgetSearchKeywords', 'Actions', 'getSiteSearchKeywords'); + + if (self::isCustomVariablesPluginsEnabled()) { + WidgetsList::add('Actions_SubmenuSitesearch', 'Actions_WidgetSearchCategories', 'Actions', 'getSiteSearchCategories'); + } + WidgetsList::add('Actions_SubmenuSitesearch', 'Actions_WidgetSearchNoResultKeywords', 'Actions', 'getSiteSearchNoResultKeywords'); + WidgetsList::add('Actions_SubmenuSitesearch', 'Actions_WidgetPageUrlsFollowingSearch', 'Actions', 'getPageUrlsFollowingSiteSearch'); + WidgetsList::add('Actions_SubmenuSitesearch', 'Actions_WidgetPageTitlesFollowingSearch', 'Actions', 'getPageTitlesFollowingSiteSearch'); + } + } + + function addMenus() + { + MenuMain::getInstance()->add('General_Actions', '', array('module' => 'Actions', 'action' => 'indexPageUrls'), true, 15); + MenuMain::getInstance()->add('General_Actions', 'General_Pages', array('module' => 'Actions', 'action' => 'indexPageUrls'), true, 1); + MenuMain::getInstance()->add('General_Actions', 'Actions_SubmenuPagesEntry', array('module' => 'Actions', 'action' => 'indexEntryPageUrls'), true, 2); + MenuMain::getInstance()->add('General_Actions', 'Actions_SubmenuPagesExit', array('module' => 'Actions', 'action' => 'indexExitPageUrls'), true, 3); + MenuMain::getInstance()->add('General_Actions', 'Actions_SubmenuPageTitles', array('module' => 'Actions', 'action' => 'indexPageTitles'), true, 4); + MenuMain::getInstance()->add('General_Actions', 'General_Outlinks', array('module' => 'Actions', 'action' => 'indexOutlinks'), true, 6); + MenuMain::getInstance()->add('General_Actions', 'General_Downloads', array('module' => 'Actions', 'action' => 'indexDownloads'), true, 7); + + if ($this->isSiteSearchEnabled()) { + MenuMain::getInstance()->add('General_Actions', 'Actions_SubmenuSitesearch', array('module' => 'Actions', 'action' => 'indexSiteSearch'), true, 5); + } + } + + protected function isSiteSearchEnabled() + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + $idSites = Common::getRequestVar('idSites', '', 'string'); + $idSites = Site::getIdSitesFromIdSitesString($idSites, true); + + if (!empty($idSite)) { + $idSites[] = $idSite; + } + + if (empty($idSites)) { + return false; + } + + foreach ($idSites as $idSite) { + if (!Site::isSiteSearchEnabledFor($idSite)) { + return false; + } + } + + return true; + } + + static public function checkCustomVariablesPluginEnabled() + { + if (!self::isCustomVariablesPluginsEnabled()) { + throw new \Exception("To Track Site Search Categories, please ask the Piwik Administrator to enable the 'Custom Variables' plugin in Settings > Plugins."); + } + } + + static protected function isCustomVariablesPluginsEnabled() + { + return \Piwik\Plugin\Manager::getInstance()->isPluginActivated('CustomVariables'); + } + + + public function configureViewDataTable(ViewDataTable $view) + { + switch ($view->requestConfig->apiMethodToRequestDataTable) { + case 'Actions.getPageUrls': + $this->configureViewForPageUrls($view); + break; + case 'Actions.getEntryPageUrls': + $this->configureViewForEntryPageUrls($view); + break; + case 'Actions.getExitPageUrls': + $this->configureViewForExitPageUrls($view); + break; + case 'Actions.getSiteSearchKeywords': + $this->configureViewForSiteSearchKeywords($view); + break; + case 'Actions.getSiteSearchNoResultKeywords': + $this->configureViewForSiteSearchNoResultKeywords($view); + break; + case 'Actions.getSiteSearchCategories': + $this->configureViewForSiteSearchCategories($view); + break; + case 'Actions.getPageUrlsFollowingSiteSearch': + $this->configureViewForGetPageUrlsOrTitlesFollowingSiteSearch($view, false); + break; + case 'Actions.getPageTitlesFollowingSiteSearch': + $this->configureViewForGetPageUrlsOrTitlesFollowingSiteSearch($view, true); + break; + case 'Actions.getPageTitles': + $this->configureViewForGetPageTitles($view); + break; + case 'Actions.getEntryPageTitles': + $this->configureViewForGetEntryPageTitles($view); + break; + case 'Actions.getExitPageTitles': + $this->configureViewForGetExitPageTitles($view); + break; + case 'Actions.getDownloads': + $this->configureViewForGetDownloads($view); + break; + case 'Actions.getOutlinks': + $this->configureViewForGetOutlinks($view); + break; + } + + if ($this->pluginName == $view->requestConfig->getApiModuleToRequest()) { + if ($view->isRequestingSingleDataTable()) { + // make sure custom visualizations are shown on actions reports + $view->config->show_all_views_icons = true; + $view->config->show_bar_chart = false; + $view->config->show_pie_chart = false; + $view->config->show_tag_cloud = false; + } + } + } + + private function addBaseDisplayProperties(ViewDataTable $view) + { + $view->config->datatable_js_type = 'ActionsDataTable'; + $view->config->search_recursive = true; + $view->config->show_table_all_columns = false; + $view->requestConfig->filter_limit = self::ACTIONS_REPORT_ROWS_DISPLAY; + $view->config->show_all_views_icons = false; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->show_embedded_subtable = true; + } + + // if the flat parameter is not provided, make sure it is set to 0 in the URL, + // so users can see that they can set it to 1 (see #3365) + $view->config->custom_parameters = array('flat' => 0); + + if (Request::shouldLoadExpanded()) { + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->show_expanded = true; + } + + $view->config->filters[] = function ($dataTable) { + Actions::setDataTableRowLevels($dataTable); + }; + } + + $view->config->filters[] = function ($dataTable) use ($view) { + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->datatable_css_class = 'dataTableActions'; + } + }; + } + + /** + * @param \Piwik\DataTable $dataTable + * @param int $level + */ + public static function setDataTableRowLevels($dataTable, $level = 0) + { + foreach ($dataTable->getRows() as $row) { + $row->setMetadata('css_class', 'level' . $level); + + $subtable = $row->getSubtable(); + if ($subtable) { + self::setDataTableRowLevels($subtable, $level + 1); + } + } + } + + private function addExcludeLowPopDisplayProperties(ViewDataTable $view) + { + if (Common::getRequestVar('enable_filter_excludelowpop', '0', 'string') != '0') { + $view->requestConfig->filter_excludelowpop = 'nb_hits'; + $view->requestConfig->filter_excludelowpop_value = function () { + // computing minimum value to exclude (2 percent of the total number of actions) + $visitsInfo = \Piwik\Plugins\VisitsSummary\Controller::getVisitsSummary()->getFirstRow(); + $nbActions = $visitsInfo->getColumn('nb_actions'); + $nbActionsLowPopulationThreshold = floor(0.02 * $nbActions); + + // we remove 1 to make sure some actions/downloads are displayed in the case we have a very few of them + // and each of them has 1 or 2 hits... + return min($visitsInfo->getColumn('max_actions') - 1, $nbActionsLowPopulationThreshold - 1); + }; + } + } + + private function addPageDisplayProperties(ViewDataTable $view) + { + $view->config->addTranslations(array( + 'nb_hits' => Piwik::translate('General_ColumnPageviews'), + 'nb_visits' => Piwik::translate('General_ColumnUniquePageviews'), + 'avg_time_on_page' => Piwik::translate('General_ColumnAverageTimeOnPage'), + 'bounce_rate' => Piwik::translate('General_ColumnBounceRate'), + 'exit_rate' => Piwik::translate('General_ColumnExitRate'), + 'avg_time_generation' => Piwik::translate('General_ColumnAverageGenerationTime'), + )); + + // prettify avg_time_on_page column + $getPrettyTimeFromSeconds = '\Piwik\MetricsFormatter::getPrettyTimeFromSeconds'; + $view->config->filters[] = array('ColumnCallbackReplace', array('avg_time_on_page', $getPrettyTimeFromSeconds)); + + // prettify avg_time_generation column + $avgTimeCallback = function ($time) { + return $time ? MetricsFormatter::getPrettyTimeFromSeconds($time, true, true, false) : "-"; + }; + $view->config->filters[] = array('ColumnCallbackReplace', array('avg_time_generation', $avgTimeCallback)); + + // add avg_generation_time tooltip + $tooltipCallback = function ($hits, $min, $max) { + if (!$hits) { + return false; + } + + return Piwik::translate("Actions_AvgGenerationTimeTooltip", array( + $hits, + "<br />", + MetricsFormatter::getPrettyTimeFromSeconds($min), + MetricsFormatter::getPrettyTimeFromSeconds($max) + )); + }; + $view->config->filters[] = array('ColumnCallbackAddMetadata', + array( + array('nb_hits_with_time_generation', 'min_time_generation', 'max_time_generation'), + 'avg_time_generation_tooltip', + $tooltipCallback + ) + ); + + $this->addExcludeLowPopDisplayProperties($view); + } + + public function configureViewForPageUrls(ViewDataTable $view) + { + $view->config->addTranslation('label', Piwik::translate('Actions_ColumnPageURL')); + $view->config->columns_to_display = array('label', 'nb_hits', 'nb_visits', 'bounce_rate', + 'avg_time_on_page', 'exit_rate', 'avg_time_generation'); + + $this->addPageDisplayProperties($view); + $this->addBaseDisplayProperties($view); + } + + public function configureViewForEntryPageUrls(ViewDataTable $view) + { + // link to the page, not just the report, but only if not a widget + $widget = Common::getRequestVar('widget', false); + + $view->config->self_url = Request::getCurrentUrlWithoutGenericFilters(array( + 'module' => 'Actions', + 'action' => $widget === false ? 'indexEntryPageUrls' : 'getEntryPageUrls' + )); + + $view->config->addTranslations(array( + 'label' => Piwik::translate('Actions_ColumnEntryPageURL'), + 'entry_bounce_count' => Piwik::translate('General_ColumnBounces'), + 'entry_nb_visits' => Piwik::translate('General_ColumnEntrances')) + ); + + $view->config->title = Piwik::translate('Actions_SubmenuPagesEntry'); + $view->config->addRelatedReport('Actions.getEntryPageTitles', Piwik::translate('Actions_EntryPageTitles')); + $view->config->columns_to_display = array('label', 'entry_nb_visits', 'entry_bounce_count', 'bounce_rate'); + $view->requestConfig->filter_sort_column = 'entry_nb_visits'; + $view->requestConfig->filter_sort_order = 'desc'; + + $this->addPageDisplayProperties($view); + $this->addBaseDisplayProperties($view); + } + + public function configureViewForExitPageUrls(ViewDataTable $view) + { + // link to the page, not just the report, but only if not a widget + $widget = Common::getRequestVar('widget', false); + + $view->config->self_url = Request::getCurrentUrlWithoutGenericFilters(array( + 'module' => 'Actions', + 'action' => $widget === false ? 'indexExitPageUrls' : 'getExitPageUrls' + )); + + $view->config->addTranslations(array( + 'label' => Piwik::translate('Actions_ColumnExitPageURL'), + 'exit_nb_visits' => Piwik::translate('General_ColumnExits')) + ); + + $view->config->title = Piwik::translate('Actions_SubmenuPagesExit'); + $view->config->addRelatedReport('Actions.getExitPageTitles', Piwik::translate('Actions_ExitPageTitles')); + + $view->config->columns_to_display = array('label', 'exit_nb_visits', 'nb_visits', 'exit_rate'); + $view->requestConfig->filter_sort_column = 'exit_nb_visits'; + $view->requestConfig->filter_sort_order = 'desc'; + + $this->addPageDisplayProperties($view); + $this->addBaseDisplayProperties($view); + } + + private function addSiteSearchDisplayProperties(ViewDataTable $view) + { + $view->config->addTranslations(array( + 'nb_visits' => Piwik::translate('Actions_ColumnSearches'), + 'exit_rate' => str_replace("% ", "% ", Piwik::translate('Actions_ColumnSearchExits')), + 'nb_pages_per_search' => Piwik::translate('Actions_ColumnPagesPerSearch') + )); + + $view->config->show_bar_chart = false; + $view->config->show_table_all_columns = false; + } + + public function configureViewForSiteSearchKeywords(ViewDataTable $view) + { + $view->config->addTranslation('label', Piwik::translate('General_ColumnKeyword')); + $view->config->columns_to_display = array('label', 'nb_visits', 'nb_pages_per_search', 'exit_rate'); + + $this->addSiteSearchDisplayProperties($view); + } + + public function configureViewForSiteSearchNoResultKeywords(ViewDataTable $view) + { + $view->config->addTranslation('label', Piwik::translate('Actions_ColumnNoResultKeyword')); + $view->config->columns_to_display = array('label', 'nb_visits', 'exit_rate'); + + $this->addSiteSearchDisplayProperties($view); + } + + public function configureViewForSiteSearchCategories(ViewDataTable $view) + { + $view->config->addTranslations(array( + 'label' => Piwik::translate('Actions_ColumnSearchCategory'), + 'nb_visits' => Piwik::translate('Actions_ColumnSearches'), + 'nb_pages_per_search' => Piwik::translate('Actions_ColumnPagesPerSearch') + )); + + $view->config->columns_to_display = array('label', 'nb_visits', 'nb_pages_per_search'); + $view->config->show_table_all_columns = false; + $view->config->show_bar_chart = false; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->disable_row_evolution = false; + } + } + + public function configureViewForGetPageUrlsOrTitlesFollowingSiteSearch(ViewDataTable $view, $isTitle) + { + $title = $isTitle ? Piwik::translate('Actions_WidgetPageTitlesFollowingSearch') + : Piwik::translate('Actions_WidgetPageUrlsFollowingSearch'); + + $relatedReports = array( + 'Actions.getPageTitlesFollowingSiteSearch' => Piwik::translate('Actions_WidgetPageTitlesFollowingSearch'), + 'Actions.getPageUrlsFollowingSiteSearch' => Piwik::translate('Actions_WidgetPageUrlsFollowingSearch'), + ); + + $view->config->addRelatedReports($relatedReports); + $view->config->addTranslations(array( + 'label' => Piwik::translate('General_ColumnDestinationPage'), + 'nb_hits_following_search' => Piwik::translate('General_ColumnViewedAfterSearch'), + 'nb_hits' => Piwik::translate('General_ColumnTotalPageviews') + )); + + $view->config->title = $title; + $view->config->columns_to_display = array('label', 'nb_hits_following_search', 'nb_hits'); + $view->config->show_exclude_low_population = false; + $view->requestConfig->filter_sort_column = 'nb_hits_following_search'; + $view->requestConfig->filter_sort_order = 'desc'; + + $this->addExcludeLowPopDisplayProperties($view); + $this->addBaseDisplayProperties($view); + } + + public function configureViewForGetPageTitles(ViewDataTable $view) + { + // link to the page, not just the report, but only if not a widget + $widget = Common::getRequestVar('widget', false); + + $view->config->self_url = Request::getCurrentUrlWithoutGenericFilters(array( + 'module' => 'Actions', + 'action' => $widget === false ? 'indexPageTitles' : 'getPageTitles' + )); + + $view->config->title = Piwik::translate('Actions_SubmenuPageTitles'); + $view->config->addRelatedReports(array( + 'Actions.getEntryPageTitles' => Piwik::translate('Actions_EntryPageTitles'), + 'Actions.getExitPageTitles' => Piwik::translate('Actions_ExitPageTitles'), + )); + + $view->config->addTranslation('label', Piwik::translate('Actions_ColumnPageName')); + $view->config->columns_to_display = array('label', 'nb_hits', 'nb_visits', 'bounce_rate', + 'avg_time_on_page', 'exit_rate', 'avg_time_generation'); + + $this->addPageDisplayProperties($view); + $this->addBaseDisplayProperties($view); + } + + public function configureViewForGetEntryPageTitles(ViewDataTable $view) + { + $entryPageUrlAction = + Common::getRequestVar('widget', false) === false ? 'indexEntryPageUrls' : 'getEntryPageUrls'; + + $view->config->addTranslations(array( + 'label' => Piwik::translate('Actions_ColumnEntryPageTitle'), + 'entry_bounce_count' => Piwik::translate('General_ColumnBounces'), + 'entry_nb_visits' => Piwik::translate('General_ColumnEntrances'), + )); + $view->config->addRelatedReports(array( + 'Actions.getPageTitles' => Piwik::translate('Actions_SubmenuPageTitles'), + "Actions.$entryPageUrlAction" => Piwik::translate('Actions_SubmenuPagesEntry') + )); + + $view->config->columns_to_display = array('label', 'entry_nb_visits', 'entry_bounce_count', 'bounce_rate'); + $view->config->title = Piwik::translate('Actions_EntryPageTitles'); + + $view->requestConfig->filter_sort_column = 'entry_nb_visits'; + + $this->addPageDisplayProperties($view); + $this->addBaseDisplayProperties($view); + } + + public function configureViewForGetExitPageTitles(ViewDataTable $view) + { + $exitPageUrlAction = + Common::getRequestVar('widget', false) === false ? 'indexExitPageUrls' : 'getExitPageUrls'; + + $view->config->addTranslations(array( + 'label' => Piwik::translate('Actions_ColumnExitPageTitle'), + 'exit_nb_visits' => Piwik::translate('General_ColumnExits'), + )); + $view->config->addRelatedReports(array( + 'Actions.getPageTitles' => Piwik::translate('Actions_SubmenuPageTitles'), + "Actions.$exitPageUrlAction" => Piwik::translate('Actions_SubmenuPagesExit'), + )); + + $view->config->title = Piwik::translate('Actions_ExitPageTitles'); + $view->config->columns_to_display = array('label', 'exit_nb_visits', 'nb_visits', 'exit_rate'); + + $this->addPageDisplayProperties($view); + $this->addBaseDisplayProperties($view); + } + + public function configureViewForGetDownloads(ViewDataTable $view) + { + $view->config->addTranslations(array( + 'label' => Piwik::translate('Actions_ColumnDownloadURL'), + 'nb_visits' => Piwik::translate('Actions_ColumnUniqueDownloads'), + 'nb_hits' => Piwik::translate('General_Downloads'), + )); + + $view->config->columns_to_display = array('label', 'nb_visits', 'nb_hits'); + $view->config->show_exclude_low_population = false; + + $this->addBaseDisplayProperties($view); + } + + public function configureViewForGetOutlinks(ViewDataTable $view) + { + $view->config->addTranslations(array( + 'label' => Piwik::translate('Actions_ColumnClickedURL'), + 'nb_visits' => Piwik::translate('Actions_ColumnUniqueClicks'), + 'nb_hits' => Piwik::translate('Actions_ColumnClicks'), + )); + + $view->config->columns_to_display = array('label', 'nb_visits', 'nb_hits'); + $view->config->show_exclude_low_population = false; + + $this->addBaseDisplayProperties($view); + } +} + diff --git a/www/analytics/plugins/Actions/Archiver.php b/www/analytics/plugins/Actions/Archiver.php new file mode 100644 index 00000000..f7f83982 --- /dev/null +++ b/www/analytics/plugins/Actions/Archiver.php @@ -0,0 +1,548 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\Actions; + +use Piwik\DataTable; +use Piwik\Metrics; +use Piwik\RankingQuery; +use Piwik\Tracker\Action; +use Piwik\Tracker\ActionSiteSearch; + +/** + * Class encapsulating logic to process Day/Period Archiving for the Actions reports + * + */ +class Archiver extends \Piwik\Plugin\Archiver +{ + const DOWNLOADS_RECORD_NAME = 'Actions_downloads'; + const OUTLINKS_RECORD_NAME = 'Actions_outlink'; + const PAGE_TITLES_RECORD_NAME = 'Actions_actions'; + const SITE_SEARCH_RECORD_NAME = 'Actions_sitesearch'; + const PAGE_URLS_RECORD_NAME = 'Actions_actions_url'; + + const METRIC_PAGEVIEWS_RECORD_NAME = 'Actions_nb_pageviews'; + const METRIC_UNIQ_PAGEVIEWS_RECORD_NAME = 'Actions_nb_uniq_pageviews'; + const METRIC_SUM_TIME_RECORD_NAME = 'Actions_sum_time_generation'; + const METRIC_HITS_TIMED_RECORD_NAME = 'Actions_nb_hits_with_time_generation'; + const METRIC_DOWNLOADS_RECORD_NAME = 'Actions_nb_downloads'; + const METRIC_UNIQ_DOWNLOADS_RECORD_NAME = 'Actions_nb_uniq_downloads'; + const METRIC_OUTLINKS_RECORD_NAME = 'Actions_nb_outlinks'; + const METRIC_UNIQ_OUTLINKS_RECORD_NAME = 'Actions_nb_uniq_outlinks'; + const METRIC_SEARCHES_RECORD_NAME = 'Actions_nb_searches'; + const METRIC_KEYWORDS_RECORD_NAME = 'Actions_nb_keywords'; + + /* Metrics in use by the API Actions.get */ + public static $actionsAggregateMetrics = array( + self::METRIC_PAGEVIEWS_RECORD_NAME => 'nb_pageviews', + self::METRIC_UNIQ_PAGEVIEWS_RECORD_NAME => 'nb_uniq_pageviews', + self::METRIC_DOWNLOADS_RECORD_NAME => 'nb_downloads', + self::METRIC_UNIQ_DOWNLOADS_RECORD_NAME => 'nb_uniq_downloads', + self::METRIC_OUTLINKS_RECORD_NAME => 'nb_outlinks', + self::METRIC_UNIQ_OUTLINKS_RECORD_NAME => 'nb_uniq_outlinks', + self::METRIC_SEARCHES_RECORD_NAME => 'nb_searches', + self::METRIC_KEYWORDS_RECORD_NAME => 'nb_keywords', + ); + + public static $actionTypes = array( + Action::TYPE_PAGE_URL, + Action::TYPE_OUTLINK, + Action::TYPE_DOWNLOAD, + Action::TYPE_PAGE_TITLE, + Action::TYPE_SITE_SEARCH, + ); + static protected $columnsToRenameAfterAggregation = array( + Metrics::INDEX_NB_UNIQ_VISITORS => Metrics::INDEX_SUM_DAILY_NB_UNIQ_VISITORS, + Metrics::INDEX_PAGE_ENTRY_NB_UNIQ_VISITORS => Metrics::INDEX_PAGE_ENTRY_SUM_DAILY_NB_UNIQ_VISITORS, + Metrics::INDEX_PAGE_EXIT_NB_UNIQ_VISITORS => Metrics::INDEX_PAGE_EXIT_SUM_DAILY_NB_UNIQ_VISITORS, + ); + static public $columnsToDeleteAfterAggregation = array( + Metrics::INDEX_NB_UNIQ_VISITORS, + Metrics::INDEX_PAGE_ENTRY_NB_UNIQ_VISITORS, + Metrics::INDEX_PAGE_EXIT_NB_UNIQ_VISITORS, + ); + private static $columnsAggregationOperation = array( + Metrics::INDEX_PAGE_MAX_TIME_GENERATION => 'max', + Metrics::INDEX_PAGE_MIN_TIME_GENERATION => 'min' + ); + protected $actionsTablesByType = null; + protected $isSiteSearchEnabled = false; + + function __construct($processor) + { + parent::__construct($processor); + $this->isSiteSearchEnabled = $processor->getParams()->getSite()->isSiteSearchEnabled(); + } + + /** + * Archives Actions reports for a Day + * + * @return bool + */ + public function aggregateDayReport() + { + $rankingQueryLimit = ArchivingHelper::getRankingQueryLimit(); + ArchivingHelper::reloadConfig(); + + $this->initActionsTables(); + $this->archiveDayActions($rankingQueryLimit); + $this->archiveDayEntryActions($rankingQueryLimit); + $this->archiveDayExitActions($rankingQueryLimit); + $this->archiveDayActionsTime($rankingQueryLimit); + + $this->insertDayReports(); + + return true; + } + + /** + * @return array + */ + protected function getMetricNames() + { + return array( + self::METRIC_PAGEVIEWS_RECORD_NAME, + self::METRIC_UNIQ_PAGEVIEWS_RECORD_NAME, + self::METRIC_DOWNLOADS_RECORD_NAME, + self::METRIC_UNIQ_DOWNLOADS_RECORD_NAME, + self::METRIC_OUTLINKS_RECORD_NAME, + self::METRIC_UNIQ_OUTLINKS_RECORD_NAME, + self::METRIC_SEARCHES_RECORD_NAME, + self::METRIC_SUM_TIME_RECORD_NAME, + self::METRIC_HITS_TIMED_RECORD_NAME, + ); + } + + /** + * @return string + */ + static public function getWhereClauseActionIsNotEvent() + { + return " AND log_link_visit_action.idaction_event_category IS NULL"; + } + + /** + * @param $select + * @param $from + */ + protected function updateQuerySelectFromForSiteSearch(&$select, &$from) + { + $selectFlagNoResultKeywords = ", + CASE WHEN (MAX(log_link_visit_action.custom_var_v" . ActionSiteSearch::CVAR_INDEX_SEARCH_COUNT . ") = 0 + AND log_link_visit_action.custom_var_k" . ActionSiteSearch::CVAR_INDEX_SEARCH_COUNT . " = '" . ActionSiteSearch::CVAR_KEY_SEARCH_COUNT . "') + THEN 1 ELSE 0 END + AS `" . Metrics::INDEX_SITE_SEARCH_HAS_NO_RESULT . "`"; + + //we need an extra JOIN to know whether the referrer "idaction_name_ref" was a Site Search request + $from[] = array( + "table" => "log_action", + "tableAlias" => "log_action_name_ref", + "joinOn" => "log_link_visit_action.idaction_name_ref = log_action_name_ref.idaction" + ); + + $selectPageIsFollowingSiteSearch = ", + SUM( CASE WHEN log_action_name_ref.type = " . Action::TYPE_SITE_SEARCH . " + THEN 1 ELSE 0 END) + AS `" . Metrics::INDEX_PAGE_IS_FOLLOWING_SITE_SEARCH_NB_HITS . "`"; + + $select .= $selectFlagNoResultKeywords + . $selectPageIsFollowingSiteSearch; + } + + /** + * Initializes the DataTables created by the archiveDay function. + */ + private function initActionsTables() + { + $this->actionsTablesByType = array(); + foreach (self::$actionTypes as $type) { + $dataTable = new DataTable(); + $dataTable->setMaximumAllowedRows(ArchivingHelper::$maximumRowsInDataTableLevelZero); + + if ($type == Action::TYPE_PAGE_URL + || $type == Action::TYPE_PAGE_TITLE + ) { + // for page urls and page titles, performance metrics exist and have to be aggregated correctly + $dataTable->setMetadata(DataTable::COLUMN_AGGREGATION_OPS_METADATA_NAME, self::$columnsAggregationOperation); + } + + $this->actionsTablesByType[$type] = $dataTable; + } + } + + protected function archiveDayActions($rankingQueryLimit) + { + $select = "log_action.name, + log_action.type, + log_action.idaction, + log_action.url_prefix, + count(distinct log_link_visit_action.idvisit) as `" . Metrics::INDEX_NB_VISITS . "`, + count(distinct log_link_visit_action.idvisitor) as `" . Metrics::INDEX_NB_UNIQ_VISITORS . "`, + count(*) as `" . Metrics::INDEX_PAGE_NB_HITS . "`, + sum( + case when " . Action::DB_COLUMN_CUSTOM_FLOAT . " is null + then 0 + else " . Action::DB_COLUMN_CUSTOM_FLOAT . " + end + ) / 1000 as `" . Metrics::INDEX_PAGE_SUM_TIME_GENERATION . "`, + sum( + case when " . Action::DB_COLUMN_CUSTOM_FLOAT . " is null + then 0 + else 1 + end + ) as `" . Metrics::INDEX_PAGE_NB_HITS_WITH_TIME_GENERATION . "`, + min(" . Action::DB_COLUMN_CUSTOM_FLOAT . ") / 1000 + as `" . Metrics::INDEX_PAGE_MIN_TIME_GENERATION . "`, + max(" . Action::DB_COLUMN_CUSTOM_FLOAT . ") / 1000 + as `" . Metrics::INDEX_PAGE_MAX_TIME_GENERATION . "` + "; + + $from = array( + "log_link_visit_action", + array( + "table" => "log_action", + "joinOn" => "log_link_visit_action.%s = log_action.idaction" + ) + ); + + $where = "log_link_visit_action.server_time >= ? + AND log_link_visit_action.server_time <= ? + AND log_link_visit_action.idsite = ? + AND log_link_visit_action.%s IS NOT NULL" + . $this->getWhereClauseActionIsNotEvent(); + + $groupBy = "log_action.idaction"; + $orderBy = "`" . Metrics::INDEX_PAGE_NB_HITS . "` DESC, name ASC"; + + $rankingQuery = false; + if ($rankingQueryLimit > 0) { + $rankingQuery = new RankingQuery($rankingQueryLimit); + $rankingQuery->setOthersLabel(DataTable::LABEL_SUMMARY_ROW); + $rankingQuery->addLabelColumn(array('idaction', 'name')); + $rankingQuery->addColumn(array('url_prefix', Metrics::INDEX_NB_UNIQ_VISITORS)); + $rankingQuery->addColumn(array(Metrics::INDEX_PAGE_NB_HITS, Metrics::INDEX_NB_VISITS), 'sum'); + if ($this->isSiteSearchEnabled()) { + $rankingQuery->addColumn(Metrics::INDEX_SITE_SEARCH_HAS_NO_RESULT, 'min'); + $rankingQuery->addColumn(Metrics::INDEX_PAGE_IS_FOLLOWING_SITE_SEARCH_NB_HITS, 'sum'); + } + $rankingQuery->addColumn(Metrics::INDEX_PAGE_SUM_TIME_GENERATION, 'sum'); + $rankingQuery->addColumn(Metrics::INDEX_PAGE_NB_HITS_WITH_TIME_GENERATION, 'sum'); + $rankingQuery->addColumn(Metrics::INDEX_PAGE_MIN_TIME_GENERATION, 'min'); + $rankingQuery->addColumn(Metrics::INDEX_PAGE_MAX_TIME_GENERATION, 'max'); + $rankingQuery->partitionResultIntoMultipleGroups('type', array_keys($this->actionsTablesByType)); + } + + // Special Magic to get + // 1) No result Keywords + // 2) For each page view, count number of times the referrer page was a Site Search + if ($this->isSiteSearchEnabled()) { + $this->updateQuerySelectFromForSiteSearch($select, $from); + } + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, "idaction_name", $rankingQuery); + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, "idaction_url", $rankingQuery); + } + + protected function isSiteSearchEnabled() + { + return $this->isSiteSearchEnabled; + } + + protected function archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, $sprintfField, $rankingQuery = false) + { + $select = sprintf($select, $sprintfField); + + // get query with segmentation + $query = $this->getLogAggregator()->generateQuery($select, $from, $where, $groupBy, $orderBy); + + // replace the rest of the %s + $querySql = str_replace("%s", $sprintfField, $query['sql']); + + // apply ranking query + if ($rankingQuery) { + $querySql = $rankingQuery->generateQuery($querySql); + } + + // get result + $resultSet = $this->getLogAggregator()->getDb()->query($querySql, $query['bind']); + $modified = ArchivingHelper::updateActionsTableWithRowQuery($resultSet, $sprintfField, $this->actionsTablesByType); + return $modified; + } + + /** + * Entry actions for Page URLs and Page names + */ + protected function archiveDayEntryActions($rankingQueryLimit) + { + $rankingQuery = false; + if ($rankingQueryLimit > 0) { + $rankingQuery = new RankingQuery($rankingQueryLimit); + $rankingQuery->setOthersLabel(DataTable::LABEL_SUMMARY_ROW); + $rankingQuery->addLabelColumn('idaction'); + $rankingQuery->addColumn(Metrics::INDEX_PAGE_ENTRY_NB_UNIQ_VISITORS); + $rankingQuery->addColumn(array(Metrics::INDEX_PAGE_ENTRY_NB_VISITS, + Metrics::INDEX_PAGE_ENTRY_NB_ACTIONS, + Metrics::INDEX_PAGE_ENTRY_SUM_VISIT_LENGTH, + Metrics::INDEX_PAGE_ENTRY_BOUNCE_COUNT), 'sum'); + $rankingQuery->partitionResultIntoMultipleGroups('type', array_keys($this->actionsTablesByType)); + + $extraSelects = 'log_action.type, log_action.name,'; + $from = array( + "log_visit", + array( + "table" => "log_action", + "joinOn" => "log_visit.%s = log_action.idaction" + ) + ); + $orderBy = "`" . Metrics::INDEX_PAGE_ENTRY_NB_ACTIONS . "` DESC, log_action.name ASC"; + } else { + $extraSelects = false; + $from = "log_visit"; + $orderBy = false; + } + + $select = "log_visit.%s as idaction, $extraSelects + count(distinct log_visit.idvisitor) as `" . Metrics::INDEX_PAGE_ENTRY_NB_UNIQ_VISITORS . "`, + count(*) as `" . Metrics::INDEX_PAGE_ENTRY_NB_VISITS . "`, + sum(log_visit.visit_total_actions) as `" . Metrics::INDEX_PAGE_ENTRY_NB_ACTIONS . "`, + sum(log_visit.visit_total_time) as `" . Metrics::INDEX_PAGE_ENTRY_SUM_VISIT_LENGTH . "`, + sum(case log_visit.visit_total_actions when 1 then 1 when 0 then 1 else 0 end) as `" . Metrics::INDEX_PAGE_ENTRY_BOUNCE_COUNT . "`"; + + $where = "log_visit.visit_last_action_time >= ? + AND log_visit.visit_last_action_time <= ? + AND log_visit.idsite = ? + AND log_visit.%s > 0"; + + $groupBy = "log_visit.%s, idaction"; + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, "visit_entry_idaction_url", $rankingQuery); + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, "visit_entry_idaction_name", $rankingQuery); + } + + /** + * Exit actions + */ + protected function archiveDayExitActions($rankingQueryLimit) + { + $rankingQuery = false; + if ($rankingQueryLimit > 0) { + $rankingQuery = new RankingQuery($rankingQueryLimit); + $rankingQuery->setOthersLabel(DataTable::LABEL_SUMMARY_ROW); + $rankingQuery->addLabelColumn('idaction'); + $rankingQuery->addColumn(Metrics::INDEX_PAGE_EXIT_NB_UNIQ_VISITORS); + $rankingQuery->addColumn(Metrics::INDEX_PAGE_EXIT_NB_VISITS, 'sum'); + $rankingQuery->partitionResultIntoMultipleGroups('type', array_keys($this->actionsTablesByType)); + + $extraSelects = 'log_action.type, log_action.name,'; + $from = array( + "log_visit", + array( + "table" => "log_action", + "joinOn" => "log_visit.%s = log_action.idaction" + ) + ); + $orderBy = "`" . Metrics::INDEX_PAGE_EXIT_NB_VISITS . "` DESC, log_action.name ASC"; + } else { + $extraSelects = false; + $from = "log_visit"; + $orderBy = false; + } + + $select = "log_visit.%s as idaction, $extraSelects + count(distinct log_visit.idvisitor) as `" . Metrics::INDEX_PAGE_EXIT_NB_UNIQ_VISITORS . "`, + count(*) as `" . Metrics::INDEX_PAGE_EXIT_NB_VISITS . "`"; + + $where = "log_visit.visit_last_action_time >= ? + AND log_visit.visit_last_action_time <= ? + AND log_visit.idsite = ? + AND log_visit.%s > 0"; + + $groupBy = "log_visit.%s, idaction"; + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, "visit_exit_idaction_url", $rankingQuery); + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, "visit_exit_idaction_name", $rankingQuery); + return array($rankingQuery, $extraSelects, $from, $orderBy, $select, $where, $groupBy); + } + + /** + * Time per action + */ + protected function archiveDayActionsTime($rankingQueryLimit) + { + $rankingQuery = false; + if ($rankingQueryLimit > 0) { + $rankingQuery = new RankingQuery($rankingQueryLimit); + $rankingQuery->setOthersLabel(DataTable::LABEL_SUMMARY_ROW); + $rankingQuery->addLabelColumn('idaction'); + $rankingQuery->addColumn(Metrics::INDEX_PAGE_SUM_TIME_SPENT, 'sum'); + $rankingQuery->partitionResultIntoMultipleGroups('type', array_keys($this->actionsTablesByType)); + + $extraSelects = "log_action.type, log_action.name, count(*) as `" . Metrics::INDEX_PAGE_NB_HITS . "`,"; + $from = array( + "log_link_visit_action", + array( + "table" => "log_action", + "joinOn" => "log_link_visit_action.%s = log_action.idaction" + ) + ); + $orderBy = "`" . Metrics::INDEX_PAGE_NB_HITS . "` DESC, log_action.name ASC"; + } else { + $extraSelects = false; + $from = "log_link_visit_action"; + $orderBy = false; + } + + $select = "log_link_visit_action.%s as idaction, $extraSelects + sum(log_link_visit_action.time_spent_ref_action) as `" . Metrics::INDEX_PAGE_SUM_TIME_SPENT . "`"; + + $where = "log_link_visit_action.server_time >= ? + AND log_link_visit_action.server_time <= ? + AND log_link_visit_action.idsite = ? + AND log_link_visit_action.time_spent_ref_action > 0 + AND log_link_visit_action.%s > 0" + . $this->getWhereClauseActionIsNotEvent(); + + $groupBy = "log_link_visit_action.%s, idaction"; + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, "idaction_url_ref", $rankingQuery); + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, "idaction_name_ref", $rankingQuery); + } + + /** + * Records in the DB the archived reports for Page views, Downloads, Outlinks, and Page titles + */ + protected function insertDayReports() + { + ArchivingHelper::clearActionsCache(); + + $this->insertPageUrlsReports(); + $this->insertDownloadsReports(); + $this->insertOutlinksReports(); + $this->insertPageTitlesReports(); + $this->insertSiteSearchReports(); + } + + protected function insertPageUrlsReports() + { + $dataTable = $this->getDataTable(Action::TYPE_PAGE_URL); + $this->insertTable($dataTable, self::PAGE_URLS_RECORD_NAME); + + $records = array( + self::METRIC_PAGEVIEWS_RECORD_NAME => array_sum($dataTable->getColumn(Metrics::INDEX_PAGE_NB_HITS)), + self::METRIC_UNIQ_PAGEVIEWS_RECORD_NAME => array_sum($dataTable->getColumn(Metrics::INDEX_NB_VISITS)), + self::METRIC_SUM_TIME_RECORD_NAME => array_sum($dataTable->getColumn(Metrics::INDEX_PAGE_SUM_TIME_GENERATION)), + self::METRIC_HITS_TIMED_RECORD_NAME => array_sum($dataTable->getColumn(Metrics::INDEX_PAGE_NB_HITS_WITH_TIME_GENERATION)) + ); + $this->getProcessor()->insertNumericRecords($records); + } + + /** + * @param $typeId + * @return DataTable + */ + protected function getDataTable($typeId) + { + return $this->actionsTablesByType[$typeId]; + } + + protected function insertTable(DataTable $dataTable, $recordName) + { + ArchivingHelper::deleteInvalidSummedColumnsFromDataTable($dataTable); + $report = $dataTable->getSerialized(ArchivingHelper::$maximumRowsInDataTableLevelZero, ArchivingHelper::$maximumRowsInSubDataTable, ArchivingHelper::$columnToSortByBeforeTruncation); + $this->getProcessor()->insertBlobRecord($recordName, $report); + } + + + protected function insertDownloadsReports() + { + $dataTable = $this->getDataTable(Action::TYPE_DOWNLOAD); + $this->insertTable($dataTable, self::DOWNLOADS_RECORD_NAME); + + $this->getProcessor()->insertNumericRecord(self::METRIC_DOWNLOADS_RECORD_NAME, array_sum($dataTable->getColumn(Metrics::INDEX_PAGE_NB_HITS))); + $this->getProcessor()->insertNumericRecord(self::METRIC_UNIQ_DOWNLOADS_RECORD_NAME, array_sum($dataTable->getColumn(Metrics::INDEX_NB_VISITS))); + } + + protected function insertOutlinksReports() + { + $dataTable = $this->getDataTable(Action::TYPE_OUTLINK); + $this->insertTable($dataTable, self::OUTLINKS_RECORD_NAME); + + $this->getProcessor()->insertNumericRecord(self::METRIC_OUTLINKS_RECORD_NAME, array_sum($dataTable->getColumn(Metrics::INDEX_PAGE_NB_HITS))); + $this->getProcessor()->insertNumericRecord(self::METRIC_UNIQ_OUTLINKS_RECORD_NAME, array_sum($dataTable->getColumn(Metrics::INDEX_NB_VISITS))); + } + + protected function insertPageTitlesReports() + { + $dataTable = $this->getDataTable(Action::TYPE_PAGE_TITLE); + $this->insertTable($dataTable, self::PAGE_TITLES_RECORD_NAME); + } + + protected function insertSiteSearchReports() + { + $dataTable = $this->getDataTable(Action::TYPE_SITE_SEARCH); + $this->deleteUnusedColumnsFromKeywordsDataTable($dataTable); + $this->insertTable($dataTable, self::SITE_SEARCH_RECORD_NAME); + + $this->getProcessor()->insertNumericRecord(self::METRIC_SEARCHES_RECORD_NAME, array_sum($dataTable->getColumn(Metrics::INDEX_NB_VISITS))); + $this->getProcessor()->insertNumericRecord(self::METRIC_KEYWORDS_RECORD_NAME, $dataTable->getRowsCount()); + } + + protected function deleteUnusedColumnsFromKeywordsDataTable(DataTable $dataTable) + { + $columnsToDelete = array( + Metrics::INDEX_NB_UNIQ_VISITORS, + Metrics::INDEX_PAGE_IS_FOLLOWING_SITE_SEARCH_NB_HITS, + Metrics::INDEX_PAGE_ENTRY_NB_UNIQ_VISITORS, + Metrics::INDEX_PAGE_ENTRY_NB_ACTIONS, + Metrics::INDEX_PAGE_ENTRY_SUM_VISIT_LENGTH, + Metrics::INDEX_PAGE_ENTRY_NB_VISITS, + Metrics::INDEX_PAGE_ENTRY_BOUNCE_COUNT, + Metrics::INDEX_PAGE_EXIT_NB_UNIQ_VISITORS, + ); + $dataTable->deleteColumns($columnsToDelete); + } + + public function aggregateMultipleReports() + { + ArchivingHelper::reloadConfig(); + $dataTableToSum = array( + self::PAGE_TITLES_RECORD_NAME, + self::PAGE_URLS_RECORD_NAME, + ); + $this->getProcessor()->aggregateDataTableRecords($dataTableToSum, + ArchivingHelper::$maximumRowsInDataTableLevelZero, + ArchivingHelper::$maximumRowsInSubDataTable, + ArchivingHelper::$columnToSortByBeforeTruncation, + self::$columnsAggregationOperation, + self::$columnsToRenameAfterAggregation + ); + + $dataTableToSum = array( + self::DOWNLOADS_RECORD_NAME, + self::OUTLINKS_RECORD_NAME, + self::SITE_SEARCH_RECORD_NAME, + ); + $aggregation = null; + $nameToCount = $this->getProcessor()->aggregateDataTableRecords($dataTableToSum, + ArchivingHelper::$maximumRowsInDataTableLevelZero, + ArchivingHelper::$maximumRowsInSubDataTable, + ArchivingHelper::$columnToSortByBeforeTruncation, + $aggregation, + self::$columnsToRenameAfterAggregation + ); + + $this->getProcessor()->aggregateNumericMetrics($this->getMetricNames()); + + // Unique Keywords can't be summed, instead we take the RowsCount() of the keyword table + $this->getProcessor()->insertNumericRecord(self::METRIC_KEYWORDS_RECORD_NAME, $nameToCount[self::SITE_SEARCH_RECORD_NAME]['level0']); + } +} diff --git a/www/analytics/plugins/Actions/ArchivingHelper.php b/www/analytics/plugins/Actions/ArchivingHelper.php new file mode 100644 index 00000000..2ed50efa --- /dev/null +++ b/www/analytics/plugins/Actions/ArchivingHelper.php @@ -0,0 +1,612 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\Actions; + +use PDOStatement; +use Piwik\Config; +use Piwik\DataTable\Manager; +use Piwik\DataTable\Row; +use Piwik\DataTable; +use Piwik\DataTable\Row\DataTableSummaryRow; +use Piwik\Metrics; +use Piwik\Piwik; +use Piwik\Tracker\Action; +use Piwik\Tracker\PageUrl; +use Zend_Db_Statement; + +/** + * This static class provides: + * - logic to parse/cleanup Action names, + * - logic to efficiently process aggregate the array data during Archiving + * + */ +class ArchivingHelper +{ + const OTHERS_ROW_KEY = ''; + + /** + * Ideally this should use the DataArray object instead of custom data structure + * + * @param Zend_Db_Statement|PDOStatement $query + * @param string|bool $fieldQueried + * @param array $actionsTablesByType + * @return int + */ + static public function updateActionsTableWithRowQuery($query, $fieldQueried, & $actionsTablesByType) + { + $rowsProcessed = 0; + while ($row = $query->fetch()) { + if (empty($row['idaction'])) { + $row['type'] = ($fieldQueried == 'idaction_url' ? Action::TYPE_PAGE_URL : Action::TYPE_PAGE_TITLE); + // This will be replaced with 'X not defined' later + $row['name'] = ''; + // Yes, this is kind of a hack, so we don't mix 'page url not defined' with 'page title not defined' etc. + $row['idaction'] = -$row['type']; + } + + if ($row['type'] != Action::TYPE_SITE_SEARCH) { + unset($row[Metrics::INDEX_SITE_SEARCH_HAS_NO_RESULT]); + } + + // This will appear as <url /> in the API, which is actually very important to keep + // eg. When there's at least one row in a report that does not have a URL, not having this <url/> would break HTML/PDF reports. + $url = ''; + if ($row['type'] == Action::TYPE_SITE_SEARCH + || $row['type'] == Action::TYPE_PAGE_TITLE + ) { + $url = null; + } elseif (!empty($row['name']) + && $row['name'] != DataTable::LABEL_SUMMARY_ROW) { + $url = PageUrl::reconstructNormalizedUrl((string)$row['name'], $row['url_prefix']); + } + + if (isset($row['name']) + && isset($row['type']) + ) { + $actionName = $row['name']; + $actionType = $row['type']; + $urlPrefix = $row['url_prefix']; + $idaction = $row['idaction']; + + // in some unknown case, the type field is NULL, as reported in #1082 - we ignore this page view + if (empty($actionType)) { + if ($idaction != DataTable::LABEL_SUMMARY_ROW) { + self::setCachedActionRow($idaction, $actionType, false); + } + continue; + } + + $actionRow = self::getActionRow($actionName, $actionType, $urlPrefix, $actionsTablesByType); + + self::setCachedActionRow($idaction, $actionType, $actionRow); + } else { + $actionRow = self::getCachedActionRow($row['idaction'], $row['type']); + + // Action processed as "to skip" for some reasons + if ($actionRow === false) { + continue; + } + } + + if (is_null($actionRow)) { + continue; + } + + // Here we do ensure that, the Metadata URL set for a given row, is the one from the Pageview with the most hits. + // This is to ensure that when, different URLs are loaded with the same page name. + // For example http://piwik.org and http://id.piwik.org are reported in Piwik > Actions > Pages with /index + // But, we must make sure http://piwik.org is used to link & for transitions + // Note: this code is partly duplicated from Row->sumRowMetadata() + if (!is_null($url) + && !$actionRow->isSummaryRow() + ) { + if (($existingUrl = $actionRow->getMetadata('url')) !== false) { + if (!empty($row[Metrics::INDEX_PAGE_NB_HITS]) + && $row[Metrics::INDEX_PAGE_NB_HITS] > $actionRow->maxVisitsSummed + ) { + $actionRow->setMetadata('url', $url); + $actionRow->maxVisitsSummed = $row[Metrics::INDEX_PAGE_NB_HITS]; + } + } else { + $actionRow->setMetadata('url', $url); + $actionRow->maxVisitsSummed = !empty($row[Metrics::INDEX_PAGE_NB_HITS]) ? $row[Metrics::INDEX_PAGE_NB_HITS] : 0; + } + } + + if ($row['type'] != Action::TYPE_PAGE_URL + && $row['type'] != Action::TYPE_PAGE_TITLE + ) { + // only keep performance metrics when they're used (i.e. for URLs and page titles) + if (array_key_exists(Metrics::INDEX_PAGE_SUM_TIME_GENERATION, $row)) { + unset($row[Metrics::INDEX_PAGE_SUM_TIME_GENERATION]); + } + if (array_key_exists(Metrics::INDEX_PAGE_NB_HITS_WITH_TIME_GENERATION, $row)) { + unset($row[Metrics::INDEX_PAGE_NB_HITS_WITH_TIME_GENERATION]); + } + if (array_key_exists(Metrics::INDEX_PAGE_MIN_TIME_GENERATION, $row)) { + unset($row[Metrics::INDEX_PAGE_MIN_TIME_GENERATION]); + } + if (array_key_exists(Metrics::INDEX_PAGE_MAX_TIME_GENERATION, $row)) { + unset($row[Metrics::INDEX_PAGE_MAX_TIME_GENERATION]); + } + } + + unset($row['name']); + unset($row['type']); + unset($row['idaction']); + unset($row['url_prefix']); + + foreach ($row as $name => $value) { + // in some edge cases, we have twice the same action name with 2 different idaction + // - this happens when 2 visitors visit the same new page at the same time, and 2 actions get recorded for the same name + // - this could also happen when 2 URLs end up having the same label (eg. 2 subdomains get aggregated to the "/index" page name) + if (($alreadyValue = $actionRow->getColumn($name)) !== false) { + $newValue = self::getColumnValuesMerged($name, $alreadyValue, $value); + $actionRow->setColumn($name, $newValue); + } else { + $actionRow->addColumn($name, $value); + } + } + + // if the exit_action was not recorded properly in the log_link_visit_action + // there would be an error message when getting the nb_hits column + // we must fake the record and add the columns + if ($actionRow->getColumn(Metrics::INDEX_PAGE_NB_HITS) === false) { + // to test this code: delete the entries in log_link_action_visit for + // a given exit_idaction_url + foreach (self::getDefaultRow()->getColumns() as $name => $value) { + $actionRow->addColumn($name, $value); + } + } + $rowsProcessed++; + } + + // just to make sure php copies the last $actionRow in the $parentTable array + $actionRow =& $actionsTablesByType; + return $rowsProcessed; + } + + public static function removeEmptyColumns($dataTable) + { + // Delete all columns that have a value of zero + $dataTable->filter('ColumnDelete', array( + $columnsToRemove = array(Metrics::INDEX_PAGE_IS_FOLLOWING_SITE_SEARCH_NB_HITS), + $columnsToKeep = array(), + $deleteIfZeroOnly = true + )); + } + + /** + * For rows which have subtables (eg. directories with sub pages), + * deletes columns which don't make sense when all values of sub pages are summed. + * + * @param $dataTable DataTable + */ + public static function deleteInvalidSummedColumnsFromDataTable($dataTable) + { + foreach ($dataTable->getRows() as $id => $row) { + if (($idSubtable = $row->getIdSubDataTable()) !== null + || $id === DataTable::ID_SUMMARY_ROW + ) { + if ($idSubtable !== null) { + $subtable = Manager::getInstance()->getTable($idSubtable); + self::deleteInvalidSummedColumnsFromDataTable($subtable); + } + + if ($row instanceof DataTableSummaryRow) { + $row->recalculate(); + } + + foreach (Archiver::$columnsToDeleteAfterAggregation as $name) { + $row->deleteColumn($name); + } + } + } + + // And this as well + ArchivingHelper::removeEmptyColumns($dataTable); + } + + /** + * Returns the limit to use with RankingQuery for this plugin. + * + * @return int + */ + public static function getRankingQueryLimit() + { + $configGeneral = Config::getInstance()->General; + $configLimit = $configGeneral['archiving_ranking_query_row_limit']; + $limit = $configLimit == 0 ? 0 : max( + $configLimit, + $configGeneral['datatable_archiving_maximum_rows_actions'], + $configGeneral['datatable_archiving_maximum_rows_subtable_actions'] + ); + + // FIXME: This is a quick fix for #3482. The actual cause of the bug is that + // the site search & performance metrics additions to + // ArchivingHelper::updateActionsTableWithRowQuery expect every + // row to have 'type' data, but not all of the SQL queries that are run w/o + // ranking query join on the log_action table and thus do not select the + // log_action.type column. + // + // NOTES: Archiving logic can be generalized as follows: + // 0) Do SQL query over log_link_visit_action & join on log_action to select + // some metrics (like visits, hits, etc.) + // 1) For each row, cache the action row & metrics. (This is done by + // updateActionsTableWithRowQuery for result set rows that have + // name & type columns.) + // 2) Do other SQL queries for metrics we can't put in the first query (like + // entry visits, exit vists, etc.) w/o joining log_action. + // 3) For each row, find the cached row by idaction & add the new metrics to + // it. (This is done by updateActionsTableWithRowQuery for result set rows + // that DO NOT have name & type columns.) + // + // The site search & performance metrics additions expect a 'type' all the time + // which breaks the original pre-rankingquery logic. Ranking query requires a + // join, so the bug is only seen when ranking query is disabled. + if ($limit === 0) { + $limit = 100000; + } + return $limit; + + } + + /** + * @param $columnName + * @param $alreadyValue + * @param $value + * @return mixed + */ + private static function getColumnValuesMerged($columnName, $alreadyValue, $value) + { + if ($columnName == Metrics::INDEX_PAGE_MIN_TIME_GENERATION) { + if (empty($alreadyValue)) { + $newValue = $value; + } else if (empty($value)) { + $newValue = $alreadyValue; + } else { + $newValue = min($alreadyValue, $value); + } + return $newValue; + } + if ($columnName == Metrics::INDEX_PAGE_MAX_TIME_GENERATION) { + $newValue = max($alreadyValue, $value); + return $newValue; + } + + $newValue = $alreadyValue + $value; + return $newValue; + } + + static public $maximumRowsInDataTableLevelZero; + static public $maximumRowsInSubDataTable; + static public $columnToSortByBeforeTruncation; + + static protected $actionUrlCategoryDelimiter = null; + static protected $actionTitleCategoryDelimiter = null; + static protected $defaultActionName = null; + static protected $defaultActionNameWhenNotDefined = null; + static protected $defaultActionUrlWhenNotDefined = null; + + static public function reloadConfig() + { + // for BC, we read the old style delimiter first (see #1067)Row + $actionDelimiter = @Config::getInstance()->General['action_category_delimiter']; + if (empty($actionDelimiter)) { + self::$actionUrlCategoryDelimiter = Config::getInstance()->General['action_url_category_delimiter']; + self::$actionTitleCategoryDelimiter = Config::getInstance()->General['action_title_category_delimiter']; + } else { + self::$actionUrlCategoryDelimiter = self::$actionTitleCategoryDelimiter = $actionDelimiter; + } + + self::$defaultActionName = Config::getInstance()->General['action_default_name']; + self::$columnToSortByBeforeTruncation = Metrics::INDEX_NB_VISITS; + self::$maximumRowsInDataTableLevelZero = Config::getInstance()->General['datatable_archiving_maximum_rows_actions']; + self::$maximumRowsInSubDataTable = Config::getInstance()->General['datatable_archiving_maximum_rows_subtable_actions']; + + DataTable::setMaximumDepthLevelAllowedAtLeast(self::getSubCategoryLevelLimit() + 1); + } + + /** + * The default row is used when archiving, if data is inconsistent in the DB, + * there could be pages that have exit/entry hits, but don't yet + * have a record in the table (or the record was truncated). + * + * @return Row + */ + static private function getDefaultRow() + { + static $row = false; + if ($row === false) { + // This row is used in the case where an action is know as an exit_action + // but this action was not properly recorded when it was hit in the first place + // so we add this fake row information to make sure there is a nb_hits, etc. column for every action + $row = new Row(array( + Row::COLUMNS => array( + Metrics::INDEX_NB_VISITS => 1, + Metrics::INDEX_NB_UNIQ_VISITORS => 1, + Metrics::INDEX_PAGE_NB_HITS => 1, + ))); + } + return $row; + } + + /** + * Given a page name and type, builds a recursive datatable where + * each level of the tree is a category, based on the page name split by a delimiter (slash / by default) + * + * @param string $actionName + * @param int $actionType + * @param int $urlPrefix + * @param array $actionsTablesByType + * @return DataTable + */ + private static function getActionRow($actionName, $actionType, $urlPrefix = null, &$actionsTablesByType) + { + // we work on the root table of the given TYPE (either ACTION_URL or DOWNLOAD or OUTLINK etc.) + /* @var DataTable $currentTable */ + $currentTable =& $actionsTablesByType[$actionType]; + + if(is_null($currentTable)) { + throw new \Exception("Action table for type '$actionType' was not found during Actions archiving."); + } + + // check for ranking query cut-off + if ($actionName == DataTable::LABEL_SUMMARY_ROW) { + $summaryRow = $currentTable->getRowFromId(DataTable::ID_SUMMARY_ROW); + if ($summaryRow === false) { + $summaryRow = $currentTable->addSummaryRow(self::createSummaryRow()); + } + return $summaryRow; + } + + // go to the level of the subcategory + $actionExplodedNames = self::getActionExplodedNames($actionName, $actionType, $urlPrefix); + list($row, $level) = $currentTable->walkPath( + $actionExplodedNames, self::getDefaultRowColumns(), self::$maximumRowsInSubDataTable); + + return $row; + } + + /** + * Returns the configured sub-category level limit. + * + * @return int + */ + public static function getSubCategoryLevelLimit() + { + return Config::getInstance()->General['action_category_level_limit']; + } + + /** + * Returns default label for the action type + * + * @param $type + * @return string + */ + static public function getUnknownActionName($type) + { + if (empty(self::$defaultActionNameWhenNotDefined)) { + self::$defaultActionNameWhenNotDefined = Piwik::translate('General_NotDefined', Piwik::translate('Actions_ColumnPageName')); + self::$defaultActionUrlWhenNotDefined = Piwik::translate('General_NotDefined', Piwik::translate('Actions_ColumnPageURL')); + } + if ($type == Action::TYPE_PAGE_TITLE) { + return self::$defaultActionNameWhenNotDefined; + } + return self::$defaultActionUrlWhenNotDefined; + } + + + /** + * Explodes action name into an array of elements. + * + * NOTE: before calling this function make sure ArchivingHelper::reloadConfig(); is called + * + * for downloads: + * we explode link http://piwik.org/some/path/piwik.zip into an array( 'piwik.org', '/some/path/piwik.zip' ); + * + * for outlinks: + * we explode link http://dev.piwik.org/some/path into an array( 'dev.piwik.org', '/some/path' ); + * + * for action urls: + * we explode link http://piwik.org/some/path into an array( 'some', 'path' ); + * + * for action names: + * we explode name 'Piwik / Category 1 / Category 2' into an array('Piwik', 'Category 1', 'Category 2'); + * + * @param string $name action name + * @param int $type action type + * @param int $urlPrefix url prefix (only used for TYPE_PAGE_URL) + * @return array of exploded elements from $name + */ + static public function getActionExplodedNames($name, $type, $urlPrefix = null) + { + // Site Search does not split Search keywords + if ($type == Action::TYPE_SITE_SEARCH) { + return array($name); + } + + $name = str_replace("\n", "", $name); + + $name = self::parseNameFromPageUrl($name, $type, $urlPrefix); + + // outlinks and downloads + if(is_array($name)) { + return $name; + } + $split = self::splitNameByDelimiter($name, $type); + + if (empty($split)) { + $defaultName = self::getUnknownActionName($type); + return array(trim($defaultName)); + } + + $lastPageName = end($split); + // we are careful to prefix the page URL / name with some value + // so that if a page has the same name as a category + // we don't merge both entries + if ($type != Action::TYPE_PAGE_TITLE) { + $lastPageName = '/' . $lastPageName; + } else { + $lastPageName = ' ' . $lastPageName; + } + $split[count($split) - 1] = $lastPageName; + return array_values($split); + } + + /** + * Gets the key for the cache of action rows from an action ID and type. + * + * @param int $idAction + * @param int $actionType + * @return string|int + */ + private static function getCachedActionRowKey($idAction, $actionType) + { + return $idAction == DataTable::LABEL_SUMMARY_ROW + ? $actionType . '_others' + : $idAction; + } + + /** + * Static cache to store Rows during processing + */ + static protected $cacheParsedAction = array(); + + public static function clearActionsCache() + { + self::$cacheParsedAction = array(); + } + + /** + * Get cached action row by id & type. If $idAction is set to -1, the 'Others' row + * for the specific action type will be returned. + * + * @param int $idAction + * @param int $actionType + * @return Row|false + */ + private static function getCachedActionRow($idAction, $actionType) + { + $cacheLabel = self::getCachedActionRowKey($idAction, $actionType); + + if (!isset(self::$cacheParsedAction[$cacheLabel])) { + // This can happen when + // - We select an entry page ID that was only seen yesterday, so wasn't selected in the first query + // - We count time spent on a page, when this page was only seen yesterday + return false; + } + + return self::$cacheParsedAction[$cacheLabel]; + } + + /** + * Set cached action row for an id & type. + * + * @param int $idAction + * @param int $actionType + * @param \DataTable\Row + */ + private static function setCachedActionRow($idAction, $actionType, $actionRow) + { + $cacheLabel = self::getCachedActionRowKey($idAction, $actionType); + self::$cacheParsedAction[$cacheLabel] = $actionRow; + } + + /** + * Returns the default columns for a row in an Actions DataTable. + * + * @return array + */ + private static function getDefaultRowColumns() + { + return array(Metrics::INDEX_NB_VISITS => 0, + Metrics::INDEX_NB_UNIQ_VISITORS => 0, + Metrics::INDEX_PAGE_NB_HITS => 0, + Metrics::INDEX_PAGE_SUM_TIME_SPENT => 0); + } + + /** + * Creates a summary row for an Actions DataTable. + * + * @return Row + */ + private static function createSummaryRow() + { + return new Row(array( + Row::COLUMNS => + array('label' => DataTable::LABEL_SUMMARY_ROW) + self::getDefaultRowColumns() + )); + } + + private static function splitNameByDelimiter($name, $type) + { + if(is_array($name)) { + return $name; + } + if ($type == Action::TYPE_PAGE_TITLE) { + $categoryDelimiter = self::$actionTitleCategoryDelimiter; + } else { + $categoryDelimiter = self::$actionUrlCategoryDelimiter; + } + + if (empty($categoryDelimiter)) { + return array(trim($name)); + } + + $split = explode($categoryDelimiter, $name, self::getSubCategoryLevelLimit()); + + // trim every category and remove empty categories + $split = array_map('trim', $split); + $split = array_filter($split, 'strlen'); + + // forces array key to start at 0 + $split = array_values($split); + + return $split; + } + + private static function parseNameFromPageUrl($name, $type, $urlPrefix) + { + $urlRegexAfterDomain = '([^/]+)[/]?([^#]*)[#]?(.*)'; + if ($urlPrefix === null) { + // match url with protocol (used for outlinks / downloads) + $urlRegex = '@^http[s]?://' . $urlRegexAfterDomain . '$@i'; + } else { + // the name is a url that does not contain protocol and www anymore + // we know that normalization has been done on db level because $urlPrefix is set + $urlRegex = '@^' . $urlRegexAfterDomain . '$@i'; + } + + $matches = array(); + preg_match($urlRegex, $name, $matches); + if (!count($matches)) { + return $name; + } + $urlHost = $matches[1]; + $urlPath = $matches[2]; + $urlFragment = $matches[3]; + + if (in_array($type, array(Action::TYPE_DOWNLOAD, Action::TYPE_OUTLINK))) { + return array(trim($urlHost), '/' . trim($urlPath)); + } + + $name = $urlPath; + if ($name === '' || substr($name, -1) == '/') { + $name .= self::$defaultActionName; + } + + $urlFragment = PageUrl::processUrlFragment($urlFragment); + if (!empty($urlFragment)) { + $name .= '#' . $urlFragment; + } + + return $name; + } +} diff --git a/www/analytics/plugins/Actions/Controller.php b/www/analytics/plugins/Actions/Controller.php new file mode 100644 index 00000000..af56986a --- /dev/null +++ b/www/analytics/plugins/Actions/Controller.php @@ -0,0 +1,151 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\Actions; + +use Piwik\Piwik; +use Piwik\View; +use Piwik\ViewDataTable\Factory; + +/** + * Actions controller + * + */ +class Controller extends \Piwik\Plugin\Controller +{ + // + // Actions that render whole pages + // + + public function indexPageUrls() + { + return View::singleReport( + Piwik::translate('General_Pages'), + $this->getPageUrls(true)); + } + + public function indexEntryPageUrls() + { + return View::singleReport( + Piwik::translate('Actions_SubmenuPagesEntry'), + $this->getEntryPageUrls(true)); + } + + public function indexExitPageUrls() + { + return View::singleReport( + Piwik::translate('Actions_SubmenuPagesExit'), + $this->getExitPageUrls(true)); + } + + public function indexSiteSearch() + { + $view = new View('@Actions/indexSiteSearch'); + + $view->keywords = $this->getSiteSearchKeywords(true); + $view->noResultKeywords = $this->getSiteSearchNoResultKeywords(true); + $view->pagesUrlsFollowingSiteSearch = $this->getPageUrlsFollowingSiteSearch(true); + + $categoryTrackingEnabled = \Piwik\Plugin\Manager::getInstance()->isPluginActivated('CustomVariables'); + if ($categoryTrackingEnabled) { + $view->categories = $this->getSiteSearchCategories(true); + } + + return $view->render(); + } + + public function indexPageTitles() + { + return View::singleReport( + Piwik::translate('Actions_SubmenuPageTitles'), + $this->getPageTitles(true)); + } + + public function indexDownloads() + { + return View::singleReport( + Piwik::translate('General_Downloads'), + $this->getDownloads(true)); + } + + public function indexOutlinks() + { + return View::singleReport( + Piwik::translate('General_Outlinks'), + $this->getOutlinks(true)); + } + + // + // Actions that render individual reports + // + + public function getPageUrls() + { + return $this->renderReport(__FUNCTION__); + } + + public function getEntryPageUrls() + { + return $this->renderReport(__FUNCTION__); + } + + public function getExitPageUrls() + { + return $this->renderReport(__FUNCTION__); + } + + public function getSiteSearchKeywords() + { + return $this->renderReport(__FUNCTION__); + } + + public function getSiteSearchNoResultKeywords() + { + return $this->renderReport(__FUNCTION__); + } + + public function getSiteSearchCategories() + { + return $this->renderReport(__FUNCTION__); + } + + public function getPageUrlsFollowingSiteSearch() + { + return $this->renderReport(__FUNCTION__); + } + + public function getPageTitlesFollowingSiteSearch() + { + return $this->renderReport(__FUNCTION__); + } + + public function getPageTitles() + { + return $this->renderReport(__FUNCTION__); + } + + public function getEntryPageTitles() + { + return $this->renderReport(__FUNCTION__); + } + + public function getExitPageTitles() + { + return $this->renderReport(__FUNCTION__); + } + + public function getDownloads() + { + return $this->renderReport(__FUNCTION__); + } + + public function getOutlinks() + { + return $this->renderReport(__FUNCTION__); + } +} diff --git a/www/analytics/plugins/Actions/javascripts/actionsDataTable.js b/www/analytics/plugins/Actions/javascripts/actionsDataTable.js new file mode 100644 index 00000000..209c3309 --- /dev/null +++ b/www/analytics/plugins/Actions/javascripts/actionsDataTable.js @@ -0,0 +1,328 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + + (function ($, require) { + + var exports = require('piwik/UI'), + DataTable = exports.DataTable, + dataTablePrototype = DataTable.prototype; + + // helper function for ActionDataTable + function getLevelFromClass(style) { + if (!style || typeof style == "undefined") return 0; + + var currentLevel = 0; + + var currentLevelIndex = style.indexOf('level'); + if (currentLevelIndex >= 0) { + currentLevel = Number(style.substr(currentLevelIndex + 5, 1)); + } + return currentLevel; + } + + // helper function for ActionDataTable + function setImageMinus(domElem) { + $('img.plusMinus', domElem).attr('src', 'plugins/Zeitgeist/images/minus.png'); + } + + // helper function for ActionDataTable + function setImagePlus(domElem) { + $('img.plusMinus', domElem).attr('src', 'plugins/Zeitgeist/images/plus.png'); + } + + /** + * UI control that handles extra functionality for Actions datatables. + * + * @constructor + */ + exports.ActionsDataTable = function (element) { + this.parentAttributeParent = ''; + this.parentId = ''; + this.disabledRowDom = {}; // to handle double click on '+' row + + DataTable.call(this, element); + }; + + $.extend(exports.ActionsDataTable.prototype, dataTablePrototype, { + + //see dataTable::bindEventsAndApplyStyle + bindEventsAndApplyStyle: function (domElem, rows) { + var self = this; + + self.cleanParams(); + + if (!rows) { + rows = $('tr', domElem); + } + + // we dont display the link on the row with subDataTable when we are already + // printing all the subTables (case of recursive search when the content is + // including recursively all the subtables + if (!self.param.filter_pattern_recursive) { + self.numberOfSubtables = rows.filter('.subDataTable').click(function () { + self.onClickActionSubDataTable(this) + }).size(); + } + self.applyCosmetics(domElem, rows); + self.handleColumnHighlighting(domElem); + self.handleRowActions(domElem, rows); + self.handleLimit(domElem); + self.handleAnnotationsButton(domElem); + self.handleExportBox(domElem); + self.handleSort(domElem); + self.handleOffsetInformation(domElem); + if (self.workingDivId != undefined) { + var dataTableLoadedProxy = function (response) { + self.dataTableLoaded(response, self.workingDivId); + }; + + self.handleSearchBox(domElem, dataTableLoadedProxy); + self.handleConfigurationBox(domElem, dataTableLoadedProxy); + } + + self.handleColumnDocumentation(domElem); + self.handleRelatedReports(domElem); + self.handleTriggeredEvents(domElem); + self.handleCellTooltips(domElem); + self.handleExpandFooter(domElem); + self.setFixWidthToMakeEllipsisWork(domElem); + }, + + //see dataTable::applyCosmetics + applyCosmetics: function (domElem, rows) { + var self = this; + var rowsWithSubtables = rows.filter('.subDataTable'); + + rowsWithSubtables.css('font-weight', 'bold'); + + $("th:first-child", domElem).addClass('label'); + var imagePlusMinusWidth = 12; + var imagePlusMinusHeight = 12; + $('td:first-child', rowsWithSubtables) + .each(function () { + $(this).prepend('<img width="' + imagePlusMinusWidth + '" height="' + imagePlusMinusHeight + '" class="plusMinus" src="" />'); + if (self.param.filter_pattern_recursive) { + setImageMinus(this); + } + else { + setImagePlus(this); + } + }); + + var rootRow = rows.first().prev(); + + // we look at the style of the row before the new rows to determine the rows' + // level + var level = rootRow.length ? getLevelFromClass(rootRow.attr('class')) + 1 : 0; + + rows.each(function () { + var currentStyle = $(this).attr('class') || ''; + + if (currentStyle.indexOf('level') == -1) { + $(this).addClass('level' + level); + } + + // we add an attribute parent that contains the ID of all the parent categories + // this ID is used when collapsing a parent row, it searches for all children rows + // which 'parent' attribute's value contains the collapsed row ID + $(this).prop('parent', function () { + return self.parentAttributeParent + ' ' + self.parentId; + }); + }); + + self.addOddAndEvenClasses(domElem); + }, + + addOddAndEvenClasses: function(domElem) { + // Add some styles on the cells even/odd + // label (first column of a data row) or not + $("tr:not(.hidden):odd td:first-child", domElem) + .removeClass('labeleven').addClass('label labelodd'); + $("tr:not(.hidden):even td:first-child", domElem) + .removeClass('labelodd').addClass('label labeleven'); + $("tr:not(.hidden):odd td", domElem).slice(1) + .removeClass('columneven').addClass('column columnodd'); + $("tr:not(.hidden):even td", domElem).slice(1) + .removeClass('columnodd').addClass('column columneven'); + }, + + handleRowActions: function (domElem, rows) { + this.doHandleRowActions(rows); + }, + + // Called when the user click on an actionDataTable row + onClickActionSubDataTable: function (domElem) { + var self = this; + + // get the idSubTable + var idSubTable = $(domElem).attr('id'); + + var divIdToReplaceWithSubTable = 'subDataTable_' + idSubTable; + + var NextStyle = $(domElem).next().attr('class'); + var CurrentStyle = $(domElem).attr('class'); + + var currentRowLevel = getLevelFromClass(CurrentStyle); + var nextRowLevel = getLevelFromClass(NextStyle); + + // if the row has not been clicked + // which is the same as saying that the next row level is equal or less than the current row + // because when we click a row the level of the next rows is higher (level2 row gives level3 rows) + if (currentRowLevel >= nextRowLevel) { + //unbind click to avoid double click problem + $(domElem).off('click'); + self.disabledRowDom = $(domElem); + + var numberOfColumns = $(domElem).children().length; + $(domElem).after('\ + <tr id="' + divIdToReplaceWithSubTable + '" class="cellSubDataTable">\ + <td colspan="' + numberOfColumns + '">\ + <span class="loadingPiwik" style="display:inline"><img src="plugins/Zeitgeist/images/loading-blue.gif" /> Loading...</span>\ + </td>\ + </tr>\ + '); + var savedActionVariable = self.param.action; + + // reset all the filters from the Parent table + var filtersToRestore = self.resetAllFilters(); + + // Do not reset the sorting filters that must be applied to sub tables + this.param['filter_sort_column'] = filtersToRestore['filter_sort_column']; + this.param['filter_sort_order'] = filtersToRestore['filter_sort_order']; + this.param['enable_filter_excludelowpop'] = filtersToRestore['enable_filter_excludelowpop']; + + self.param.idSubtable = idSubTable; + self.param.action = self.props.subtable_controller_action; + + self.reloadAjaxDataTable(false, function (resp) { + self.actionsSubDataTableLoaded(resp, idSubTable); + self.repositionRowActions($(domElem)); + }); + self.param.action = savedActionVariable; + + self.restoreAllFilters(filtersToRestore); + + delete self.param.idSubtable; + } + // else we toggle all these rows + else { + var plusDetected = $('td img.plusMinus', domElem).attr('src').indexOf('plus') >= 0; + var stripingNeeded = false; + + $(domElem).siblings().each(function () { + var parents = $(this).prop('parent').split(' '); + if (parents) { + if (parents.indexOf(idSubTable) >= 0 + || parents.indexOf('subDataTable_' + idSubTable) >= 0) { + if (plusDetected) { + $(this).css('display', '').removeClass('hidden'); + stripingNeeded = !stripingNeeded; + + //unroll everything and display '-' sign + //if the row is already opened + var NextStyle = $(this).next().attr('class'); + var CurrentStyle = $(this).attr('class'); + + var currentRowLevel = getLevelFromClass(CurrentStyle); + var nextRowLevel = getLevelFromClass(NextStyle); + + if (currentRowLevel < nextRowLevel) + setImageMinus(this); + } + else { + $(this).css('display', 'none').addClass('hidden'); + stripingNeeded = !stripingNeeded; + } + self.repositionRowActions($(domElem)); + } + } + }); + + var table = $(domElem); + if (!table.hasClass('dataTable')) { + table = table.closest('.dataTable'); + } + if (stripingNeeded) { + self.addOddAndEvenClasses(table); + } + + self.$element.trigger('piwik:actionsSubTableToggled'); + } + + // toggle the +/- image + var plusDetected = $('td img.plusMinus', domElem).attr('src').indexOf('plus') >= 0; + if (plusDetected) { + setImageMinus(domElem); + } + else { + setImagePlus(domElem); + } + }, + + //called when the full table actions is loaded + dataTableLoaded: function (response, workingDivId) { + var content = $(response); + var idToReplace = workingDivId || $(content).attr('id'); + + //reset parents id + self.parentAttributeParent = ''; + self.parentId = ''; + + var dataTableSel = $('#' + idToReplace); + + // keep the original list of related reports + var oldReportsElem = $('.datatableRelatedReports', dataTableSel); + $('.datatableRelatedReports', content).replaceWith(oldReportsElem); + + dataTableSel.replaceWith(content); + + content.trigger('piwik:dataTableLoaded'); + + piwikHelper.lazyScrollTo(content[0], 400); + + return content; + }, + + // Called when a set of rows for a category of actions is loaded + actionsSubDataTableLoaded: function (response, idSubTable) { + var self = this; + var idToReplace = 'subDataTable_' + idSubTable; + var root = $('#' + self.workingDivId); + + var response = $(response); + self.parentAttributeParent = $('tr#' + idToReplace).prev().prop('parent'); + self.parentId = idToReplace; + + $('tr#' + idToReplace, root).after(response).remove(); + + var missingColumns = (response.prev().find('td').size() - response.find('td').size()); + for (var i = 0; i < missingColumns; i++) { + // if the subtable has fewer columns than the parent table, add some columns. + // this happens for example, when the parent table has performance metrics and the subtable doesn't. + response.append('<td>-</td>'); + } + + var re = /subDataTable_(\d+)/; + var ok = re.exec(self.parentId); + if (ok) { + self.parentId = ok[1]; + } + + // we execute the bindDataTableEvent function for the new DIV + self.bindEventsAndApplyStyle($('#' + self.workingDivId), response); + + self.$element.trigger('piwik:actionsSubDataTableLoaded'); + + //bind back the click event (disabled to avoid double-click problem) + self.disabledRowDom.click( + function () { + self.onClickActionSubDataTable(this) + }); + } + }); + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/Actions/stylesheets/dataTableActions.less b/www/analytics/plugins/Actions/stylesheets/dataTableActions.less new file mode 100644 index 00000000..7c059d94 --- /dev/null +++ b/www/analytics/plugins/Actions/stylesheets/dataTableActions.less @@ -0,0 +1,4 @@ +.dataTableActions > .dataTableWrapper { + width: 500px; + min-height: 1px; +} \ No newline at end of file diff --git a/www/analytics/plugins/Actions/templates/indexSiteSearch.twig b/www/analytics/plugins/Actions/templates/indexSiteSearch.twig new file mode 100644 index 00000000..7d6093c6 --- /dev/null +++ b/www/analytics/plugins/Actions/templates/indexSiteSearch.twig @@ -0,0 +1,17 @@ +<div id='leftcolumn'> + <h2 piwik-enriched-headline>{{ 'Actions_WidgetSearchKeywords'|translate }}</h2> + {{ keywords|raw }} + + <h2 piwik-enriched-headline>{{ 'Actions_WidgetSearchNoResultKeywords'|translate }}</h2> + {{ noResultKeywords|raw }} + + {% if categories is defined %} + <h2 piwik-enriched-headline>{{ 'Actions_WidgetSearchCategories'|translate }}</h2> + {{ categories|raw }} + {% endif %} +</div> + +<div id='rightcolumn'> + <h2 piwik-enriched-headline>{{ 'Actions_WidgetPageUrlsFollowingSearch'|translate }}</h2> + {{ pagesUrlsFollowingSiteSearch|raw }} +</div> diff --git a/www/analytics/plugins/Annotations/API.php b/www/analytics/plugins/Annotations/API.php new file mode 100755 index 00000000..a390f018 --- /dev/null +++ b/www/analytics/plugins/Annotations/API.php @@ -0,0 +1,371 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\Annotations; + +use Exception; + +use Piwik\Date; +use Piwik\Period; +use Piwik\Period\Range; +use Piwik\Piwik; +use Piwik\Plugins\CoreVisualizations\Visualizations\JqplotGraph\Evolution as EvolutionViz; + +/** + * @see plugins/Annotations/AnnotationList.php + */ +require_once PIWIK_INCLUDE_PATH . '/plugins/Annotations/AnnotationList.php'; + +/** + * API for annotations plugin. Provides methods to create, modify, delete & query + * annotations. + * + * @method static \Piwik\Plugins\Annotations\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + /** + * Create a new annotation for a site. + * + * @param string $idSite The site ID to add the annotation to. + * @param string $date The date the annotation is attached to. + * @param string $note The text of the annotation. + * @param int $starred Either 0 or 1. Whether the annotation should be starred. + * @return array Returns an array of two elements. The first element (indexed by + * 'annotation') is the new annotation. The second element (indexed + * by 'idNote' is the new note's ID). + */ + public function add($idSite, $date, $note, $starred = 0) + { + $this->checkSingleIdSite($idSite, $extraMessage = "Note: Cannot add one note to multiple sites."); + $this->checkDateIsValid($date); + $this->checkUserCanAddNotesFor($idSite); + + // add, save & return a new annotation + $annotations = new AnnotationList($idSite); + + $newAnnotation = $annotations->add($idSite, $date, $note, $starred); + $annotations->save($idSite); + + return $newAnnotation; + } + + /** + * Modifies an annotation for a site and returns the modified annotation + * and its ID. + * + * If the current user is not allowed to modify an annotation, an exception + * will be thrown. A user can modify a note if: + * - the user has admin access for the site, OR + * - the user has view access, is not the anonymous user and is the user that + * created the note + * + * @param string $idSite The site ID to add the annotation to. + * @param string $idNote The ID of the note. + * @param string|null $date The date the annotation is attached to. If null, the annotation's + * date is not modified. + * @param string|null $note The text of the annotation. If null, the annotation's text + * is not modified. + * @param string|null $starred Either 0 or 1. Whether the annotation should be starred. + * If null, the annotation is not starred/un-starred. + * @return array Returns an array of two elements. The first element (indexed by + * 'annotation') is the new annotation. The second element (indexed + * by 'idNote' is the new note's ID). + */ + public function save($idSite, $idNote, $date = null, $note = null, $starred = null) + { + $this->checkSingleIdSite($idSite, $extraMessage = "Note: Cannot modify more than one note at a time."); + $this->checkDateIsValid($date, $canBeNull = true); + + // get the annotations for the site + $annotations = new AnnotationList($idSite); + + // check permissions + $this->checkUserCanModifyOrDelete($idSite, $annotations->get($idSite, $idNote)); + + // modify the annotation, and save the whole list + $annotations->update($idSite, $idNote, $date, $note, $starred); + $annotations->save($idSite); + + return $annotations->get($idSite, $idNote); + } + + /** + * Removes an annotation from a site's list of annotations. + * + * If the current user is not allowed to delete the annotation, an exception + * will be thrown. A user can delete a note if: + * - the user has admin access for the site, OR + * - the user has view access, is not the anonymous user and is the user that + * created the note + * + * @param string $idSite The site ID to add the annotation to. + * @param string $idNote The ID of the note to delete. + */ + public function delete($idSite, $idNote) + { + $this->checkSingleIdSite($idSite, $extraMessage = "Note: Cannot delete annotations from multiple sites."); + + $annotations = new AnnotationList($idSite); + + // check permissions + $this->checkUserCanModifyOrDelete($idSite, $annotations->get($idSite, $idNote)); + + // remove the note & save the list + $annotations->remove($idSite, $idNote); + $annotations->save($idSite); + } + + /** + * Removes all annotations for a single site. Only super users can use this method. + * + * @param string $idSite The ID of the site to remove annotations for. + */ + public function deleteAll($idSite) + { + $this->checkSingleIdSite($idSite, $extraMessage = "Note: Cannot delete annotations from multiple sites."); + Piwik::checkUserHasSuperUserAccess(); + + $annotations = new AnnotationList($idSite); + + // remove the notes & save the list + $annotations->removeAll($idSite); + $annotations->save($idSite); + } + + /** + * Returns a single note for one site. + * + * @param string $idSite The site ID to add the annotation to. + * @param string $idNote The ID of the note to get. + * @return array The annotation. It will contain the following properties: + * - date: The date the annotation was recorded for. + * - note: The note text. + * - starred: Whether the note is starred or not. + * - user: The user that created the note. + * - canEditOrDelete: Whether the user that called this method can edit or + * delete the annotation returned. + */ + public function get($idSite, $idNote) + { + $this->checkSingleIdSite($idSite, $extraMessage = "Note: Specify only one site ID when getting ONE note."); + Piwik::checkUserHasViewAccess($idSite); + + // get single annotation + $annotations = new AnnotationList($idSite); + return $annotations->get($idSite, $idNote); + } + + /** + * Returns every annotation for a specific site within a specific date range. + * The date range is specified by a date, the period type (day/week/month/year) + * and an optional number of N periods in the past to include. + * + * @param string $idSite The site ID to add the annotation to. Can be one ID or + * a list of site IDs. + * @param bool|string $date The date of the period. + * @param string $period The period type. + * @param bool|int $lastN Whether to include the last N number of periods in the + * date range or not. + * @return array An array that indexes arrays of annotations by site ID. ie, + * array( + * 5 => array( + * array(...), // annotation #1 + * array(...), // annotation #2 + * ), + * 8 => array(...) + * ) + */ + public function getAll($idSite, $date = false, $period = 'day', $lastN = false) + { + Piwik::checkUserHasViewAccess($idSite); + + $annotations = new AnnotationList($idSite); + + // if date/period are supplied, determine start/end date for search + list($startDate, $endDate) = self::getDateRangeForPeriod($date, $period, $lastN); + + return $annotations->search($startDate, $endDate); + } + + /** + * Returns the count of annotations for a list of periods, including the count of + * starred annotations. + * + * @param string $idSite The site ID to add the annotation to. + * @param string|bool $date The date of the period. + * @param string $period The period type. + * @param int|bool $lastN Whether to get counts for the last N number of periods or not. + * @param bool $getAnnotationText + * @return array An array mapping site IDs to arrays holding dates & the count of + * annotations made for those dates. eg, + * array( + * 5 => array( + * array('2012-01-02', array('count' => 4, 'starred' => 2)), + * array('2012-01-03', array('count' => 0, 'starred' => 0)), + * array('2012-01-04', array('count' => 2, 'starred' => 0)), + * ), + * 6 => array( + * array('2012-01-02', array('count' => 1, 'starred' => 0)), + * array('2012-01-03', array('count' => 4, 'starred' => 3)), + * array('2012-01-04', array('count' => 2, 'starred' => 0)), + * ), + * ... + * ) + */ + public function getAnnotationCountForDates($idSite, $date, $period, $lastN = false, $getAnnotationText = false) + { + Piwik::checkUserHasViewAccess($idSite); + + // get start & end date for request. lastN is ignored if $period == 'range' + list($startDate, $endDate) = self::getDateRangeForPeriod($date, $period, $lastN); + if ($period == 'range') { + $period = 'day'; + } + + // create list of dates + $dates = array(); + for (; $startDate->getTimestamp() <= $endDate->getTimestamp(); $startDate = $startDate->addPeriod(1, $period)) { + $dates[] = $startDate; + } + // we add one for the end of the last period (used in for loop below to bound annotation dates) + $dates[] = $startDate; + + // get annotations for the site + $annotations = new AnnotationList($idSite); + + // create result w/ 0-counts + $result = array(); + for ($i = 0; $i != count($dates) - 1; ++$i) { + $date = $dates[$i]; + $nextDate = $dates[$i + 1]; + $strDate = $date->toString(); + + foreach ($annotations->getIdSites() as $idSite) { + $result[$idSite][$strDate] = $annotations->count($idSite, $date, $nextDate); + + // if only one annotation, return the one annotation's text w/ the counts + if ($getAnnotationText + && $result[$idSite][$strDate]['count'] == 1 + ) { + $annotationsForSite = $annotations->search( + $date, Date::factory($nextDate->getTimestamp() - 1), $idSite); + $annotation = reset($annotationsForSite[$idSite]); + + $result[$idSite][$strDate]['note'] = $annotation['note']; + } + } + } + + // convert associative array into array of pairs (so it can be traversed by index) + $pairResult = array(); + foreach ($result as $idSite => $counts) { + foreach ($counts as $date => $count) { + $pairResult[$idSite][] = array($date, $count); + } + } + return $pairResult; + } + + /** + * Throws if the current user is not allowed to modify or delete an annotation. + * + * @param int $idSite The site ID the annotation belongs to. + * @param array $annotation The annotation. + * @throws Exception if the current user is not allowed to modify/delete $annotation. + */ + private function checkUserCanModifyOrDelete($idSite, $annotation) + { + if (!$annotation['canEditOrDelete']) { + throw new Exception(Piwik::translate('Annotations_YouCannotModifyThisNote')); + } + } + + /** + * Throws if the current user is not allowed to create annotations for a site. + * + * @param int $idSite The site ID. + * @throws Exception if the current user is anonymous or does not have view access + * for site w/ id=$idSite. + */ + private static function checkUserCanAddNotesFor($idSite) + { + if (!AnnotationList::canUserAddNotesFor($idSite)) { + throw new Exception("The current user is not allowed to add notes for site #$idSite."); + } + } + + /** + * Returns start & end dates for the range described by a period and optional lastN + * argument. + * + * @param string|bool $date The start date of the period (or the date range of a range + * period). + * @param string $period The period type ('day', 'week', 'month', 'year' or 'range'). + * @param bool|int $lastN Whether to include the last N periods in the range or not. + * Ignored if period == range. + * + * @return Date[] array of Date objects or array(false, false) + * @ignore + */ + public static function getDateRangeForPeriod($date, $period, $lastN = false) + { + if ($date === false) { + return array(false, false); + } + + // if the range is just a normal period (or the period is a range in which case lastN is ignored) + if ($lastN === false + || $period == 'range' + ) { + if ($period == 'range') { + $oPeriod = new Range('day', $date); + } else { + $oPeriod = Period::factory($period, Date::factory($date)); + } + + $startDate = $oPeriod->getDateStart(); + $endDate = $oPeriod->getDateEnd(); + } else // if the range includes the last N periods + { + list($date, $lastN) = EvolutionViz::getDateRangeAndLastN($period, $date, $lastN); + list($startDate, $endDate) = explode(',', $date); + + $startDate = Date::factory($startDate); + $endDate = Date::factory($endDate); + } + return array($startDate, $endDate); + } + + /** + * Utility function, makes sure idSite string has only one site ID and throws if + * otherwise. + */ + private function checkSingleIdSite($idSite, $extraMessage) + { + // can only add a note to one site + if (!is_numeric($idSite)) { + throw new Exception("Invalid idSite: '$idSite'. $extraMessage"); + } + } + + /** + * Utility function, makes sure date string is valid date, and throws if + * otherwise. + */ + private function checkDateIsValid($date, $canBeNull = false) + { + if ($date === null + && $canBeNull + ) { + return; + } + + Date::factory($date); + } +} diff --git a/www/analytics/plugins/Annotations/AnnotationList.php b/www/analytics/plugins/Annotations/AnnotationList.php new file mode 100755 index 00000000..ad95e47f --- /dev/null +++ b/www/analytics/plugins/Annotations/AnnotationList.php @@ -0,0 +1,455 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\Annotations; + +use Exception; +use Piwik\Date; +use Piwik\Option; +use Piwik\Piwik; +use Piwik\Site; + +/** + * This class can be used to query & modify annotations for multiple sites + * at once. + * + * Example use: + * $annotations = new AnnotationList($idSites = "1,2,5"); + * $annotation = $annotations->get($idSite = 1, $idNote = 4); + * // do stuff w/ annotation + * $annotations->update($idSite = 2, $idNote = 4, $note = "This is the new text."); + * $annotations->save($idSite); + * + * Note: There is a concurrency issue w/ this code. If two users try to save + * an annotation for the same site, it's possible one of their changes will + * never get made (as it will be overwritten by the other's). + * + */ +class AnnotationList +{ + const ANNOTATION_COLLECTION_OPTION_SUFFIX = '_annotations'; + + /** + * List of site IDs this instance holds annotations for. + * + * @var array + */ + private $idSites; + + /** + * Array that associates lists of annotations with site IDs. + * + * @var array + */ + private $annotations; + + /** + * Constructor. Loads annotations from the database. + * + * @param string|int $idSites The list of site IDs to load annotations for. + */ + public function __construct($idSites) + { + $this->idSites = Site::getIdSitesFromIdSitesString($idSites); + $this->annotations = $this->getAnnotationsForSite(); + } + + /** + * Returns the list of site IDs this list contains annotations for. + * + * @return array + */ + public function getIdSites() + { + return $this->idSites; + } + + /** + * Creates a new annotation for a site. This method does not perist the result. + * To save the new annotation in the database, call $this->save. + * + * @param int $idSite The ID of the site to add an annotation to. + * @param string $date The date the annotation is in reference to. + * @param string $note The text of the new annotation. + * @param int $starred Either 1 or 0. If 1, the new annotation has been starred, + * otherwise it will start out unstarred. + * @return array The added annotation. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + */ + public function add($idSite, $date, $note, $starred = 0) + { + $this->checkIdSiteIsLoaded($idSite); + + $this->annotations[$idSite][] = self::makeAnnotation($date, $note, $starred); + + // get the id of the new annotation + end($this->annotations[$idSite]); + $newNoteId = key($this->annotations[$idSite]); + + return $this->get($idSite, $newNoteId); + } + + /** + * Persists the annotations list for a site, overwriting whatever exists. + * + * @param int $idSite The ID of the site to save annotations for. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + */ + public function save($idSite) + { + $this->checkIdSiteIsLoaded($idSite); + + $optionName = self::getAnnotationCollectionOptionName($idSite); + Option::set($optionName, serialize($this->annotations[$idSite])); + } + + /** + * Modifies an annotation in this instance's collection of annotations. + * + * Note: This method does not perist the change in the DB. The save method must + * be called for that. + * + * @param int $idSite The ID of the site whose annotation will be updated. + * @param int $idNote The ID of the note. + * @param string|null $date The new date of the annotation, eg '2012-01-01'. If + * null, no change is made. + * @param string|null $note The new text of the annotation. If null, no change + * is made. + * @param int|null $starred Either 1 or 0, whether the annotation should be + * starred or not. If null, no change is made. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + * @throws Exception if $idNote does not refer to valid note for the site. + */ + public function update($idSite, $idNote, $date = null, $note = null, $starred = null) + { + $this->checkIdSiteIsLoaded($idSite); + $this->checkNoteExists($idSite, $idNote); + + $annotation =& $this->annotations[$idSite][$idNote]; + if ($date !== null) { + $annotation['date'] = $date; + } + if ($note !== null) { + $annotation['note'] = $note; + } + if ($starred !== null) { + $annotation['starred'] = $starred; + } + } + + /** + * Removes a note from a site's collection of annotations. + * + * Note: This method does not perist the change in the DB. The save method must + * be called for that. + * + * @param int $idSite The ID of the site whose annotation will be updated. + * @param int $idNote The ID of the note. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + * @throws Exception if $idNote does not refer to valid note for the site. + */ + public function remove($idSite, $idNote) + { + $this->checkIdSiteIsLoaded($idSite); + $this->checkNoteExists($idSite, $idNote); + + unset($this->annotations[$idSite][$idNote]); + } + + /** + * Removes all notes for a single site. + * + * Note: This method does not perist the change in the DB. The save method must + * be called for that. + * + * @param int $idSite The ID of the site to get an annotation for. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + */ + public function removeAll($idSite) + { + $this->checkIdSiteIsLoaded($idSite); + + $this->annotations[$idSite] = array(); + } + + /** + * Retrieves an annotation by ID. + * + * This function returns an array with the following elements: + * - idNote: The ID of the annotation. + * - date: The date of the annotation. + * - note: The text of the annotation. + * - starred: 1 or 0, whether the annotation is stared; + * - user: (unless current user is anonymous) The user that created the annotation. + * - canEditOrDelete: True if the user can edit/delete the annotation. + * + * @param int $idSite The ID of the site to get an annotation for. + * @param int $idNote The ID of the note to get. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + * @throws Exception if $idNote does not refer to valid note for the site. + */ + public function get($idSite, $idNote) + { + $this->checkIdSiteIsLoaded($idSite); + $this->checkNoteExists($idSite, $idNote); + + $annotation = $this->annotations[$idSite][$idNote]; + $this->augmentAnnotationData($idSite, $idNote, $annotation); + return $annotation; + } + + /** + * Returns all annotations within a specific date range. The result is + * an array that maps site IDs with arrays of annotations within the range. + * + * Note: The date range is inclusive. + * + * @see self::get for info on what attributes stored within annotations. + * + * @param Date|bool $startDate The start of the date range. + * @param Date|bool $endDate The end of the date range. + * @param array|bool|int|string $idSite IDs of the sites whose annotations to + * search through. + * @return array Array mapping site IDs with arrays of annotations, eg: + * array( + * '5' => array( + * array(...), // annotation + * array(...), // annotation + * ... + * ), + * '6' => array( + * array(...), // annotation + * array(...), // annotation + * ... + * ), + * ) + */ + public function search($startDate, $endDate, $idSite = false) + { + if ($idSite) { + $idSites = Site::getIdSitesFromIdSitesString($idSite); + } else { + $idSites = array_keys($this->annotations); + } + + // collect annotations that are within the right date range & belong to the right + // site + $result = array(); + foreach ($idSites as $idSite) { + if (!isset($this->annotations[$idSite])) { + continue; + } + + foreach ($this->annotations[$idSite] as $idNote => $annotation) { + if ($startDate !== false) { + $annotationDate = Date::factory($annotation['date']); + if ($annotationDate->getTimestamp() < $startDate->getTimestamp() + || $annotationDate->getTimestamp() > $endDate->getTimestamp() + ) { + continue; + } + } + + $this->augmentAnnotationData($idSite, $idNote, $annotation); + $result[$idSite][] = $annotation; + } + + // sort by annotation date + if (!empty($result[$idSite])) { + uasort($result[$idSite], array($this, 'compareAnnotationDate')); + } + } + return $result; + } + + /** + * Counts annotations & starred annotations within a date range and returns + * the counts. The date range includes the start date, but not the end date. + * + * @param int $idSite The ID of the site to count annotations for. + * @param string|false $startDate The start date of the range or false if no + * range check is desired. + * @param string|false $endDate The end date of the range or false if no + * range check is desired. + * @return array eg, array('count' => 5, 'starred' => 2) + */ + public function count($idSite, $startDate, $endDate) + { + $this->checkIdSiteIsLoaded($idSite); + + // search includes end date, and count should not, so subtract one from the timestamp + $annotations = $this->search($startDate, Date::factory($endDate->getTimestamp() - 1)); + + // count the annotations + $count = $starred = 0; + if (!empty($annotations[$idSite])) { + $count = count($annotations[$idSite]); + foreach ($annotations[$idSite] as $annotation) { + if ($annotation['starred']) { + ++$starred; + } + } + } + + return array('count' => $count, 'starred' => $starred); + } + + /** + * Utility function. Creates a new annotation. + * + * @param string $date + * @param string $note + * @param int $starred + * @return array + */ + private function makeAnnotation($date, $note, $starred = 0) + { + return array('date' => $date, + 'note' => $note, + 'starred' => (int)$starred, + 'user' => Piwik::getCurrentUserLogin()); + } + + /** + * Retrieves annotations from the database for the sites supplied to the + * constructor. + * + * @return array Lists of annotations mapped by site ID. + */ + private function getAnnotationsForSite() + { + $result = array(); + foreach ($this->idSites as $id) { + $optionName = self::getAnnotationCollectionOptionName($id); + $serialized = Option::get($optionName); + + if ($serialized !== false) { + $result[$id] = @unserialize($serialized); + if(empty($result[$id])) { + // in case unserialize failed + $result[$id] = array(); + } + } else { + $result[$id] = array(); + } + } + return $result; + } + + /** + * Utility function that checks if a site ID was supplied and if not, + * throws an exception. + * + * We can only modify/read annotations for sites that we've actually + * loaded the annotations for. + * + * @param int $idSite + * @throws Exception + */ + private function checkIdSiteIsLoaded($idSite) + { + if (!in_array($idSite, $this->idSites)) { + throw new Exception("This AnnotationList was not initialized with idSite '$idSite'."); + } + } + + /** + * Utility function that checks if a note exists for a site, and if not, + * throws an exception. + * + * @param int $idSite + * @param int $idNote + * @throws Exception + */ + private function checkNoteExists($idSite, $idNote) + { + if (empty($this->annotations[$idSite][$idNote])) { + throw new Exception("There is no note with id '$idNote' for site with id '$idSite'."); + } + } + + /** + * Returns true if the current user can modify or delete a specific annotation. + * + * A user can modify/delete a note if the user has admin access for the site OR + * the user has view access, is not the anonymous user and is the user that + * created the note in question. + * + * @param int $idSite The site ID the annotation belongs to. + * @param array $annotation The annotation. + * @return bool + */ + public static function canUserModifyOrDelete($idSite, $annotation) + { + // user can save if user is admin or if has view access, is not anonymous & is user who wrote note + $canEdit = Piwik::isUserHasAdminAccess($idSite) + || (!Piwik::isUserIsAnonymous() + && Piwik::getCurrentUserLogin() == $annotation['user']); + return $canEdit; + } + + /** + * Adds extra data to an annotation, including the annotation's ID and whether + * the current user can edit or delete it. + * + * Also, if the current user is anonymous, the user attribute is removed. + * + * @param int $idSite + * @param int $idNote + * @param array $annotation + */ + private function augmentAnnotationData($idSite, $idNote, &$annotation) + { + $annotation['idNote'] = $idNote; + $annotation['canEditOrDelete'] = self::canUserModifyOrDelete($idSite, $annotation); + + // we don't supply user info if the current user is anonymous + if (Piwik::isUserIsAnonymous()) { + unset($annotation['user']); + } + } + + /** + * Utility function that compares two annotations. + * + * @param array $lhs An annotation. + * @param array $rhs An annotation. + * @return int -1, 0 or 1 + */ + public function compareAnnotationDate($lhs, $rhs) + { + if ($lhs['date'] == $rhs['date']) { + return $lhs['idNote'] <= $rhs['idNote'] ? -1 : 1; + } + + return $lhs['date'] < $rhs['date'] ? -1 : 1; // string comparison works because date format should be YYYY-MM-DD + } + + /** + * Returns true if the current user can add notes for a specific site. + * + * @param int $idSite The site to add notes to. + * @return bool + */ + public static function canUserAddNotesFor($idSite) + { + return Piwik::isUserHasViewAccess($idSite) + && !Piwik::isUserIsAnonymous($idSite); + } + + /** + * Returns the option name used to store annotations for a site. + * + * @param int $idSite The site ID. + * @return string + */ + public static function getAnnotationCollectionOptionName($idSite) + { + return $idSite . self::ANNOTATION_COLLECTION_OPTION_SUFFIX; + } +} diff --git a/www/analytics/plugins/Annotations/Annotations.php b/www/analytics/plugins/Annotations/Annotations.php new file mode 100755 index 00000000..56d142ed --- /dev/null +++ b/www/analytics/plugins/Annotations/Annotations.php @@ -0,0 +1,44 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\Annotations; + +/** + * Annotations plugins. Provides the ability to attach text notes to + * dates for each sites. Notes can be viewed, modified, deleted or starred. + * + */ +class Annotations extends \Piwik\Plugin +{ + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + ); + } + + /** + * Adds css files for this plugin to the list in the event notification. + */ + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/Annotations/stylesheets/annotations.less"; + } + + /** + * Adds js files for this plugin to the list in the event notification. + */ + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/Annotations/javascripts/annotations.js"; + } +} diff --git a/www/analytics/plugins/Annotations/Controller.php b/www/analytics/plugins/Annotations/Controller.php new file mode 100755 index 00000000..616da88e --- /dev/null +++ b/www/analytics/plugins/Annotations/Controller.php @@ -0,0 +1,215 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\Annotations; + +use Piwik\API\Request; +use Piwik\Common; +use Piwik\Piwik; +use Piwik\View; + +/** + * Controller for the Annotations plugin. + * + */ +class Controller extends \Piwik\Plugin\Controller +{ + /** + * Controller action that returns HTML displaying annotations for a site and + * specific date range. + * + * Query Param Input: + * - idSite: The ID of the site to get annotations for. Only one allowed. + * - date: The date to get annotations for. If lastN is not supplied, this is the start date, + * otherwise the start date in the last period. + * - period: The period type. + * - lastN: If supplied, the last N # of periods will be included w/ the range specified + * by date + period. + * + * Output: + * - HTML displaying annotations for a specific range. + * + * @param bool $fetch True if the annotation manager should be returned as a string, + * false if it should be echo-ed. + * @param bool|string $date Override for 'date' query parameter. + * @param bool|string $period Override for 'period' query parameter. + * @param bool|string $lastN Override for 'lastN' query parameter. + * @return string|void + */ + public function getAnnotationManager($fetch = false, $date = false, $period = false, $lastN = false) + { + $idSite = Common::getRequestVar('idSite'); + + if ($date === false) { + $date = Common::getRequestVar('date', false); + } + + if ($period === false) { + $period = Common::getRequestVar('period', 'day'); + } + + if ($lastN === false) { + $lastN = Common::getRequestVar('lastN', false); + } + + // create & render the view + $view = new View('@Annotations/getAnnotationManager'); + + $allAnnotations = Request::processRequest( + 'Annotations.getAll', array('date' => $date, 'period' => $period, 'lastN' => $lastN)); + $view->annotations = empty($allAnnotations[$idSite]) ? array() : $allAnnotations[$idSite]; + + $view->period = $period; + $view->lastN = $lastN; + + list($startDate, $endDate) = API::getDateRangeForPeriod($date, $period, $lastN); + $view->startDate = $startDate->toString(); + $view->endDate = $endDate->toString(); + + $dateFormat = Piwik::translate('CoreHome_ShortDateFormatWithYear'); + $view->startDatePretty = $startDate->getLocalized($dateFormat); + $view->endDatePretty = $endDate->getLocalized($dateFormat); + + $view->canUserAddNotes = AnnotationList::canUserAddNotesFor($idSite); + + return $view->render(); + } + + /** + * Controller action that modifies an annotation and returns HTML displaying + * the modified annotation. + * + * Query Param Input: + * - idSite: The ID of the site the annotation belongs to. Only one ID is allowed. + * - idNote: The ID of the annotation. + * - date: The new date value for the annotation. (optional) + * - note: The new text for the annotation. (optional) + * - starred: Either 1 or 0. Whether the note should be starred or not. (optional) + * + * Output: + * - HTML displaying modified annotation. + * + * If an optional query param is not supplied, that part of the annotation is + * not modified. + */ + public function saveAnnotation() + { + if ($_SERVER["REQUEST_METHOD"] == "POST") { + $this->checkTokenInUrl(); + + $view = new View('@Annotations/saveAnnotation'); + + // NOTE: permissions checked in API method + // save the annotation + $view->annotation = Request::processRequest("Annotations.save"); + + return $view->render(); + } + } + + /** + * Controller action that adds a new annotation for a site and returns new + * annotation manager HTML for the site and date range. + * + * Query Param Input: + * - idSite: The ID of the site to add an annotation to. + * - date: The date for the new annotation. + * - note: The text of the annotation. + * - starred: Either 1 or 0, whether the annotation should be starred or not. + * Defaults to 0. + * - managerDate: The date for the annotation manager. If a range is given, the start + * date is used for the new annotation. + * - managerPeriod: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * - lastN: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * Output: + * - @see self::getAnnotationManager + */ + public function addAnnotation() + { + if ($_SERVER["REQUEST_METHOD"] == "POST") { + $this->checkTokenInUrl(); + + // the date used is for the annotation manager HTML that gets echo'd. we + // use this date for the new annotation, unless it is a date range, in + // which case we use the first date of the range. + $date = Common::getRequestVar('date'); + if (strpos($date, ',') !== false) { + $date = reset(explode(',', $date)); + } + + // add the annotation. NOTE: permissions checked in API method + Request::processRequest("Annotations.add", array('date' => $date)); + + $managerDate = Common::getRequestVar('managerDate', false); + $managerPeriod = Common::getRequestVar('managerPeriod', false); + return $this->getAnnotationManager($fetch = true, $managerDate, $managerPeriod); + } + } + + /** + * Controller action that deletes an annotation and returns new annotation + * manager HTML for the site & date range. + * + * Query Param Input: + * - idSite: The ID of the site this annotation belongs to. + * - idNote: The ID of the annotation to delete. + * - date: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * - period: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * - lastN: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * + * Output: + * - @see self::getAnnotationManager + */ + public function deleteAnnotation() + { + if ($_SERVER["REQUEST_METHOD"] == "POST") { + $this->checkTokenInUrl(); + + // delete annotation. NOTE: permissions checked in API method + Request::processRequest("Annotations.delete"); + + return $this->getAnnotationManager($fetch = true); + } + } + + /** + * Controller action that echo's HTML that displays marker icons for an + * evolution graph's x-axis. The marker icons still need to be positioned + * by the JavaScript. + * + * Query Param Input: + * - idSite: The ID of the site this annotation belongs to. Only one is allowed. + * - date: The date to check for annotations. If lastN is not supplied, this is + * the start of the date range used to check for annotations. If supplied, + * this is the start of the last period in the date range. + * - period: The period type. + * - lastN: If supplied, the last N # of periods are included in the date range + * used to check for annotations. + * + * Output: + * - HTML that displays marker icons for an evolution graph based on the + * number of annotations & starred annotations in the graph's date range. + */ + public function getEvolutionIcons() + { + // get annotation the count + $annotationCounts = Request::processRequest( + "Annotations.getAnnotationCountForDates", array('getAnnotationText' => 1)); + + // create & render the view + $view = new View('@Annotations/getEvolutionIcons'); + $view->annotationCounts = reset($annotationCounts); // only one idSite allowed for this action + + return $view->render(); + } +} diff --git a/www/analytics/plugins/Annotations/javascripts/annotations.js b/www/analytics/plugins/Annotations/javascripts/annotations.js new file mode 100755 index 00000000..8ab218ef --- /dev/null +++ b/www/analytics/plugins/Annotations/javascripts/annotations.js @@ -0,0 +1,599 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, piwik) { + + var annotationsApi = { + + // calls Annotations.getAnnotationManager + getAnnotationManager: function (idSite, date, period, lastN, callback) { + var ajaxParams = + { + module: 'Annotations', + action: 'getAnnotationManager', + idSite: idSite, + date: date, + period: period + }; + if (lastN) { + ajaxParams.lastN = lastN; + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + }, + + // calls Annotations.addAnnotation + addAnnotation: function (idSite, managerDate, managerPeriod, date, note, callback) { + var ajaxParams = + { + module: 'Annotations', + action: 'addAnnotation', + idSite: idSite, + date: date, + managerDate: managerDate, + managerPeriod: managerPeriod, + note: note + }; + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + }, + + // calls Annotations.saveAnnotation + saveAnnotation: function (idSite, idNote, date, noteData, callback) { + var ajaxParams = + { + module: 'Annotations', + action: 'saveAnnotation', + idSite: idSite, + idNote: idNote, + date: date + }; + + for (var key in noteData) { + ajaxParams[key] = noteData[key]; + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + }, + + // calls Annotations.deleteAnnotation + deleteAnnotation: function (idSite, idNote, managerDate, managerPeriod, callback) { + var ajaxParams = + { + module: 'Annotations', + action: 'deleteAnnotation', + idSite: idSite, + idNote: idNote, + date: managerDate, + period: managerPeriod + }; + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + }, + + // calls Annotations.getEvolutionIcons + getEvolutionIcons: function (idSite, date, period, lastN, callback) { + var ajaxParams = + { + module: 'Annotations', + action: 'getEvolutionIcons', + idSite: idSite, + date: date, + period: period + }; + if (lastN) { + ajaxParams.lastN = lastN; + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.setFormat('html'); + ajaxRequest.setCallback(callback); + ajaxRequest.send(false); + } + }; + + var today = new Date(); + + /** + * Returns options to configure an annotation's datepicker shown in edit mode. + * + * @param {Element} annotation The annotation element. + */ + var getDatePickerOptions = function (annotation) { + var annotationDateStr = annotation.attr('data-date'), + parts = annotationDateStr.split('-'), + annotationDate = new Date(parts[0], parts[1] - 1, parts[2]); + + var result = piwik.getBaseDatePickerOptions(annotationDate); + + // make sure days before site start & after today cannot be selected + var piwikMinDate = result.minDate; + result.beforeShowDay = function (date) { + var valid = true; + + // if date is after today or before date of site creation, it cannot be selected + if (date > today + || date < piwikMinDate) { + valid = false; + } + + return [valid, '']; + }; + + // on select a date, change the text of the edit date link + result.onSelect = function (dateText) { + $('.annotation-period-edit>a', annotation).text(dateText); + $('.datepicker', annotation).hide(); + }; + + return result; + }; + + /** + * Switches the current mode of an annotation between the view/edit modes. + * + * @param {Element} inAnnotationElement An element within the annotation to toggle the mode of. + * Should be two levels nested in the .annotation-value + * element. + * @return {Element} The .annotation-value element. + */ + var toggleAnnotationMode = function (inAnnotationElement) { + var annotation = $(inAnnotationElement).closest('.annotation'); + $('.annotation-period,.annotation-period-edit,.delete-annotation,' + + '.annotation-edit-mode,.annotation-view-mode', annotation).toggle(); + + return $(inAnnotationElement).find('.annotation-value'); + }; + + /** + * Creates the datepicker for an annotation element. + * + * @param {Element} annotation The annotation element. + */ + var createDatePicker = function (annotation) { + $('.datepicker', annotation).datepicker(getDatePickerOptions(annotation)).hide(); + }; + + /** + * Creates datepickers for every period edit in an annotation manager. + * + * @param {Element} manager The annotation manager element. + */ + var createDatePickers = function (manager) { + $('.annotation-period-edit', manager).each(function () { + createDatePicker($(this).parent().parent()); + }); + }; + + /** + * Replaces the HTML of an annotation manager element, and resets date/period + * attributes. + * + * @param {Element} manager The annotation manager. + * @param {string} html The HTML of the new annotation manager. + */ + var replaceAnnotationManager = function (manager, html) { + var newManager = $(html); + manager.html(newManager.html()) + .attr('data-date', newManager.attr('data-date')) + .attr('data-period', newManager.attr('data-period')); + createDatePickers(manager); + }; + + /** + * Returns true if an annotation element is starred, false if otherwise. + * + * @param {Element} annotation The annotation element. + * @return {boolean} + */ + var isAnnotationStarred = function (annotation) { + return !!(+$('.annotation-star', annotation).attr('data-starred') == 1); + }; + + /** + * Replaces the HTML of an annotation element with HTML returned from Piwik, and + * makes sure the data attributes are correct. + * + * @param {Element} annotation The annotation element. + * @param {string} html The replacement HTML (or alternatively, the replacement + * element/jQuery object). + */ + var replaceAnnotationHtml = function (annotation, html) { + var newHtml = $(html); + annotation.html(newHtml.html()).attr('data-date', newHtml.attr('data-date')); + createDatePicker(annotation); + }; + + /** + * Binds events to an annotation manager element. + * + * @param {Element} manager The annotation manager. + * @param {int} idSite The site ID the manager is showing annotations for. + * @param {function} onAnnotationCountChange Callback that is called when there is a change + * in the number of annotations and/or starred annotations, + * eg, when a user adds a new one or deletes an existing one. + */ + var bindAnnotationManagerEvents = function (manager, idSite, onAnnotationCountChange) { + if (!onAnnotationCountChange) { + onAnnotationCountChange = function () {}; + } + + // show new annotation row if create new annotation link is clicked + manager.on('click', '.add-annotation', function (e) { + e.preventDefault(); + + $('.new-annotation-row', manager).show(); + $(this).hide(); + + return false; + }); + + // hide new annotation row if cancel button clicked + manager.on('click', '.new-annotation-cancel', function () { + var newAnnotationRow = $(this).parent().parent(); + newAnnotationRow.hide(); + + $('.add-annotation', newAnnotationRow.closest('.annotation-manager')).show(); + }); + + // save new annotation when new annotation row save is clicked + manager.on('click', '.new-annotation-save', function () { + var addRow = $(this).parent().parent(), + addNoteInput = addRow.find('.new-annotation-edit'), + noteDate = addRow.find('.annotation-period-edit>a').text(); + + // do nothing if input is empty + if (!addNoteInput.val()) { + return; + } + + // disable input & link + addNoteInput.attr('disabled', 'disabled'); + $(this).attr('disabled', 'disabled'); + + // add a new annotation for the site, date & period + annotationsApi.addAnnotation( + idSite, + manager.attr('data-date'), + manager.attr('data-period'), + noteDate, + addNoteInput.val(), + function (response) { + replaceAnnotationManager(manager, response); + + // increment annotation count for this date + onAnnotationCountChange(noteDate, 1, 0); + } + ); + }); + + // add new annotation when enter key pressed on new annotation input + manager.on('keypress', '.new-annotation-edit', function (e) { + if (e.which == 13) { + $(this).parent().find('.new-annotation-save').click(); + } + }); + + // show annotation editor if edit link, annotation text or period text is clicked + manager.on('click', '.annotation-enter-edit-mode', function (e) { + e.preventDefault(); + + var annotationContent = toggleAnnotationMode(this); + annotationContent.find('.annotation-edit').focus(); + + return false; + }); + + // hide annotation editor if cancel button is clicked + manager.on('click', '.annotation-cancel', function () { + toggleAnnotationMode(this); + }); + + // save annotation if save button clicked + manager.on('click', '.annotation-edit-mode .annotation-save', function () { + var annotation = $(this).parent().parent().parent(), + input = $('.annotation-edit', annotation), + dateEditText = $('.annotation-period-edit>a', annotation).text(); + + // if annotation value/date has not changed, just show the view mode instead of edit + if (input[0].defaultValue == input.val() + && dateEditText == annotation.attr('data-date')) { + toggleAnnotationMode(this); + return; + } + + // disable input while ajax is happening + input.attr('disabled', 'disabled'); + $(this).attr('disabled', 'disabled'); + + // save the note w/ the new note text & date + annotationsApi.saveAnnotation( + idSite, + annotation.attr('data-id'), + dateEditText, + { + note: input.val() + }, + function (response) { + response = $(response); + + var newDate = response.attr('data-date'), + isStarred = isAnnotationStarred(response), + originalDate = annotation.attr('data-date'); + + replaceAnnotationHtml(annotation, response); + + // if the date has been changed, update the evolution icon counts to reflect the change + if (originalDate != newDate) { + // reduce count for original date + onAnnotationCountChange(originalDate, -1, isStarred ? -1 : 0); + + // increase count for new date + onAnnotationCountChange(newDate, 1, isStarred ? 1 : 0); + } + } + ); + }); + + // save annotation if 'enter' pressed on input + manager.on('keypress', '.annotation-value input', function (e) { + if (e.which == 13) { + $(this).parent().find('.annotation-save').click(); + } + }); + + // delete annotation if delete link clicked + manager.on('click', '.delete-annotation', function (e) { + e.preventDefault(); + + var annotation = $(this).parent().parent(); + $(this).attr('disabled', 'disabled'); + + // delete annotation by ajax + annotationsApi.deleteAnnotation( + idSite, + annotation.attr('data-id'), + manager.attr('data-date'), + manager.attr('data-period'), + function (response) { + replaceAnnotationManager(manager, response); + + // update evolution icons + var isStarred = isAnnotationStarred(annotation); + onAnnotationCountChange(annotation.attr('data-date'), -1, isStarred ? -1 : 0); + } + ); + + return false; + }); + + // star/unstar annotation if star clicked + manager.on('click', '.annotation-star-changeable', function (e) { + var annotation = $(this).parent().parent(), + newStarredVal = $(this).attr('data-starred') == 0 ? 1 : 0 // flip existing 'starred' value + ; + + // perform ajax request to star annotation + annotationsApi.saveAnnotation( + idSite, + annotation.attr('data-id'), + annotation.attr('data-date'), + { + starred: newStarredVal + }, + function (response) { + replaceAnnotationHtml(annotation, response); + + // change starred count for this annotation in evolution graph based on what we're + // changing the starred value to + onAnnotationCountChange(annotation.attr('data-date'), 0, newStarredVal == 0 ? -1 : 1); + } + ); + }); + + // when period edit is clicked, show datepicker + manager.on('click', '.annotation-period-edit>a', function (e) { + e.preventDefault(); + $('.datepicker', $(this).parent()).toggle(); + return false; + }); + + // make sure datepicker popups are closed if someone clicks elsewhere + $('body').on('mouseup', function (e) { + var container = $('.annotation-period-edit>.datepicker:visible').parent(); + + if (!container.has(e.target).length) { + container.find('.datepicker').hide(); + } + }); + }; + +// used in below function + var loadingAnnotationManager = false; + + /** + * Shows an annotation manager under a report for a specific site & date range. + * + * @param {Element} domElem The element of the report to show the annotation manger + * under. + * @param {int} idSite The ID of the site to show the annotations of. + * @param {string} date The start date of the period. + * @param {string} period The period type. + * @param {int} lastN Whether to include the last N periods in the date range or not. Can + * be undefined. + * @param {function} [callback] + */ + var showAnnotationViewer = function (domElem, idSite, date, period, lastN, callback) { + var addToAnnotationCount = function (date, amt, starAmt) { + if (date.indexOf(',') != -1) { + date = date.split(',')[0]; + } + + $('.evolution-annotations>span', domElem).each(function () { + if ($(this).attr('data-date') == date) { + // get counts from attributes (and convert them to ints) + var starredCount = +$(this).attr('data-starred'), + annotationCount = +$(this).attr('data-count'); + + // modify the starred count & make sure the correct image is used + var newStarCount = starredCount + starAmt; + if (newStarCount > 0) { + var newImg = 'plugins/Zeitgeist/images/annotations_starred.png'; + } else { + var newImg = 'plugins/Zeitgeist/images/annotations.png'; + } + $(this).attr('data-starred', newStarCount).find('img').attr('src', newImg); + + // modify the annotation count & hide/show based on new count + var newCount = annotationCount + amt; + $(this).attr('data-count', newCount).css('opacity', newCount > 0 ? 1 : 0); + + return false; + } + }); + }; + + var manager = $('.annotation-manager', domElem); + if (manager.length) { + // if annotations for the requested date + period are already loaded, then just toggle the + // visibility of the annotation viewer. otherwise, we reload the annotations. + if (manager.attr('data-date') == date + && manager.attr('data-period') == period) { + // toggle manager view + if (manager.is(':hidden')) { + manager.slideDown('slow', function () { if (callback) callback(manager) }); + } + else { + manager.slideUp('slow', function () { if (callback) callback(manager) }); + } + } + else { + // show nothing but the loading gif + $('.annotations', manager).html(''); + $('.loadingPiwik', manager).show(); + + // reload annotation manager for new date/period + annotationsApi.getAnnotationManager(idSite, date, period, lastN, function (response) { + replaceAnnotationManager(manager, response); + + createDatePickers(manager); + + // show if hidden + if (manager.is(':hidden')) { + manager.slideDown('slow', function () { if (callback) callback(manager) }); + } + else { + if (callback) { + callback(manager); + } + } + }); + } + } + else { + // if we are already loading the annotation manager, don't load it again + if (loadingAnnotationManager) { + return; + } + + loadingAnnotationManager = true; + + var isDashboard = !!$('#dashboardWidgetsArea').length; + + if (isDashboard) { + $('.loadingPiwikBelow', domElem).insertAfter($('.evolution-annotations', domElem)); + } + + var loading = $('.loadingPiwikBelow', domElem).css({display: 'block'}); + + // the annotations for this report have not been retrieved yet, so do an ajax request + // & show the result + annotationsApi.getAnnotationManager(idSite, date, period, lastN, function (response) { + var manager = $(response).hide(); + + // if an error occurred (and response does not contain the annotation manager), do nothing + if (!manager.hasClass('annotation-manager')) { + return; + } + + // create datepickers for each shown annotation + createDatePickers(manager); + + bindAnnotationManagerEvents(manager, idSite, addToAnnotationCount); + + loading.css('visibility', 'hidden'); + + // add & show annotation manager + if (isDashboard) { + manager.insertAfter($('.evolution-annotations', domElem)); + } else { + $('.dataTableFeatures', domElem).append(manager); + } + + manager.slideDown('slow', function () { + loading.hide().css('visibility', 'visible'); + loadingAnnotationManager = false; + + if (callback) callback(manager) + }); + }); + } + }; + + /** + * Determines the x-coordinates of a set of evolution annotation icons. + * + * @param {Element} annotations The '.evolution-annotations' element. + * @param {Element} graphElem The evolution graph's datatable element. + */ + var placeEvolutionIcons = function (annotations, graphElem) { + var canvases = $('.piwik-graph .jqplot-xaxis canvas', graphElem), + noteSize = 16; + + // if no graph available, hide all icons + if (!canvases || canvases.length == 0) { + $('span', annotations).hide(); + return true; + } + + // set position of each individual icon + $('span', annotations).each(function (i) { + var canvas = $(canvases[i]), + canvasCenterX = canvas.position().left + (canvas.width() / 2); + $(this).css({ + left: canvasCenterX - noteSize / 2, + // show if there are annotations for this x-axis tick + opacity: +$(this).attr('data-count') > 0 ? 1 : 0 + }); + }); + }; + +// make showAnnotationViewer, placeEvolutionIcons & annotationsApi globally accessible + piwik.annotations = { + showAnnotationViewer: showAnnotationViewer, + placeEvolutionIcons: placeEvolutionIcons, + api: annotationsApi + }; + +}(jQuery, piwik)); diff --git a/www/analytics/plugins/Annotations/stylesheets/annotations.less b/www/analytics/plugins/Annotations/stylesheets/annotations.less new file mode 100755 index 00000000..c848328d --- /dev/null +++ b/www/analytics/plugins/Annotations/stylesheets/annotations.less @@ -0,0 +1,209 @@ +.evolution-annotations { + position: relative; + height: 16px; + width: 100%; + margin-top: 12px; + margin-bottom: -28px; + cursor: pointer; +} + +.evolution-annotations > span { + position: absolute; + top:10px; +} + +#dashboard, .ui-dialog { + .evolution-annotations { + margin-top: -5px; + margin-bottom: -5px; + } + .evolution-annotations > span { + top: -1px; + position: absolute; + } + .annotation-manager { + margin-top: 15px; + } +} + +.annotation-manager { + text-align: left; + margin-top: -18px; +} + +.annotations-header { + display: inline-block; + width: 128px; + text-align: right; + font-size: 12px; + font-style: italic; + margin-bottom: 8px; + vertical-align: top; + color: #666; +} + +.annotation-controls { + display: inline-block; + margin-left: 132px; +} + +.annotation-controls>a { + font-size: 11px; + font-style: italic; + color: #666; + cursor: pointer; + padding: 3px 0 6px 0; + display: inline-block; +} + +.annotation-controls>a:hover { + text-decoration: none; +} + +.annotation-list { + margin-left: 8px; +} + +.annotation-list table { + width: 100%; +} + +.annotation-list-range { + display: inline-block; + font-size: 12px; + font-style: italic; + color: #666; + vertical-align: top; + margin: 0 0 8px 8px; +} + +.empty-annotation-list, .annotation-list .loadingPiwik { + display: block; + + font-style: italic; + color: #666; + margin: 0 0 12px 140px; +} + +.annotation-meta { + width: 128px; + text-align: right; + vertical-align: top; + font-size: 14px; +} + +.annotation-user { + font-style: italic; + font-size: 11px; + color: #444; +} + +.annotation-user-cell { + vertical-align: top; + width: 92px; +} + +.annotation-period { + display: inline-block; + font-style: italic; + margin: 0 8px 8px 8px; + vertical-align: top; +} + +.annotation-value { + margin: 0 12px 12px 8px; + vertical-align: top; + position: relative; + font-size: 14px; +} + +.annotation-enter-edit-mode { + cursor: pointer; +} + +.annotation-edit, .new-annotation-edit { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + width: 98%; +} + +.annotation-star { + display: inline-block; + margin: 0 8px 8px 0; + width: 16px; +} + +.annotation-star-changeable { + cursor: pointer; +} + +.delete-annotation { + font-size: 12px; + font-style: italic; + color: red; + text-decoration: none; + display: inline-block; +} + +.delete-annotation:hover { + text-decoration: underline; +} + +.annotation-manager .submit { + float: none; +} + +.edit-annotation { + font-size: 10px; + color: #666; + font-style: italic; +} + +.edit-annotation:hover { + text-decoration: none; +} + +.annotationView { + float: right; + margin-left: 5px; + position: relative; + cursor: pointer; +} + +.annotationView > span { + font-style: italic; + display: inline-block; + margin: 4px 4px 0 4px; +} + +.annotation-period-edit { + display: inline-block; + background: white; + color: #444; + font-size: 12px; + border: 1px solid #e4e5e4; + padding: 5px 5px 6px 3px; + border-radius: 4px; +} + +.annotation-period-edit:hover { + background: #f1f0eb; + border-color: #a9a399; +} + +.annotation-period-edit>a { + text-decoration: none; + cursor: pointer; + display: block; +} + +.annotation-period-edit>.datepicker { + position: absolute; + margin-top: 6px; + margin-left: -5px; + z-index: 15; + background: white; + border: 1px solid #e4e5e4; + border-radius: 4px; +} diff --git a/www/analytics/plugins/Annotations/templates/_annotation.twig b/www/analytics/plugins/Annotations/templates/_annotation.twig new file mode 100755 index 00000000..ee2e6404 --- /dev/null +++ b/www/analytics/plugins/Annotations/templates/_annotation.twig @@ -0,0 +1,45 @@ +<tr class="annotation" data-id="{{ annotation.idNote }}" data-date="{{ annotation.date }}"> + <td class="annotation-meta"> + <div class="annotation-star{% if annotation.canEditOrDelete %} annotation-star-changeable{% endif %}" data-starred="{{ annotation.starred }}" + {% if annotation.canEditOrDelete %}title="{{ 'Annotations_ClickToStarOrUnstar'|translate }}"{% endif %}> + {% if annotation.starred %} + <img src="plugins/Zeitgeist/images/star.png"/> + {% else %} + <img src="plugins/Zeitgeist/images/star_empty.png"/> + {% endif %} + </div> + <div class="annotation-period {% if annotation.canEditOrDelete %}annotation-enter-edit-mode{% endif %}">({{ annotation.date }})</div> + {% if annotation.canEditOrDelete %} + <div class="annotation-period-edit" style="display:none;"> + <a href="#">{{ annotation.date }}</a> + <div class="datepicker" style="display:none;"/> + </div> + {% endif %} + </td> + <td class="annotation-value"> + <div class="annotation-view-mode"> + <span {% if annotation.canEditOrDelete %}title="{{ 'Annotations_ClickToEdit'|translate }}" + class="annotation-enter-edit-mode"{% endif %}>{{ annotation.note|raw }}</span> + {% if annotation.canEditOrDelete %} + <a href="#" class="edit-annotation annotation-enter-edit-mode" title="{{ 'Annotations_ClickToEdit'|translate }}">{{ 'General_Edit'|translate }}...</a> + {% endif %} + </div> + {% if annotation.canEditOrDelete %} + <div class="annotation-edit-mode" style="display:none;"> + <input class="annotation-edit" type="text" value="{{ annotation.note|raw }}"/> + <br/> + <input class="annotation-save submit" type="button" value="{{ 'General_Save'|translate }}"/> + <input class="annotation-cancel submit" type="button" value="{{ 'General_Cancel'|translate }}"/> + </div> + {% endif %} + </td> + {% if annotation.user is defined and userLogin != 'anonymous' %} + <td class="annotation-user-cell"> + <span class="annotation-user">{{ annotation.user }}</span><br/> + {% if annotation.canEditOrDelete %} + <a href="#" class="delete-annotation" style="display:none;" title="{{ 'Annotations_ClickToDelete'|translate }}">{{ 'General_Delete'|translate }}</a> + {% endif %} + </td> + {% endif %} +</tr> + diff --git a/www/analytics/plugins/Annotations/templates/_annotationList.twig b/www/analytics/plugins/Annotations/templates/_annotationList.twig new file mode 100755 index 00000000..79f7f31c --- /dev/null +++ b/www/analytics/plugins/Annotations/templates/_annotationList.twig @@ -0,0 +1,29 @@ +<div class="annotations"> + + {% if annotations is empty %} + <div class="empty-annotation-list">{{ 'Annotations_NoAnnotations'|translate }}</div> + {% endif %} + + <table> + {% for annotation in annotations %} + {% include "@Annotations/_annotation.twig" %} + {% endfor %} + <tr class="new-annotation-row" style="display:none;" data-date="{{ startDate }}"> + <td class="annotation-meta"> + <div class="annotation-star"> </div> + <div class="annotation-period-edit"> + <a href="#">{{ startDate }}</a> + + <div class="datepicker" style="display:none;"/> + </div> + </td> + <td class="annotation-value"> + <input type="text" value="" class="new-annotation-edit" placeholder="{{ 'Annotations_EnterAnnotationText'|translate }}"/><br/> + <input type="button" class="submit new-annotation-save" value="{{ 'General_Save'|translate }}"/> + <input type="button" class="submit new-annotation-cancel" value="{{ 'General_Cancel'|translate }}"/> + </td> + <td class="annotation-user-cell"><span class="annotation-user">{{ userLogin }}</span></td> + </tr> + </table> + +</div> diff --git a/www/analytics/plugins/Annotations/templates/getAnnotationManager.twig b/www/analytics/plugins/Annotations/templates/getAnnotationManager.twig new file mode 100755 index 00000000..0cb59442 --- /dev/null +++ b/www/analytics/plugins/Annotations/templates/getAnnotationManager.twig @@ -0,0 +1,27 @@ +<div class="annotation-manager" + {% if startDate != endDate %}data-date="{{ startDate }},{{ endDate }}" data-period="range" + {% else %}data-date="{{ startDate }}" data-period="{{ period }}" + {% endif %}> + + <div class="annotations-header"> + <span>{{ 'Annotations_Annotations'|translate }}</span> + </div> + + <div class="annotation-list-range">{{ startDatePretty }}{% if startDate != endDate %} — {{ endDatePretty }}{% endif %}</div> + + <div class="annotation-list"> + {% include "@Annotations/_annotationList.twig" %} + + <span class="loadingPiwik" style="display:none;"><img src="plugins/Zeitgeist/images/loading-blue.gif"/>{{ 'General_Loading'|translate }}</span> + + </div> + + <div class="annotation-controls"> + {% if canUserAddNotes %} + <a href="#" class="add-annotation" title="{{ 'Annotations_CreateNewAnnotation'|translate }}">{{ 'Annotations_CreateNewAnnotation'|translate }}</a> + {% elseif userLogin == 'anonymous' %} + <a href="index.php?module=Login">{{ 'Annotations_LoginToAnnotate'|translate }}</a> + {% endif %} + </div> + +</div> diff --git a/www/analytics/plugins/Annotations/templates/getEvolutionIcons.twig b/www/analytics/plugins/Annotations/templates/getEvolutionIcons.twig new file mode 100755 index 00000000..b9955599 --- /dev/null +++ b/www/analytics/plugins/Annotations/templates/getEvolutionIcons.twig @@ -0,0 +1,14 @@ +<div class="evolution-annotations"> + {% for dateCountPair in annotationCounts %} + {% set date=dateCountPair[0] %} + {% set counts=dateCountPair[1] %} + <span data-date="{{ date }}" data-count="{{ counts.count }}" data-starred="{{ counts.starred }}" + {% if counts.count == 0 %}title="{{ 'Annotations_AddAnnotationsFor'|translate(date) }}" + {% elseif counts.count == 1 %}title="{{ 'Annotations_AnnotationOnDate'|translate(date, + counts.note)|raw }} +{{ 'Annotations_ClickToEditOrAdd'|translate }}" + {% else %}}title="{{ 'Annotations_ViewAndAddAnnotations'|translate(date) }}"{% endif %}> + <img src="plugins/Zeitgeist/images/{% if counts.starred > 0 %}annotations_starred.png{% else %}annotations.png{% endif %}" width="16" height="16"/> + </span> + {% endfor %} +</div> diff --git a/www/analytics/plugins/Annotations/templates/saveAnnotation.twig b/www/analytics/plugins/Annotations/templates/saveAnnotation.twig new file mode 100644 index 00000000..e5cd1202 --- /dev/null +++ b/www/analytics/plugins/Annotations/templates/saveAnnotation.twig @@ -0,0 +1 @@ +{% include "@Annotations/_annotation.twig" %} \ No newline at end of file diff --git a/www/analytics/plugins/CoreAdminHome/API.php b/www/analytics/plugins/CoreAdminHome/API.php new file mode 100644 index 00000000..8a0f0ed8 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/API.php @@ -0,0 +1,209 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreAdminHome; + +use Exception; +use Piwik\Config; +use Piwik\DataAccess\ArchiveTableCreator; +use Piwik\Date; +use Piwik\Db; +use Piwik\Option; +use Piwik\Period; +use Piwik\Period\Week; +use Piwik\Piwik; +use Piwik\Plugins\PrivacyManager\PrivacyManager; +use Piwik\Plugins\SitesManager\SitesManager; +use Piwik\SettingsPiwik; +use Piwik\Site; +use Piwik\TaskScheduler; + +/** + * @method static \Piwik\Plugins\CoreAdminHome\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + /** + * Will run all scheduled tasks due to run at this time. + * + * @return array + */ + public function runScheduledTasks() + { + Piwik::checkUserHasSuperUserAccess(); + return TaskScheduler::runTasks(); + } + + /* + * stores the list of websites IDs to re-reprocess in archive.php + */ + const OPTION_INVALIDATED_IDSITES = 'InvalidatedOldReports_WebsiteIds'; + + /** + * When tracking data in the past (using Tracking API), this function + * can be used to invalidate reports for the idSites and dates where new data + * was added. + * DEV: If you call this API, the UI should display the data correctly, but will process + * in real time, which could be very slow after large data imports. + * After calling this function via REST, you can manually force all data + * to be reprocessed by visiting the script as the Super User: + * http://example.net/piwik/misc/cron/archive.php?token_auth=$SUPER_USER_TOKEN_AUTH_HERE + * REQUIREMENTS: On large piwik setups, you will need in PHP configuration: max_execution_time = 0 + * We recommend to use an hourly schedule of the script at misc/cron/archive.php + * More information: http://piwik.org/setup-auto-archiving/ + * + * @param string $idSites Comma separated list of idSite that have had data imported for the specified dates + * @param string $dates Comma separated list of dates to invalidate for all these websites + * @throws Exception + * @return array + */ + public function invalidateArchivedReports($idSites, $dates) + { + $idSites = Site::getIdSitesFromIdSitesString($idSites); + if (empty($idSites)) { + throw new Exception("Specify a value for &idSites= as a comma separated list of website IDs, for which your token_auth has 'admin' permission"); + } + Piwik::checkUserHasAdminAccess($idSites); + + // Ensure the specified dates are valid + $toInvalidate = $invalidDates = array(); + $dates = explode(',', $dates); + $dates = array_unique($dates); + foreach ($dates as $theDate) { + try { + $date = Date::factory($theDate); + } catch (Exception $e) { + $invalidDates[] = $theDate; + continue; + } + if ($date->toString() == $theDate) { + $toInvalidate[] = $date; + } else { + $invalidDates[] = $theDate; + } + } + + // If using the feature "Delete logs older than N days"... + $purgeDataSettings = PrivacyManager::getPurgeDataSettings(); + $logsAreDeletedBeforeThisDate = $purgeDataSettings['delete_logs_schedule_lowest_interval']; + $logsDeleteEnabled = $purgeDataSettings['delete_logs_enable']; + $minimumDateWithLogs = false; + if ($logsDeleteEnabled + && $logsAreDeletedBeforeThisDate + ) { + $minimumDateWithLogs = Date::factory('today')->subDay($logsAreDeletedBeforeThisDate); + } + + // Given the list of dates, process which tables they should be deleted from + $minDate = false; + $warningDates = $processedDates = array(); + /* @var $date Date */ + foreach ($toInvalidate as $date) { + // we should only delete reports for dates that are more recent than N days + if ($minimumDateWithLogs + && $date->isEarlier($minimumDateWithLogs) + ) { + $warningDates[] = $date->toString(); + } else { + $processedDates[] = $date->toString(); + } + + $month = $date->toString('Y_m'); + // For a given date, we must invalidate in the monthly archive table + $datesByMonth[$month][] = $date->toString(); + + // But also the year stored in January + $year = $date->toString('Y_01'); + $datesByMonth[$year][] = $date->toString(); + + // but also weeks overlapping several months stored in the month where the week is starting + /* @var $week Week */ + $week = Period::factory('week', $date); + $weekAsString = $week->getDateStart()->toString('Y_m'); + $datesByMonth[$weekAsString][] = $date->toString(); + + // Keep track of the minimum date for each website + if ($minDate === false + || $date->isEarlier($minDate) + ) { + $minDate = $date; + } + } + + // In each table, invalidate day/week/month/year containing this date + $archiveTables = ArchiveTableCreator::getTablesArchivesInstalled(); + foreach ($archiveTables as $table) { + // Extract Y_m from table name + $suffix = ArchiveTableCreator::getDateFromTableName($table); + if (!isset($datesByMonth[$suffix])) { + continue; + } + // Dates which are to be deleted from this table + $datesToDeleteInTable = $datesByMonth[$suffix]; + + // Build one statement to delete all dates from the given table + $sql = $bind = array(); + $datesToDeleteInTable = array_unique($datesToDeleteInTable); + foreach ($datesToDeleteInTable as $dateToDelete) { + $sql[] = '(date1 <= ? AND ? <= date2)'; + $bind[] = $dateToDelete; + $bind[] = $dateToDelete; + } + $sql = implode(" OR ", $sql); + + $query = "DELETE FROM $table " . + " WHERE ( $sql ) " . + " AND idsite IN (" . implode(",", $idSites) . ")"; + Db::query($query, $bind); + } + \Piwik\Plugins\SitesManager\API::getInstance()->updateSiteCreatedTime($idSites, $minDate); + + // Force to re-process data for these websites in the next archive.php cron run + $invalidatedIdSites = self::getWebsiteIdsToInvalidate(); + $invalidatedIdSites = array_merge($invalidatedIdSites, $idSites); + $invalidatedIdSites = array_unique($invalidatedIdSites); + $invalidatedIdSites = array_values($invalidatedIdSites); + Option::set(self::OPTION_INVALIDATED_IDSITES, serialize($invalidatedIdSites)); + + Site::clearCache(); + + $output = array(); + // output logs + if ($warningDates) { + $output[] = 'Warning: the following Dates have not been invalidated, because they are earlier than your Log Deletion limit: ' . + implode(", ", $warningDates) . + "\n The last day with logs is " . $minimumDateWithLogs . ". " . + "\n Please disable 'Delete old Logs' or set it to a higher deletion threshold (eg. 180 days or 365 years).'."; + } + $output[] = "Success. The following dates were invalidated successfully: " . + implode(", ", $processedDates); + return $output; + } + + /** + * Returns array of idSites to force re-process next time archive.php runs + * + * @ignore + * @return mixed + */ + static public function getWebsiteIdsToInvalidate() + { + Piwik::checkUserHasSomeAdminAccess(); + + Option::clearCachedOption(self::OPTION_INVALIDATED_IDSITES); + $invalidatedIdSites = Option::get(self::OPTION_INVALIDATED_IDSITES); + if ($invalidatedIdSites + && ($invalidatedIdSites = unserialize($invalidatedIdSites)) + && count($invalidatedIdSites) + ) { + return $invalidatedIdSites; + } + return array(); + } + +} diff --git a/www/analytics/plugins/CoreAdminHome/Controller.php b/www/analytics/plugins/CoreAdminHome/Controller.php new file mode 100644 index 00000000..aadb87bd --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/Controller.php @@ -0,0 +1,351 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreAdminHome; + +use Exception; +use Piwik\API\ResponseBuilder; +use Piwik\ArchiveProcessor\Rules; +use Piwik\Common; +use Piwik\Config; +use Piwik\DataTable\Renderer\Json; +use Piwik\Menu\MenuTop; +use Piwik\Nonce; +use Piwik\Option; +use Piwik\Piwik; +use Piwik\Plugins\CorePluginsAdmin\UpdateCommunication; +use Piwik\Plugins\CustomVariables\CustomVariables; +use Piwik\Plugins\LanguagesManager\API as APILanguagesManager; +use Piwik\Plugins\LanguagesManager\LanguagesManager; +use Piwik\Plugins\SitesManager\API as APISitesManager; +use Piwik\Settings\Manager as SettingsManager; +use Piwik\Site; +use Piwik\Tracker\IgnoreCookie; +use Piwik\Url; +use Piwik\View; + +/** + * + */ +class Controller extends \Piwik\Plugin\ControllerAdmin +{ + const SET_PLUGIN_SETTINGS_NONCE = 'CoreAdminHome.setPluginSettings'; + + public function index() + { + $this->redirectToIndex('UsersManager', 'userSettings'); + return; + } + + public function generalSettings() + { + Piwik::checkUserHasSomeAdminAccess(); + $view = new View('@CoreAdminHome/generalSettings'); + + if (Piwik::hasUserSuperUserAccess()) { + $this->handleGeneralSettingsAdmin($view); + + $view->trustedHosts = Url::getTrustedHostsFromConfig(); + + $logo = new CustomLogo(); + $view->branding = array('use_custom_logo' => $logo->isEnabled()); + $view->logosWriteable = $logo->isCustomLogoWritable(); + $view->pathUserLogo = CustomLogo::getPathUserLogo(); + $view->pathUserLogoSmall = CustomLogo::getPathUserLogoSmall(); + $view->pathUserLogoSVG = CustomLogo::getPathUserSvgLogo(); + $view->pathUserLogoDirectory = realpath(dirname($view->pathUserLogo) . '/'); + } + + $view->language = LanguagesManager::getLanguageCodeForCurrentUser(); + $this->setBasicVariablesView($view); + return $view->render(); + } + + public function pluginSettings() + { + Piwik::checkUserIsNotAnonymous(); + + $settings = $this->getPluginSettings(); + + $view = new View('@CoreAdminHome/pluginSettings'); + $view->nonce = Nonce::getNonce(static::SET_PLUGIN_SETTINGS_NONCE); + $view->pluginSettings = $settings; + $view->firstSuperUserSettingNames = $this->getFirstSuperUserSettingNames($settings); + + $this->setBasicVariablesView($view); + + return $view->render(); + } + + private function getPluginSettings() + { + $pluginsSettings = SettingsManager::getPluginSettingsForCurrentUser(); + + ksort($pluginsSettings); + + return $pluginsSettings; + } + + /** + * @param \Piwik\Plugin\Settings[] $pluginsSettings + * @return array array([pluginName] => []) + */ + private function getFirstSuperUserSettingNames($pluginsSettings) + { + $names = array(); + foreach ($pluginsSettings as $pluginName => $pluginSettings) { + + foreach ($pluginSettings->getSettingsForCurrentUser() as $setting) { + if ($setting instanceof \Piwik\Settings\SystemSetting) { + $names[$pluginName] = $setting->getName(); + break; + } + } + } + + return $names; + } + + public function setPluginSettings() + { + Piwik::checkUserIsNotAnonymous(); + Json::sendHeaderJSON(); + + $nonce = Common::getRequestVar('nonce', null, 'string'); + + if (!Nonce::verifyNonce(static::SET_PLUGIN_SETTINGS_NONCE, $nonce)) { + return json_encode(array( + 'result' => 'error', + 'message' => Piwik::translate('General_ExceptionNonceMismatch') + )); + } + + $pluginsSettings = SettingsManager::getPluginSettingsForCurrentUser(); + + try { + + foreach ($pluginsSettings as $pluginName => $pluginSetting) { + foreach ($pluginSetting->getSettingsForCurrentUser() as $setting) { + + $value = $this->findSettingValueFromRequest($pluginName, $setting->getKey()); + + if (!is_null($value)) { + $setting->setValue($value); + } + } + } + + foreach ($pluginsSettings as $pluginSetting) { + $pluginSetting->save(); + } + + } catch (Exception $e) { + $message = html_entity_decode($e->getMessage(), ENT_QUOTES, 'UTF-8'); + return json_encode(array('result' => 'error', 'message' => $message)); + } + + Nonce::discardNonce(static::SET_PLUGIN_SETTINGS_NONCE); + return json_encode(array('result' => 'success')); + } + + private function findSettingValueFromRequest($pluginName, $settingKey) + { + $changedPluginSettings = Common::getRequestVar('settings', null, 'array'); + + if (!array_key_exists($pluginName, $changedPluginSettings)) { + return; + } + + $settings = $changedPluginSettings[$pluginName]; + + foreach ($settings as $setting) { + if ($setting['name'] == $settingKey) { + return $setting['value']; + } + } + } + + public function setGeneralSettings() + { + Piwik::checkUserHasSuperUserAccess(); + $response = new ResponseBuilder(Common::getRequestVar('format')); + try { + $this->checkTokenInUrl(); + + $this->saveGeneralSettings(); + + $customLogo = new CustomLogo(); + if (Common::getRequestVar('useCustomLogo', '0')) { + $customLogo->enable(); + } else { + $customLogo->disable(); + } + + $toReturn = $response->getResponse(); + } catch (Exception $e) { + $toReturn = $response->getResponseException($e); + } + + return $toReturn; + } + + /** + * Renders and echo's an admin page that lets users generate custom JavaScript + * tracking code and custom image tracker links. + */ + public function trackingCodeGenerator() + { + $view = new View('@CoreAdminHome/trackingCodeGenerator'); + $this->setBasicVariablesView($view); + $view->topMenu = MenuTop::getInstance()->getMenu(); + + $viewableIdSites = APISitesManager::getInstance()->getSitesIdWithAtLeastViewAccess(); + + $defaultIdSite = reset($viewableIdSites); + $view->idSite = Common::getRequestVar('idSite', $defaultIdSite, 'int'); + + $view->defaultReportSiteName = Site::getNameFor($view->idSite); + $view->defaultSiteRevenue = \Piwik\MetricsFormatter::getCurrencySymbol($view->idSite); + $view->maxCustomVariables = CustomVariables::getMaxCustomVariables(); + + $allUrls = APISitesManager::getInstance()->getSiteUrlsFromId($view->idSite); + if (isset($allUrls[1])) { + $aliasUrl = $allUrls[1]; + } else { + $aliasUrl = 'x.domain.com'; + } + $view->defaultReportSiteAlias = $aliasUrl; + + $mainUrl = Site::getMainUrlFor($view->idSite); + $view->defaultReportSiteDomain = @parse_url($mainUrl, PHP_URL_HOST); + + // get currencies for each viewable site + $view->currencySymbols = APISitesManager::getInstance()->getCurrencySymbols(); + + $view->serverSideDoNotTrackEnabled = \Piwik\Plugins\PrivacyManager\DoNotTrackHeaderChecker::isActive(); + + return $view->render(); + } + + /** + * Shows the "Track Visits" checkbox. + */ + public function optOut() + { + $trackVisits = !IgnoreCookie::isIgnoreCookieFound(); + + $nonce = Common::getRequestVar('nonce', false); + $language = Common::getRequestVar('language', ''); + if ($nonce !== false && Nonce::verifyNonce('Piwik_OptOut', $nonce)) { + Nonce::discardNonce('Piwik_OptOut'); + IgnoreCookie::setIgnoreCookie(); + $trackVisits = !$trackVisits; + } + + $view = new View('@CoreAdminHome/optOut'); + $view->trackVisits = $trackVisits; + $view->nonce = Nonce::getNonce('Piwik_OptOut', 3600); + $view->language = APILanguagesManager::getInstance()->isLanguageAvailable($language) + ? $language + : LanguagesManager::getLanguageCodeForCurrentUser(); + return $view->render(); + } + + public function uploadCustomLogo() + { + Piwik::checkUserHasSuperUserAccess(); + + $logo = new CustomLogo(); + $success = $logo->copyUploadedLogoToFilesystem(); + + if($success) { + return '1'; + } + return '0'; + } + + static public function isGeneralSettingsAdminEnabled() + { + return (bool) Config::getInstance()->General['enable_general_settings_admin']; + } + + private function saveGeneralSettings() + { + if(!self::isGeneralSettingsAdminEnabled()) { + // General settings + Beta channel + SMTP settings is disabled + return; + } + + // General Setting + $enableBrowserTriggerArchiving = Common::getRequestVar('enableBrowserTriggerArchiving'); + $todayArchiveTimeToLive = Common::getRequestVar('todayArchiveTimeToLive'); + Rules::setBrowserTriggerArchiving((bool)$enableBrowserTriggerArchiving); + Rules::setTodayArchiveTimeToLive($todayArchiveTimeToLive); + + // update beta channel setting + $debug = Config::getInstance()->Debug; + $debug['allow_upgrades_to_beta'] = Common::getRequestVar('enableBetaReleaseCheck', '0', 'int'); + Config::getInstance()->Debug = $debug; + + // Update email settings + $mail = array(); + $mail['transport'] = (Common::getRequestVar('mailUseSmtp') == '1') ? 'smtp' : ''; + $mail['port'] = Common::getRequestVar('mailPort', ''); + $mail['host'] = Common::unsanitizeInputValue(Common::getRequestVar('mailHost', '')); + $mail['type'] = Common::getRequestVar('mailType', ''); + $mail['username'] = Common::unsanitizeInputValue(Common::getRequestVar('mailUsername', '')); + $mail['password'] = Common::unsanitizeInputValue(Common::getRequestVar('mailPassword', '')); + $mail['encryption'] = Common::getRequestVar('mailEncryption', ''); + + Config::getInstance()->mail = $mail; + + // update trusted host settings + $trustedHosts = Common::getRequestVar('trustedHosts', false, 'json'); + if ($trustedHosts !== false) { + Url::saveTrustedHostnameInConfig($trustedHosts); + } + Config::getInstance()->forceSave(); + + $pluginUpdateCommunication = new UpdateCommunication(); + if (Common::getRequestVar('enablePluginUpdateCommunication', '0', 'int')) { + $pluginUpdateCommunication->enable(); + } else { + $pluginUpdateCommunication->disable(); + } + } + + private function handleGeneralSettingsAdmin($view) + { + // Whether to display or not the general settings (cron, beta, smtp) + $view->isGeneralSettingsAdminEnabled = self::isGeneralSettingsAdminEnabled(); + if($view->isGeneralSettingsAdminEnabled) { + $this->displayWarningIfConfigFileNotWritable(); + } + + $enableBrowserTriggerArchiving = Rules::isBrowserTriggerEnabled(); + $todayArchiveTimeToLive = Rules::getTodayArchiveTimeToLive(); + $showWarningCron = false; + if (!$enableBrowserTriggerArchiving + && $todayArchiveTimeToLive < 3600 + ) { + $showWarningCron = true; + } + $view->showWarningCron = $showWarningCron; + $view->todayArchiveTimeToLive = $todayArchiveTimeToLive; + $view->enableBrowserTriggerArchiving = $enableBrowserTriggerArchiving; + + $view->enableBetaReleaseCheck = Config::getInstance()->Debug['allow_upgrades_to_beta']; + $view->mail = Config::getInstance()->mail; + + $pluginUpdateCommunication = new UpdateCommunication(); + $view->canUpdateCommunication = $pluginUpdateCommunication->canBeEnabled(); + $view->enableSendPluginUpdateCommunication = $pluginUpdateCommunication->isEnabled(); + } + + +} diff --git a/www/analytics/plugins/CoreAdminHome/CoreAdminHome.php b/www/analytics/plugins/CoreAdminHome/CoreAdminHome.php new file mode 100644 index 00000000..f750f449 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/CoreAdminHome.php @@ -0,0 +1,129 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreAdminHome; + +use Piwik\DataAccess\ArchiveSelector; +use Piwik\DataAccess\ArchiveTableCreator; +use Piwik\Date; +use Piwik\Db; +use Piwik\Menu\MenuAdmin; +use Piwik\Piwik; +use Piwik\ScheduledTask; +use Piwik\ScheduledTime; +use Piwik\Settings\Manager as SettingsManager; +use Piwik\Settings\UserSetting; + +/** + * + */ +class CoreAdminHome extends \Piwik\Plugin +{ + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'Menu.Admin.addItems' => 'addMenu', + 'TaskScheduler.getScheduledTasks' => 'getScheduledTasks', + 'UsersManager.deleteUser' => 'cleanupUser' + ); + } + + public function cleanupUser($userLogin) + { + UserSetting::removeAllUserSettingsForUser($userLogin); + } + + public function getScheduledTasks(&$tasks) + { + // general data purge on older archive tables, executed daily + $purgeArchiveTablesTask = new ScheduledTask ($this, + 'purgeOutdatedArchives', + null, + ScheduledTime::factory('daily'), + ScheduledTask::HIGH_PRIORITY); + $tasks[] = $purgeArchiveTablesTask; + + // lowest priority since tables should be optimized after they are modified + $optimizeArchiveTableTask = new ScheduledTask ($this, + 'optimizeArchiveTable', + null, + ScheduledTime::factory('daily'), + ScheduledTask::LOWEST_PRIORITY); + $tasks[] = $optimizeArchiveTableTask; + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "libs/jquery/themes/base/jquery-ui.css"; + $stylesheets[] = "plugins/CoreAdminHome/stylesheets/menu.less"; + $stylesheets[] = "plugins/Zeitgeist/stylesheets/base.less"; + $stylesheets[] = "plugins/CoreAdminHome/stylesheets/generalSettings.less"; + $stylesheets[] = "plugins/CoreAdminHome/stylesheets/pluginSettings.less"; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "libs/jquery/jquery.js"; + $jsFiles[] = "libs/jquery/jquery-ui.js"; + $jsFiles[] = "libs/jquery/jquery.browser.js"; + $jsFiles[] = "libs/javascript/sprintf.js"; + $jsFiles[] = "plugins/Zeitgeist/javascripts/piwikHelper.js"; + $jsFiles[] = "plugins/Zeitgeist/javascripts/ajaxHelper.js"; + $jsFiles[] = "libs/jquery/jquery.history.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/broadcast.js"; + $jsFiles[] = "plugins/CoreAdminHome/javascripts/generalSettings.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/donate.js"; + $jsFiles[] = "plugins/CoreAdminHome/javascripts/pluginSettings.js"; + } + + function addMenu() + { + MenuAdmin::getInstance()->add('CoreAdminHome_MenuManage', null, "", Piwik::isUserHasSomeAdminAccess(), $order = 1); + MenuAdmin::getInstance()->add('CoreAdminHome_MenuDiagnostic', null, "", Piwik::isUserHasSomeAdminAccess(), $order = 10); + MenuAdmin::getInstance()->add('General_Settings', null, "", Piwik::isUserHasSomeAdminAccess(), $order = 5); + MenuAdmin::getInstance()->add('General_Settings', 'CoreAdminHome_MenuGeneralSettings', + array('module' => 'CoreAdminHome', 'action' => 'generalSettings'), + Piwik::isUserHasSomeAdminAccess(), + $order = 6); + MenuAdmin::getInstance()->add('CoreAdminHome_MenuManage', 'CoreAdminHome_TrackingCode', + array('module' => 'CoreAdminHome', 'action' => 'trackingCodeGenerator'), + Piwik::isUserHasSomeAdminAccess(), + $order = 4); + + MenuAdmin::getInstance()->add('General_Settings', 'CoreAdminHome_PluginSettings', + array('module' => 'CoreAdminHome', 'action' => 'pluginSettings'), + SettingsManager::hasPluginsSettingsForCurrentUser(), + $order = 7); + + } + + function purgeOutdatedArchives() + { + $archiveTables = ArchiveTableCreator::getTablesArchivesInstalled(); + foreach ($archiveTables as $table) { + $date = ArchiveTableCreator::getDateFromTableName($table); + list($year, $month) = explode('_', $date); + + // Somehow we may have archive tables created with older dates, prevent exception from being thrown + if($year > 1990) { + ArchiveSelector::purgeOutdatedArchives(Date::factory("$year-$month-15")); + } + } + } + + function optimizeArchiveTable() + { + $archiveTables = ArchiveTableCreator::getTablesArchivesInstalled(); + Db::optimizeTables($archiveTables); + } +} diff --git a/www/analytics/plugins/CoreAdminHome/CustomLogo.php b/www/analytics/plugins/CoreAdminHome/CustomLogo.php new file mode 100644 index 00000000..ca845fe4 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/CustomLogo.php @@ -0,0 +1,203 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreAdminHome; + +use Piwik\Config; +use Piwik\Filesystem; +use Piwik\Option; +use Piwik\SettingsPiwik; + +class CustomLogo +{ + const LOGO_HEIGHT = 300; + const LOGO_SMALL_HEIGHT = 100; + + public function getLogoUrl($pathOnly = false) + { + $defaultLogo = 'plugins/Zeitgeist/images/logo.png'; + $themeLogo = 'plugins/%s/images/logo.png'; + $userLogo = CustomLogo::getPathUserLogo(); + return $this->getPathToLogo($pathOnly, $defaultLogo, $themeLogo, $userLogo); + } + + public function getHeaderLogoUrl($pathOnly = false) + { + $defaultLogo = 'plugins/Zeitgeist/images/logo-header.png'; + $themeLogo = 'plugins/%s/images/logo-header.png'; + $customLogo = CustomLogo::getPathUserLogoSmall(); + return $this->getPathToLogo($pathOnly, $defaultLogo, $themeLogo, $customLogo); + } + + public function getSVGLogoUrl($pathOnly = false) + { + $defaultLogo = 'plugins/Zeitgeist/images/logo.svg'; + $themeLogo = 'plugins/%s/images/logo.svg'; + $customLogo = CustomLogo::getPathUserSvgLogo(); + $svg = $this->getPathToLogo($pathOnly, $defaultLogo, $themeLogo, $customLogo); + return $svg; + } + + public function isEnabled() + { + return (bool) Option::get('branding_use_custom_logo'); + } + + public function enable() + { + Option::set('branding_use_custom_logo', '1', true); + } + + public function disable() + { + Option::set('branding_use_custom_logo', '0', true); + } + + public function hasSVGLogo() + { + if (!$this->isEnabled()) { + /* We always have our application logo */ + return true; + } + + if ($this->isEnabled() + && file_exists(Filesystem::getPathToPiwikRoot() . '/' . CustomLogo::getPathUserSvgLogo()) + ) { + return true; + } + + return false; + } + + /** + * @return bool + */ + public function isCustomLogoWritable() + { + if(Config::getInstance()->General['enable_custom_logo_check'] == 0) { + return true; + } + $pathUserLogo = $this->getPathUserLogo(); + + $directoryWritingTo = PIWIK_DOCUMENT_ROOT . '/' . dirname($pathUserLogo); + + // Create directory if not already created + Filesystem::mkdir($directoryWritingTo, $denyAccess = false); + + $directoryWritable = is_writable($directoryWritingTo); + $logoFilesWriteable = is_writeable(PIWIK_DOCUMENT_ROOT . '/' . $pathUserLogo) + && is_writeable(PIWIK_DOCUMENT_ROOT . '/' . $this->getPathUserSvgLogo()) + && is_writeable(PIWIK_DOCUMENT_ROOT . '/' . $this->getPathUserLogoSmall());; + + $serverUploadEnabled = ini_get('file_uploads') == 1; + $isCustomLogoWritable = ($logoFilesWriteable || $directoryWritable) && $serverUploadEnabled; + + return $isCustomLogoWritable; + } + + protected function getPathToLogo($pathOnly, $defaultLogo, $themeLogo, $customLogo) + { + $pathToPiwikRoot = Filesystem::getPathToPiwikRoot(); + + $logo = $defaultLogo; + + $themeName = \Piwik\Plugin\Manager::getInstance()->getThemeEnabled()->getPluginName(); + $themeLogo = sprintf($themeLogo, $themeName); + + if (file_exists($pathToPiwikRoot . '/' . $themeLogo)) { + $logo = $themeLogo; + } + if ($this->isEnabled() + && file_exists($pathToPiwikRoot . '/' . $customLogo) + ) { + $logo = $customLogo; + } + + if (!$pathOnly) { + return SettingsPiwik::getPiwikUrl() . $logo; + } + return $pathToPiwikRoot . '/' . $logo; + } + + public static function getPathUserLogo() + { + return self::rewritePath('misc/user/logo.png'); + } + + public static function getPathUserSvgLogo() + { + return self::rewritePath('misc/user/logo.svg'); + } + + public static function getPathUserLogoSmall() + { + return self::rewritePath('misc/user/logo-header.png'); + } + + protected static function rewritePath($path) + { + return SettingsPiwik::rewriteMiscUserPathWithHostname($path); + } + + public function copyUploadedLogoToFilesystem() + { + + if (empty($_FILES['customLogo']) + || !empty($_FILES['customLogo']['error']) + ) { + return false; + } + + $file = $_FILES['customLogo']['tmp_name']; + if (!file_exists($file)) { + return false; + } + + list($width, $height) = getimagesize($file); + switch ($_FILES['customLogo']['type']) { + case 'image/jpeg': + $image = imagecreatefromjpeg($file); + break; + case 'image/png': + $image = imagecreatefrompng($file); + break; + case 'image/gif': + $image = imagecreatefromgif($file); + break; + default: + return false; + } + + $widthExpected = round($width * self::LOGO_HEIGHT / $height); + $smallWidthExpected = round($width * self::LOGO_SMALL_HEIGHT / $height); + + $logo = imagecreatetruecolor($widthExpected, self::LOGO_HEIGHT); + $logoSmall = imagecreatetruecolor($smallWidthExpected, self::LOGO_SMALL_HEIGHT); + + // Handle transparency + $background = imagecolorallocate($logo, 0, 0, 0); + $backgroundSmall = imagecolorallocate($logoSmall, 0, 0, 0); + imagecolortransparent($logo, $background); + imagecolortransparent($logoSmall, $backgroundSmall); + + if ($_FILES['customLogo']['type'] == 'image/png') { + imagealphablending($logo, false); + imagealphablending($logoSmall, false); + imagesavealpha($logo, true); + imagesavealpha($logoSmall, true); + } + + imagecopyresized($logo, $image, 0, 0, 0, 0, $widthExpected, self::LOGO_HEIGHT, $width, $height); + imagecopyresized($logoSmall, $image, 0, 0, 0, 0, $smallWidthExpected, self::LOGO_SMALL_HEIGHT, $width, $height); + + imagepng($logo, PIWIK_DOCUMENT_ROOT . '/' . $this->getPathUserLogo(), 3); + imagepng($logoSmall, PIWIK_DOCUMENT_ROOT . '/' . $this->getPathUserLogoSmall(), 3); + return true; + } + +} diff --git a/www/analytics/plugins/CoreAdminHome/javascripts/generalSettings.js b/www/analytics/plugins/CoreAdminHome/javascripts/generalSettings.js new file mode 100644 index 00000000..30483202 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/javascripts/generalSettings.js @@ -0,0 +1,142 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +function sendGeneralSettingsAJAX() { + var enableBrowserTriggerArchiving = $('input[name=enableBrowserTriggerArchiving]:checked').val(); + var enablePluginUpdateCommunication = $('input[name=enablePluginUpdateCommunication]:checked').val(); + var enableBetaReleaseCheck = $('input[name=enableBetaReleaseCheck]:checked').val(); + var todayArchiveTimeToLive = $('#todayArchiveTimeToLive').val(); + + var trustedHosts = []; + $('input[name=trusted_host]').each(function () { + trustedHosts.push($(this).val()); + }); + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.setLoadingElement(); + ajaxHandler.addParams({ + format: 'json', + enableBrowserTriggerArchiving: enableBrowserTriggerArchiving, + enablePluginUpdateCommunication: enablePluginUpdateCommunication, + enableBetaReleaseCheck: enableBetaReleaseCheck, + todayArchiveTimeToLive: todayArchiveTimeToLive, + mailUseSmtp: isSmtpEnabled(), + mailPort: $('#mailPort').val(), + mailHost: $('#mailHost').val(), + mailType: $('#mailType').val(), + mailUsername: $('#mailUsername').val(), + mailPassword: $('#mailPassword').val(), + mailEncryption: $('#mailEncryption').val(), + useCustomLogo: isCustomLogoEnabled(), + trustedHosts: JSON.stringify(trustedHosts) + }, 'POST'); + ajaxHandler.addParams({ + module: 'CoreAdminHome', + action: 'setGeneralSettings' + }, 'GET'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.send(true); +} +function showSmtpSettings(value) { + $('#smtpSettings').toggle(value == 1); +} +function isSmtpEnabled() { + return $('input[name="mailUseSmtp"]:checked').val(); +} +function showCustomLogoSettings(value) { + $('#logoSettings').toggle(value == 1); +} +function isCustomLogoEnabled() { + return $('input[name="useCustomLogo"]:checked').val(); +} + +function refreshCustomLogo() { + var imageDiv = $("#currentLogo"); + if (imageDiv && imageDiv.attr("src")) { + var logoUrl = imageDiv.attr("src").split("?")[0]; + imageDiv.attr("src", logoUrl + "?" + (new Date()).getTime()); + } +} + +$(document).ready(function () { + var originalTrustedHostCount = $('input[name=trusted_host]').length; + + showSmtpSettings(isSmtpEnabled()); + showCustomLogoSettings(isCustomLogoEnabled()); + $('#generalSettingsSubmit').click(function () { + var doSubmit = function () { + sendGeneralSettingsAJAX(); + }; + + var hasTrustedHostsChanged = false, + hosts = $('input[name=trusted_host]'); + if (hosts.length != originalTrustedHostCount) { + hasTrustedHostsChanged = true; + } + else { + hosts.each(function () { + hasTrustedHostsChanged |= this.defaultValue != this.value; + }); + } + + // if trusted hosts have changed, make sure to ask for confirmation + if (hasTrustedHostsChanged) { + piwikHelper.modalConfirm('#confirmTrustedHostChange', {yes: doSubmit}); + } + else { + doSubmit(); + } + }); + + $('input[name=mailUseSmtp]').click(function () { + showSmtpSettings($(this).val()); + }); + $('input[name=useCustomLogo]').click(function () { + refreshCustomLogo(); + showCustomLogoSettings($(this).val()); + }); + $('input').keypress(function (e) { + var key = e.keyCode || e.which; + if (key == 13) { + $('#generalSettingsSubmit').click(); + } + } + ); + + $("#logoUploadForm").submit(function (data) { + var submittingForm = $(this); + var frameName = "upload" + (new Date()).getTime(); + var uploadFrame = $("<iframe name=\"" + frameName + "\" />"); + uploadFrame.css("display", "none"); + uploadFrame.load(function (data) { + setTimeout(function () { + refreshCustomLogo(); + uploadFrame.remove(); + }, 1000); + }); + $("body:first").append(uploadFrame); + submittingForm.attr("target", frameName); + }); + + $('#customLogo').change(function () {$("#logoUploadForm").submit()}); + + // trusted hosts event handling + var trustedHostSettings = $('#trustedHostSettings'); + trustedHostSettings.on('click', '.remove-trusted-host', function (e) { + e.preventDefault(); + $(this).parent('li').remove(); + return false; + }); + trustedHostSettings.find('.add-trusted-host').click(function (e) { + e.preventDefault(); + + // append new row to the table + trustedHostSettings.find('ul').append(trustedHostSettings.find('li:last').clone()); + trustedHostSettings.find('li:last input').val(''); + return false; + }); +}); diff --git a/www/analytics/plugins/CoreAdminHome/javascripts/jsTrackingGenerator.js b/www/analytics/plugins/CoreAdminHome/javascripts/jsTrackingGenerator.js new file mode 100644 index 00000000..88660f13 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/javascripts/jsTrackingGenerator.js @@ -0,0 +1,310 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var piwikHost = window.location.host, + piwikPath = location.pathname.substring(0, location.pathname.lastIndexOf('/')), + exports = require('piwik/Tracking'); + + /** + * This class is deprecated. Use server-side events instead. + * + * @deprecated + */ + var TrackingCodeGenerator = function () { + // empty + }; + + var TrackingCodeGeneratorSingleton = exports.TrackingCodeGenerator = new TrackingCodeGenerator(); + + $(document).ready(function () { + + // get preloaded server-side data necessary for code generation + var dataElement = $('#js-tracking-generator-data'), + currencySymbols = JSON.parse(dataElement.attr('data-currencies')), + maxCustomVariables = parseInt(dataElement.attr('max-custom-variables'), 10), + siteUrls = {}, + siteCurrencies = {}, + allGoals = {}, + noneText = $('#image-tracker-goal').find('>option').text(); + + // + // utility methods + // + + // returns JavaScript code for tracking custom variables based on an array of + // custom variable name-value pairs (so an array of 2-element arrays) and + // a scope (either 'visit' or 'page') + var getCustomVariableJS = function (customVariables, scope) { + var result = ''; + for (var i = 0; i != 5; ++i) { + if (customVariables[i]) { + var key = customVariables[i][0], + value = customVariables[i][1]; + result += ' _paq.push(["setCustomVariable", ' + (i + 1) + ', ' + JSON.stringify(key) + ', ' + + JSON.stringify(value) + ', ' + JSON.stringify(scope) + ']);\n'; + } + } + return result; + }; + + // gets the list of custom variables entered by the user in a custom variable + // section + var getCustomVariables = function (sectionId) { + var customVariableNames = $('.custom-variable-name', '#' + sectionId), + customVariableValues = $('.custom-variable-value', '#' + sectionId); + + var result = []; + if ($('.section-toggler-link', '#' + sectionId).is(':checked')) { + for (var i = 0; i != customVariableNames.length; ++i) { + var name = $(customVariableNames[i]).val(); + + result[i] = null; + if (name) { + result[i] = [name, $(customVariableValues[i]).val()]; + } + } + } + return result; + }; + + // quickly gets the host + port from a url + var getHostNameFromUrl = function (url) { + var element = $('<a></a>')[0]; + element.href = url; + return element.hostname; + }; + + // queries Piwik for needed site info for one site + var getSiteData = function (idSite, sectionSelect, callback) { + // if data is already loaded, don't do an AJAX request + if (siteUrls[idSite] + && siteCurrencies[idSite] + && typeof allGoals[idSite] !== 'undefined' + ) { + callback(); + return; + } + + // disable section + $(sectionSelect).find('input,select,textarea').attr('disabled', 'disabled'); + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.setBulkRequests( + // get site info (for currency) + { + module: 'API', + method: 'SitesManager.getSiteFromId', + idSite: idSite + }, + + // get site urls + { + module: 'API', + method: 'SitesManager.getSiteUrlsFromId', + idSite: idSite + }, + + // get site goals + { + module: 'API', + method: 'Goals.getGoals', + idSite: idSite + } + ); + ajaxRequest.setCallback(function (data) { + var currency = data[0][0].currency || ''; + siteCurrencies[idSite] = currencySymbols[currency.toUpperCase()]; + siteUrls[idSite] = data[1] || []; + allGoals[idSite] = data[2] || []; + + // re-enable controls + $(sectionSelect).find('input,select,textarea').removeAttr('disabled'); + + callback(); + }); + ajaxRequest.setFormat('json'); + ajaxRequest.send(false); + }; + + // resets the select options of a goal select using a site ID + var resetGoalSelectItems = function (idsite, id) { + var selectElement = $('#' + id).html(''); + + selectElement.append($('<option value=""></option>').text(noneText)); + + var goals = allGoals[idsite] || []; + for (var key in goals) { + var goal = goals[key]; + selectElement.append($('<option/>').val(goal.idgoal).text(goal.name)); + } + + // set currency string + $('#' + id).parent().find('.currency').text(siteCurrencies[idsite]); + }; + + // function that generates JS code + var generateJsCodeAjax = null, + generateJsCode = function () { + // get params used to generate JS code + var params = { + piwikUrl: piwikHost + piwikPath, + groupPageTitlesByDomain: $('#javascript-tracking-group-by-domain').is(':checked') ? 1 : 0, + mergeSubdomains: $('#javascript-tracking-all-subdomains').is(':checked') ? 1 : 0, + mergeAliasUrls: $('#javascript-tracking-all-aliases').is(':checked') ? 1 : 0, + visitorCustomVariables: getCustomVariables('javascript-tracking-visitor-cv'), + pageCustomVariables: getCustomVariables('javascript-tracking-page-cv'), + customCampaignNameQueryParam: null, + customCampaignKeywordParam: null, + doNotTrack: $('#javascript-tracking-do-not-track').is(':checked') ? 1 : 0, + }; + + if ($('#custom-campaign-query-params-check').is(':checked')) { + params.customCampaignNameQueryParam = $('#custom-campaign-name-query-param').val(); + params.customCampaignKeywordParam = $('#custom-campaign-keyword-query-param').val(); + } + + if (generateJsCodeAjax) { + generateJsCodeAjax.abort(); + } + + generateJsCodeAjax = new ajaxHelper(); + generateJsCodeAjax.addParams({ + module: 'API', + format: 'json', + method: 'SitesManager.getJavascriptTag', + idSite: $('#js-tracker-website').attr('siteid') + }, 'GET'); + generateJsCodeAjax.addParams(params, 'POST'); + generateJsCodeAjax.setCallback(function (response) { + generateJsCodeAjax = null; + + $('#javascript-text').find('textarea').val(response.value); + }); + generateJsCodeAjax.send(); + }; + + // function that generates image tracker link + var generateImageTrackingAjax = null, + generateImageTrackerLink = function () { + // get data used to generate the link + var generateDataParams = { + piwikUrl: piwikHost + piwikPath, + actionName: $('#image-tracker-action-name').val(), + }; + + if ($('#image-tracking-goal-check').is(':checked')) { + generateDataParams.idGoal = $('#image-tracker-goal').val(); + if (generateDataParams.idGoal) { + generateDataParams.revenue = $('#image-tracker-advanced-options').find('.revenue').val(); + } + } + + if (generateImageTrackingAjax) { + generateImageTrackingAjax.abort(); + } + + generateImageTrackingAjax = new ajaxHelper(); + generateImageTrackingAjax.addParams({ + module: 'API', + format: 'json', + method: 'SitesManager.getImageTrackingCode', + idSite: $('#image-tracker-website').attr('siteid') + }, 'GET'); + generateImageTrackingAjax.addParams(generateDataParams, 'POST'); + generateImageTrackingAjax.setCallback(function (response) { + generateImageTrackingAjax = null; + + $('#image-tracking-text').find('textarea').val(response.value); + }); + generateImageTrackingAjax.send(); + }; + + // on image link tracker site change, change available goals + $('#image-tracker-website').bind('change', function (e, site) { + getSiteData(site.id, '#image-tracking-code-options', function () { + resetGoalSelectItems(site.id, 'image-tracker-goal'); + generateImageTrackerLink(); + }); + }); + + // on js link tracker site change, change available goals + $('#js-tracker-website').bind('change', function (e, site) { + $('.current-site-name', '#optional-js-tracking-options').each(function () { + $(this).html(site.name); + }); + + getSiteData(site.id, '#js-code-options', function () { + var siteHost = getHostNameFromUrl(siteUrls[site.id][0]); + $('.current-site-host', '#optional-js-tracking-options').each(function () { + $(this).text(siteHost); + }); + + var defaultAliasUrl = 'x.' + siteHost; + $('.current-site-alias').text(siteUrls[site.id][1] || defaultAliasUrl); + + resetGoalSelectItems(site.id, 'js-tracker-goal'); + generateJsCode(); + }); + }); + + // on click 'add' link in custom variable section, add a new row, but only + // allow 5 custom variable entry rows + $('.add-custom-variable').click(function (e) { + e.preventDefault(); + + var newRow = '<tr>\ + <td> </td>\ + <td><input type="textbox" class="custom-variable-name"/></td>\ + <td> </td>\ + <td><input type="textbox" class="custom-variable-value"/></td>\ + </tr>', + row = $(this).closest('tr'); + + row.before(newRow); + + // hide add button if max # of custom variables has been reached + // (X custom variables + 1 row for add new row) + if ($('tr', row.parent()).length == (maxCustomVariables + 1)) { + $(this).hide(); + } + + return false; + }); + + // when any input in the JS tracking options section changes, regenerate JS code + $('#optional-js-tracking-options').on('change', 'input', function () { + generateJsCode(); + }); + + // when any input/select in the image tracking options section changes, regenerate + // image tracker link + $('#image-tracking-section').on('change', 'input,select', function () { + generateImageTrackerLink(); + }); + + // on click generated code textareas, select the text so it can be easily copied + $('#javascript-text>textarea,#image-tracking-text>textarea').click(function () { + $(this).select(); + }); + + // initial generation + getSiteData( + $('#js-tracker-website').attr('siteid'), + '#js-code-options,#image-tracking-code-options', + function () { + var imageTrackerSiteId = $('#image-tracker-website').attr('siteid'); + resetGoalSelectItems(imageTrackerSiteId, 'image-tracker-goal'); + + generateJsCode(); + generateImageTrackerLink(); + } + ); + }); + +}(jQuery, require)); diff --git a/www/analytics/plugins/CoreAdminHome/javascripts/pluginSettings.js b/www/analytics/plugins/CoreAdminHome/javascripts/pluginSettings.js new file mode 100644 index 00000000..084ef0cd --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/javascripts/pluginSettings.js @@ -0,0 +1,82 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready(function () { + + $submit = $('.pluginsSettingsSubmit'); + + if (!$submit) { + return; + } + + $submit.click(updatePluginSettings); + + function updatePluginSettings() + { + var $nonce = $('[name="setpluginsettingsnonce"]'); + var nonceValue = ''; + + if ($nonce) { + nonceValue = $nonce.val(); + } + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'CoreAdminHome', + action: 'setPluginSettings', + nonce: nonceValue + }, 'GET'); + ajaxHandler.addParams({settings: getSettings()}, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(getLoadingElement()); + ajaxHandler.setErrorElement(getErrorElement()); + ajaxHandler.send(true); + } + + function getSettings() + { + var $pluginSections = $( "#pluginSettings[data-pluginname]" ); + + var values = {}; + + $pluginSections.each(function (index, pluginSection) { + $pluginSection = $(pluginSection); + + var pluginName = $pluginSection.attr('data-pluginname'); + var serialized = $('input, textarea, select:not([multiple])', $pluginSection).serializeArray(); + + // by default, it does not generate an array + var $multiSelects = $('select[multiple]', $pluginSection); + $multiSelects.each(function (index, multiSelect) { + var name = $(multiSelect).attr('name'); + serialized.push({name: name, value: $(multiSelect).val()}); + }); + + // by default, values of unchecked checkboxes are not send + var $uncheckedNodes = $('input[type=checkbox]:not(:checked)', $pluginSection); + $uncheckedNodes.each(function (index, uncheckedNode) { + var name = $(uncheckedNode).attr('name'); + serialized.push({name: name, value: 0}); + }); + + values[pluginName] = serialized; + }); + + return values; + } + + function getErrorElement() + { + return $('#ajaxErrorPluginSettings'); + } + + function getLoadingElement() + { + return $('#ajaxLoadingPluginSettings'); + } + +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreAdminHome/stylesheets/generalSettings.less b/www/analytics/plugins/CoreAdminHome/stylesheets/generalSettings.less new file mode 100644 index 00000000..994c4614 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/stylesheets/generalSettings.less @@ -0,0 +1,176 @@ +.admin img { + vertical-align: middle;; +} + +.admin a { + color: black; +} + +#content.admin { + margin: 0 0 0 260px; + padding: 0 0 40px; + display: table; + font: 13px Arial, Helvetica, sans-serif; +} + +.admin #header_message { + margin-top: 10px; +} + +table.admin { + font-size: 0.9em; + font-family: Arial, Helvetica, verdana sans-serif; + background-color: #fff; + border-collapse: collapse; +} + +table.admin thead th { + border-right: 1px solid #fff; + color: #fff; + text-align: center; + padding: 5px; + text-transform: uppercase; + height: 25px; + background-color: #a3c159; + font-weight: bold; +} + +table.admin tbody tr { + background-color: #fff; + border-bottom: 1px solid #f0f0f0; +} + +table.admin tbody td { + color: #414141; + text-align: left; + vertical-align: top; +} + +table.admin tbody th { + text-align: left; + padding: 2px; +} + +table.admin tbody td, table.admin tbody th { + color: #536C2A; + text-decoration: none; + font-weight: normal; + padding: 10px; +} + +table.admin tbody td:hover, table.admin tbody th:hover { + color: #009193; + text-decoration: none; +} + +.warning { + border: 1px dotted gray; + padding: 15px; + font-size: .8em; +} + +.warning ul { + margin-left: 50px; +} + +.access_error { + font-size: .7em; + padding: 15px; +} + +.admin h2 { + border-bottom: 1px solid #DADADA; + margin: 15px -15px 20px 0; + padding: 0 0 5px 0; + font-size: 24px; + width:100%; +} + +.admin p, .admin section { + margin-top: 10px; + line-height: 140%; + padding-bottom: 20px; +} + +.adminTable { + width: 100%; + clear: both; + margin: 0; +} + +.adminTable a { + text-decoration: none; + color: #2B5C97; +} + +.adminTable abbr { + white-space: nowrap; +} + +.adminTable td { + font-size: 13px; + vertical-align: top; + padding: 7px 15px 9px 10px; + vertical-align: top; +} + +.adminTable td.action-links { + text-align: right; +} + +.adminTable .check-column { + text-align: right; + width: 1.5em; + padding: 0; +} + +.adminTable .num { + text-align: center; +} + +.adminTable .name { + font-weight: bold; +} + +.adminTable .ui-inline-help { + margin-top: 0; + width: 100%; +} + +/* other styles */ +.form-description { + color: #666666; + font-style: italic; + margin-left: 10px; +} + +#logoSettings, #smtpSettings { + margin-left: 50px; +} + +/* to override .admin a */ +.admin .sites_autocomplete a { + color: #255792; +} + +/* trusted host styles */ +#trustedHostSettings .adminTable { + width: 300px; +} + +#trustedHostSettings .adminTable td { + vertical-align: middle; + padding-bottom: 0; +} + +#trustedHostSettings .adminTable tr td:last-child { + padding: 0 0 0 0; +} + +#trustedHostSettings input { + width: 238px; +} + +#trustedHostSettings .add-trusted-host-container { + padding: 12px 24px; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreAdminHome/stylesheets/jsTrackingGenerator.css b/www/analytics/plugins/CoreAdminHome/stylesheets/jsTrackingGenerator.css new file mode 100644 index 00000000..2aeafbbe --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/stylesheets/jsTrackingGenerator.css @@ -0,0 +1,79 @@ +#javascript-output-section textarea, #image-link-output-section textarea { + width: 100%; + display: block; + color: #111; + font-family: "Courier New", Courier, monospace; + + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +#javascript-output-section textarea { + height: 256px; +} + +#image-link-output-section textarea { + height: 128px; +} + +label { + margin-right: .35em; + display: inline-block; +} + +#optional-js-tracking-options>tbody>tr>td, #image-tracking-section>tbody>tr>td { + width: 488px; + max-width: 488px; +} + +.custom-variable-name, .custom-variable-value { + width: 100px; +} + +h3 { + margin-top: 0; +} + +.small-form-description { + color: #666; + font-size: 1em; + font-style: italic; + margin-left: 4em; +} + +.tracking-option-section { + margin-bottom: 1.5em; +} + +#javascript-output-section, #image-link-output-section { + padding-top: 1em; +} + +#optional-js-tracking-options th, #image-tracking-section th { + text-align: left; +} + +#js-visitor-cv-extra, #js-page-cv-extra, #js-campaign-query-param-extra { + margin-left: 1em; +} + +#js-visitor-cv-extra td, #js-page-cv-extra td, #js-campaign-query-param-extra td { + vertical-align: middle; +} + +.revenue { + width: 32px; +} + +.goal-picker { + height: 1.2em; +} + +.goal-picker select { + width: 128px; +} + +#js-campaign-query-param-extra input { + width: 72px; +} diff --git a/www/analytics/plugins/CoreAdminHome/stylesheets/menu.less b/www/analytics/plugins/CoreAdminHome/stylesheets/menu.less new file mode 100644 index 00000000..55ee392c --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/stylesheets/menu.less @@ -0,0 +1,78 @@ +#container { + clear: left; +} + +.Menu--admin { + padding: 0; + float: left; + width: 240px; +} + +.Menu--admin > .Menu-tabList { + background-image: linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + background-image: -o-linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + background-image: -moz-linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + background-image: -webkit-linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + background-image: -ms-linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #FECB00), color-stop(0.25, #FE9800), color-stop(0.5, #FE6702), color-stop(0.75, #CA0000), color-stop(1, #670002)); + + -moz-background-size: 5px 100%; + background-size: 5px 100%; + background-position: 0 0, 100% 0; + background-repeat: no-repeat; +} + +.Menu--admin > .Menu-tabList { + padding-left: 5px; + margin-bottom: 0; + margin-top: 0.1em; + border: 1px solid #ddd; + border-radius: 5px; +} + +.Menu--admin > .Menu-tabList li { + list-style: none; + margin: 0; +} + +.Menu--admin > .Menu-tabList > li { + padding-bottom: 5px; +} + +.Menu--admin > .Menu-tabList > li > a, +.Menu--admin > .Menu-tabList > li > span { + text-decoration: none; + border-bottom: 1px dotted #778; + display: block; + padding: 5px 10px; + font-size: 18px; + color: #7E7363; +} + +.Menu--admin > .Menu-tabList li li a { + text-decoration: none; + padding: 0.6em 0.9em; + font: 14px Arial, Helvetica, sans-serif; + display: block; +} + +.Menu--admin > .Menu-tabList li li a:link, +.Menu--admin > .Menu-tabList li li a:visited { + color: #000; +} + +.Menu--admin > .Menu-tabList li li a:hover, +.Menu--admin > .Menu-tabList li li a.active { + color: #e87500; + background: #f1f1f1; + border-color: #000; +} + +.Menu--admin > .Menu-tabList li li a:hover { + text-decoration: underline; +} + +.Menu--admin > .Menu-tabList li li a.current { + background: #defdbb; +} diff --git a/www/analytics/plugins/CoreAdminHome/stylesheets/pluginSettings.less b/www/analytics/plugins/CoreAdminHome/stylesheets/pluginSettings.less new file mode 100644 index 00000000..78ef1cb7 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/stylesheets/pluginSettings.less @@ -0,0 +1,40 @@ +#pluginSettings { + width: 820px; + border-spacing: 0px 15px; + + .columnTitle { + width:400px + } + .columnField { + width:220px + } + .columnHelp { + width:200px + } + + .title { + font-weight: bold + } + + .settingIntroduction { + padding-bottom: 0px; + } + + .form-description { + font-style: normal; + margin-top: 3px; + display: block; + } + + .superUserSettings { + margin-top: 1em; + } +} + +#pluginsSettings { + .submitSeparator { + background-color: #DADADA; + height: 1px; + border: 0px; + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreAdminHome/templates/_menu.twig b/www/analytics/plugins/CoreAdminHome/templates/_menu.twig new file mode 100644 index 00000000..5ec9b95c --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/templates/_menu.twig @@ -0,0 +1,23 @@ +{% if adminMenu|length > 1 %} + <div class="Menu Menu--admin"> + <ul class="Menu-tabList"> + {% for name,submenu in adminMenu %} + {% if submenu._hasSubmenu %} + <li> + <span>{{ name|translate }}</span> + <ul> + {% for sname,url in submenu %} + {% if sname|slice(0,1) != '_' %} + <li> + <a href='index.php{{ url._url|urlRewriteWithParameters }}' + {% if currentAdminMenuName is defined and sname==currentAdminMenuName %}class='active'{% endif %}>{{ sname|translate }}</a> + </li> + {% endif %} + {% endfor %} + </ul> + </li> + {% endif %} + {% endfor %} + </ul> + </div> +{% endif %} diff --git a/www/analytics/plugins/CoreAdminHome/templates/generalSettings.twig b/www/analytics/plugins/CoreAdminHome/templates/generalSettings.twig new file mode 100644 index 00000000..bed62b3a --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/templates/generalSettings.twig @@ -0,0 +1,324 @@ +{% extends 'admin.twig' %} + +{% block content %} +{# load macros #} +{% import 'macros.twig' as piwik %} +{% import 'ajaxMacros.twig' as ajax %} + +{% if isSuperUser %} + {{ ajax.errorDiv() }} + {{ ajax.loadingDiv() }} + + <h2>{{ 'CoreAdminHome_ArchivingSettings'|translate }}</h2> + <table class="adminTable" style='width:900px;'> + + {% if isGeneralSettingsAdminEnabled %} + <tr> + <td style="width:400px;">{{ 'General_AllowPiwikArchivingToTriggerBrowser'|translate }}</td> + <td style="width:220px;"> + <fieldset> + <input id="enableBrowserTriggerArchiving-yes" type="radio" value="1" name="enableBrowserTriggerArchiving"{% if enableBrowserTriggerArchiving==1 %} checked="checked"{% endif %} /> + <label for="enableBrowserTriggerArchiving-yes">{{ 'General_Yes'|translate }}</label><br/> + <span class="form-description">{{ 'General_Default'|translate }}</span> + <br/><br/> + + <input id="enableBrowserTriggerArchiving-no" type="radio" value="0" name="enableBrowserTriggerArchiving"{% if enableBrowserTriggerArchiving==0 %} checked="checked"{% endif %} /> + <label for="enableBrowserTriggerArchiving-no">{{ 'General_No'|translate }}</label><br/> + <span class="form-description">{{ 'General_ArchivingTriggerDescription'|translate("<a href='?module=Proxy&action=redirect&url=http://piwik.org/docs/setup-auto-archiving/' target='_blank'>","</a>")|raw }}</span> + </fieldset> + <td> + {% set browserArchivingHelp %} + {{ 'General_ArchivingInlineHelp'|translate }} + <br/> + {{ 'General_SeeTheOfficialDocumentationForMoreInformation'|translate("<a href='?module=Proxy&action=redirect&url=http://piwik.org/docs/setup-auto-archiving/' target='_blank'>","</a>")|raw }} + {% endset %} + {{ piwik.inlineHelp(browserArchivingHelp) }} + </td> + </tr> + {% else %} + <tr> + <td style="width:400px;">{{ 'General_AllowPiwikArchivingToTriggerBrowser'|translate }}</td> + <td style="width:220px;"> + <input id="enableBrowserTriggerArchiving-disabled" type="radio" checked="checked" disabled="disabled" /> + <label for="enableBrowserTriggerArchiving-disabled">{% if enableBrowserTriggerArchiving==1 %}{{ 'General_Yes'|translate }}{% else %}{{ 'General_No'|translate }}{% endif %}</label><br/> + </td> + </tr> + {% endif %} + + <tr> + <td width="400px"> + <label for="todayArchiveTimeToLive"> + {{ 'General_ReportsContainingTodayWillBeProcessedAtMostEvery'|translate }} + </label> + </td> + <td> + {% set timeOutInput %} + <input size='3' value='{{ todayArchiveTimeToLive }}' id='todayArchiveTimeToLive' {% if not isGeneralSettingsAdminEnabled %}disabled="disabled"{% endif %}/> + {% endset %} + + {{ 'General_NSeconds'|translate(timeOutInput)|raw }} + </td> + + {% if isGeneralSettingsAdminEnabled %} + <td width='450px'> + {% set archiveTodayTTLHelp %} + {% if showWarningCron %} + <strong> + {{ 'General_NewReportsWillBeProcessedByCron'|translate }}<br/> + {{ 'General_ReportsWillBeProcessedAtMostEveryHour'|translate }} + {{ 'General_IfArchivingIsFastYouCanSetupCronRunMoreOften'|translate }}<br/> + </strong> + {% endif %} + {{ 'General_SmallTrafficYouCanLeaveDefault'|translate(10) }} + <br/> + {{ 'General_MediumToHighTrafficItIsRecommendedTo'|translate(1800,3600) }} + {% endset %} + {{ piwik.inlineHelp(archiveTodayTTLHelp) }} + </td> + {% endif %} + </tr> + + {% if isGeneralSettingsAdminEnabled %} + <tr> + <td colspan="3"> + + <h2>{{ 'CoreAdminHome_UpdateSettings'|translate }}</h2> + </td> + </tr> + <tr> + <td style="width:400px;">{{ 'CoreAdminHome_CheckReleaseGetVersion'|translate }}</td> + <td style="width:220px;"> + <fieldset> + <input id="enableBetaReleaseCheck-0" type="radio" value="0" name="enableBetaReleaseCheck"{% if enableBetaReleaseCheck==0 %} checked="checked"{% endif %} /> + <label for="enableBetaReleaseCheck-0">{{ 'CoreAdminHome_LatestStableRelease'|translate }}</label><br/> + <span class="form-description">{{ 'General_Recommended'|translate }}</span> + <br/><br/> + + <input id="enableBetaReleaseCheck-1" type="radio" value="1" name="enableBetaReleaseCheck"{% if enableBetaReleaseCheck==1 %} checked="checked"{% endif %} /> + <label for="enableBetaReleaseCheck-1">{{ 'CoreAdminHome_LatestBetaRelease'|translate }}</label><br/> + <span class="form-description">{{ 'CoreAdminHome_ForBetaTestersOnly'|translate }}</span> + </fieldset> + <td> + {% set checkReleaseHelp %} + {{ 'CoreAdminHome_DevelopmentProcess'|translate("<a href='?module=Proxy&action=redirect&url=http://piwik.org/participate/development-process/' target='_blank'>","</a>")|raw }} + <br/> + {{ 'CoreAdminHome_StableReleases'|translate("<a href='?module=Proxy&action=redirect&url=http://piwik.org/participate/user-feedback/' target='_blank'>","</a>")|raw }} + {% endset %} + {{ piwik.inlineHelp(checkReleaseHelp) }} + </td> + </tr> + + {% if canUpdateCommunication %} + + <tr> + <td style="width:400px;">{{ 'CoreAdminHome_SendPluginUpdateCommunication'|translate }}</td> + <td style="width:220px;"> + <fieldset> + <input id="enablePluginUpdateCommunication-1" type="radio" + name="enablePluginUpdateCommunication" value="1" + {% if enableSendPluginUpdateCommunication==1 %} checked="checked"{% endif %}/> + <label for="enablePluginUpdateCommunication-1">{{ 'General_Yes'|translate }}</label> + <br /> + <br /> + <input class="indented-radio-button" id="enablePluginUpdateCommunication-0" type="radio" + name="enablePluginUpdateCommunication" value="0" + {% if enableSendPluginUpdateCommunication==0 %} checked="checked"{% endif %}/> + <label for="enablePluginUpdateCommunication-0">{{ 'General_No'|translate }}</label> + <br /> + <span class="form-description">{{ 'General_Default'|translate }}</span> + </fieldset> + <td> + {{ piwik.inlineHelp('CoreAdminHome_SendPluginUpdateCommunicationHelp'|translate) }} + </td> + </tr> + + {% endif %} + + {% endif %} + </table> + + {% if isGeneralSettingsAdminEnabled %} + <h2>{{ 'CoreAdminHome_EmailServerSettings'|translate }}</h2> + <div id='emailSettings'> + <table class="adminTable" style='width:600px;'> + <tr> + <td>{{ 'General_UseSMTPServerForEmail'|translate }}<br/> + <span class="form-description">{{ 'General_SelectYesIfYouWantToSendEmailsViaServer'|translate }}</span> + </td> + <td style="width:200px;"> + <input id="mailUseSmtp-1" type="radio" name="mailUseSmtp" value="1" {% if mail.transport == 'smtp' %} checked {% endif %}/> + <label for="mailUseSmtp-1">{{ 'General_Yes'|translate }}</label> + <input class="indented-radio-button" id="mailUseSmtp-0" type="radio" name="mailUseSmtp" value="0" + {% if mail.transport == '' %} checked {% endif %}/> + <label for="mailUseSmtp-0">{{ 'General_No'|translate }}</label> + </td> + </tr> + </table> + </div> + <div id='smtpSettings'> + <table class="adminTable" style='width:550px;'> + <tr> + <td><label for="mailHost">{{ 'General_SmtpServerAddress'|translate }}</label></td> + <td style="width:200px;"><input type="text" id="mailHost" value="{{ mail.host }}"></td> + </tr> + <tr> + <td><label for="mailPort">{{ 'General_SmtpPort'|translate }}</label><br/> + <span class="form-description">{{ 'General_OptionalSmtpPort'|translate }}</span></td> + <td><input type="text" id="mailPort" value="{{ mail.port }}"></td> + </tr> + <tr> + <td><label for="mailType">{{ 'General_AuthenticationMethodSmtp'|translate }}</label><br/> + <span class="form-description">{{ 'General_OnlyUsedIfUserPwdIsSet'|translate }}</span> + </td> + <td> + <select id="mailType"> + <option value="" {% if mail.type == '' %} selected="selected" {% endif %}></option> + <option id="plain" {% if mail.type == 'Plain' %} selected="selected" {% endif %} value="Plain">Plain</option> + <option id="login" {% if mail.type == 'Login' %} selected="selected" {% endif %} value="Login"> Login</option> + <option id="cram-md5" {% if mail.type == 'Crammd5' %} selected="selected" {% endif %} value="Crammd5"> Crammd5</option> + </select> + </td> + </tr> + <tr> + <td><label for="mailUsername">{{ 'General_SmtpUsername'|translate }}</label><br/> + <span class="form-description">{{ 'General_OnlyEnterIfRequired'|translate }}</span></td> + <td> + <input type="text" id="mailUsername" value="{{ mail.username }}"/> + </td> + </tr> + <tr> + <td><label for="mailPassword">{{ 'General_SmtpPassword'|translate }}</label><br/> + <span class="form-description">{{ 'General_OnlyEnterIfRequiredPassword'|translate }}<br/> + {{ 'General_WarningPasswordStored'|translate("<strong>","</strong>")|raw }}</span> + </td> + <td> + <input type="password" id="mailPassword" value="{{ mail.password }}"/> + </td> + </tr> + <tr> + <td><label for="mailEncryption">{{ 'General_SmtpEncryption'|translate }}</label><br/> + <span class="form-description">{{ 'General_EncryptedSmtpTransport'|translate }}</span></td> + <td> + <select id="mailEncryption"> + <option value="" {% if mail.encryption == '' %} selected="selected" {% endif %}></option> + <option id="ssl" {% if mail.encryption == 'ssl' %} selected="selected" {% endif %} value="ssl">SSL</option> + <option id="tls" {% if mail.encryption == 'tls' %} selected="selected" {% endif %} value="tls">TLS</option> + </select> + </td> + </tr> + </table> + </div> + {% endif %} + + <h2>{{ 'CoreAdminHome_BrandingSettings'|translate }}</h2> + <div id='brandSettings'> + {{ 'CoreAdminHome_CustomLogoHelpText'|translate }} + <table class="adminTable" style="width:900px;"> + <tr> + <td style="width:200px;">{{ 'CoreAdminHome_UseCustomLogo'|translate }}</td> + <td style="width:200px;"> + <input id="useCustomLogo-1" type="radio" name="useCustomLogo" value="1" {% if branding.use_custom_logo == 1 %} checked {% endif %}/> + <label for="useCustomLogo-1">{{ 'General_Yes'|translate }}</label> + <input class="indented-radio-button" id="useCustomLogo-0" type="radio" name="useCustomLogo" value="0" {% if branding.use_custom_logo == 0 %} checked {% endif %} /> + <label for="useCustomLogo-0" class>{{ 'General_No'|translate }}</label> + </td> + <td id="inlineHelpCustomLogo"> + {% set giveUsFeedbackText %}"{{ 'General_GiveUsYourFeedback'|translate }}"{% endset %} + {% set customLogoHelp %} + {{ 'CoreAdminHome_CustomLogoFeedbackInfo'|translate(giveUsFeedbackText,"<a href='?module=CorePluginsAdmin&action=plugins' target='_blank'>","</a>")|raw }} + {% endset %} + {{ piwik.inlineHelp(customLogoHelp) }} + </td> + </tr> + </table> + </div> + <div id='logoSettings'> + <form id="logoUploadForm" method="post" enctype="multipart/form-data" action="index.php?module=CoreAdminHome&format=json&action=uploadCustomLogo"> + <table class="adminTable" style='width:550px;'> + <tr> + {% if logosWriteable %} + <td> + <label for="customLogo">{{ 'CoreAdminHome_LogoUpload'|translate }}:<br/> + <span class="form-description">{{ 'CoreAdminHome_LogoUploadHelp'|translate("JPG / PNG / GIF",110) }}</span> + </label> + </td> + <td style="width:200px;"> + <input name="customLogo" type="file" id="customLogo"/> + <img src="{{ pathUserLogo }}?r={{ random() }}" id="currentLogo" height="150"/> + </td> + {% else %} + <td> + <div style="display:inline-block;margin-top:10px;" id="CoreAdminHome_LogoNotWriteable"> + {{ 'CoreAdminHome_LogoNotWriteableInstruction' + |translate("<strong>"~pathUserLogoDirectory~"</strong><br/>", pathUserLogo ~", "~ pathUserLogoSmall ~", "~ pathUserLogoSVG ~"") + |notification({'placeAt': '#CoreAdminHome_LogoNotWriteable', 'noclear': true, 'context': 'warning', 'raw': true}) }} + + + </div> + </td> + {% endif %} + </tr> + </table> + </form> + </div> + + <div class="ui-confirm" id="confirmTrustedHostChange"> + <h2>{{ 'CoreAdminHome_TrustedHostConfirm'|translate }}</h2> + <input role="yes" type="button" value="{{ 'General_Yes'|translate }}"/> + <input role="no" type="button" value="{{ 'General_No'|translate }}"/> + </div> + <h2 id="trustedHostsSection">{{ 'CoreAdminHome_TrustedHostSettings'|translate }}</h2> + <div id='trustedHostSettings'> + + {% include "@CoreHome/_warningInvalidHost.twig" %} + + {% if not isGeneralSettingsAdminEnabled %} + {{ 'CoreAdminHome_PiwikIsInstalledAt'|translate }}: {{ trustedHosts|join(", ") }} + {% else %} + <p>{{ 'CoreAdminHome_PiwikIsInstalledAt'|translate }}:</p> + <strong>{{ 'CoreAdminHome_ValidPiwikHostname'|translate }}</strong> + <ul> + {% for hostIdx, host in trustedHosts %} + <li> + <input name="trusted_host" type="text" value="{{ host }}"/> + <a href="#" class="remove-trusted-host" title="{{ 'General_Delete'|translate }}"> + <img alt="{{ 'General_Delete'|translate }}" src="plugins/Morpheus/images/ico_delete.png" /> + </a> + </li> + {% endfor %} + </ul> + <div class="add-trusted-host-container"> + <a href="#" class="add-trusted-host"><em>{{ 'General_Add'|translate }}</em></a> + </div> + {% endif %} + </div> + + <input type="submit" value="{{ 'General_Save'|translate }}" id="generalSettingsSubmit" class="submit"/> + <br/> + <br/> + + {% if isDataPurgeSettingsEnabled %} + {% set clickDeleteLogSettings %}{{ 'PrivacyManager_DeleteDataSettings'|translate }}{% endset %} + <h2>{{ 'PrivacyManager_DeleteDataSettings'|translate }}</h2> + <p> + {{ 'PrivacyManager_DeleteDataDescription'|translate }} {{ 'PrivacyManager_DeleteDataDescription2'|translate }} + <br/> + <a href='{{ linkTo({'module':"PrivacyManager", 'action':"privacySettings"}) }}#deleteLogsAnchor'> + {{ 'PrivacyManager_ClickHereSettings'|translate("'" ~ clickDeleteLogSettings ~ "'") }} + </a> + </p> + {% endif %} +{% endif %} +<h2>{{ 'CoreAdminHome_OptOutForYourVisitors'|translate }}</h2> + +<p>{{ 'CoreAdminHome_OptOutExplanation'|translate }} + {% set optOutUrl %}{{ piwikUrl }}index.php?module=CoreAdminHome&action=optOut&language={{ language }}{% endset %} + {% set iframeOptOut %} + <iframe style="border: 0; height: 200px; width: 600px;" src="{{ optOutUrl }}"></iframe> + {% endset %} + <code>{{ iframeOptOut|escape }}</code> + <br/> + {{ 'CoreAdminHome_OptOutExplanationBis'|translate("<a href='" ~ optOutUrl ~ "' target='_blank'>","</a>")|raw }} +</p> + +{% endblock %} diff --git a/www/analytics/plugins/CoreAdminHome/templates/optOut.twig b/www/analytics/plugins/CoreAdminHome/templates/optOut.twig new file mode 100644 index 00000000..6665172f --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/templates/optOut.twig @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> +{% if not trackVisits %} + {{ 'CoreAdminHome_OptOutComplete'|translate }} + <br/> + {{ 'CoreAdminHome_OptOutCompleteBis'|translate }} +{% else %} + {{ 'CoreAdminHome_YouMayOptOut'|translate }} + <br/> + {{ 'CoreAdminHome_YouMayOptOutBis'|translate }} +{% endif %} +<br/><br/> + +<form method="post" action="?module=CoreAdminHome&action=optOut{% if language %}&language={{ language }}{% endif %}"> + <input type="hidden" name="nonce" value="{{ nonce }}" /> + <input type="hidden" name="fuzz" value="{{ "now"|date }}" /> + <input onclick="this.form.submit()" type="checkbox" id="trackVisits" name="trackVisits" {% if trackVisits %}checked="checked"{% endif %} /> + <label for="trackVisits"><strong> + {% if trackVisits %} + {{ 'CoreAdminHome_YouAreOptedIn'|translate }} {{ 'CoreAdminHome_ClickHereToOptOut'|translate }} + {% else %} + {{ 'CoreAdminHome_YouAreOptedOut'|translate }} {{ 'CoreAdminHome_ClickHereToOptIn'|translate }} + {% endif %} + </strong></label> +</form> +</body> +</html> diff --git a/www/analytics/plugins/CoreAdminHome/templates/pluginSettings.twig b/www/analytics/plugins/CoreAdminHome/templates/pluginSettings.twig new file mode 100644 index 00000000..393c6e19 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/templates/pluginSettings.twig @@ -0,0 +1,173 @@ +{% extends 'admin.twig' %} + +{% block content %} + +<div id="pluginsSettings"> + {% import 'macros.twig' as piwik %} + {% import 'ajaxMacros.twig' as ajax %} + + <p> + {{ 'CoreAdminHome_PluginSettingsIntro'|translate }} + {% for pluginName, settings in pluginSettings %} + <a href="#{{ pluginName|e('html_attr') }}">{{ pluginName }}</a>{% if not loop.last %}, {% endif %} + {% endfor %} + </p> + + <input type="hidden" name="setpluginsettingsnonce" value="{{ nonce }}"> + + {% for pluginName, settings in pluginSettings %} + + <h2 id="{{ pluginName|e('html_attr') }}">{{ pluginName }}</h2> + + {% if settings.getIntroduction %} + <p class="pluginIntroduction"> + {{ settings.getIntroduction }} + </p> + {% endif %} + + <table class="adminTable" id="pluginSettings" data-pluginname="{{ pluginName|e('html_attr') }}"> + + {% for name, setting in settings.getSettingsForCurrentUser %} + {% set settingValue = setting.getValue %} + + {% if pluginName in firstSuperUserSettingNames|keys and name == firstSuperUserSettingNames[pluginName] %} + <tr> + <td colspan="3"> + <h3 class="superUserSettings">{{ 'MobileMessaging_Settings_SuperAdmin'|translate }}</h3> + </td> + </tr> + {% endif %} + + {% if setting.introduction %} + <tr> + <td colspan="3"> + <p class="settingIntroduction"> + {{ setting.introduction }} + </p> + </td> + </tr> + {% endif %} + + <tr> + <td class="columnTitle"> + <span class="title">{{ setting.title }}</span> + <br /> + <span class='form-description'> + {{ setting.description }} + </span> + + </td> + <td class="columnField"> + <fieldset> + <label> + {% if setting.uiControlType == 'select' or setting.uiControlType == 'multiselect' %} + <select + {% for attr, val in setting.uiControlAttributes %} + {{ attr|e('html_attr') }}="{{ val|e('html_attr') }}" + {% endfor %} + name="{{ setting.getKey|e('html_attr') }}" + {% if setting.uiControlType == 'multiselect' %}multiple{% endif %}> + + {% for key, value in setting.availableValues %} + <option value='{{ key }}' + {% if settingValue is iterable and key in settingValue %} + selected='selected' + {% elseif settingValue==key %} + selected='selected' + {% endif %}> + {{ value }} + </option> + {% endfor %} + + </select> + {% elseif setting.uiControlType == 'textarea' %} + <textarea style="width: 176px;" + {% for attr, val in setting.uiControlAttributes %} + {{ attr|e('html_attr') }}="{{ val|e('html_attr') }}" + {% endfor %} + name="{{ setting.getKey|e('html_attr') }}" + > + {{- settingValue -}} + </textarea> + {% elseif setting.uiControlType == 'radio' %} + + {% for key, value in setting.availableValues %} + + <input + id="name-value-{{ loop.index }}" + {% for attr, val in setting.uiControlAttributes %} + {{ attr|e('html_attr') }}="{{ val|e('html_attr') }}" + {% endfor %} + {% if settingValue==key %} + checked="checked" + {% endif %} + type="radio" + value="{{ key|e('html_attr') }}" + name="{{ setting.getKey|e('html_attr') }}" /> + + <label for="name-value-{{ loop.index }}">{{ value }}</label> + + <br /> + + {% endfor %} + + {% else %} + + <input + {% for attr, val in setting.uiControlAttributes %} + {{ attr|e('html_attr') }}="{{ val|e('html_attr') }}" + {% endfor %} + {% if setting.uiControlType == 'checkbox' %} + value="1" + {% endif %} + {% if setting.uiControlType == 'checkbox' and settingValue %} + checked="checked" + {% endif %} + type="{{ setting.uiControlType|e('html_attr') }}" + name="{{ setting.getKey|e('html_attr') }}" + value="{{ settingValue|e('html_attr') }}" + > + + {% endif %} + + {% if setting.defaultValue and setting.uiControlType != 'checkbox' %} + <br/> + <span class='form-description'> + {{ 'General_Default'|translate }} + {% if setting.defaultValue is iterable %} + {{ setting.defaultValue|join(', ')|truncate(50) }} + {% else %} + {{ setting.defaultValue|truncate(50) }} + {% endif %} + </span> + {% endif %} + + </label> + </fieldset> + </td> + <td class="columnHelp"> + {% if setting.inlineHelp %} + <div class="ui-widget"> + <div class="ui-inline-help"> + {{ setting.inlineHelp }} + </div> + </div> + {% endif %} + </td> + </tr> + + {% endfor %} + + </table> + + {% endfor %} + + <hr class="submitSeparator"/> + + {{ ajax.errorDiv('ajaxErrorPluginSettings') }} + {{ ajax.loadingDiv('ajaxLoadingPluginSettings') }} + + <input type="submit" value="{{ 'General_Save'|translate }}" class="pluginsSettingsSubmit submit"/> + +</div> +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/CoreAdminHome/templates/trackingCodeGenerator.twig b/www/analytics/plugins/CoreAdminHome/templates/trackingCodeGenerator.twig new file mode 100644 index 00000000..2666ec62 --- /dev/null +++ b/www/analytics/plugins/CoreAdminHome/templates/trackingCodeGenerator.twig @@ -0,0 +1,272 @@ +{% extends 'admin.twig' %} + +{% block head %} + {{ parent() }} + <link rel="stylesheet" href="plugins/CoreAdminHome/stylesheets/jsTrackingGenerator.css" /> + <script type="text/javascript" src="plugins/CoreAdminHome/javascripts/jsTrackingGenerator.js"></script> +{% endblock %} + +{% block content %} +<div id="js-tracking-generator-data" max-custom-variables="{{ maxCustomVariables|e('html_attr') }}" data-currencies="{{ currencySymbols|json_encode }}"></div> + +<h2 piwik-enriched-headline + feature-name="{{ 'CoreAdminHome_TrackingCode'|translate }}" + help-url="http://piwik.org/docs/tracking-api/">{{ 'CoreAdminHome_JavaScriptTracking'|translate }}</h2> + +<div id="js-code-options" class="adminTable"> + + <p> + {{ 'CoreAdminHome_JSTrackingIntro1'|translate }} + <br/><br/> + {{ 'CoreAdminHome_JSTrackingIntro2'|translate }} {{ 'CoreAdminHome_JSTrackingIntro3'|translate('<a href="http://piwik.org/integrate/" target="_blank">','</a>')|raw }} + <br/><br/> + {{ 'CoreAdminHome_JSTrackingIntro4'|translate('<a href="#image-tracking-link">','</a>')|raw }} + <br/><br/> + {{ 'CoreAdminHome_JSTrackingIntro5'|translate('<a target="_blank" href="http://piwik.org/docs/javascript-tracking/">','</a>')|raw }} + </p> + + <div> + {# website #} + <label class="website-label"><strong>{{ 'General_Website'|translate }}</strong></label> + + <div piwik-siteselector + class="sites_autocomplete" + siteid="{{ idSite }}" + sitename="{{ defaultReportSiteName }}" + show-all-sites-item="false" + switch-site-on-select="false" + id="js-tracker-website" + show-selected-site="true"></div> + + <br/><br/><br/> + </div> + + <table id="optional-js-tracking-options" class="adminTable"> + <tr> + <th>{{ 'General_Options'|translate }}</th> + <th>{{ 'Mobile_Advanced'|translate }} + <a href="#" class="section-toggler-link" data-section-id="javascript-advanced-options">({{ 'General_Show'|translate }})</a> + </th> + </tr> + <tr> + <td> + {# track across all subdomains #} + <div class="tracking-option-section"> + <input type="checkbox" id="javascript-tracking-all-subdomains"/> + <label for="javascript-tracking-all-subdomains">{{ 'CoreAdminHome_JSTracking_MergeSubdomains'|translate }} + <span class='current-site-name'>{{ defaultReportSiteName|raw }}</span> + </label> + + <div class="small-form-description"> + {{ 'CoreAdminHome_JSTracking_MergeSubdomainsDesc'|translate("x.<span class='current-site-host'>"~defaultReportSiteDomain~"</span>","y.<span class='current-site-host'>"~defaultReportSiteDomain~"</span>")|raw }} + </div> + </div> + + {# group page titles by site domain #} + <div class="tracking-option-section"> + <input type="checkbox" id="javascript-tracking-group-by-domain"/> + <label for="javascript-tracking-group-by-domain">{{ 'CoreAdminHome_JSTracking_GroupPageTitlesByDomain'|translate }}</label> + + <div class="small-form-description"> + {{ 'CoreAdminHome_JSTracking_GroupPageTitlesByDomainDesc1'|translate("<span class='current-site-host'>" ~ defaultReportSiteDomain ~ "</span>")|raw }} + </div> + </div> + + {# track across all site aliases #} + <div class="tracking-option-section"> + <input type="checkbox" id="javascript-tracking-all-aliases"/> + <label for="javascript-tracking-all-aliases">{{ 'CoreAdminHome_JSTracking_MergeAliases'|translate }} + <span class='current-site-name'>{{ defaultReportSiteName|raw }}</span> + </label> + + <div class="small-form-description"> + {{ 'CoreAdminHome_JSTracking_MergeAliasesDesc'|translate("<span class='current-site-alias'>"~defaultReportSiteAlias~"</span>")|raw }} + </div> + </div> + + </td> + <td> + <div id="javascript-advanced-options" style="display:none;"> + {# visitor custom variable #} + <div class="custom-variable tracking-option-section" id="javascript-tracking-visitor-cv"> + <input class="section-toggler-link" type="checkbox" id="javascript-tracking-visitor-cv-check" data-section-id="js-visitor-cv-extra"/> + <label for="javascript-tracking-visitor-cv-check">{{ 'CoreAdminHome_JSTracking_VisitorCustomVars'|translate }}</label> + + <div class="small-form-description"> + {{ 'CoreAdminHome_JSTracking_VisitorCustomVarsDesc'|translate }} + </div> + + <table style="display:none;" id="js-visitor-cv-extra"> + <tr> + <td><strong>{{ 'General_Name'|translate }}</strong></td> + <td><input type="textbox" class="custom-variable-name" placeholder="e.g. Type"/></td> + <td><strong>{{ 'General_Value'|translate }}</strong></td> + <td><input type="textbox" class="custom-variable-value" placeholder="e.g. Customer"/></td> + </tr> + <tr> + <td colspan="4" style="text-align:right;"> + <a href="#" class="add-custom-variable">{{ 'General_Add'|translate }}</a> + </td> + </tr> + </table> + </div> + + {# page view custom variable #} + <div class="custom-variable tracking-option-section" id="javascript-tracking-page-cv"> + <input class="section-toggler-link" type="checkbox" id="javascript-tracking-page-cv-check" data-section-id="js-page-cv-extra"/> + <label for="javascript-tracking-page-cv-check">{{ 'CoreAdminHome_JSTracking_PageCustomVars'|translate }}</label> + + <div class="small-form-description"> + {{ 'CoreAdminHome_JSTracking_PageCustomVarsDesc'|translate }} + </div> + + <table style="display:none;" id="js-page-cv-extra"> + <tr> + <td><strong>{{ 'General_Name'|translate }}</strong></td> + <td><input type="textbox" class="custom-variable-name" placeholder="e.g. Category"/></td> + <td><strong>{{ 'General_Value'|translate }}</strong></td> + <td><input type="textbox" class="custom-variable-value" placeholder="e.g. White Papers"/></td> + </tr> + <tr> + <td colspan="4" style="text-align:right;"> + <a href="#" class="add-custom-variable">{{ 'General_Add'|translate }}</a> + </td> + </tr> + </table> + </div> + + {# do not track support #} + <div class="tracking-option-section"> + <input type="checkbox" id="javascript-tracking-do-not-track"/> + <label for="javascript-tracking-do-not-track">{{ 'CoreAdminHome_JSTracking_EnableDoNotTrack'|translate }}</label> + + <div class="small-form-description"> + {{ 'CoreAdminHome_JSTracking_EnableDoNotTrackDesc'|translate }} + {% if serverSideDoNotTrackEnabled %} + <br/> + <br/> + {{ 'CoreAdminHome_JSTracking_EnableDoNotTrack_AlreadyEnabled'|translate }} + {% endif %} + </div> + </div> + + {# custom campaign name/keyword query params #} + <div class="tracking-option-section"> + <input class="section-toggler-link" type="checkbox" id="custom-campaign-query-params-check" + data-section-id="js-campaign-query-param-extra"/> + <label for="custom-campaign-query-params-check">{{ 'CoreAdminHome_JSTracking_CustomCampaignQueryParam'|translate }}</label> + + <div class="small-form-description"> + {{ 'CoreAdminHome_JSTracking_CustomCampaignQueryParamDesc'|translate('<a href="http://piwik.org/faq/general/#faq_119" target="_blank">','</a>')|raw }} + </div> + + <table style="display:none;" id="js-campaign-query-param-extra"> + <tr> + <td><strong>{{ 'CoreAdminHome_JSTracking_CampaignNameParam'|translate }}</strong></td> + <td><input type="text" id="custom-campaign-name-query-param"/></td> + </tr> + <tr> + <td><strong>{{ 'CoreAdminHome_JSTracking_CampaignKwdParam'|translate }}</strong></td> + <td><input type="text" id="custom-campaign-keyword-query-param"/></td> + </tr> + </table> + </div> + </div> + </td> + </tr> + </table> + +</div> + +<div id="javascript-output-section"> + <h3>{{ 'General_JsTrackingTag'|translate }}</h3> + + <p class="form-description">{{ 'CoreAdminHome_JSTracking_CodeNote'|translate("</body>")|raw }}</p> + + <div id="javascript-text"> + <textarea> </textarea> + </div> + <br/> +</div> + +<h2 id="image-tracking-link">{{ 'CoreAdminHome_ImageTracking'|translate }}</h2> + +<div id="image-tracking-code-options" class="adminTable"> + + <p> + {{ 'CoreAdminHome_ImageTrackingIntro1'|translate }} {{ 'CoreAdminHome_ImageTrackingIntro2'|translate("<em><noscript></noscript></em>")|raw }} + <br/><br/> + {{ 'CoreAdminHome_ImageTrackingIntro3'|translate('<a href="http://piwik.org/docs/tracking-api/reference/" target="_blank">','</a>')|raw }} + </p> + + <div> + {# website #} + <label class="website-label"><strong>{{ 'General_Website'|translate }}</strong></label> + <div piwik-siteselector + class="sites_autocomplete" + siteid="{{ idSite }}" + sitename="{{ defaultReportSiteName }}" + id="image-tracker-website" + show-all-sites-item="false" + switch-site-on-select="false" + show-selected-site="true"></div> + + <br/><br/><br/> + </div> + + <table id="image-tracking-section" class="adminTable"> + <tr> + <th>{{ 'General_Options'|translate }}</th> + <th>{{ 'Mobile_Advanced'|translate }} + <a href="#" class="section-toggler-link" data-section-id="image-tracker-advanced-options"> + ({{ 'General_Show'|translate }}) + </a> + </th> + </tr> + <tr> + <td> + {# action_name #} + <div class="tracking-option-section"> + <label for="image-tracker-action-name">{{ 'Actions_ColumnPageName'|translate }}</label> + <input type="text" id="image-tracker-action-name"/> + </div> + </td> + <td> + <div id="image-tracker-advanced-options" style="display:none;"> + {# goal #} + <div class="goal-picker tracking-option-section"> + <input class="section-toggler-link" type="checkbox" id="image-tracking-goal-check" data-section-id="image-goal-picker-extra"/> + <label for="image-tracking-goal-check">{{ 'CoreAdminHome_TrackAGoal'|translate }}</label> + + <div style="display:none;" id="image-goal-picker-extra"> + <select id="image-tracker-goal"> + <option value="">{{ 'UserCountryMap_None'|translate }}</option> + </select> + <span>{{ 'CoreAdminHome_WithOptionalRevenue'|translate }}</span> + <span class="currency">{{ defaultSiteRevenue }}</span> + <input type="text" class="revenue" value=""/> + </div> + </div> + </div> + </td> + </tr> + </table> + + <div id="image-link-output-section" width="560px"> + <h3>{{ 'CoreAdminHome_ImageTrackingLink'|translate }}</h3><br/><br/> + + <div id="image-tracking-text"> + <textarea> </textarea> + </div> + <br/> + </div> + +</div> + +<h2>{{ 'CoreAdminHome_ImportingServerLogs'|translate }}</h2> + +<p> + {{ 'CoreAdminHome_ImportingServerLogsDesc'|translate('<a href="http://piwik.org/log-analytics/" target="_blank">','</a>')|raw }} +</p> + +{% endblock %} diff --git a/www/analytics/plugins/CoreConsole/Commands/CodeCoverage.php b/www/analytics/plugins/CoreConsole/Commands/CodeCoverage.php new file mode 100644 index 00000000..1054b73f --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/CodeCoverage.php @@ -0,0 +1,90 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class CodeCoverage extends ConsoleCommand +{ + protected function configure() + { + $this->setName('tests:coverage'); + $this->setDescription('Run all phpunit tests and generate a combined code coverage'); + $this->addArgument('group', InputArgument::OPTIONAL, 'Run only a specific test group. Separate multiple groups by comma, for instance core,integration', ''); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $phpCovPath = trim(shell_exec('which phpcov')); + + if (empty($phpCovPath)) { + + $output->writeln('phpcov not installed. please install pear.phpunit.de/phpcov.'); + return; + } + + $command = $this->getApplication()->find('tests:run'); + $arguments = array( + 'command' => 'tests:run', + '--options' => sprintf('--coverage-php %s/tests/results/logs/%%group%%.cov', PIWIK_DOCUMENT_ROOT), + ); + + $groups = $input->getArgument('group'); + if (!empty($groups)) { + $arguments['group'] = $groups; + } else { + shell_exec(sprintf('rm %s/tests/results/logs/*.cov', PIWIK_DOCUMENT_ROOT)); + } + + $inputObject = new ArrayInput($arguments); + $inputObject->setInteractive($input->isInteractive()); + $command->run($inputObject, $output); + + $command = 'phpcov'; + + // force xdebug usage for coverage options + if (!extension_loaded('xdebug')) { + + $output->writeln('<info>xdebug extension required for code coverage.</info>'); + + $output->writeln('<info>searching for xdebug extension...</info>'); + + $extensionDir = shell_exec('php-config --extension-dir'); + $xdebugFile = trim($extensionDir) . DIRECTORY_SEPARATOR . 'xdebug.so'; + + if (!file_exists($xdebugFile)) { + + $dialog = $this->getHelperSet()->get('dialog'); + + $xdebugFile = $dialog->askAndValidate($output, 'xdebug not found. Please provide path to xdebug.so', function($xdebugFile) { + return file_exists($xdebugFile); + }); + } else { + + $output->writeln('<info>xdebug extension found in extension path.</info>'); + } + + $output->writeln("<info>using $xdebugFile as xdebug extension.</info>"); + + $command = sprintf('php -d zend_extension=%s %s', $xdebugFile, $phpCovPath); + } + + shell_exec(sprintf('rm -rf %s/tests/results/coverage/*', PIWIK_DOCUMENT_ROOT)); + + passthru(sprintf('cd %1$s && %2$s --merge --html tests/results/coverage/ --whitelist ./core/ --whitelist ./plugins/ --add-uncovered %1$s/tests/results/logs/', PIWIK_DOCUMENT_ROOT, $command)); + } + +} diff --git a/www/analytics/plugins/CoreConsole/Commands/CoreArchiver.php b/www/analytics/plugins/CoreConsole/Commands/CoreArchiver.php new file mode 100644 index 00000000..0fe60d3b --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/CoreArchiver.php @@ -0,0 +1,55 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\CronArchive; +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class CoreArchiver extends ConsoleCommand +{ + protected function configure() + { + $this->configureArchiveCommand($this); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if ($input->getOption('piwik-domain') && !$input->getOption('url')) { + $_SERVER['argv'][] = '--url=' . $input->getOption('piwik-domain'); + } + + include PIWIK_INCLUDE_PATH . '/misc/cron/archive.php'; + } + + // This is reused by another console command + static public function configureArchiveCommand(ConsoleCommand $command) + { + $command->setName('core:archive'); + $command->setDescription("Runs the CLI archiver. It is an important tool for general maintenance and to keep Piwik very fast."); + $command->setHelp("* It is recommended to run the script with the option --piwik-domain=[piwik-server-url] only. Other options are not required. +* This script should be executed every hour via crontab, or as a daemon. +* You can also run it via http:// by specifying the Super User &token_auth=XYZ as a parameter ('Web Cron'), + but it is recommended to run it via command line/CLI instead. +* If you have any suggestion about this script, please let the team know at hello@piwik.org +* Enjoy!"); + $command->addOption('url', null, InputOption::VALUE_REQUIRED, "Mandatory option as an alternative to '--piwik-domain'. Must be set to the Piwik base URL.\nFor example: --url=http://analytics.example.org/ or --url=https://example.org/piwik/"); + $command->addOption('force-all-websites', null, InputOption::VALUE_NONE, "If specified, the script will trigger archiving on all websites and all past dates.\nYou may use --force-all-periods=[seconds] to trigger archiving on those websites\nthat had visits in the last [seconds] seconds."); + $command->addOption('force-all-periods', null, InputOption::VALUE_OPTIONAL, "Limits archiving to websites with some traffic in the last [seconds] seconds. \nFor example --force-all-periods=86400 will archive websites that had visits in the last 24 hours. \nIf [seconds] is not specified, all websites with visits in the last " . CronArchive::ARCHIVE_SITES_WITH_TRAFFIC_SINCE . "\n seconds (" . round(CronArchive::ARCHIVE_SITES_WITH_TRAFFIC_SINCE / 86400) . " days) will be archived."); + $command->addOption('force-timeout-for-periods', null, InputOption::VALUE_OPTIONAL, "The current week/ current month/ current year will be processed at most every [seconds].\nIf not specified, defaults to " . CronArchive::SECONDS_DELAY_BETWEEN_PERIOD_ARCHIVES . "."); + $command->addOption('force-date-last-n', null, InputOption::VALUE_REQUIRED, "This script calls the API with period=lastN. You can force the N in lastN by specifying this value."); + $command->addOption('force-idsites', null, InputOption::VALUE_OPTIONAL, 'If specified, archiving will be processed only for these Sites Ids (comma separated)'); + $command->addOption('skip-idsites', null, InputOption::VALUE_OPTIONAL, 'If specified, archiving will be skipped for these websites (in case these website ids would have been archived).'); + $command->addOption('disable-scheduled-tasks', null, InputOption::VALUE_NONE, "Skips executing Scheduled tasks (sending scheduled reports, db optimization, etc.)."); + $command->addOption('xhprof', null, InputOption::VALUE_NONE, "Enables XHProf profiler for this archive.php run. Requires XHPRof (see tests/README.xhprof.md)."); + $command->addOption('accept-invalid-ssl-certificate', null, InputOption::VALUE_NONE, "It is _NOT_ recommended to use this argument. Instead, you should use a valid SSL certificate!\nIt can be useful if you specified --url=https://... or if you are using Piwik with force_ssl=1"); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreConsole/Commands/GenerateApi.php b/www/analytics/plugins/CoreConsole/Commands/GenerateApi.php new file mode 100644 index 00000000..06397aad --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GenerateApi.php @@ -0,0 +1,58 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GenerateApi extends GeneratePluginBase +{ + protected function configure() + { + $this->setName('generate:api') + ->setDescription('Adds an API to an existing plugin') + ->addOption('pluginname', null, InputOption::VALUE_REQUIRED, 'The name of an existing plugin which does not have an API yet'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $pluginName = $this->getPluginName($input, $output); + + $exampleFolder = PIWIK_INCLUDE_PATH . '/plugins/ExamplePlugin'; + $replace = array('ExamplePlugin' => $pluginName); + $whitelistFiles = array('/API.php'); + + $this->copyTemplateToPlugin($exampleFolder, $pluginName, $replace, $whitelistFiles); + + $this->writeSuccessMessage($output, array( + sprintf('API.php for %s generated.', $pluginName), + 'You can now start adding API methods', + 'Enjoy!' + )); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return array + * @throws \RunTimeException + */ + protected function getPluginName(InputInterface $input, OutputInterface $output) + { + $pluginNames = $this->getPluginNamesHavingNotSpecificFile('API.php'); + $invalidName = 'You have to enter the name of an existing plugin which does not already have an API'; + + return $this->askPluginNameAndValidate($input, $output, $pluginNames, $invalidName); + } + +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GenerateCommand.php b/www/analytics/plugins/CoreConsole/Commands/GenerateCommand.php new file mode 100644 index 00000000..d5dee39a --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GenerateCommand.php @@ -0,0 +1,89 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Common; +use Symfony\Component\Console\Formatter\OutputFormatterStyle; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GenerateCommand extends GeneratePluginBase +{ + protected function configure() + { + $this->setName('generate:command') + ->setDescription('Adds a command to an existing plugin') + ->addOption('pluginname', null, InputOption::VALUE_REQUIRED, 'The name of an existing plugin') + ->addOption('command', null, InputOption::VALUE_REQUIRED, 'The name of the command you want to create'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $pluginName = $this->getPluginName($input, $output); + $commandName = $this->getCommandName($input, $output); + + $exampleFolder = PIWIK_INCLUDE_PATH . '/plugins/ExampleCommand'; + $replace = array( + 'ExampleCommand' => $pluginName, + 'examplecommand' => strtolower($pluginName), + 'HelloWorld' => $commandName, + 'helloworld' => strtolower($commandName) + ); + + $whitelistFiles = array('/Commands', '/Commands/HelloWorld.php'); + + $this->copyTemplateToPlugin($exampleFolder, $pluginName, $replace, $whitelistFiles); + + $this->writeSuccessMessage($output, array( + sprintf('Command %s for plugin %s generated', $commandName, $pluginName) + )); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return string + * @throws \RunTimeException + */ + private function getCommandName(InputInterface $input, OutputInterface $output) + { + $testname = $input->getOption('command'); + + $validate = function ($testname) { + if (empty($testname)) { + throw new \InvalidArgumentException('You have to enter a command name'); + } + + return $testname; + }; + + if (empty($testname)) { + $dialog = $this->getHelperSet()->get('dialog'); + $testname = $dialog->askAndValidate($output, 'Enter the name of the command: ', $validate); + } else { + $validate($testname); + } + + $testname = ucfirst($testname); + + return $testname; + } + + protected function getPluginName(InputInterface $input, OutputInterface $output) + { + $pluginNames = $this->getPluginNames(); + $invalidName = 'You have to enter the name of an existing plugin'; + + return $this->askPluginNameAndValidate($input, $output, $pluginNames, $invalidName); + } +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GenerateController.php b/www/analytics/plugins/CoreConsole/Commands/GenerateController.php new file mode 100644 index 00000000..b395392e --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GenerateController.php @@ -0,0 +1,58 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GenerateController extends GeneratePluginBase +{ + protected function configure() + { + $this->setName('generate:controller') + ->setDescription('Adds a Controller to an existing plugin') + ->addOption('pluginname', null, InputOption::VALUE_REQUIRED, 'The name of an existing plugin which does not have a Controller yet'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $pluginName = $this->getPluginName($input, $output); + + $exampleFolder = PIWIK_INCLUDE_PATH . '/plugins/ExamplePlugin'; + $replace = array('ExamplePlugin' => $pluginName); + $whitelistFiles = array('/Controller.php', '/templates', '/templates/index.twig'); + + $this->copyTemplateToPlugin($exampleFolder, $pluginName, $replace, $whitelistFiles); + + $this->writeSuccessMessage($output, array( + sprintf('Controller for %s generated.', $pluginName), + 'You can now start adding Controller actions', + 'Enjoy!' + )); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return array + * @throws \RunTimeException + */ + protected function getPluginName(InputInterface $input, OutputInterface $output) + { + $pluginNames = $this->getPluginNamesHavingNotSpecificFile('Controller.php'); + $invalidName = 'You have to enter the name of an existing plugin which does not already have a Controller'; + + return $this->askPluginNameAndValidate($input, $output, $pluginNames, $invalidName); + } + +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GeneratePlugin.php b/www/analytics/plugins/CoreConsole/Commands/GeneratePlugin.php new file mode 100644 index 00000000..71d1ace3 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GeneratePlugin.php @@ -0,0 +1,223 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + + +use Piwik\Filesystem; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GeneratePlugin extends GeneratePluginBase +{ + protected function configure() + { + $this->setName('generate:plugin') + ->setAliases(array('generate:theme')) + ->setDescription('Generates a new plugin/theme including all needed files') + ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Plugin name ([a-Z0-9_-])') + ->addOption('description', null, InputOption::VALUE_REQUIRED, 'Plugin description, max 150 characters') + ->addOption('pluginversion', null, InputOption::VALUE_OPTIONAL, 'Plugin version') + ->addOption('full', null, InputOption::VALUE_OPTIONAL, 'If a value is set, an API and a Controller will be created as well. Option is only available for creating plugins, not for creating themes.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $isTheme = $this->isTheme($input); + $pluginName = $this->getPluginName($input, $output); + $description = $this->getPluginDescription($input, $output); + $version = $this->getPluginVersion($input, $output); + $createFullPlugin = !$isTheme && $this->getCreateFullPlugin($input, $output); + + $this->generatePluginFolder($pluginName); + + if ($isTheme) { + $exampleFolder = PIWIK_INCLUDE_PATH . '/plugins/ExampleTheme'; + $replace = array( + 'ExampleTheme' => $pluginName, + 'ExampleDescription' => $description, + '0.1.0' => $version + ); + $whitelistFiles = array(); + + } else { + + $exampleFolder = PIWIK_INCLUDE_PATH . '/plugins/ExamplePlugin'; + $replace = array( + 'ExamplePlugin' => $pluginName, + 'ExampleDescription' => $description, + '0.1.0' => $version + ); + $whitelistFiles = array( + '/ExamplePlugin.php', + '/plugin.json', + '/README.md', + '/.travis.yml', + '/screenshots', + '/screenshots/.gitkeep', + '/javascripts', + '/javascripts/plugin.js', + ); + + } + + $this->copyTemplateToPlugin($exampleFolder, $pluginName, $replace, $whitelistFiles); + + $this->writeSuccessMessage($output, array( + sprintf('%s %s %s generated.', $isTheme ? 'Theme' : 'Plugin', $pluginName, $version), + 'Enjoy!' + )); + + if ($createFullPlugin) { + $this->executePluginCommand($output, 'generate:api', $pluginName); + $this->executePluginCommand($output, 'generate:controller', $pluginName); + } + } + + private function executePluginCommand(OutputInterface $output, $commandName, $pluginName) + { + $command = $this->getApplication()->find($commandName); + $arguments = array( + 'command' => $commandName, + '--pluginname' => $pluginName + ); + + $input = new ArrayInput($arguments); + $command->run($input, $output); + } + + /** + * @param InputInterface $input + * @return bool + */ + private function isTheme(InputInterface $input) + { + $commandName = $input->getFirstArgument(); + + return false !== strpos($commandName, 'theme'); + } + + protected function generatePluginFolder($pluginName) + { + $pluginPath = $this->getPluginPath($pluginName); + Filesystem::mkdir($pluginPath, true); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return array + * @throws \RunTimeException + */ + protected function getPluginName(InputInterface $input, OutputInterface $output) + { + $self = $this; + + $validate = function ($pluginName) use ($self) { + if (empty($pluginName)) { + throw new \RunTimeException('You have to enter a plugin name'); + } + + if (!Filesystem::isValidFilename($pluginName)) { + throw new \RunTimeException(sprintf('The plugin name %s is not valid', $pluginName)); + } + + $pluginPath = $self->getPluginPath($pluginName); + + if (file_exists($pluginPath)) { + throw new \RunTimeException('A plugin with this name already exists'); + } + + return $pluginName; + }; + + $pluginName = $input->getOption('name'); + + if (empty($pluginName)) { + $dialog = $this->getHelperSet()->get('dialog'); + $pluginName = $dialog->askAndValidate($output, 'Enter a plugin name: ', $validate); + } else { + $validate($pluginName); + } + + $pluginName = ucfirst($pluginName); + + return $pluginName; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return mixed + * @throws \RunTimeException + */ + protected function getPluginDescription(InputInterface $input, OutputInterface $output) + { + $validate = function ($description) { + if (empty($description)) { + throw new \RunTimeException('You have to enter a description'); + } + if (150 < strlen($description)) { + throw new \RunTimeException('Description is too long, max 150 characters allowed.'); + } + + return $description; + }; + + $description = $input->getOption('description'); + + if (empty($description)) { + $dialog = $this->getHelperSet()->get('dialog'); + $description = $dialog->askAndValidate($output, 'Enter a plugin description: ', $validate); + } else { + $validate($description); + } + + return $description; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return string + */ + protected function getPluginVersion(InputInterface $input, OutputInterface $output) + { + $version = $input->getOption('pluginversion'); + + if (is_null($version)) { + $dialog = $this->getHelperSet()->get('dialog'); + $version = $dialog->ask($output, 'Enter a plugin version number (default to 0.1.0): ', '0.1.0'); + } + + return $version; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return mixed + */ + protected function getCreateFullPlugin(InputInterface $input, OutputInterface $output) + { + $full = $input->getOption('full'); + + if (is_null($full)) { + $dialog = $this->getHelperSet()->get('dialog'); + $full = $dialog->askConfirmation($output, 'Shall we also create an API and a Controller? (y/N)', false); + } + + return !empty($full); + } + +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GeneratePluginBase.php b/www/analytics/plugins/CoreConsole/Commands/GeneratePluginBase.php new file mode 100644 index 00000000..bfc5cec3 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GeneratePluginBase.php @@ -0,0 +1,143 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + + +use Piwik\Filesystem; +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +abstract class GeneratePluginBase extends ConsoleCommand +{ + public function getPluginPath($pluginName) + { + return PIWIK_INCLUDE_PATH . '/plugins/' . ucfirst($pluginName); + } + + private function createFolderWithinPluginIfNotExists($pluginName, $folder) + { + $pluginPath = $this->getPluginPath($pluginName); + + if (!file_exists($pluginName . $folder)) { + Filesystem::mkdir($pluginPath . $folder, true); + } + } + + protected function createFileWithinPluginIfNotExists($pluginName, $fileName, $content) + { + $pluginPath = $this->getPluginPath($pluginName); + + if (!file_exists($pluginPath . $fileName)) { + file_put_contents($pluginPath . $fileName, $content); + } + } + + /** + * @param string $templateFolder full path like /home/... + * @param string $pluginName + * @param array $replace array(key => value) $key will be replaced by $value in all templates + * @param array $whitelistFiles If not empty, only given files/directories will be copied. + * For instance array('/Controller.php', '/templates', '/templates/index.twig') + */ + protected function copyTemplateToPlugin($templateFolder, $pluginName, array $replace = array(), $whitelistFiles = array()) + { + $replace['PLUGINNAME'] = $pluginName; + + $files = array_merge( + Filesystem::globr($templateFolder, '*'), + // Also copy files starting with . such as .gitignore + Filesystem::globr($templateFolder, '.*') + ); + + foreach ($files as $file) { + $fileNamePlugin = str_replace($templateFolder, '', $file); + + if (!empty($whitelistFiles) && !in_array($fileNamePlugin, $whitelistFiles)) { + continue; + } + + if (is_dir($file)) { + $this->createFolderWithinPluginIfNotExists($pluginName, $fileNamePlugin); + } else { + $template = file_get_contents($file); + foreach ($replace as $key => $value) { + $template = str_replace($key, $value, $template); + } + + foreach ($replace as $key => $value) { + $fileNamePlugin = str_replace($key, $value, $fileNamePlugin); + } + + $this->createFileWithinPluginIfNotExists($pluginName, $fileNamePlugin, $template); + } + + } + } + + protected function getPluginNames() + { + $pluginDirs = \_glob(PIWIK_INCLUDE_PATH . '/plugins/*', GLOB_ONLYDIR); + + $pluginNames = array(); + foreach ($pluginDirs as $pluginDir) { + $pluginNames[] = basename($pluginDir); + } + + return $pluginNames; + } + + protected function getPluginNamesHavingNotSpecificFile($filename) + { + $pluginDirs = \_glob(PIWIK_INCLUDE_PATH . '/plugins/*', GLOB_ONLYDIR); + + $pluginNames = array(); + foreach ($pluginDirs as $pluginDir) { + if (!file_exists($pluginDir . '/' . $filename)) { + $pluginNames[] = basename($pluginDir); + } + } + + return $pluginNames; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return array + * @throws \RunTimeException + */ + protected function askPluginNameAndValidate(InputInterface $input, OutputInterface $output, $pluginNames, $invalidArgumentException) + { + $validate = function ($pluginName) use ($pluginNames, $invalidArgumentException) { + if (!in_array($pluginName, $pluginNames)) { + throw new \InvalidArgumentException($invalidArgumentException); + } + + return $pluginName; + }; + + $pluginName = $input->getOption('pluginname'); + + if (empty($pluginName)) { + $dialog = $this->getHelperSet()->get('dialog'); + $pluginName = $dialog->askAndValidate($output, 'Enter the name of your plugin: ', $validate, false, null, $pluginNames); + } else { + $validate($pluginName); + } + + $pluginName = ucfirst($pluginName); + + return $pluginName; + } + +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GenerateSettings.php b/www/analytics/plugins/CoreConsole/Commands/GenerateSettings.php new file mode 100644 index 00000000..2726e70c --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GenerateSettings.php @@ -0,0 +1,58 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GenerateSettings extends GeneratePluginBase +{ + protected function configure() + { + $this->setName('generate:settings') + ->setDescription('Adds a plugin setting class to an existing plugin') + ->addOption('pluginname', null, InputOption::VALUE_REQUIRED, 'The name of an existing plugin which does not have settings yet'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $pluginName = $this->getPluginName($input, $output); + + $exampleFolder = PIWIK_INCLUDE_PATH . '/plugins/ExampleSettingsPlugin'; + $replace = array('ExampleSettingsPlugin' => $pluginName); + $whitelistFiles = array('/Settings.php'); + + $this->copyTemplateToPlugin($exampleFolder, $pluginName, $replace, $whitelistFiles); + + $this->writeSuccessMessage($output, array( + sprintf('Settings.php for %s generated.', $pluginName), + 'You can now start defining your plugin settings', + 'Enjoy!' + )); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return array + * @throws \RunTimeException + */ + protected function getPluginName(InputInterface $input, OutputInterface $output) + { + $pluginNames = $this->getPluginNamesHavingNotSpecificFile('Settings.php'); + $invalidName = 'You have to enter the name of an existing plugin which does not already have settings'; + + return $this->askPluginNameAndValidate($input, $output, $pluginNames, $invalidName); + } + +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GenerateTest.php b/www/analytics/plugins/CoreConsole/Commands/GenerateTest.php new file mode 100644 index 00000000..598676e7 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GenerateTest.php @@ -0,0 +1,189 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Common; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GenerateTest extends GeneratePluginBase +{ + protected function configure() + { + $this->setName('generate:test') + ->setDescription('Adds a test to an existing plugin') + ->addOption('pluginname', null, InputOption::VALUE_REQUIRED, 'The name of an existing plugin') + ->addOption('testname', null, InputOption::VALUE_REQUIRED, 'The name of the test to create') + ->addOption('testtype', null, InputOption::VALUE_REQUIRED, 'Whether you want to create a "unit", "integration" or "database" test'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $pluginName = $this->getPluginName($input, $output); + $testName = $this->getTestName($input, $output); + $testType = $this->getTestType($input, $output); + + $exampleFolder = PIWIK_INCLUDE_PATH . '/plugins/ExamplePlugin'; + $replace = array( + 'ExamplePlugin' => $pluginName, + 'SimpleTest' => $testName, + 'SimpleIntegrationTest' => $testName, + '@group Plugins' => '@group ' . $testType + ); + + $testClass = $this->getTestClass($testType); + if(!empty($testClass)) { + $replace['\PHPUnit_Framework_TestCase'] = $testClass; + + } + + $whitelistFiles = $this->getTestFilesWhitelist($testType); + $this->copyTemplateToPlugin($exampleFolder, $pluginName, $replace, $whitelistFiles); + + $this->writeSuccessMessage($output, array( + sprintf('Test %s for plugin %s generated.', $testName, $pluginName), + 'You can now start writing beautiful tests!', + + )); + + $this->writeSuccessMessage($output, array( + 'To run all your plugin tests, execute the command: ', + sprintf('./console tests:run %s', $pluginName), + 'To run only this test: ', + sprintf('./console tests:run %s', $testName), + 'Enjoy!' + )); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return string + * @throws \RunTimeException + */ + private function getTestName(InputInterface $input, OutputInterface $output) + { + $testname = $input->getOption('testname'); + + $validate = function ($testname) { + if (empty($testname)) { + throw new \InvalidArgumentException('You have to enter a valid test name '); + } + + return $testname; + }; + + if (empty($testname)) { + $dialog = $this->getHelperSet()->get('dialog'); + $testname = $dialog->askAndValidate($output, 'Enter the name of the test: ', $validate); + } else { + $validate($testname); + } + + if (!Common::stringEndsWith(strtolower($testname), 'test')) { + $testname = $testname . 'Test'; + } + + $testname = ucfirst($testname); + + return $testname; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return array + * @throws \RunTimeException + */ + protected function getPluginName(InputInterface $input, OutputInterface $output) + { + $pluginNames = $this->getPluginNames(); + $invalidName = 'You have to enter the name of an existing plugin'; + + return $this->askPluginNameAndValidate($input, $output, $pluginNames, $invalidName); + } + + /** + * @param InputInterface $input + * @return string + */ + private function getTestClass($testType) + { + if ('Database' == $testType) { + return '\DatabaseTestCase'; + } + if ('Unit' == $testType) { + return '\PHPUnit_Framework_TestCase'; + } + return false; + } + + public function getValidTypes() + { + return array('unit', 'integration', 'database'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return string Unit, Integration, Database + */ + private function getTestType(InputInterface $input, OutputInterface $output) + { + $testtype = $input->getOption('testtype'); + + $self = $this; + + $validate = function ($testtype) use ($self) { + if (empty($testtype) || !in_array($testtype, $self->getValidTypes())) { + throw new \InvalidArgumentException('You have to enter a valid test type: ' . implode(" or ", $self->getValidTypes())); + } + return $testtype; + }; + + if (empty($testtype)) { + $dialog = $this->getHelperSet()->get('dialog'); + $testtype = $dialog->askAndValidate($output, 'Enter the type of the test to generate ('. implode(", ", $this->getValidTypes()).'): ', $validate, false, null, $this->getValidTypes()); + } else { + $validate($testtype); + } + + $testtype = ucfirst($testtype); + return $testtype; + } + + /** + * @return array + */ + protected function getTestFilesWhitelist($testType) + { + if('Integration' == $testType) { + return array( + '/.gitignore', + '/tests', + '/tests/SimpleIntegrationTest.php', + '/tests/expected', + '/tests/expected/test___API.get_day.xml', + '/tests/expected/test___Goals.getItemsSku_day.xml', + '/tests/processed', + '/tests/processed/.gitignore', + '/tests/fixtures', + '/tests/fixtures/SimpleFixtureTrackFewVisits.php' + ); + } + return array( + '/tests', + '/tests/SimpleTest.php' + ); + } +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GenerateVisualizationPlugin.php b/www/analytics/plugins/CoreConsole/Commands/GenerateVisualizationPlugin.php new file mode 100644 index 00000000..ab811a03 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GenerateVisualizationPlugin.php @@ -0,0 +1,96 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + + +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GenerateVisualizationPlugin extends GeneratePlugin +{ + protected function configure() + { + $this->setName('generate:visualizationplugin') + ->setDescription('Generates a new visualization plugin including all needed files') + ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Plugin name ([a-Z0-9_-])') + ->addOption('visualizationname', null, InputOption::VALUE_REQUIRED, 'Visualization name ([a-Z0-9])') + ->addOption('description', null, InputOption::VALUE_REQUIRED, 'Plugin description, max 150 characters') + ->addOption('pluginversion', null, InputOption::VALUE_OPTIONAL, 'Plugin version') + ->addOption('full', null, InputOption::VALUE_OPTIONAL, 'If a value is set, an API and a Controller will be created as well. Option is only available for creating plugins, not for creating themes.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $pluginName = $this->getPluginName($input, $output); + $description = $this->getPluginDescription($input, $output); + $version = $this->getPluginVersion($input, $output); + $visualizationName = $this->getVisualizationName($input, $output); + + $this->generatePluginFolder($pluginName); + + $exampleFolder = PIWIK_INCLUDE_PATH . '/plugins/ExampleVisualization'; + $replace = array( + 'SimpleTable' => $visualizationName, + 'simpleTable' => lcfirst($visualizationName), + 'Simple Table' => $visualizationName, + 'ExampleVisualization' => $pluginName, + 'ExampleVisualizationDescription' => $description + ); + + $this->copyTemplateToPlugin($exampleFolder, $pluginName, $replace, $whitelistFiles = array()); + + $this->writeSuccessMessage($output, array( + sprintf('Visualization plugin %s %s generated.', $pluginName, $version), + 'Enjoy!' + )); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return string + * @throws \RunTimeException + */ + private function getVisualizationName(InputInterface $input, OutputInterface $output) + { + $self = $this; + + $validate = function ($visualizationName) use ($self) { + if (empty($visualizationName)) { + throw new \RunTimeException('You have to enter a visualization name'); + } + + if (!ctype_alnum($visualizationName)) { + throw new \RunTimeException(sprintf('The visualization name %s is not valid', $visualizationName)); + } + + return $visualizationName; + }; + + $visualizationName = $input->getOption('visualizationname'); + + if (empty($visualizationName)) { + $dialog = $this->getHelperSet()->get('dialog'); + $visualizationName = $dialog->askAndValidate($output, 'Enter a visualization name: ', $validate); + } else { + $validate($visualizationName); + } + + $visualizationName = ucfirst($visualizationName); + + return $visualizationName; + } + + +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GitCommit.php b/www/analytics/plugins/CoreConsole/Commands/GitCommit.php new file mode 100644 index 00000000..ba28b599 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GitCommit.php @@ -0,0 +1,142 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GitCommit extends ConsoleCommand +{ + protected function configure() + { + $this->setName('git:commit') + ->setDescription('Commit') + ->addOption('message', 'm', InputOption::VALUE_REQUIRED, 'Commit Message'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $submodules = $this->getSubmodulePaths(); + + foreach ($submodules as $submodule) { + if (empty($submodule)) { + continue; + } + + $status = $this->getStatusOfSubmodule($submodule); + if (false !== strpos($status, '?? ')) { + $output->writeln(sprintf('<error>%s has untracked files or folders. Delete or add them and try again.</error>', $submodule)); + $output->writeln('<error>Status:</error>'); + $output->writeln(sprintf('<comment>%s</comment>', $status)); + return; + } + } + + $commitMessage = $input->getOption('message'); + + if (empty($commitMessage)) { + $output->writeln('No message specified. Use option -m or --message.'); + return; + } + + if (!$this->hasChangesToBeCommitted()) { + $dialog = $this->getHelperSet()->get('dialog'); + $question = '<question>There are no changes to be commited in the super repo, do you just want to commit and converge submodules?</question>'; + if (!$dialog->askConfirmation($output, $question, false)) { + $output->writeln('<info>Cool, nothing done. Stage files using "git add" and try again.</info>'); + return; + } + } + + foreach ($submodules as $submodule) { + if (empty($submodule)) { + continue; + } + + $status = $this->getStatusOfSubmodule($submodule); + if (empty($status)) { + $output->writeln(sprintf('%s has no changes, will ignore', $submodule)); + continue; + } + + $cmd = sprintf('cd %s/%s && git pull && git add . && git commit -am "%s"', PIWIK_DOCUMENT_ROOT, $submodule, $commitMessage); + $this->passthru($cmd, $output); + } + + if ($this->hasChangesToBeCommitted()) { + $cmd = sprintf('cd %s && git commit -m "%s"', PIWIK_DOCUMENT_ROOT, $commitMessage); + $this->passthru($cmd, $output); + } + + foreach ($submodules as $submodule) { + if (empty($submodule)) { + continue; + } + + $cmd = sprintf('cd %s && git add %s', PIWIK_DOCUMENT_ROOT, $submodule); + $this->passthru($cmd, $output); + } + + if ($this->hasChangesToBeCommitted()) { + $cmd = sprintf('cd %s && git commit -m "Updating submodules"', PIWIK_DOCUMENT_ROOT); + $this->passthru($cmd, $output); + } + } + + private function passthru($cmd, OutputInterface $output) + { + $output->writeln('Executing command: ' . $cmd); + passthru($cmd); + } + + private function hasChangesToBeCommitted() + { + $cmd = sprintf('cd %s && git status --porcelain', PIWIK_DOCUMENT_ROOT); + $result = shell_exec($cmd); + $result = trim($result); + + if (false !== strpos($result, 'M ')) { + // stages + return true; + } + + if (false !== strpos($result, 'MM ')) { + // staged and modified + return true; + } + + return false; + } + + /** + * @return array + */ + private function getSubmodulePaths() + { + $cmd = sprintf("grep path .gitmodules | sed 's/.*= //'"); + $submodules = shell_exec($cmd); + $submodules = explode("\n", $submodules); + + return $submodules; + } + + protected function getStatusOfSubmodule($submodule) + { + $cmd = sprintf('cd %s/%s && git status --porcelain', PIWIK_DOCUMENT_ROOT, $submodule); + $status = trim(shell_exec($cmd)); + + return $status; + } +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GitPull.php b/www/analytics/plugins/CoreConsole/Commands/GitPull.php new file mode 100644 index 00000000..8b1c07fa --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GitPull.php @@ -0,0 +1,55 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GitPull extends ConsoleCommand +{ + protected function configure() + { + $this->setName('git:pull'); + $this->setDescription('Pull Piwik repo and all submodules'); + } + + protected function getBranchName() + { + $cmd = sprintf('cd %s && git rev-parse --abbrev-ref HEAD', PIWIK_DOCUMENT_ROOT); + $branch = shell_exec($cmd); + + return trim($branch); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if ('master' != $this->getBranchName()) { + $output->writeln('<info>Doing nothing because you are not on the master branch in super repo.</info>'); + return; + } + + $cmd = sprintf('cd %s && git checkout master && git pull && git submodule update --init --recursive --remote', PIWIK_DOCUMENT_ROOT); + $this->passthru($cmd, $output); + + $cmd = 'git submodule foreach "(git checkout master; git pull)&"'; + $this->passthru($cmd, $output); + } + + private function passthru($cmd, OutputInterface $output) + { + $output->writeln('Executing command: ' . $cmd); + passthru($cmd); + } +} diff --git a/www/analytics/plugins/CoreConsole/Commands/GitPush.php b/www/analytics/plugins/CoreConsole/Commands/GitPush.php new file mode 100644 index 00000000..051f6318 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/GitPush.php @@ -0,0 +1,43 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class GitPush extends ConsoleCommand +{ + protected function configure() + { + $this->setName('git:push'); + $this->setDescription('Push Piwik repo and all submodules'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $cmd = sprintf('cd %s && git push --recurse-submodules=on-demand', PIWIK_DOCUMENT_ROOT); + $output->writeln('Executing command: ' . $cmd); + passthru($cmd); + } + + private function hasUnpushedCommits() + { + $cmd = sprintf('cd %s && git log @{u}..',PIWIK_DOCUMENT_ROOT); + $hasUnpushedCommits = shell_exec($cmd); + $hasUnpushedCommits = trim($hasUnpushedCommits); + + return !empty($hasUnpushedCommits); + } +} diff --git a/www/analytics/plugins/CoreConsole/Commands/ManagePlugin.php b/www/analytics/plugins/CoreConsole/Commands/ManagePlugin.php new file mode 100644 index 00000000..cf111284 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/ManagePlugin.php @@ -0,0 +1,68 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\Manager; +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * core:plugin console command. + */ +class ManagePlugin extends ConsoleCommand +{ + private $operations = array(); + + protected function configure() + { + $this->setName('core:plugin'); + $this->setDescription("Perform various actions regarding one or more plugins."); + $this->addArgument("operation", InputArgument::REQUIRED, "Operation to apply (can be 'activate' or 'deactivate')."); + $this->addArgument("plugins", InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Plugin name(s) to activate.'); + $this->addOption('domain', null, InputOption::VALUE_REQUIRED, "The domain to activate the plugin for."); + + $this->operations['activate'] = 'activatePlugin'; + $this->operations['deactivate'] = 'deactivatePlugin'; + } + + /** + * Execute command like: ./console cloudadmin:plugin activate CustomAlerts --piwik-domain=testcustomer.piwik.pro + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $operation = $input->getArgument("operation"); + $plugins = $input->getArgument('plugins'); + + if (empty($this->operations[$operation])) { + throw new Exception("Invalid operation '$operation'."); + } + + $fn = $this->operations[$operation]; + foreach ($plugins as $plugin) { + call_user_func(array($this, $fn), $input, $output, $plugin); + } + } + + private function activatePlugin(InputInterface $input, OutputInterface $output, $plugin) + { + Manager::getInstance()->activatePlugin($plugin, $input, $output); + + $output->writeln("Activated plugin <info>$plugin</info>"); + } + + private function deactivatePlugin(InputInterface $input, OutputInterface $output, $plugin) + { + Manager::getInstance()->deactivatePlugin($plugin, $input, $output); + + $output->writeln("Deactivated plugin <info>$plugin</info>"); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreConsole/Commands/ManageTestFiles.php b/www/analytics/plugins/CoreConsole/Commands/ManageTestFiles.php new file mode 100644 index 00000000..5f5ad9a5 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/ManageTestFiles.php @@ -0,0 +1,60 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ManageTestFiles extends ConsoleCommand +{ + protected function configure() + { + $this->setName('development:test-files'); + $this->setDescription("Manage test files."); + + $this->addArgument('operation', InputArgument::REQUIRED, 'The operation to apply. Supported operations include: ' + . '"copy"'); + $this->addOption('file', null, InputOption::VALUE_REQUIRED, "The file (or files) to apply the operation to."); + + // TODO: allow copying by regex pattern + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $operation = $input->getArgument('operation'); + + if ($operation == 'copy') { + $this->copy($input, $output); + } else { + throw new \Exception("Invalid operation '$operation'."); + } + } + + private function copy($input, $output) + { + $file = $input->getOption('file'); + + $prefix = PIWIK_INCLUDE_PATH . '/tests/PHPUnit/Integration/processed/'; + $guesses = array( + '/' . $file, + $prefix . $file, + $prefix . $file . '.xml' + ); + + foreach ($guesses as $guess) { + if (is_file($guess)) { + $file = $guess; + } + } + + copy($file, PIWIK_INCLUDE_PATH . '/tests/PHPUnit/Integration/expected/' . basename($file)); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreConsole/Commands/RunTests.php b/www/analytics/plugins/CoreConsole/Commands/RunTests.php new file mode 100644 index 00000000..0912d1fd --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/RunTests.php @@ -0,0 +1,87 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class RunTests extends ConsoleCommand +{ + protected function configure() + { + $this->setName('tests:run'); + $this->setDescription('Run Piwik PHPUnit tests one group after the other'); + $this->addArgument('group', InputArgument::OPTIONAL, 'Run only a specific test group. Separate multiple groups by comma, for instance core,integration', ''); + $this->addOption('options', 'o', InputOption::VALUE_OPTIONAL, 'All options will be forwarded to phpunit', ''); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $options = $input->getOption('options'); + $groups = $input->getArgument('group'); + + $groups = explode(",", $groups); + $groups = array_map('ucfirst', $groups); + $groups = array_filter($groups, 'strlen'); + + $command = 'phpunit'; + + // force xdebug usage for coverage options + if (false !== strpos($options, '--coverage') && !extension_loaded('xdebug')) { + + $output->writeln('<info>xdebug extension required for code coverage.</info>'); + + $output->writeln('<info>searching for xdebug extension...</info>'); + + $extensionDir = shell_exec('php-config --extension-dir'); + $xdebugFile = trim($extensionDir) . DIRECTORY_SEPARATOR . 'xdebug.so'; + + if (!file_exists($xdebugFile)) { + + $dialog = $this->getHelperSet()->get('dialog'); + + $xdebugFile = $dialog->askAndValidate($output, 'xdebug not found. Please provide path to xdebug.so', function($xdebugFile) { + return file_exists($xdebugFile); + }); + } else { + + $output->writeln('<info>xdebug extension found in extension path.</info>'); + } + + $output->writeln("<info>using $xdebugFile as xdebug extension.</info>"); + + $phpunitPath = trim(shell_exec('which phpunit')); + + $command = sprintf('php -d zend_extension=%s %s', $xdebugFile, $phpunitPath); + } + + if(empty($groups)) { + $groups = $this->getTestsGroups(); + } + foreach($groups as $group) { + $params = '--group ' . $group . ' ' . str_replace('%group%', $group, $options); + $cmd = sprintf('cd %s/tests/PHPUnit && %s %s', PIWIK_DOCUMENT_ROOT, $command, $params); + $output->writeln('Executing command: <info>' . $cmd . '</info>'); + passthru($cmd); + $output->writeln(""); + } + } + + private function getTestsGroups() + { + return array('Core', 'Plugins', 'Integration', 'UI'); + } + +} diff --git a/www/analytics/plugins/CoreConsole/Commands/RunUITests.php b/www/analytics/plugins/CoreConsole/Commands/RunUITests.php new file mode 100644 index 00000000..23ac45fe --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/RunUITests.php @@ -0,0 +1,70 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class RunUITests extends ConsoleCommand +{ + protected function configure() + { + $this->setName('tests:run-ui'); + $this->setDescription('Run screenshot tests'); + $this->addArgument('specs', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Run only a specific test spec. Separate multiple specs by comma, for instance core,integration', array()); + $this->addOption("persist-fixture-data", null, InputOption::VALUE_NONE, "Persist test data in a database and do not execute tear down."); + $this->addOption('keep-symlinks', null, InputOption::VALUE_NONE, "Keep recursive directory symlinks so test pages can be viewed in a browser."); + $this->addOption('print-logs', null, InputOption::VALUE_NONE, "Print webpage logs even if tests succeed."); + $this->addOption('drop', null, InputOption::VALUE_NONE, "Drop the existing database and re-setup a persisted fixture."); + $this->addOption('plugin', null, InputOption::VALUE_REQUIRED, "Execute all tests for a plugin."); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $specs = $input->getArgument('specs'); + $persistFixtureData = $input->getOption("persist-fixture-data"); + $keepSymlinks = $input->getOption('keep-symlinks'); + $printLogs = $input->getOption('print-logs'); + $drop = $input->getOption('drop'); + $plugin = $input->getOption('plugin'); + + $options = array(); + if ($persistFixtureData) { + $options[] = "--persist-fixture-data"; + } + + if ($keepSymlinks) { + $options[] = "--keep-symlinks"; + } + + if ($printLogs) { + $options[] = "--print-logs"; + } + + if ($drop) { + $options[] = "--drop"; + } + + if ($plugin) { + $options[] = "--plugin=" . $plugin; + } + $options = implode(" ", $options); + + $specs = implode(" ", $specs); + + $cmd = "phantomjs '" . PIWIK_INCLUDE_PATH . "/tests/lib/screenshot-testing/run-tests.js' $options $specs"; + + $output->writeln('Executing command: <info>' . $cmd . '</info>'); + $output->writeln(''); + + passthru($cmd); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreConsole/Commands/SetupFixture.php b/www/analytics/plugins/CoreConsole/Commands/SetupFixture.php new file mode 100644 index 00000000..513e828f --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/SetupFixture.php @@ -0,0 +1,156 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Url; +use Piwik\Piwik; +use Piwik\Config; +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Console commands that sets up a fixture either in a local MySQL database or a remote one. + * + * TODO: use this console command in UI tests instead of setUpDatabase.php/tearDownDatabase.php scripts + */ +class SetupFixture extends ConsoleCommand +{ + protected function configure() + { + $this->setName('tests:setup-fixture'); + $this->setDescription('Create a database and fill it with data using a Piwik test fixture.'); + + $this->addArgument('fixture', InputArgument::REQUIRED, + "The class name of the fixture to apply. Doesn't need to have a namespace if it exists in the " . + "Piwik\\Tests\\Fixtures namespace."); + + $this->addOption('db-name', null, InputOption::VALUE_REQUIRED, + "The name of the database that will contain the fixture data. This option is required to be set."); + $this->addOption('file', null, InputOption::VALUE_REQUIRED, + "The file location of the fixture. If this option is included the file will be required explicitly."); + $this->addOption('db-host', null, InputOption::VALUE_REQUIRED, + "The hostname of the MySQL database to use. Uses the default config value if not specified."); + $this->addOption('db-user', null, InputOption::VALUE_REQUIRED, + "The name of the MySQL user to use. Uses the default config value if not specified."); + $this->addOption('db-pass', null, InputOption::VALUE_REQUIRED, + "The MySQL user password to use. Uses the default config value if not specified."); + $this->addOption('teardown', null, InputOption::VALUE_NONE, + "If specified, the fixture will be torn down and the database deleted. Won't work if the --db-name " . + "option isn't supplied."); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $dbName = $input->getOption('db-name'); + if (!$dbName) { + throw new \Exception("Required argument --db-name is not set."); + } + + $this->requireFixtureFiles(); + $this->setIncludePathAsInTestBootstrap(); + + $file = $input->getOption('file'); + if ($file) { + if (is_file($file)) { + require_once $file; + } else if (is_file(PIWIK_INCLUDE_PATH . '/' . $file)) { + require_once PIWIK_INCLUDE_PATH . '/' . $file; + } else { + throw new \Exception("Cannot find --file option file '$file'."); + } + } + + $host = Url::getHost(); + if (empty($host)) { + Url::setHost('localhost'); + } + + // get the fixture class + $fixtureClass = $input->getArgument('fixture'); + if (class_exists("Piwik\\Tests\\Fixtures\\" . $fixtureClass)) { + $fixtureClass = "Piwik\\Tests\\Fixtures\\" . $fixtureClass; + } + + if (!class_exists($fixtureClass)) { + throw new \Exception("Cannot find fixture class '$fixtureClass'."); + } + + // create the fixture + $fixture = new $fixtureClass(); + $fixture->dbName = $dbName; + $fixture->printToScreen = true; + + Config::getInstance()->setTestEnvironment(); + $fixture->createConfig = false; + + // setup database overrides + $testingEnvironment = $fixture->getTestEnvironment(); + + $optionsToOverride = array( + 'dbname' => $dbName, + 'host' => $input->getOption('db-host'), + 'user' => $input->getOption('db-user'), + 'password' => $input->getOption('db-pass') + ); + foreach ($optionsToOverride as $configOption => $value) { + if ($value) { + $configOverride = $testingEnvironment->configOverride; + $configOverride['database_tests'][$configOption] = $configOverride['database'][$configOption] = $value; + $testingEnvironment->configOverride = $configOverride; + + Config::getInstance()->database[$configOption] = $value; + } + } + + // perform setup and/or teardown + if ($input->getOption('teardown')) { + $testingEnvironment->save(); + $fixture->performTearDown(); + } else { + $fixture->performSetUp(); + } + + $this->writeSuccessMessage($output, array("Fixture successfully setup!")); + } + + private function requireFixtureFiles() + { + require_once "PHPUnit/Autoload.php"; + + require_once PIWIK_INCLUDE_PATH . '/libs/PiwikTracker/PiwikTracker.php'; + require_once PIWIK_INCLUDE_PATH . '/tests/PHPUnit/FakeAccess.php'; + require_once PIWIK_INCLUDE_PATH . '/tests/PHPUnit/TestingEnvironment.php'; + require_once PIWIK_INCLUDE_PATH . '/tests/PHPUnit/Fixture.php'; + + $fixturesToLoad = array( + '/tests/PHPUnit/Fixtures/*.php', + '/tests/PHPUnit/UI/Fixtures/*.php', + ); + foreach($fixturesToLoad as $fixturePath) { + foreach (glob(PIWIK_INCLUDE_PATH . $fixturePath) as $file) { + require_once $file; + } + } + } + + private function setIncludePathAsInTestBootstrap() + { + if (!defined('PIWIK_INCLUDE_SEARCH_PATH')) { + define('PIWIK_INCLUDE_SEARCH_PATH', get_include_path() + . PATH_SEPARATOR . PIWIK_INCLUDE_PATH . '/core' + . PATH_SEPARATOR . PIWIK_INCLUDE_PATH . '/libs' + . PATH_SEPARATOR . PIWIK_INCLUDE_PATH . '/plugins'); + } + @ini_set('include_path', PIWIK_INCLUDE_SEARCH_PATH); + @set_include_path(PIWIK_INCLUDE_SEARCH_PATH); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreConsole/Commands/SyncUITestScreenshots.php b/www/analytics/plugins/CoreConsole/Commands/SyncUITestScreenshots.php new file mode 100644 index 00000000..3deb5d72 --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/SyncUITestScreenshots.php @@ -0,0 +1,81 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Http; +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class SyncUITestScreenshots extends ConsoleCommand +{ + protected function configure() + { + $this->setName('development:sync-ui-test-screenshots'); + $this->setDescription('For Piwik core devs. Copies screenshots ' + . 'from travis artifacts to tests/PHPUnit/UI/expected-ui-screenshots/'); + $this->addArgument('buildnumber', InputArgument::REQUIRED, 'Travis build number you want to sync.'); + $this->addArgument('screenshotsRegex', InputArgument::OPTIONAL, + 'A regex to use when selecting screenshots to copy. If not supplied all screenshots are copied.', '.*'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $buildNumber = $input->getArgument('buildnumber'); + $screenshotsRegex = $input->getArgument('screenshotsRegex'); + + if (empty($buildNumber)) { + throw new \InvalidArgumentException('Missing build number.'); + } + + $urlBase = sprintf('http://builds-artifacts.piwik.org/ui-tests.master/%s', $buildNumber); + $diffviewer = Http::sendHttpRequest($urlBase . "/screenshot-diffs/diffviewer.html", $timeout = 60); + + $dom = new \DOMDocument(); + $dom->loadHTML($diffviewer); + foreach ($dom->getElementsByTagName("tr") as $row) { + $columns = $row->getElementsByTagName("td"); + + $nameColumn = $columns->item(0); + $processedColumn = $columns->item(2); + + $testPlugin = null; + if ($nameColumn + && preg_match("/\(for ([a-zA-Z_]+) plugin\)/", $dom->saveXml($nameColumn), $matches) + ) { + $testPlugin = $matches[1]; + } + + $file = null; + if ($processedColumn + && preg_match("/href=\".*\/(.*)\"/", $dom->saveXml($processedColumn), $matches) + ) { + $file = $matches[1]; + } + + if ($file !== null + && preg_match("/" . $screenshotsRegex . "/", $file) + ) { + if ($testPlugin == null) { + $downloadTo = "tests/PHPUnit/UI/expected-ui-screenshots/$file"; + } else { + $downloadTo = "plugins/$testPlugin/tests/UI/expected-ui-screenshots/$file"; + } + + $output->write("<info>Downloading $file to .$downloadTo...</info>\n"); + Http::sendHttpRequest("$urlBase/processed-ui-screenshots/$file", $timeout = 60, $userAgent = null, + PIWIK_DOCUMENT_ROOT . "/" . $downloadTo); + } + } + } +} diff --git a/www/analytics/plugins/CoreConsole/Commands/WatchLog.php b/www/analytics/plugins/CoreConsole/Commands/WatchLog.php new file mode 100644 index 00000000..659ae08e --- /dev/null +++ b/www/analytics/plugins/CoreConsole/Commands/WatchLog.php @@ -0,0 +1,33 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CoreConsole\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + */ +class WatchLog extends ConsoleCommand +{ + protected function configure() + { + $this->setName('log:watch'); + $this->setDescription('Outputs the last parts of the log files and follows as the log file grows. Does not work on Windows'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $cmd = sprintf('tail -f %s/tmp/logs/*.log', PIWIK_DOCUMENT_ROOT); + + $output->writeln('Executing command: ' . $cmd); + passthru($cmd); + } +} diff --git a/www/analytics/plugins/CoreHome/Controller.php b/www/analytics/plugins/CoreHome/Controller.php new file mode 100644 index 00000000..a68b1f22 --- /dev/null +++ b/www/analytics/plugins/CoreHome/Controller.php @@ -0,0 +1,242 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreHome; + +use Exception; +use Piwik\API\Request; +use Piwik\Common; +use Piwik\Date; +use Piwik\FrontController; +use Piwik\Menu\MenuMain; +use Piwik\Notification\Manager as NotificationManager; +use Piwik\Piwik; +use Piwik\Plugins\CoreHome\DataTableRowAction\MultiRowEvolution; +use Piwik\Plugins\CoreHome\DataTableRowAction\RowEvolution; +use Piwik\Plugins\CorePluginsAdmin\MarketplaceApiClient; +use Piwik\Plugins\Dashboard\DashboardManagerControl; +use Piwik\Plugins\UsersManager\API; +use Piwik\Site; +use Piwik\UpdateCheck; +use Piwik\Url; +use Piwik\View; + +/** + * + */ +class Controller extends \Piwik\Plugin\Controller +{ + function getDefaultAction() + { + return 'redirectToCoreHomeIndex'; + } + + function redirectToCoreHomeIndex() + { + $defaultReport = API::getInstance()->getUserPreference(Piwik::getCurrentUserLogin(), API::PREFERENCE_DEFAULT_REPORT); + $module = 'CoreHome'; + $action = 'index'; + + // User preference: default report to load is the All Websites dashboard + if ($defaultReport == 'MultiSites' + && \Piwik\Plugin\Manager::getInstance()->isPluginActivated('MultiSites') + ) { + $module = 'MultiSites'; + } + if ($defaultReport == Piwik::getLoginPluginName()) { + $module = Piwik::getLoginPluginName(); + } + $idSite = Common::getRequestVar('idSite', false, 'int'); + parent::redirectToIndex($module, $action, $idSite); + } + + public function showInContext() + { + $controllerName = Common::getRequestVar('moduleToLoad'); + $actionName = Common::getRequestVar('actionToLoad', 'index'); + if ($actionName == 'showInContext') { + throw new Exception("Preventing infinite recursion..."); + } + $view = $this->getDefaultIndexView(); + $view->content = FrontController::getInstance()->fetchDispatch($controllerName, $actionName); + return $view->render(); + } + + public function markNotificationAsRead() + { + $notificationId = Common::getRequestVar('notificationId'); + NotificationManager::cancel($notificationId); + } + + protected function getDefaultIndexView() + { + $view = new View('@CoreHome/getDefaultIndexView'); + $this->setGeneralVariablesView($view); + $view->menu = MenuMain::getInstance()->getMenu(); + $view->dashboardSettingsControl = new DashboardManagerControl(); + $view->content = ''; + return $view; + } + + protected function setDateTodayIfWebsiteCreatedToday() + { + $date = Common::getRequestVar('date', false); + if ($date == 'today' + || Common::getRequestVar('period', false) == 'range' + ) { + return; + } + $websiteId = Common::getRequestVar('idSite', false, 'int'); + if ($websiteId) { + $website = new Site($websiteId); + $datetimeCreationDate = $website->getCreationDate()->getDatetime(); + $creationDateLocalTimezone = Date::factory($datetimeCreationDate, $website->getTimezone())->toString('Y-m-d'); + $todayLocalTimezone = Date::factory('now', $website->getTimezone())->toString('Y-m-d'); + if ($creationDateLocalTimezone == $todayLocalTimezone) { + Piwik::redirectToModule('CoreHome', 'index', + array('date' => 'today', + 'idSite' => $websiteId, + 'period' => Common::getRequestVar('period')) + ); + } + } + } + + public function index() + { + $this->setDateTodayIfWebsiteCreatedToday(); + $view = $this->getDefaultIndexView(); + return $view->render(); + } + + // -------------------------------------------------------- + // ROW EVOLUTION + // The following methods render the popover that shows the + // evolution of a singe or multiple rows in a data table + // -------------------------------------------------------- + + /** Render the entire row evolution popover for a single row */ + public function getRowEvolutionPopover() + { + $rowEvolution = $this->makeRowEvolution($isMulti = false); + $view = new View('@CoreHome/getRowEvolutionPopover'); + return $rowEvolution->renderPopover($this, $view); + } + + /** Render the entire row evolution popover for multiple rows */ + public function getMultiRowEvolutionPopover() + { + $rowEvolution = $this->makeRowEvolution($isMulti = true); + $view = new View('@CoreHome/getMultiRowEvolutionPopover'); + return $rowEvolution->renderPopover($this, $view); + } + + /** Generic method to get an evolution graph or a sparkline for the row evolution popover */ + public function getRowEvolutionGraph($fetch = false, $rowEvolution = null) + { + if (empty($rowEvolution)) { + $label = Common::getRequestVar('label', '', 'string'); + $isMultiRowEvolution = strpos($label, ',') !== false; + + $rowEvolution = $this->makeRowEvolution($isMultiRowEvolution, $graphType = 'graphEvolution'); + $rowEvolution->useAvailableMetrics(); + } + + $view = $rowEvolution->getRowEvolutionGraph(); + return $this->renderView($view); + } + + /** Utility function. Creates a RowEvolution instance. */ + private function makeRowEvolution($isMultiRowEvolution, $graphType = null) + { + if ($isMultiRowEvolution) { + return new MultiRowEvolution($this->idSite, $this->date, $graphType); + } else { + return new RowEvolution($this->idSite, $this->date, $graphType); + } + } + + /** + * Forces a check for updates and re-renders the header message. + * + * This will check piwik.org at most once per 10s. + */ + public function checkForUpdates() + { + Piwik::checkUserHasSomeAdminAccess(); + $this->checkTokenInUrl(); + + // perform check (but only once every 10s) + UpdateCheck::check($force = false, UpdateCheck::UI_CLICK_CHECK_INTERVAL); + + MarketplaceApiClient::clearAllCacheEntries(); + + $view = new View('@CoreHome/checkForUpdates'); + $this->setGeneralVariablesView($view); + return $view->render(); + } + + /** + * Renders and echo's the in-app donate form w/ slider. + */ + public function getDonateForm() + { + $view = new View('@CoreHome/getDonateForm'); + if (Common::getRequestVar('widget', false) + && Piwik::hasUserSuperUserAccess() + ) { + $view->footerMessage = Piwik::translate('CoreHome_OnlyForSuperUserAccess'); + } + return $view->render(); + } + + /** + * Renders and echo's HTML that displays the Piwik promo video. + */ + public function getPromoVideo() + { + $view = new View('@CoreHome/getPromoVideo'); + $view->shareText = Piwik::translate('CoreHome_SharePiwikShort'); + $view->shareTextLong = Piwik::translate('CoreHome_SharePiwikLong'); + $view->promoVideoUrl = 'http://www.youtube.com/watch?v=OslfF_EH81g'; + return $view->render(); + } + + /** + * Redirects the user to a paypal so they can donate to Piwik. + */ + public function redirectToPaypal() + { + $parameters = Request::getRequestArrayFromString($request = null); + foreach ($parameters as $name => $param) { + if ($name == 'idSite' + || $name == 'module' + || $name == 'action' + ) { + unset($parameters[$name]); + } + } + + $url = "https://www.paypal.com/cgi-bin/webscr?" . Url::getQueryStringFromParameters($parameters); + + header("Location: $url"); + exit; + } + + public function getSiteSelector() + { + return "<div piwik-siteselector class=\"sites_autocomplete\" switch-site-on-select=\"false\"></div>"; + } + + public function getPeriodSelector() + { + $view = new View("@CoreHome/_periodSelect"); + $this->setGeneralVariablesView($view); + return $view->render(); + } +} diff --git a/www/analytics/plugins/CoreHome/CoreHome.php b/www/analytics/plugins/CoreHome/CoreHome.php new file mode 100644 index 00000000..e2fd3b30 --- /dev/null +++ b/www/analytics/plugins/CoreHome/CoreHome.php @@ -0,0 +1,201 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreHome; + +use Piwik\WidgetsList; + +/** + * + */ +class CoreHome extends \Piwik\Plugin +{ + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'WidgetsList.addWidgets' => 'addWidgets', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys' + ); + } + + /** + * Adds the donate form widget. + */ + public function addWidgets() + { + WidgetsList::add('Example Widgets', 'CoreHome_SupportPiwik', 'CoreHome', 'getDonateForm'); + WidgetsList::add('Example Widgets', 'Installation_Welcome', 'CoreHome', 'getPromoVideo'); + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "libs/jquery/themes/base/jquery-ui.css"; + $stylesheets[] = "libs/jquery/stylesheets/jquery.jscrollpane.css"; + $stylesheets[] = "libs/jquery/stylesheets/scroll.less"; + $stylesheets[] = "plugins/Zeitgeist/stylesheets/base.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/coreHome.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/menu.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/dataTable.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/cloud.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/jquery.ui.autocomplete.css"; + $stylesheets[] = "plugins/CoreHome/stylesheets/jqplotColors.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/sparklineColors.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/promo.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/color_manager.css"; + $stylesheets[] = "plugins/CoreHome/stylesheets/sparklineColors.less"; + $stylesheets[] = "plugins/CoreHome/stylesheets/notification.less"; + $stylesheets[] = "plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.less"; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "libs/jquery/jquery.js"; + $jsFiles[] = "libs/jquery/jquery-ui.js"; + $jsFiles[] = "libs/jquery/jquery.browser.js"; + $jsFiles[] = "libs/jquery/jquery.truncate.js"; + $jsFiles[] = "libs/jquery/jquery.scrollTo.js"; + $jsFiles[] = "libs/jquery/jquery.history.js"; + $jsFiles[] = "libs/jquery/jquery.jscrollpane.js"; + $jsFiles[] = "libs/jquery/jquery.mousewheel.js"; + $jsFiles[] = "libs/jquery/mwheelIntent.js"; + $jsFiles[] = "libs/javascript/sprintf.js"; + $jsFiles[] = "libs/angularjs/angular.min.js"; + $jsFiles[] = "libs/angularjs/angular-sanitize.min.js"; + $jsFiles[] = "libs/angularjs/angular-animate.min.js"; + $jsFiles[] = "plugins/Zeitgeist/javascripts/piwikHelper.js"; + $jsFiles[] = "plugins/Zeitgeist/javascripts/ajaxHelper.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/require.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/uiControl.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/dataTable.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/dataTable_rowactions.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/popover.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/broadcast.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/menu.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/menu_init.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/calendar.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/sparkline.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/corehome.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/top_controls.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/donate.js"; + $jsFiles[] = "libs/jqplot/jqplot-custom.min.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/promo.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/color_manager.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/notification.js"; + $jsFiles[] = "plugins/CoreHome/javascripts/notification_parser.js"; + + $jsFiles[] = "plugins/CoreHome/angularjs/piwikAppConfig.js"; + + $jsFiles[] = "plugins/CoreHome/angularjs/common/services/service.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/services/piwik.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/services/piwik-api.js"; + + $jsFiles[] = "plugins/CoreHome/angularjs/common/filters/filter.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/filters/translate.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/filters/startfrom.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/filters/evolution.js"; + + $jsFiles[] = "plugins/CoreHome/angularjs/common/directives/directive.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/directives/autocomplete-matched.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/directives/focus-anywhere-but-here.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/directives/ignore-click.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/directives/onenter.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/directives/focusif.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/common/directives/dialog.js"; + + $jsFiles[] = "plugins/CoreHome/angularjs/piwikApp.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/anchorLinkFix.js"; + + $jsFiles[] = "plugins/CoreHome/angularjs/siteselector/siteselector-model.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/siteselector/siteselector-controller.js"; + $jsFiles[] = "plugins/CoreHome/angularjs/siteselector/siteselector-directive.js"; + + $jsFiles[] = "plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline-directive.js"; + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = 'General_InvalidDateRange'; + $translationKeys[] = 'General_Loading'; + $translationKeys[] = 'General_Show'; + $translationKeys[] = 'General_Hide'; + $translationKeys[] = 'General_YearShort'; + $translationKeys[] = 'General_MultiSitesSummary'; + $translationKeys[] = 'CoreHome_YouAreUsingTheLatestVersion'; + $translationKeys[] = 'CoreHome_IncludeRowsWithLowPopulation'; + $translationKeys[] = 'CoreHome_ExcludeRowsWithLowPopulation'; + $translationKeys[] = 'CoreHome_DataTableIncludeAggregateRows'; + $translationKeys[] = 'CoreHome_DataTableExcludeAggregateRows'; + $translationKeys[] = 'CoreHome_Default'; + $translationKeys[] = 'CoreHome_PageOf'; + $translationKeys[] = 'CoreHome_FlattenDataTable'; + $translationKeys[] = 'CoreHome_UnFlattenDataTable'; + $translationKeys[] = 'CoreHome_ExternalHelp'; + $translationKeys[] = 'SitesManager_NotFound'; + $translationKeys[] = 'Annotations_ViewAndAddAnnotations'; + $translationKeys[] = 'General_RowEvolutionRowActionTooltipTitle'; + $translationKeys[] = 'General_RowEvolutionRowActionTooltip'; + $translationKeys[] = 'Annotations_IconDesc'; + $translationKeys[] = 'Annotations_IconDescHideNotes'; + $translationKeys[] = 'Annotations_HideAnnotationsFor'; + $translationKeys[] = 'General_LoadingPopover'; + $translationKeys[] = 'General_LoadingPopoverFor'; + $translationKeys[] = 'General_ShortMonth_1'; + $translationKeys[] = 'General_ShortMonth_2'; + $translationKeys[] = 'General_ShortMonth_3'; + $translationKeys[] = 'General_ShortMonth_4'; + $translationKeys[] = 'General_ShortMonth_5'; + $translationKeys[] = 'General_ShortMonth_6'; + $translationKeys[] = 'General_ShortMonth_7'; + $translationKeys[] = 'General_ShortMonth_8'; + $translationKeys[] = 'General_ShortMonth_9'; + $translationKeys[] = 'General_ShortMonth_10'; + $translationKeys[] = 'General_ShortMonth_11'; + $translationKeys[] = 'General_ShortMonth_12'; + $translationKeys[] = 'General_LongMonth_1'; + $translationKeys[] = 'General_LongMonth_2'; + $translationKeys[] = 'General_LongMonth_3'; + $translationKeys[] = 'General_LongMonth_4'; + $translationKeys[] = 'General_LongMonth_5'; + $translationKeys[] = 'General_LongMonth_6'; + $translationKeys[] = 'General_LongMonth_7'; + $translationKeys[] = 'General_LongMonth_8'; + $translationKeys[] = 'General_LongMonth_9'; + $translationKeys[] = 'General_LongMonth_10'; + $translationKeys[] = 'General_LongMonth_11'; + $translationKeys[] = 'General_LongMonth_12'; + $translationKeys[] = 'General_ShortDay_1'; + $translationKeys[] = 'General_ShortDay_2'; + $translationKeys[] = 'General_ShortDay_3'; + $translationKeys[] = 'General_ShortDay_4'; + $translationKeys[] = 'General_ShortDay_5'; + $translationKeys[] = 'General_ShortDay_6'; + $translationKeys[] = 'General_ShortDay_7'; + $translationKeys[] = 'General_LongDay_1'; + $translationKeys[] = 'General_LongDay_2'; + $translationKeys[] = 'General_LongDay_3'; + $translationKeys[] = 'General_LongDay_4'; + $translationKeys[] = 'General_LongDay_5'; + $translationKeys[] = 'General_LongDay_6'; + $translationKeys[] = 'General_LongDay_7'; + $translationKeys[] = 'General_DayMo'; + $translationKeys[] = 'General_DayTu'; + $translationKeys[] = 'General_DayWe'; + $translationKeys[] = 'General_DayTh'; + $translationKeys[] = 'General_DayFr'; + $translationKeys[] = 'General_DaySa'; + $translationKeys[] = 'General_DaySu'; + $translationKeys[] = 'General_Search'; + $translationKeys[] = 'General_MoreDetails'; + $translationKeys[] = 'General_Help'; + } +} diff --git a/www/analytics/plugins/CoreHome/DataTableRowAction/MultiRowEvolution.php b/www/analytics/plugins/CoreHome/DataTableRowAction/MultiRowEvolution.php new file mode 100644 index 00000000..4ce804e8 --- /dev/null +++ b/www/analytics/plugins/CoreHome/DataTableRowAction/MultiRowEvolution.php @@ -0,0 +1,71 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreHome\DataTableRowAction; + +use Piwik\Common; +use Piwik\Piwik; + +/** + * MULTI ROW EVOLUTION + * The class handles the popover that shows the evolution of a multiple rows in a data table + */ +class MultiRowEvolution extends RowEvolution +{ + /** The requested metric */ + protected $metric; + + /** Show all metrics in the evolution graph when the popover opens */ + protected $initiallyShowAllMetrics = true; + + /** The metrics available in the metrics select */ + protected $metricsForSelect; + + /** + * The constructor + * @param int $idSite + * @param \Piwik\Date $date ($this->date from controller) + */ + public function __construct($idSite, $date) + { + $this->metric = Common::getRequestVar('column', '', 'string'); + parent::__construct($idSite, $date); + } + + protected function loadEvolutionReport($column = false) + { + // set the "column" parameter for the API.getRowEvolution call + parent::loadEvolutionReport($this->metric); + } + + protected function extractEvolutionReport($report) + { + $this->metric = $report['column']; + $this->dataTable = $report['reportData']; + $this->availableMetrics = $report['metadata']['metrics']; + $this->metricsForSelect = $report['metadata']['columns']; + $this->dimension = $report['metadata']['dimension']; + } + + /** + * Render the popover + * @param \Piwik\Plugins\CoreHome\Controller $controller + * @param View (the popover_rowevolution template) + */ + public function renderPopover($controller, $view) + { + // add data for metric select box + $view->availableMetrics = $this->metricsForSelect; + $view->selectedMetric = $this->metric; + + $view->availableRecordsText = $this->dimension . ': ' + . Piwik::translate('RowEvolution_ComparingRecords', array(count($this->availableMetrics))); + + return parent::renderPopover($controller, $view); + } +} diff --git a/www/analytics/plugins/CoreHome/DataTableRowAction/RowEvolution.php b/www/analytics/plugins/CoreHome/DataTableRowAction/RowEvolution.php new file mode 100644 index 00000000..28fe057c --- /dev/null +++ b/www/analytics/plugins/CoreHome/DataTableRowAction/RowEvolution.php @@ -0,0 +1,342 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreHome\DataTableRowAction; + +use Exception; +use Piwik\API\Request; +use Piwik\API\ResponseBuilder; +use Piwik\Common; +use Piwik\DataTable; +use Piwik\Date; +use Piwik\Metrics; +use Piwik\Piwik; +use Piwik\Plugins\CoreVisualizations\Visualizations\JqplotGraph\Evolution as EvolutionViz; +use Piwik\Url; +use Piwik\ViewDataTable\Factory; + +/** + * ROW EVOLUTION + * The class handles the popover that shows the evolution of a singe row in a data table + */ +class RowEvolution +{ + + /** The current site id */ + protected $idSite; + + /** The api method to get the data. Format: Plugin.apiAction */ + protected $apiMethod; + + /** The label of the requested row */ + protected $label; + + /** The requested period */ + protected $period; + + /** The requested date */ + protected $date; + + /** The request segment */ + protected $segment; + + /** The metrics that are available for the requested report and period */ + protected $availableMetrics; + + /** The name of the dimension of the current report */ + protected $dimension; + + /** + * The data + * @var \Piwik\DataTable + */ + protected $dataTable; + + /** The label of the current record */ + protected $rowLabel; + + /** The icon of the current record */ + protected $rowIcon; + + /** The type of graph that has been requested last */ + protected $graphType; + + /** The metrics for the graph that has been requested last */ + protected $graphMetrics; + + /** Whether or not to show all metrics in the evolution graph when to popover opens */ + protected $initiallyShowAllMetrics = false; + + /** + * The constructor + * Initialize some local variables from the request + * @param int $idSite + * @param Date $date ($this->date from controller) + * @param null|string $graphType + * @throws Exception + */ + public function __construct($idSite, $date, $graphType = null) + { + $this->apiMethod = Common::getRequestVar('apiMethod', '', 'string'); + if (empty($this->apiMethod)) throw new Exception("Parameter apiMethod not set."); + + $this->label = ResponseBuilder::getLabelFromRequest($_GET); + $this->label = $this->label[0]; + + if ($this->label === '') throw new Exception("Parameter label not set."); + + $this->period = Common::getRequestVar('period', '', 'string'); + if (empty($this->period)) throw new Exception("Parameter period not set."); + + $this->idSite = $idSite; + $this->graphType = $graphType; + + if ($this->period != 'range') { + // handle day, week, month and year: display last X periods + $end = $date->toString(); + list($this->date, $lastN) = EvolutionViz::getDateRangeAndLastN($this->period, $end); + } + $this->segment = \Piwik\API\Request::getRawSegmentFromRequest(); + + $this->loadEvolutionReport(); + } + + /** + * Render the popover + * @param \Piwik\Plugins\CoreHome\Controller $controller + * @param View (the popover_rowevolution template) + */ + public function renderPopover($controller, $view) + { + // render main evolution graph + $this->graphType = 'graphEvolution'; + $this->graphMetrics = $this->availableMetrics; + $view->graph = $controller->getRowEvolutionGraph($fetch = true, $rowEvolution = $this); + + // render metrics overview + $view->metrics = $this->getMetricsToggles(); + + // available metrics text + $metricsText = Piwik::translate('RowEvolution_AvailableMetrics'); + $popoverTitle = ''; + if ($this->rowLabel) { + $icon = $this->rowIcon ? '<img src="' . $this->rowIcon . '" alt="">' : ''; + $metricsText = sprintf(Piwik::translate('RowEvolution_MetricsFor'), $this->dimension . ': ' . $icon . ' ' . $this->rowLabel); + $popoverTitle = $icon . ' ' . $this->rowLabel; + } + + $view->availableMetricsText = $metricsText; + $view->popoverTitle = $popoverTitle; + + return $view->render(); + } + + protected function loadEvolutionReport($column = false) + { + list($apiModule, $apiAction) = explode('.', $this->apiMethod); + + $parameters = array( + 'method' => 'API.getRowEvolution', + 'label' => $this->label, + 'apiModule' => $apiModule, + 'apiAction' => $apiAction, + 'idSite' => $this->idSite, + 'period' => $this->period, + 'date' => $this->date, + 'format' => 'original', + 'serialize' => '0' + ); + if (!empty($this->segment)) { + $parameters['segment'] = $this->segment; + } + + if ($column !== false) { + $parameters['column'] = $column; + } + + $url = Url::getQueryStringFromParameters($parameters); + + $request = new Request($url); + $report = $request->process(); + + $this->extractEvolutionReport($report); + } + + protected function extractEvolutionReport($report) + { + $this->dataTable = $report['reportData']; + $this->rowLabel = $this->extractPrettyLabel($report); + $this->rowIcon = !empty($report['logo']) ? $report['logo'] : false; + $this->availableMetrics = $report['metadata']['metrics']; + $this->dimension = $report['metadata']['dimension']; + } + + /** + * Generic method to get an evolution graph or a sparkline for the row evolution popover. + * Do as much as possible from outside the controller. + * @param string|bool $graphType + * @param array|bool $metrics + * @return Factory + */ + public function getRowEvolutionGraph($graphType = false, $metrics = false) + { + // set up the view data table + $view = Factory::build($graphType ? : $this->graphType, $this->apiMethod, + $controllerAction = 'CoreHome.getRowEvolutionGraph', $forceDefault = true); + $view->setDataTable($this->dataTable); + + if (!empty($this->graphMetrics)) { // In row Evolution popover, this is empty + $view->config->columns_to_display = array_keys($metrics ? : $this->graphMetrics); + } + + $view->config->show_goals = false; + $view->config->show_all_views_icons = false; + $view->config->show_active_view_icon = false; + $view->config->show_related_reports = false; + $view->config->show_series_picker = false; + $view->config->show_footer_message = false; + + foreach ($this->availableMetrics as $metric => $metadata) { + $view->config->translations[$metric] = $metadata['name']; + } + + $view->config->external_series_toggle = 'RowEvolutionSeriesToggle'; + $view->config->external_series_toggle_show_all = $this->initiallyShowAllMetrics; + + return $view; + } + + /** + * Prepare metrics toggles with spark lines + * @return array + */ + protected function getMetricsToggles() + { + $i = 0; + $metrics = array(); + foreach ($this->availableMetrics as $metric => $metricData) { + $unit = Metrics::getUnit($metric, $this->idSite); + $change = isset($metricData['change']) ? $metricData['change'] : false; + + list($first, $last) = $this->getFirstAndLastDataPointsForMetric($metric); + $details = Piwik::translate('RowEvolution_MetricBetweenText', array($first, $last)); + + if ($change !== false) { + $lowerIsBetter = Metrics::isLowerValueBetter($metric); + if (substr($change, 0, 1) == '+') { + $changeClass = $lowerIsBetter ? 'bad' : 'good'; + $changeImage = $lowerIsBetter ? 'arrow_up_red' : 'arrow_up'; + } else if (substr($change, 0, 1) == '-') { + $changeClass = $lowerIsBetter ? 'good' : 'bad'; + $changeImage = $lowerIsBetter ? 'arrow_down_green' : 'arrow_down'; + } else { + $changeClass = 'neutral'; + $changeImage = false; + } + + $change = '<span class="' . $changeClass . '">' + . ($changeImage ? '<img src="plugins/MultiSites/images/' . $changeImage . '.png" /> ' : '') + . $change . '</span>'; + + $details .= ', ' . Piwik::translate('RowEvolution_MetricChangeText', $change); + } + + // set metric min/max text (used as tooltip for details) + $max = isset($metricData['max']) ? $metricData['max'] : 0; + $min = isset($metricData['min']) ? $metricData['min'] : 0; + $min .= $unit; + $max .= $unit; + $minmax = Piwik::translate('RowEvolution_MetricMinMax', array($metricData['name'], $min, $max)); + + $newMetric = array( + 'label' => $metricData['name'], + 'details' => $details, + 'minmax' => $minmax, + 'sparkline' => $this->getSparkline($metric), + ); + // Multi Rows, each metric can be for a particular row and display an icon + if (!empty($metricData['logo'])) { + $newMetric['logo'] = $metricData['logo']; + } + $metrics[] = $newMetric; + $i++; + } + + return $metrics; + } + + /** Get the img tag for a sparkline showing a single metric */ + protected function getSparkline($metric) + { + // sparkline is always echoed, so we need to buffer the output + $view = $this->getRowEvolutionGraph($graphType = 'sparkline', $metrics = array($metric => $metric)); + + ob_start(); + $view->render(); + $spark = ob_get_contents(); + ob_end_clean(); + + // undo header change by sparkline renderer + header('Content-type: text/html'); + + // base64 encode the image and put it in an img tag + $spark = base64_encode($spark); + return '<img src="data:image/png;base64,' . $spark . '" />'; + } + + /** Use the available metrics for the metrics of the last requested graph. */ + public function useAvailableMetrics() + { + $this->graphMetrics = $this->availableMetrics; + } + + private function getFirstAndLastDataPointsForMetric($metric) + { + $first = 0; + $firstTable = $this->dataTable->getFirstRow(); + if (!empty($firstTable)) { + $row = $firstTable->getFirstRow(); + if (!empty($row)) { + $first = floatval($row->getColumn($metric)); + } + } + + $last = 0; + $lastTable = $this->dataTable->getLastRow(); + if (!empty($lastTable)) { + $row = $lastTable->getFirstRow(); + if (!empty($row)) { + $last = floatval($row->getColumn($metric)); + } + } + + return array($first, $last); + } + + /** + * @param $report + * @return string + */ + protected function extractPrettyLabel($report) + { + // By default, use the specified label + $rowLabel = Common::sanitizeInputValue($report['label']); + $rowLabel = str_replace('/', '<wbr>/', str_replace('&', '<wbr>&', $rowLabel )); + + // If the dataTable specifies a label_html, use this instead + /** @var $dataTableMap \Piwik\DataTable\Map */ + $dataTableMap = $report['reportData']; + $labelPretty = $dataTableMap->getColumn('label_html'); + $labelPretty = array_filter($labelPretty, 'strlen'); + $labelPretty = current($labelPretty); + if(!empty($labelPretty)) { + return $labelPretty; + } + return $rowLabel; + } +} diff --git a/www/analytics/plugins/CoreHome/angularjs/anchorLinkFix.js b/www/analytics/plugins/CoreHome/angularjs/anchorLinkFix.js new file mode 100644 index 00000000..cc82c1aa --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/anchorLinkFix.js @@ -0,0 +1,108 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * See http://dev.piwik.org/trac/ticket/4795 "linking to #hash tag does not work after merging AngularJS" + */ +(function () { + + function scrollToAnchorNode($node) + { + $.scrollTo($node, 20); + } + + function preventDefaultIfEventExists(event) + { + if (event) { + event.preventDefault(); + } + } + + function scrollToAnchorIfPossible(hash, event) + { + if (!hash) { + return; + } + + if (-1 !== hash.indexOf('&')) { + return; + } + + var $node = $('#' + hash); + + if ($node && $node.length) { + scrollToAnchorNode($node); + preventDefaultIfEventExists(event); + return; + } + + $node = $('a[name='+ hash + ']'); + + if ($node && $node.length) { + scrollToAnchorNode($node); + preventDefaultIfEventExists(event); + } + } + + function isLinkWithinSamePage(location, newUrl) + { + if (location && location.origin && -1 === newUrl.indexOf(location.origin)) { + // link to different domain + return false; + } + + if (location && location.pathname && -1 === newUrl.indexOf(location.pathname)) { + // link to different path + return false; + } + + if (location && location.search && -1 === newUrl.indexOf(location.search)) { + // link with different search + return false; + } + + return true; + } + + function handleScrollToAnchorIfPresentOnPageLoad() + { + if (location.hash.substr(0, 2) == '#/') { + var hash = location.hash.substr(2); + scrollToAnchorIfPossible(hash, null); + } + } + + function handleScrollToAnchorAfterPageLoad() + { + angular.module('piwikApp').run(['$rootScope', function ($rootScope) { + + $rootScope.$on('$locationChangeStart', function (event, newUrl, oldUrl, $location) { + + if (!newUrl) { + return; + } + + var hashPos = newUrl.indexOf('#/'); + if (-1 === hashPos) { + return; + } + + if (!isLinkWithinSamePage(this.location, newUrl)) { + return; + } + + var hash = newUrl.substr(hashPos + 2); + + scrollToAnchorIfPossible(hash, event); + }); + }]); + } + + handleScrollToAnchorAfterPageLoad(); + $(handleScrollToAnchorIfPresentOnPageLoad); + +})(); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/directives/autocomplete-matched.js b/www/analytics/plugins/CoreHome/angularjs/common/directives/autocomplete-matched.js new file mode 100644 index 00000000..34829334 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/directives/autocomplete-matched.js @@ -0,0 +1,42 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * If the given text or resolved expression matches any text within the element, the matching text will be wrapped + * with a class. + * + * Example: + * <div piwik-autocomplete-matched="'text'">My text</div> ==> <div>My <span class="autocompleteMatched">text</span></div> + * + * <div piwik-autocomplete-matched="searchTerm">{{ name }}</div> + * <input type="text" ng-model="searchTerm"> + */ +angular.module('piwikApp.directive').directive('piwikAutocompleteMatched', function() { + return function(scope, element, attrs) { + var searchTerm; + + scope.$watch(attrs.piwikAutocompleteMatched, function(value) { + searchTerm = value; + updateText(); + }); + + function updateText () { + if (!searchTerm || !element) { + return; + } + + var content = element.html(); + var startTerm = content.toLowerCase().indexOf(searchTerm.toLowerCase()); + + if (-1 !== startTerm) { + var word = content.substr(startTerm, searchTerm.length); + content = content.replace(word, '<span class="autocompleteMatched">' + word + '</span>'); + element.html(content); + } + } + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/directives/autocomplete-matched_spec.js b/www/analytics/plugins/CoreHome/angularjs/common/directives/autocomplete-matched_spec.js new file mode 100644 index 00000000..2518687b --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/directives/autocomplete-matched_spec.js @@ -0,0 +1,43 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +describe('piwikAutocompleteMatchedDirective', function() { + var $compile; + var $rootScope; + + beforeEach(module('piwikApp.directive')); + beforeEach(inject(function(_$compile_, _$rootScope_){ + $compile = _$compile_; + $rootScope = _$rootScope_; + })); + + function assertRenderedContentIs(query, expectedResult) { + var template = '<div piwik-autocomplete-matched="\'' + query + '\'">My Content</div>'; + var element = $compile(template)($rootScope); + $rootScope.$digest(); + expect(element.html()).to.eql(expectedResult); + } + + describe('#piwikAutocompleteMatched()', function() { + + it('should not change anything if query does not match the text', function() { + assertRenderedContentIs('Whatever', 'My Content'); + }); + + it('should wrap the matching part and find case insensitive', function() { + assertRenderedContentIs('y cont', 'M<span class="autocompleteMatched">y Cont</span>ent'); + }); + + it('should be able to wrap the whole content', function() { + assertRenderedContentIs('my content', '<span class="autocompleteMatched">My Content</span>'); + }); + + it('should find matching content case sensitive', function() { + assertRenderedContentIs('My Co', '<span class="autocompleteMatched">My Co</span>ntent'); + }); + }); +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/directives/dialog.js b/www/analytics/plugins/CoreHome/angularjs/common/directives/dialog.js new file mode 100644 index 00000000..614c2794 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/directives/dialog.js @@ -0,0 +1,41 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Usage: + * <div piwik-dialog="showDialog">...</div> + * Will show dialog once showDialog evaluates to true. + * + * <div piwik-dialog="showDialog" yes="executeMyFunction();"> + * ... <input type="button" role="yes" value="button"> + * </div> + * Will execute the "executeMyFunction" function in the current scope once the yes button is pressed. + */ +angular.module('piwikApp.directive').directive('piwikDialog', function(piwik) { + + return { + restrict: 'A', + link: function(scope, element, attrs) { + + element.css('display', 'none'); + + element.on( "dialogclose", function() { + scope.$eval(attrs.piwikDialog+'=false'); + }); + + scope.$watch(attrs.piwikDialog, function(newValue, oldValue) { + if (newValue) { + piwik.helper.modalConfirm(element, {yes: function() { + if (attrs.yes) { + scope.$eval(attrs.yes); + } + }}); + } + }); + } + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/directives/directive.js b/www/analytics/plugins/CoreHome/angularjs/common/directives/directive.js new file mode 100644 index 00000000..29c7d212 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/directives/directive.js @@ -0,0 +1,8 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp.directive', []); diff --git a/www/analytics/plugins/CoreHome/angularjs/common/directives/focus-anywhere-but-here.js b/www/analytics/plugins/CoreHome/angularjs/common/directives/focus-anywhere-but-here.js new file mode 100644 index 00000000..23f9009c --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/directives/focus-anywhere-but-here.js @@ -0,0 +1,40 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * The given expression will be executed when the user presses either escape or presses something outside + * of this element + * + * Example: + * <div piwik-focus-anywhere-but-here="closeDialog()">my dialog</div> + */ +angular.module('piwikApp.directive').directive('piwikFocusAnywhereButHere', function($document){ + return { + restrict: 'A', + link: function(scope, element, attr, ctrl) { + + function onClickOutsideElement (event) { + if (element.has(event.target).length === 0) { + scope.$apply(attr.piwikFocusAnywhereButHere); + } + } + + function onEscapeHandler (event) { + if (event.which === 27) { + scope.$apply(attr.piwikFocusAnywhereButHere); + } + } + + $document.on('keyup', onEscapeHandler); + $document.on('mouseup', onClickOutsideElement); + scope.$on('$destroy', function() { + $document.off('mouseup', onClickOutsideElement); + $document.off('keyup', onEscapeHandler); + }); + } + }; +}); diff --git a/www/analytics/plugins/CoreHome/angularjs/common/directives/focusif.js b/www/analytics/plugins/CoreHome/angularjs/common/directives/focusif.js new file mode 100644 index 00000000..e771423c --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/directives/focusif.js @@ -0,0 +1,27 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * If the given expression evaluates to true the element will be focussed + * + * Example: + * <input type="text" piwik-focus-if="view.editName"> + */ +angular.module('piwikApp.directive').directive('piwikFocusIf', function($timeout) { + return { + restrict: 'A', + link: function(scope, element, attrs) { + scope.$watch(attrs.piwikFocusIf, function(newValue, oldValue) { + if (newValue) { + $timeout(function () { + element[0].focus(); + }, 5); + } + }); + } + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/directives/ignore-click.js b/www/analytics/plugins/CoreHome/angularjs/common/directives/ignore-click.js new file mode 100644 index 00000000..b0b52947 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/directives/ignore-click.js @@ -0,0 +1,21 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Prevents the default behavior of the click. For instance useful if a link should only work in case the user + * does a "right click open in new window". + * + * Example + * <a piwik-ignore-click ng-click="doSomething()" href="/">my link</a> + */ +angular.module('piwikApp.directive').directive('piwikIgnoreClick', function() { + return function(scope, element, attrs) { + $(element).click(function(event) { + event.preventDefault(); + }); + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/directives/onenter.js b/www/analytics/plugins/CoreHome/angularjs/common/directives/onenter.js new file mode 100644 index 00000000..50499e0f --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/directives/onenter.js @@ -0,0 +1,27 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Allows you to define any expression to be executed in case the user presses enter + * + * Example + * <div piwik-onenter="save()"> + * <div piwik-onenter="showList=false"> + */ +angular.module('piwikApp.directive').directive('piwikOnenter', function() { + return function(scope, element, attrs) { + element.bind("keydown keypress", function(event) { + if(event.which === 13) { + scope.$apply(function(){ + scope.$eval(attrs.piwikOnenter, {'event': event}); + }); + + event.preventDefault(); + } + }); + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/filters/evolution.js b/www/analytics/plugins/CoreHome/angularjs/common/filters/evolution.js new file mode 100644 index 00000000..9cf832bc --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/filters/evolution.js @@ -0,0 +1,44 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp.filter').filter('evolution', function() { + + function calculateEvolution(currentValue, pastValue) + { + pastValue = parseInt(pastValue, 10); + currentValue = parseInt(currentValue, 10) - pastValue; + + if (currentValue === 0 || isNaN(currentValue)) { + evolution = 0; + } else if (pastValue === 0 || isNaN(pastValue)) { + evolution = 100; + } else { + evolution = (currentValue / pastValue) * 100; + } + + return evolution; + } + + function formatEvolution(evolution) + { + evolution = Math.round(evolution); + + if (evolution > 0) { + evolution = '+' + evolution; + } + + evolution += '%'; + + return evolution; + } + + return function(currentValue, pastValue) { + var evolution = calculateEvolution(currentValue, pastValue); + + return formatEvolution(evolution); + }; +}); diff --git a/www/analytics/plugins/CoreHome/angularjs/common/filters/filter.js b/www/analytics/plugins/CoreHome/angularjs/common/filters/filter.js new file mode 100644 index 00000000..833dfa2c --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/filters/filter.js @@ -0,0 +1,7 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +angular.module('piwikApp.filter', []); diff --git a/www/analytics/plugins/CoreHome/angularjs/common/filters/startfrom.js b/www/analytics/plugins/CoreHome/angularjs/common/filters/startfrom.js new file mode 100644 index 00000000..c0175e4e --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/filters/startfrom.js @@ -0,0 +1,13 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp.filter').filter('startFrom', function() { + return function(input, start) { + start = +start; //parse to int + return input.slice(start); + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/filters/startfrom_spec.js b/www/analytics/plugins/CoreHome/angularjs/common/filters/startfrom_spec.js new file mode 100644 index 00000000..d18c99e7 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/filters/startfrom_spec.js @@ -0,0 +1,40 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +describe('startFromFilter', function() { + var startFrom; + + beforeEach(module('piwikApp.filter')); + beforeEach(inject(function($injector) { + var $filter = $injector.get('$filter'); + startFrom = $filter('startFrom'); + })); + + describe('#startFrom()', function() { + + it('should return all entries if index is zero', function() { + + var result = startFrom([1,2,3], 0); + + expect(result).to.eql([1,2,3]); + }); + + it('should return only partial entries if filter is higher than zero', function() { + + var result = startFrom([1,2,3], 2); + + expect(result).to.eql([3]); + }); + + it('should return no entries if start is higher than input length', function() { + + var result = startFrom([1,2,3], 11); + + expect(result).to.eql([]); + }); + }); +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/filters/translate.js b/www/analytics/plugins/CoreHome/angularjs/common/filters/translate.js new file mode 100644 index 00000000..164fdb5a --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/filters/translate.js @@ -0,0 +1,19 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp.filter').filter('translate', function() { + + return function(key, value1, value2, value3) { + var values = []; + if (arguments && arguments.length > 1) { + for (var index = 1; index < arguments.length; index++) { + values.push(arguments[index]); + } + } + return _pk_translate(key, values); + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/services/piwik-api.js b/www/analytics/plugins/CoreHome/angularjs/common/services/piwik-api.js new file mode 100644 index 00000000..d6e52746 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/services/piwik-api.js @@ -0,0 +1,186 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp.service').factory('piwikApi', function ($http, $q, $rootScope, piwik, $window) { + + var url = 'index.php'; + var format = 'json'; + var getParams = {}; + var postParams = {}; + var requestHandle = null; + + var piwikApi = {}; + + /** + * Adds params to the request. + * If params are given more then once, the latest given value is used for the request + * + * @param {object} params + * @return {void} + */ + function addParams (params) { + if (typeof params == 'string') { + params = piwik.broadcast.getValuesFromUrl(params); + } + + for (var key in params) { + getParams[key] = params[key]; + } + } + + function reset () { + getParams = {}; + postParams = {}; + } + + /** + * Send the request + * @return $promise + */ + function send () { + + var deferred = $q.defer(); + var requestHandle = deferred; + + var onError = function (message) { + deferred.reject(message); + requestHandle = null; + }; + + var onSuccess = function (response) { + if (response && response.result == 'error') { + + if (response.message) { + onError(response.message); + + var UI = require('piwik/UI'); + var notification = new UI.Notification(); + notification.show(response.message, { + context: 'error', + type: 'toast', + id: 'ajaxHelper' + }); + notification.scrollToNotification(); + } else { + onError(null); + } + + } else { + deferred.resolve(response); + } + requestHandle = null; + }; + + var headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + // ie 8,9,10 caches ajax requests, prevent this + 'cache-control': 'no-cache' + }; + + var ajaxCall = { + method: 'POST', + url: url, + responseType: format, + params: _mixinDefaultGetParams(getParams), + data: $.param(getPostParams(postParams)), + timeout: deferred.promise, + headers: headers + }; + + $http(ajaxCall).success(onSuccess).error(onError); + + return deferred.promise; + } + + /** + * Get the parameters to send as POST + * + * @param {object} params parameter object + * @return {object} + * @private + */ + function getPostParams () { + return { + token_auth: piwik.token_auth + }; + } + + /** + * Mixin the default parameters to send as GET + * + * @param {object} getParamsToMixin parameter object + * @return {object} + * @private + */ + function _mixinDefaultGetParams (getParamsToMixin) { + + var defaultParams = { + idSite: piwik.idSite || piwik.broadcast.getValueFromUrl('idSite'), + period: piwik.period || piwik.broadcast.getValueFromUrl('period'), + segment: piwik.broadcast.getValueFromHash('segment', $window.location.href.split('#')[1]) + }; + + // never append token_auth to url + if (getParamsToMixin.token_auth) { + getParamsToMixin.token_auth = null; + delete getParamsToMixin.token_auth; + } + + for (var key in defaultParams) { + if (!getParamsToMixin[key] && !postParams[key] && defaultParams[key]) { + getParamsToMixin[key] = defaultParams[key]; + } + } + + // handle default date & period if not already set + if (!getParamsToMixin.date && !postParams.date) { + getParamsToMixin.date = piwik.currentDateString || piwik.broadcast.getValueFromUrl('date'); + if (getParamsToMixin.period == 'range' && piwik.currentDateString) { + getParamsToMixin.date = piwik.startDateString + ',' + getParamsToMixin.date; + } + } + + return getParamsToMixin; + } + + piwikApi.abort = function () { + reset(); + + if (requestHandle) { + requestHandle.resolve(); + requestHandle = null; + } + }; + + /** + * Perform a reading API request. + * @param getParams + */ + piwikApi.fetch = function (getParams) { + + getParams.module = 'API'; + getParams.format = 'JSON'; + + addParams(getParams, 'GET'); + + var promise = send(); + + reset(); + + return promise; + }; + + piwikApi.post = function (getParams, _postParams_) { + if (_postParams_) { + postParams = _postParams_; + } + + return this.fetch(getParams); + }; + + return piwikApi; +}); diff --git a/www/analytics/plugins/CoreHome/angularjs/common/services/piwik.js b/www/analytics/plugins/CoreHome/angularjs/common/services/piwik.js new file mode 100644 index 00000000..15453029 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/services/piwik.js @@ -0,0 +1,13 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp.service').service('piwik', function () { + + piwik.helper = piwikHelper; + piwik.broadcast = broadcast; + return piwik; +}); diff --git a/www/analytics/plugins/CoreHome/angularjs/common/services/piwik_spec.js b/www/analytics/plugins/CoreHome/angularjs/common/services/piwik_spec.js new file mode 100644 index 00000000..7aa4ef4e --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/services/piwik_spec.js @@ -0,0 +1,37 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +describe('piwikService', function() { + var piwikService; + + beforeEach(module('piwikApp.service')); + beforeEach(inject(function($injector) { + piwikService = $injector.get('piwik'); + })); + + describe('#piwikService', function() { + + it('should be the same as piwik global var', function() { + piwik.should.equal(piwikService); + }); + + it('should mixin broadcast', function() { + expect(piwikService.broadcast).to.be.an('object'); + }); + + it('should mixin piwikHelper', function() { + expect(piwikService.helper).to.be.an('object'); + }); + }); + + describe('#piwik_url', function() { + + it('should contain the piwik url', function() { + expect(piwikService.piwik_url).to.eql('http://localhost/'); + }); + }); +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/common/services/service.js b/www/analytics/plugins/CoreHome/angularjs/common/services/service.js new file mode 100644 index 00000000..fc63a00c --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/common/services/service.js @@ -0,0 +1,8 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp.service', []); diff --git a/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline-directive.js b/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline-directive.js new file mode 100644 index 00000000..6a809cd7 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline-directive.js @@ -0,0 +1,66 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Usage: + * + * <h2 piwik-enriched-headline>All Websites Dashboard</h2> + * -> uses "All Websites Dashboard" as featurename + * + * <h2 piwik-enriched-headline feature-name="All Websites Dashboard">All Websites Dashboard (Total: 309 Visits)</h2> + * -> custom featurename + * + * <h2 piwik-enriched-headline help-url="http://piwik.org/guide">All Websites Dashboard</h2> + * -> shows help icon and links to external url + * + * <h2 piwik-enriched-headline>All Websites Dashboard + * <div class="inlineHelp>My <strong>inline help</strong></div> + * </h2> + * -> shows help icon to display inline help on click. Note: You can combine inlinehelp and help-url + */ +angular.module('piwikApp').directive('piwikEnrichedHeadline', function($document, piwik, $filter){ + var defaults = { + helpUrl: '' + }; + + return { + transclude: true, + restrict: 'A', + scope: { + helpUrl: '@', + featureName: '@' + }, + templateUrl: 'plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.html?cb=' + piwik.cacheBuster, + compile: function (element, attrs) { + + for (var index in defaults) { + if (!attrs[index]) { attrs[index] = defaults[index]; } + } + + return function (scope, element, attrs) { + + var helpNode = $('[ng-transclude] .inlineHelp', element); + + if ((!helpNode || !helpNode.length) && element.next()) { + // hack for reports :( + helpNode = element.next().find('.reportDocumentation'); + } + + if (helpNode && helpNode.length) { + if ($.trim(helpNode.text())) { + scope.inlineHelp = $.trim(helpNode.html()); + } + helpNode.remove(); + } + + if (!attrs.featureName) { + attrs.featureName = $.trim(element.text()); + } + }; + } + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.html b/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.html new file mode 100644 index 00000000..53f04eca --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.html @@ -0,0 +1,29 @@ +<div class="enrichedHeadline" + ng-mouseenter="view.showIcons=true" ng-mouseleave="view.showIcons=false"> + <span ng-transclude></span> + + <span ng-show="view.showIcons"> + <a ng-if="helpUrl && !inlineHelp" + target="_blank" + href="{{ helpUrl }}" + title="{{ 'CoreHome_ExternalHelp'|translate }}" + class="helpIcon"></a> + + <a ng-if="inlineHelp" + title="{{ 'General_Help'|translate }}" + ng-click="view.showInlineHelp=!view.showInlineHelp" + class="helpIcon"></a> + + <div class="ratingIcons" + piwik-rate-feature + title="{{ featureName }}"></div> + </span> + + <div class="inlineHelp" ng-show="view.showIcons && view.showInlineHelp"> + <div ng-bind-html="inlineHelp"></div> + <a ng-if="helpUrl" + target="_blank" + href="{{ helpUrl }}" + class="readMore">{{ 'General_MoreDetails'|translate }}</a> + </div> +</div> \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.less b/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.less new file mode 100644 index 00000000..6a4ab613 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/enrichedheadline.less @@ -0,0 +1,44 @@ +.inlineHelp { + display: none; +} + +.enrichedHeadline { + min-height: 22px; + + .inlineHelp { + display:block; + background: #F7F7F7; + font-size: 12px; + font-weight: normal; + border: 1px solid #E4E5E4; + margin: 10px 0 10px 0; + padding: 10px; + border-radius: 4px; + max-width: 500px; + + .readMore { + margin-top: 10px; + display: inline-block; + font-weight: bold; + } + } + + .ratingIcons { + display:inline-block; + vertical-align: bottom; + } + + .helpIcon:hover { + opacity: 0.9; + } + + .helpIcon { + cursor: pointer; + display:inline-block; + margin: 0px 0px -1px 4px; + width: 16px; + opacity: 0.3; + height: 16px; + background: url(plugins/CoreHome/angularjs/enrichedheadline/help.png) no-repeat; + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/help.png b/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/help.png new file mode 100644 index 00000000..c5174cdf Binary files /dev/null and b/www/analytics/plugins/CoreHome/angularjs/enrichedheadline/help.png differ diff --git a/www/analytics/plugins/CoreHome/angularjs/piwikApp.js b/www/analytics/plugins/CoreHome/angularjs/piwikApp.js new file mode 100644 index 00000000..a678168f --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/piwikApp.js @@ -0,0 +1,16 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp', [ + 'ngSanitize', + 'ngAnimate', + 'piwikApp.config', + 'piwikApp.service', + 'piwikApp.directive', + 'piwikApp.filter' +]); +angular.module('app', []); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/piwikAppConfig.js b/www/analytics/plugins/CoreHome/angularjs/piwikAppConfig.js new file mode 100644 index 00000000..17048e47 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/piwikAppConfig.js @@ -0,0 +1,9 @@ +angular.module('piwikApp.config', []); + +(function () { + var piwikAppConfig = angular.module('piwikApp.config'); + // we probably want this later as a separate config file, till then it serves as a "bridge" + for (var index in piwik.config) { + piwikAppConfig.constant(index.toUpperCase(), piwik.config[index]); + } +})(); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-controller.js b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-controller.js new file mode 100644 index 00000000..d93c0f92 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-controller.js @@ -0,0 +1,49 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp').controller('SiteSelectorController', function($scope, siteSelectorModel, piwik, AUTOCOMPLETE_MIN_SITES){ + + $scope.model = siteSelectorModel; + + $scope.autocompleteMinSites = AUTOCOMPLETE_MIN_SITES; + $scope.selectedSite = {id: '', name: ''}; + $scope.activeSiteId = piwik.idSite; + + $scope.switchSite = function (site) { + $scope.selectedSite.id = site.idsite; + + if (site.name === $scope.allSitesText) { + $scope.selectedSite.name = $scope.allSitesText; + } else { + $scope.selectedSite.name = site.name.replace(/[\u0000-\u2666]/g, function(c) { + return '&#'+c.charCodeAt(0)+';'; + }); + } + + if (!$scope.switchSiteOnSelect || $scope.activeSiteId == site.idsite) { + return; + } + + if (site.idsite == 'all') { + piwik.broadcast.propagateNewPage('module=MultiSites&action=index'); + } else { + piwik.broadcast.propagateNewPage('segment=&idSite=' + site.idsite, false); + } + }; + + $scope.getUrlAllSites = function () { + var newParameters = 'module=MultiSites&action=index'; + return piwik.helper.getCurrentQueryStringWithParametersModified(newParameters); + }; + $scope.getUrlForSiteId = function (idSite) { + var idSiteParam = 'idSite=' + idSite; + var newParameters = 'segment=&' + idSiteParam; + var hash = piwik.broadcast.isHashExists() ? piwik.broadcast.getHashFromUrl() : ""; + return piwik.helper.getCurrentQueryStringWithParametersModified(newParameters) + + '#' + piwik.helper.getQueryStringWithParametersModified(hash.substring(1), newParameters); + }; +}); diff --git a/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-directive.js b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-directive.js new file mode 100644 index 00000000..e7a1f029 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-directive.js @@ -0,0 +1,80 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Usage: + * <div piwik-siteselector> + * + * More advanced example + * <div piwik-siteselector + * show-selected-site="true" show-all-sites-item="true" switch-site-on-select="true" + * all-sites-location="top|bottom" all-sites-text="test" show-selected-site="true" + * show-all-sites-item="true"> + * + * Within a form + * <div piwik-siteselector input-name="siteId"> + * + * Events: + * Triggers a `change` event on any change + * <div piwik-siteselector id="mySelector"> + * $('#mySelector').on('change', function (event) { event.id/event.name }) + */ +angular.module('piwikApp').directive('piwikSiteselector', function($document, piwik, $filter){ + var defaults = { + name: '', + siteid: piwik.idSite, + sitename: piwik.siteName, + allSitesLocation: 'bottom', + allSitesText: $filter('translate')('General_MultiSitesSummary'), + showSelectedSite: 'false', + showAllSitesItem: 'true', + switchSiteOnSelect: 'true' + }; + + return { + restrict: 'A', + scope: { + showSelectedSite: '=', + showAllSitesItem: '=', + switchSiteOnSelect: '=', + inputName: '@name', + allSitesText: '@', + allSitesLocation: '@' + }, + templateUrl: 'plugins/CoreHome/angularjs/siteselector/siteselector.html?cb=' + piwik.cacheBuster, + controller: 'SiteSelectorController', + compile: function (element, attrs) { + + for (var index in defaults) { + if (!attrs[index]) { attrs[index] = defaults[index]; } + } + + return function (scope, element, attrs) { + + // selectedSite.id|.name + model is hard-coded but actually the directive should not know about this + scope.selectedSite.id = attrs.siteid; + scope.selectedSite.name = attrs.sitename; + + if (!attrs.siteid || !attrs.sitename) { + scope.model.loadInitialSites(); + } + + scope.$watch('selectedSite.id', function (newValue, oldValue, scope) { + if (newValue != oldValue) { + element.attr('siteid', newValue); + element.trigger('change', scope.selectedSite); + } + }); + + /** use observe to monitor attribute changes + attrs.$observe('maxsitenamewidth', function(val) { + // for instance trigger a function or whatever + }) */ + }; + } + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-model.js b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-model.js new file mode 100644 index 00000000..15db8e07 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector-model.js @@ -0,0 +1,75 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp').factory('siteSelectorModel', function (piwikApi, $filter) { + + var model = {}; + model.sites = []; + model.hasMultipleWebsites = false; + model.isLoading = false; + model.firstSiteName = ''; + + var initialSites = null; + + model.updateWebsitesList = function (sites) { + + if (!sites || !sites.length) { + model.sites = []; + return []; + } + + angular.forEach(sites, function (site) { + if (site.group) site.name = '[' + site.group + '] ' + site.name; + }); + + model.sites = $filter('orderBy')(sites, '+name'); + + if (!model.firstSiteName) { + model.firstSiteName = model.sites[0].name; + } + + model.hasMultipleWebsites = model.hasMultipleWebsites || sites.length > 1; + + return model.sites; + }; + + model.searchSite = function (term) { + + if (!term) { + model.loadInitialSites(); + return; + } + + if (model.isLoading) { + piwikApi.abort(); + } + + model.isLoading = true; + + return piwikApi.fetch({ + method: 'SitesManager.getPatternMatchSites', + pattern: term + }).then(function (response) { + return model.updateWebsitesList(response); + })['finally'](function () { // .finally() is not IE8 compatible see https://github.com/angular/angular.js/commit/f078762d48d0d5d9796dcdf2cb0241198677582c + model.isLoading = false; + }); + }; + + model.loadInitialSites = function () { + if (initialSites) { + model.sites = initialSites; + return; + } + + this.searchSite('%').then(function (websites) { + initialSites = websites; + }); + }; + + return model; +}); diff --git a/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector.html b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector.html new file mode 100644 index 00000000..d968dd9e --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector.html @@ -0,0 +1,61 @@ +<div piwik-focus-anywhere-but-here="view.showSitesList=false" class="custom_select" + ng-class="{'sites_autocomplete--dropdown': (model.hasMultipleWebsites || showAllSitesItem || !model.sites.length)}"> + + <script type="text/ng-template" id="siteselector_allsiteslink.html"> + <div ng-click="switchSite({idsite: 'all', name: allSitesText});view.showSitesList=false;" + class="custom_select_all"> + <a href="{{ getUrlAllSites() }}" + piwik-ignore-click + ng-bind-html="allSitesText"></a> + </div> + </script> + + <input ng-if="inputName" type="hidden" name="{{ inputName }}" ng-value="selectedSite.id"/> + + <a ng-click="view.showSitesList=!view.showSitesList; view.showSitesList && model.loadInitialSites()" + href="javascript:void(0)" + class="custom_select_main_link" + ng-class="{'loading': model.isLoading}"> + <span ng-bind-html="selectedSite.name || model.firstSiteName">?</span> + </a> + + <div ng-show="view.showSitesList" class="custom_select_block"> + <div ng-if="allSitesLocation=='top' && showAllSitesItem" + ng-include="'siteselector_allsiteslink.html'"></div> + + <div class="custom_select_container"> + <ul class="custom_select_ul_list" ng-click="view.showSitesList=false;"> + <li ng-click="switchSite(site)" + ng-repeat="site in model.sites" + ng-hide="!showSelectedSite && activeSiteId==site.idsite"> + <a piwik-ignore-click href="{{ getUrlForSiteId(site.idsite) }}" + piwik-autocomplete-matched="view.searchTerm">{{ site.name }}</a> + </li> + </ul> + <ul ng-show="!model.sites.length && view.searchTerm" class="ui-autocomplete ui-front ui-menu ui-widget ui-widget-content ui-corner-all siteSelect"> + <li class="ui-menu-item"> + <a class="ui-corner-all" tabindex="-1">{{ ('SitesManager_NotFound' | translate) + ' ' + view.searchTerm }}</a> + </li> + </ul> + </div> + + <div ng-if="allSitesLocation=='bottom' && showAllSitesItem" + ng-include="'siteselector_allsiteslink.html'"></div> + + <div class="custom_select_search" ng-show="autocompleteMinSites <= model.sites.length || view.searchTerm"> + <input type="text" + ng-click="view.searchTerm=''" + ng-model="view.searchTerm" + ng-change="model.searchSite(view.searchTerm)" + class="websiteSearch inp"/> + <input type="submit" + ng-click="model.searchSite(view.searchTerm)" + value="{{ 'General_Search' | translate }}" class="but"/> + <img title="Clear" + ng-show="view.searchTerm" + ng-click="view.searchTerm=''; model.loadInitialSites()" + class="reset" + src="plugins/CoreHome/images/reset_search.png"/> + </div> + </div> +</div> \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector.less b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector.less new file mode 100644 index 00000000..161b34c3 --- /dev/null +++ b/www/analytics/plugins/CoreHome/angularjs/siteselector/siteselector.less @@ -0,0 +1,177 @@ + + +/*sites_autocomplete*/ +.sites_autocomplete { + position: absolute; + font-size: 12px; + display: inline-block; + height: 30px; /* Hack to not push the dashboard widget below */ +} + +.sites_selector_in_dashboard { + margin-top:10px; +} +.top_bar_sites_selector { + float: right +} + +.top_bar_sites_selector > label { + display: inline-block; + padding: 7px 0 6px 0; + float: left; + font-size: 12px; +} + +.top_bar_sites_selector > .sites_autocomplete { + position: static; + padding-left: 12px; +} + +.autocompleteMatched { + color: #5256BE; + font-weight: bold; +} + +.sites_autocomplete .custom_select { + float: left; + position: relative; + z-index: 19; + background: #fff url(plugins/Zeitgeist/images/sites_selection.png) repeat-x 0 0; + border: 1px solid #d4d4d4; + color: #255792; + border-radius: 4px; + cursor: pointer; + min-width: 165px; + padding: 5px 6px 4px; +} + +.sites_autocomplete .custom_select_main_link { + display: block; + text-decoration: none; + background: none; + cursor: default; + height:1.4em; +} + +.sites_autocomplete .custom_select_ul_list li a, +.sites_autocomplete .custom_select_all a, +.sites_autocomplete .custom_select_main_link > span { + display: inline-block; + max-width: 140px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 20px 0 4px; +} + +.sites_autocomplete--dropdown .custom_select_main_link:not(.loading):before { + content: " \25BC"; + position: absolute; + right: 0; + font-size: 0.8em; + margin-top: 0.2em; + color: #444; +} + +.sites_autocomplete--dropdown .custom_select_main_link { + cursor: pointer; + position: relative; +} + +.sites_autocomplete .custom_select_main_link.loading { + background: url(plugins/Zeitgeist/images/loading-blue.gif) no-repeat right 3px; +} + +.sites_autocomplete .custom_select_ul_list, +.sites_autocomplete ul.ui-autocomplete { + position: relative; + list-style: none; + line-height: 18px; + padding: 0 0 15px 0; +} + +.sites_autocomplete .custom_select_ul_list li a, +.sites_autocomplete .custom_select_all a { + line-height: 18px; + height: auto; + display: block; + text-decoration: none; +} + +.sites_autocomplete .custom_select_ul_list li a:hover, +.sites_autocomplete .custom_select_all a:hover { + background: #ebeae6; +} + +.sites_autocomplete .custom_select_all a { + text-decoration: none; + margin: 0 0 5px 0; +} + +.sites_autocomplete .custom_select_search { + margin: 0 0 0 4px; + height: 26px; + display: block; + white-space: nowrap; + background: url(plugins/Zeitgeist/images/search_bg.png) no-repeat 0 0; +} + +.sites_autocomplete .custom_select_search .inp { + vertical-align: top; + width: 114px; + padding: 2px 6px; + border: 0; + background: transparent; + font-size: 10px; + color: #454545; + +} +.sites_autocomplete { + width: 165px; +} + +.sites_autocomplete .custom_select_search .but { + vertical-align: top; + font-size: 10px; + border: 0; + background: transparent; + width: 21px; + height: 17px; + overflow: hidden; + opacity: 0; + cursor: pointer; +} + +.sites_selector_container>.sites_autocomplete { + padding-left: 12px; +} + +.custom_selector_container .ui-menu-item, +.custom_selector_container .ui-menu-item a { + float:none;position:static +} + +.custom_select_search .reset { + position: relative; top: 4px; left: -44px; cursor: pointer; +} + +.custom_select_block { + overflow: hidden; + max-width: inherit; + visibility: visible; +} + +.custom_select_block_show { + height: auto; + overflow: visible; + max-width:inherit; +} + +.sites_selector_container { + padding-top: 5px; +} + +.siteSelect a { + white-space: normal; + text-align: left; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/images/bg_header.jpg b/www/analytics/plugins/CoreHome/images/bg_header.jpg new file mode 100644 index 00000000..1733b0e3 Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/bg_header.jpg differ diff --git a/www/analytics/plugins/CoreHome/images/bullet1.gif b/www/analytics/plugins/CoreHome/images/bullet1.gif new file mode 100644 index 00000000..f2707da6 Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/bullet1.gif differ diff --git a/www/analytics/plugins/CoreHome/images/bullet2.gif b/www/analytics/plugins/CoreHome/images/bullet2.gif new file mode 100644 index 00000000..26e1b6af Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/bullet2.gif differ diff --git a/www/analytics/plugins/CoreHome/images/favicon.ico b/www/analytics/plugins/CoreHome/images/favicon.ico new file mode 100644 index 00000000..e29ea5b4 Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/favicon.ico differ diff --git a/www/analytics/plugins/CoreHome/images/googleplay.png b/www/analytics/plugins/CoreHome/images/googleplay.png new file mode 100644 index 00000000..fd150a6b Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/googleplay.png differ diff --git a/www/analytics/plugins/CoreHome/images/more.png b/www/analytics/plugins/CoreHome/images/more.png new file mode 100644 index 00000000..cba03a05 Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/more.png differ diff --git a/www/analytics/plugins/CoreHome/images/more_date.gif b/www/analytics/plugins/CoreHome/images/more_date.gif new file mode 100644 index 00000000..87da6b49 Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/more_date.gif differ diff --git a/www/analytics/plugins/CoreHome/images/more_period.gif b/www/analytics/plugins/CoreHome/images/more_period.gif new file mode 100644 index 00000000..b0c97878 Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/more_period.gif differ diff --git a/www/analytics/plugins/CoreHome/images/promo_splash.png b/www/analytics/plugins/CoreHome/images/promo_splash.png new file mode 100755 index 00000000..9a78f8e2 Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/promo_splash.png differ diff --git a/www/analytics/plugins/CoreHome/images/reset_search.png b/www/analytics/plugins/CoreHome/images/reset_search.png new file mode 100644 index 00000000..cb1d9e80 Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/reset_search.png differ diff --git a/www/analytics/plugins/CoreHome/images/search.png b/www/analytics/plugins/CoreHome/images/search.png new file mode 100644 index 00000000..6d1f037c Binary files /dev/null and b/www/analytics/plugins/CoreHome/images/search.png differ diff --git a/www/analytics/plugins/CoreHome/javascripts/broadcast.js b/www/analytics/plugins/CoreHome/javascripts/broadcast.js new file mode 100644 index 00000000..3e02f4f3 --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/broadcast.js @@ -0,0 +1,644 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * broadcast object is to help maintain a hash for link clicks and ajax calls + * so we can have back button and refresh button working. + * + * @type {object} + */ +var broadcast = { + + /** + * Initialisation state + * @type {Boolean} + */ + _isInit: false, + + /** + * Last known hash url without popover parameter + */ + currentHashUrl: false, + + /** + * Last known popover parameter + */ + currentPopoverParameter: false, + + /** + * Callbacks for popover parameter change + */ + popoverHandlers: [], + + /** + * Force reload once + */ + forceReload: false, + + /** + * Suppress content update on hash changing + */ + updateHashOnly: false, + + /** + * Initializes broadcast object + * @return {void} + */ + init: function (noLoadingMessage) { + if (broadcast._isInit) { + return; + } + broadcast._isInit = true; + + // Initialize history plugin. + // The callback is called at once by present location.hash + $.history.init(broadcast.pageload, {unescape: true}); + + if(noLoadingMessage != true) { + piwikHelper.showAjaxLoading(); + } + }, + + /** + * ========== PageLoad function ================= + * This function is called when: + * 1. after calling $.history.init(); + * 2. after calling $.history.load(); //look at broadcast.changeParameter(); + * 3. after pushing "Go Back" button of a browser + * + * * Note: the method is manipulated in Overlay/javascripts/Piwik_Overlay.js - keep this in mind when making changes. + * + * @param {string} hash to load page with + * @return {void} + */ + pageload: function (hash) { + broadcast.init(); + + // Unbind any previously attached resize handlers + $(window).off('resize'); + + // do not update content if it should be suppressed + if (broadcast.updateHashOnly) { + broadcast.updateHashOnly = false; + return; + } + + // hash doesn't contain the first # character. + if (hash && 0 === (''+hash).indexOf('/')) { + hash = (''+hash).substr(1); + } + + if (hash) { + + if (/^popover=/.test(hash)) { + var hashParts = [ + '', + hash.replace(/^popover=/, '') + ]; + } else { + var hashParts = hash.split('&popover='); + } + var hashUrl = hashParts[0]; + var popoverParam = ''; + if (hashParts.length > 1) { + popoverParam = hashParts[1]; + // in case the $ was encoded (e.g. when using copy&paste on urls in some browsers) + popoverParam = decodeURIComponent(popoverParam); + // revert special encoding from broadcast.propagateNewPopoverParameter() + popoverParam = popoverParam.replace(/\$/g, '%'); + popoverParam = decodeURIComponent(popoverParam); + } + + var pageUrlUpdated = (popoverParam == '' || + (broadcast.currentHashUrl !== false && broadcast.currentHashUrl != hashUrl)); + + var popoverParamUpdated = (popoverParam != '' && hashUrl == broadcast.currentHashUrl); + + if (broadcast.currentHashUrl === false) { + // new page load + pageUrlUpdated = true; + popoverParamUpdated = (popoverParam != ''); + } + + if (pageUrlUpdated || broadcast.forceReload) { + Piwik_Popover.close(); + + if (hashUrl != broadcast.currentHashUrl || broadcast.forceReload) { + // restore ajax loaded state + broadcast.loadAjaxContent(hashUrl); + + // make sure the "Widgets & Dashboard" is deleted on reload + $('.top_controls .dashboard-manager').hide(); + $('#dashboardWidgetsArea').dashboard('destroy'); + + // remove unused controls + require('piwik/UI').UIControl.cleanupUnusedControls(); + } + } + + broadcast.forceReload = false; + broadcast.currentHashUrl = hashUrl; + broadcast.currentPopoverParameter = popoverParam; + + if (popoverParamUpdated && popoverParam == '') { + Piwik_Popover.close(); + } else if (popoverParamUpdated) { + var popoverParamParts = popoverParam.split(':'); + var handlerName = popoverParamParts[0]; + popoverParamParts.shift(); + var param = popoverParamParts.join(':'); + if (typeof broadcast.popoverHandlers[handlerName] != 'undefined') { + broadcast.popoverHandlers[handlerName](param); + } + } + + } else { + // start page + Piwik_Popover.close(); + + $('.pageWrap #content:not(.admin)').empty(); + } + }, + + /** + * propagateAjax -- update hash values then make ajax calls. + * example : + * 1) <a href="javascript:broadcast.propagateAjax('module=Referrers&action=getKeywords')">View keywords report</a> + * 2) Main menu li also goes through this function. + * + * Will propagate your new value into the current hash string and make ajax calls. + * + * NOTE: this method will only make ajax call and replacing main content. + * + * @param {string} ajaxUrl querystring with parameters to be updated + * @param {boolean} [disableHistory] the hash change won't be available in the browser history + * @return {void} + */ + propagateAjax: function (ajaxUrl, disableHistory) { + broadcast.init(); + + // abort all existing ajax requests + globalAjaxQueue.abort(); + + // available in global scope + var currentHashStr = broadcast.getHash(); + + ajaxUrl = ajaxUrl.replace(/^\?|&#/, ''); + + var params_vals = ajaxUrl.split("&"); + for (var i = 0; i < params_vals.length; i++) { + currentHashStr = broadcast.updateParamValue(params_vals[i], currentHashStr); + } + + // if the module is not 'Goals', we specifically unset the 'idGoal' parameter + // this is to ensure that the URLs are clean (and that clicks on graphs work as expected - they are broken with the extra parameter) + var action = broadcast.getParamValue('action', currentHashStr); + if (action != 'goalReport' && action != 'ecommerceReport') { + currentHashStr = broadcast.updateParamValue('idGoal=', currentHashStr); + } + // unset idDashboard if use doesn't display a dashboard + var module = broadcast.getParamValue('module', currentHashStr); + if (module != 'Dashboard') { + currentHashStr = broadcast.updateParamValue('idDashboard=', currentHashStr); + } + + if (disableHistory) { + var newLocation = window.location.href.split('#')[0] + '#' + currentHashStr; + // window.location.replace changes the current url without pushing it on the browser's history stack + window.location.replace(newLocation); + } + else { + // Let history know about this new Hash and load it. + broadcast.forceReload = true; + $.history.load(currentHashStr); + } + }, + + /** + * propagateNewPage() -- update url value and load new page, + * Example: + * 1) We want to update idSite to both search query and hash then reload the page, + * 2) update period to both search query and hash then reload page. + * + * ** If you'd like to make ajax call with new values then use propagateAjax ** * + * + * Expecting: + * str = "param1=newVal1¶m2=newVal2"; + * + * NOTE: This method will refresh the page with new values. + * + * @param {string} str url with parameters to be updated + * @param {boolean} [showAjaxLoading] whether to show the ajax loading gif or not. + * @return {void} + */ + propagateNewPage: function (str, showAjaxLoading) { + // abort all existing ajax requests + globalAjaxQueue.abort(); + + if (typeof showAjaxLoading === 'undefined' || showAjaxLoading) { + piwikHelper.showAjaxLoading(); + } + + var params_vals = str.split("&"); + + // available in global scope + var currentSearchStr = window.location.search; + var currentHashStr = broadcast.getHashFromUrl(); + var oldUrl = currentSearchStr + currentHashStr; + + for (var i = 0; i < params_vals.length; i++) { + // update both the current search query and hash string + currentSearchStr = broadcast.updateParamValue(params_vals[i], currentSearchStr); + + if (currentHashStr.length != 0) { + currentHashStr = broadcast.updateParamValue(params_vals[i], currentHashStr); + } + } + + // Now load the new page. + var newUrl = currentSearchStr + currentHashStr; + + if (oldUrl == newUrl) { + window.location.reload(); + } else { + this.forceReload = true; + window.location.href = newUrl; + } + return false; + }, + + /************************************************* + * + * Broadcast Supporter Methods: + * + *************************************************/ + + /** + * updateParamValue(newParamValue,urlStr) -- Helping propagate functions to update value to url string. + * eg. I want to update date value to search query or hash query + * + * Expecting: + * urlStr : A Hash or search query string. e.g: module=whatever&action=index=date=yesterday + * newParamValue : A param value pair: e.g: date=2009-05-02 + * + * Return module=whatever&action=index&date=2009-05-02 + * + * @param {string} newParamValue param to be updated + * @param {string} urlStr url to be updated + * @return {string} urlStr with updated param + */ + updateParamValue: function (newParamValue, urlStr) { + var p_v = newParamValue.split("="); + + var paramName = p_v[0]; + var valFromUrl = broadcast.getParamValue(paramName, urlStr); + // if set 'idGoal=' then we remove the parameter from the URL automatically (rather than passing an empty value) + var paramValue = p_v[1]; + if (paramValue == '') { + newParamValue = ''; + } + var getQuotedRegex = function(str) { + return (str+'').replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1"); + }; + + if (valFromUrl != '') { + // replacing current param=value to newParamValue; + valFromUrl = getQuotedRegex(valFromUrl); + var regToBeReplace = new RegExp(paramName + '=' + valFromUrl, 'ig'); + if (newParamValue == '') { + // if new value is empty remove leading &, aswell + regToBeReplace = new RegExp('[\&]?' + paramName + '=' + valFromUrl, 'ig'); + } + urlStr = urlStr.replace(regToBeReplace, newParamValue); + } else if (newParamValue != '') { + urlStr += (urlStr == '') ? newParamValue : '&' + newParamValue; + } + + return urlStr; + }, + + /** + * Loads a popover by adding a 'popover' query parameter to the current URL and + * indirectly executing the popover handler. + * + * This function should be called to open popovers that can be opened by URL alone. + * That is, if you want users to be able to copy-paste the URL displayed when a popover + * is open into a new browser window/tab and have the same popover open, you should + * call this function. + * + * In order for this function to open a popover, there must be a popover handler + * associated with handlerName. To associate one, call broadcast.addPopoverHandler. + * + * @param {String} handlerName The name of the popover handler. + * @param {String} value The String value that should be passed to the popover + * handler. + */ + propagateNewPopoverParameter: function (handlerName, value) { + // init broadcast if not already done (it is required to make popovers work in widgetize mode) + broadcast.init(true); + + var hash = broadcast.getHashFromUrl(window.location.href); + + var popover = ''; + if (handlerName) { + popover = handlerName + ':' + value; + + // between jquery.history and different browser bugs, it's impossible to ensure + // that the parameter is en- and decoded the same number of times. in order to + // make sure it doesn't change, we have to manipulate the url encoding a bit. + popover = encodeURIComponent(popover); + popover = popover.replace(/%/g, '\$'); + } + + if ('' == value || 'undefined' == typeof value) { + var newHash = hash.replace(/(&?popover=.*)/, ''); + } else if (broadcast.getParamValue('popover', hash)) { + var newHash = broadcast.updateParamValue('popover='+popover, hash); + } else if (hash && hash != '#') { + var newHash = hash + '&popover=' + popover + } else { + var newHash = '#popover='+popover; + } + + // never use an empty hash, as that might reload the page + if ('' == newHash) { + newHash = '#'; + } + + broadcast.forceReload = false; + $.history.load(newHash); + }, + + /** + * Adds a handler for the 'popover' query parameter. + * + * @see broadcast#propagateNewPopoverParameter + * + * @param {String} handlerName The handler name, eg, 'visitorProfile'. Should identify + * the popover that the callback will open up. + * @param {Function} callback This function should open the popover. It should take + * one string parameter. + */ + addPopoverHandler: function (handlerName, callback) { + broadcast.popoverHandlers[handlerName] = callback; + }, + + /** + * Loads the given url with ajax and replaces the content + * + * Note: the method is replaced in Overlay/javascripts/Piwik_Overlay.js - keep this in mind when making changes. + * + * @param {string} urlAjax url to load + * @return {Boolean} + */ + loadAjaxContent: function (urlAjax) { + if (typeof piwikMenu !== 'undefined') { + piwikMenu.activateMenu( + broadcast.getParamValue('module', urlAjax), + broadcast.getParamValue('action', urlAjax), + broadcast.getParamValue('idGoal', urlAjax) || broadcast.getParamValue('idDashboard', urlAjax) + ); + } + + piwikHelper.hideAjaxError('loadingError'); + piwikHelper.showAjaxLoading(); + $('#content').empty(); + $("object").remove(); + + urlAjax = urlAjax.match(/^\?/) ? urlAjax : "?" + urlAjax; + broadcast.lastUrlRequested = urlAjax; + function sectionLoaded(content) { + // if content is whole HTML document, do not show it, otherwise recursive page load could occur + var htmlDocType = '<!DOCTYPE'; + if (content.substring(0, htmlDocType.length) == htmlDocType) { + // if the content has an error message, display it + if ($(content).filter('title').text() == 'Piwik › Error') { + content = $(content).filter('#contentsimple'); + } else { + return; + } + } + + if (urlAjax == broadcast.lastUrlRequested) { + $('#content').html(content).show(); + $(broadcast).trigger('locationChangeSuccess', {element: $('#content'), content: content}); + piwikHelper.hideAjaxLoading(); + broadcast.lastUrlRequested = null; + + piwikHelper.compileAngularComponents('#content'); + } + + initTopControls(); + } + + var ajax = new ajaxHelper(); + ajax.setUrl(urlAjax); + ajax.setErrorCallback(broadcast.customAjaxHandleError); + ajax.setCallback(sectionLoaded); + ajax.setFormat('html'); + ajax.send(); + + return false; + }, + + /** + * Method to handle ajax errors + * @param {XMLHttpRequest} deferred + * @param {string} status + * @return {void} + */ + customAjaxHandleError: function (deferred, status) { + broadcast.lastUrlRequested = null; + + // do not display error message if request was aborted + if(status == 'abort') { + return; + } + $('#loadingError').show(); + setTimeout( function(){ + $('#loadingError').fadeOut('slow'); + }, 2000); + }, + + /** + * Return hash string if hash exists on address bar. + * else return false; + * + * @return {string|boolean} current hash or false if it is empty + */ + isHashExists: function () { + var hashStr = broadcast.getHashFromUrl(); + + if (hashStr != "") { + return hashStr; + } else { + return false; + } + }, + + /** + * Get Hash from given url or from current location. + * return empty string if no hash present. + * + * @param {string} [url] url to get hash from (defaults to current location) + * @return {string} the hash part of the given url + */ + getHashFromUrl: function (url) { + var hashStr = ""; + // If url provided, give back the hash from url, else get hash from current address. + if (url && url.match('#')) { + hashStr = url.substring(url.indexOf("#"), url.length); + } + else { + locationSplit = location.href.split('#'); + if(typeof locationSplit[1] != 'undefined') { + hashStr = '#' + locationSplit[1]; + } + } + + return hashStr; + }, + + /** + * Get search query from given url or from current location. + * return empty string if no search query present. + * + * @param {string} url + * @return {string} the query part of the given url + */ + getSearchFromUrl: function (url) { + var searchStr = ""; + // If url provided, give back the query string from url, else get query string from current address. + if (url && url.match(/\?/)) { + searchStr = url.substring(url.indexOf("?"), url.length); + } else { + searchStr = location.search; + } + + return searchStr; + }, + + /** + * Extracts from a query strings, the request array + * @param queryString + * @returns {object} + */ + extractKeyValuePairsFromQueryString: function (queryString) { + var pairs = queryString.split('&'); + var result = {}; + for (var i = 0; i != pairs.length; ++i) { + // attn: split with regex has bugs in several browsers such as IE 8 + // so we need to split, use the first part as key and rejoin the rest + var pair = pairs[i].split('='); + var key = pair.shift(); + result[key] = pair.join('='); + } + return result; + }, + + /** + * Returns all key-value pairs in query string of url. + * + * @param {string} url url to check. if undefined, null or empty, current url is used. + * @return {object} key value pair describing query string parameters + */ + getValuesFromUrl: function (url) { + var searchString = this._removeHashFromUrl(url).split('?')[1] || ''; + return this.extractKeyValuePairsFromQueryString(searchString); + }, + + + /** + * help to get param value for any given url string with provided param name + * if no url is provided, it will get param from current address. + * return: + * Empty String if param is not found. + * + * @param {string} param parameter to search for + * @param {string} [url] url to check, defaults to current location + * @return {string} value of the given param within the given url + */ + getValueFromUrl: function (param, url) { + var searchString = this._removeHashFromUrl(url); + return broadcast.getParamValue(param, searchString); + }, + + /** + * NOTE: you should probably be using broadcast.getValueFromUrl instead! + * + * @param {string} param parameter to search for + * @param {string} [url] url to check + * @return {string} value of the given param within the hash part of the given url + */ + getValueFromHash: function (param, url) { + var hashStr = broadcast.getHashFromUrl(url); + if (hashStr.substr(0, 1) == '#') { + hashStr = hashStr.substr(1); + } + hashStr = hashStr.split('#')[0]; + + return broadcast.getParamValue(param, hashStr); + }, + + + /** + * return value for the requested param, will return the first match. + * out side of this class should use getValueFromHash() or getValueFromUrl() instead. + * return: + * Empty String if param is not found. + * + * @param {string} param parameter to search for + * @param {string} url url to check + * @return {string} value of the given param within the given url + */ + getParamValue: function (param, url) { + var lookFor = param + '='; + var startStr = url.indexOf(lookFor); + + if (startStr >= 0) { + var endStr = url.indexOf("&", startStr); + if (endStr == -1) { + endStr = url.length; + } + var value = url.substring(startStr + param.length + 1, endStr); + + // we sanitize values to add a protection layer against XSS + // &segment= value is not sanitized, since segments are designed to accept any user input + if(param != 'segment') { + value = value.replace(/[^_%~\*\+\-\<\>!@\$\.()=,;0-9a-zA-Z]/gi, ''); + } + return value; + } else { + return ''; + } + }, + + /** + * Returns the hash without the starting # + * @return {string} hash part of the current url + */ + getHash: function () { + return broadcast.getHashFromUrl().replace(/^#/, '').split('#')[0]; + }, + + /** + * Removes the hash portion of a URL and returns the rest. + * + * @param {string} url + * @return {string} url w/o hash + */ + _removeHashFromUrl: function (url) { + var searchString = ''; + if (url) { + var urlParts = url.split('#'); + searchString = urlParts[0]; + } else { + searchString = location.search; + } + return searchString; + } +}; diff --git a/www/analytics/plugins/CoreHome/javascripts/calendar.js b/www/analytics/plugins/CoreHome/javascripts/calendar.js new file mode 100644 index 00000000..d380577f --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/calendar.js @@ -0,0 +1,545 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +(function ($) { + + Date.prototype.getWeek = function () { + var onejan = new Date(this.getFullYear(), 0, 1), // needed for getDay() + + // use UTC times since getTime() can differ based on user's timezone + onejan_utc = Date.UTC(this.getFullYear(), 0, 1), + this_utc = Date.UTC(this.getFullYear(), this.getMonth(), this.getDate()), + + daysSinceYearStart = (this_utc - onejan_utc) / 86400000; // constant is millisecs in one day + + return Math.ceil((daysSinceYearStart + onejan.getDay()) / 7); + }; + + var currentYear, currentMonth, currentDay, currentDate, currentWeek; + + function setCurrentDate(dateStr) { + var splitDate = dateStr.split("-"); + currentYear = splitDate[0]; + currentMonth = splitDate[1] - 1; + currentDay = splitDate[2]; + currentDate = new Date(currentYear, currentMonth, currentDay); + currentWeek = currentDate.getWeek(); + } + + if(!piwik.currentDateString) { + // eg. Login form + return; + } + setCurrentDate(piwik.currentDateString); + + var todayDate = new Date; + var todayMonth = todayDate.getMonth(); + var todayYear = todayDate.getFullYear(); + var todayDay = todayDate.getDate(); + +// min/max date for picker + var piwikMinDate = new Date(piwik.minDateYear, piwik.minDateMonth - 1, piwik.minDateDay), + piwikMaxDate = new Date(piwik.maxDateYear, piwik.maxDateMonth - 1, piwik.maxDateDay); + +// we start w/ the current period + var selectedPeriod = piwik.period; + + function isDateInCurrentPeriod(date) { + // if the selected period isn't the current period, don't highlight any dates + if (selectedPeriod != piwik.period) { + return [true, '']; + } + + var valid = false; + + var dateMonth = date.getMonth(); + var dateYear = date.getFullYear(); + var dateDay = date.getDate(); + + // we don't color dates in the future + if (dateMonth == todayMonth + && dateYear == todayYear + && dateDay > todayDay + ) { + return [true, '']; + } + + // we don't color dates before the minimum date + if (dateYear < piwik.minDateYear + || ( dateYear == piwik.minDateYear + && + ( + (dateMonth == piwik.minDateMonth - 1 + && dateDay < piwik.minDateDay) + || (dateMonth < piwik.minDateMonth - 1) + ) + ) + ) { + return [true, '']; + } + + // we color all day of the month for the same year for the month period + if (piwik.period == "month" + && dateMonth == currentMonth + && dateYear == currentYear + ) { + valid = true; + } + // we color all day of the year for the year period + else if (piwik.period == "year" + && dateYear == currentYear + ) { + valid = true; + } + else if (piwik.period == "week" + && date.getWeek() == currentWeek + && dateYear == currentYear + ) { + valid = true; + } + else if (piwik.period == "day" + && dateDay == currentDay + && dateMonth == currentMonth + && dateYear == currentYear + ) { + valid = true; + } + + if (valid) { + return [true, 'ui-datepicker-current-period']; + } + + return [true, '']; + } + + piwik.getBaseDatePickerOptions = function (defaultDate) { + return { + showOtherMonths: false, + dateFormat: 'yy-mm-dd', + firstDay: 1, + minDate: piwikMinDate, + maxDate: piwikMaxDate, + prevText: "", + nextText: "", + currentText: "", + defaultDate: defaultDate, + changeMonth: true, + changeYear: true, + stepMonths: 1, + // jquery-ui-i18n 1.7.2 lacks some translations, so we use our own + dayNamesMin: [ + _pk_translate('General_DaySu'), + _pk_translate('General_DayMo'), + _pk_translate('General_DayTu'), + _pk_translate('General_DayWe'), + _pk_translate('General_DayTh'), + _pk_translate('General_DayFr'), + _pk_translate('General_DaySa')], + dayNamesShort: [ + _pk_translate('General_ShortDay_7'), // start with sunday + _pk_translate('General_ShortDay_1'), + _pk_translate('General_ShortDay_2'), + _pk_translate('General_ShortDay_3'), + _pk_translate('General_ShortDay_4'), + _pk_translate('General_ShortDay_5'), + _pk_translate('General_ShortDay_6')], + dayNames: [ + _pk_translate('General_LongDay_7'), // start with sunday + _pk_translate('General_LongDay_1'), + _pk_translate('General_LongDay_2'), + _pk_translate('General_LongDay_3'), + _pk_translate('General_LongDay_4'), + _pk_translate('General_LongDay_5'), + _pk_translate('General_LongDay_6')], + monthNamesShort: [ + _pk_translate('General_ShortMonth_1'), + _pk_translate('General_ShortMonth_2'), + _pk_translate('General_ShortMonth_3'), + _pk_translate('General_ShortMonth_4'), + _pk_translate('General_ShortMonth_5'), + _pk_translate('General_ShortMonth_6'), + _pk_translate('General_ShortMonth_7'), + _pk_translate('General_ShortMonth_8'), + _pk_translate('General_ShortMonth_9'), + _pk_translate('General_ShortMonth_10'), + _pk_translate('General_ShortMonth_11'), + _pk_translate('General_ShortMonth_12')], + monthNames: [ + _pk_translate('General_LongMonth_1'), + _pk_translate('General_LongMonth_2'), + _pk_translate('General_LongMonth_3'), + _pk_translate('General_LongMonth_4'), + _pk_translate('General_LongMonth_5'), + _pk_translate('General_LongMonth_6'), + _pk_translate('General_LongMonth_7'), + _pk_translate('General_LongMonth_8'), + _pk_translate('General_LongMonth_9'), + _pk_translate('General_LongMonth_10'), + _pk_translate('General_LongMonth_11'), + _pk_translate('General_LongMonth_12')] + }; + }; + + var updateDate; + + function getDatePickerOptions() { + var result = piwik.getBaseDatePickerOptions(currentDate); + result.beforeShowDay = isDateInCurrentPeriod; + result.stepMonths = selectedPeriod == 'year' ? 12 : 1; + result.onSelect = function () { updateDate.apply(this, arguments); }; + return result; + } + + $(function () { + + var datepickerElem = $('#datepicker').datepicker(getDatePickerOptions()), + periodLabels = $('#periodString').find('.period-type label'), + periodTooltip = $('#periodString').find('.period-click-tooltip').html(); + + var toggleWhitespaceHighlighting = function (klass, toggleTop, toggleBottom) { + var viewedYear = $('.ui-datepicker-year', datepickerElem).val(), + viewedMonth = +$('.ui-datepicker-month', datepickerElem).val(), // convert to int w/ '+' + firstOfViewedMonth = new Date(viewedYear, viewedMonth, 1), + lastOfViewedMonth = new Date(viewedYear, viewedMonth + 1, 0); + + // only highlight dates between piwik.minDate... & piwik.maxDate... + // we select the cells to highlight by checking whether the first & last of the + // currently viewed month are within the min/max dates. + if (firstOfViewedMonth >= piwikMinDate) { + $('tbody>tr:first-child td.ui-datepicker-other-month', datepickerElem).toggleClass(klass, toggleTop); + } + if (lastOfViewedMonth < piwikMaxDate) { + $('tbody>tr:last-child td.ui-datepicker-other-month', datepickerElem).toggleClass(klass, toggleBottom); + } + }; + + // 'this' is the table cell + var highlightCurrentPeriod = function () { + switch (selectedPeriod) { + case 'day': + // highlight this link + $('a', $(this)).addClass('ui-state-hover'); + break; + case 'week': + var row = $(this).parent(); + + // highlight parent row (the week) + $('a', row).addClass('ui-state-hover'); + + // toggle whitespace if week goes into previous or next month. we check if week is on + // top or bottom row. + var toggleTop = row.is(':first-child'), + toggleBottom = row.is(':last-child'); + toggleWhitespaceHighlighting('ui-state-hover', toggleTop, toggleBottom); + break; + case 'month': + // highlight all parent rows (the month) + $('a', $(this).parent().parent()).addClass('ui-state-hover'); + break; + case 'year': + // highlight table (month + whitespace) + $('a', $(this).parent().parent()).addClass('ui-state-hover'); + toggleWhitespaceHighlighting('ui-state-hover', true, true); + break; + } + }; + + var unhighlightAllDates = function () { + // make sure nothing is highlighted + $('.ui-state-active,.ui-state-hover', datepickerElem).removeClass('ui-state-active ui-state-hover'); + + // color whitespace + if (piwik.period == 'year') { + var viewedYear = $('.ui-datepicker-year', datepickerElem).val(), + toggle = selectedPeriod == 'year' && currentYear == viewedYear; + toggleWhitespaceHighlighting('ui-datepicker-current-period', toggle, toggle); + } + else if (piwik.period == 'week') { + var toggleTop = $('tr:first-child a', datepickerElem).parent().hasClass('ui-datepicker-current-period'), + toggleBottom = $('tr:last-child a', datepickerElem).parent().hasClass('ui-datepicker-current-period'); + toggleWhitespaceHighlighting('ui-datepicker-current-period', toggleTop, toggleBottom); + } + }; + + updateDate = function (dateText) { + piwikHelper.showAjaxLoading('ajaxLoadingCalendar'); + + // select new dates in calendar + setCurrentDate(dateText); + piwik.period = selectedPeriod; + + // make sure it's called after jquery-ui is done, otherwise everything we do will + // be undone. + setTimeout(unhighlightAllDates, 1); + + datepickerElem.datepicker('refresh'); + + // Let broadcast do its job: + // It will replace date value to both search query and hash and load the new page. + broadcast.propagateNewPage('date=' + dateText + '&period=' + selectedPeriod); + }; + + var toggleMonthDropdown = function (disable) { + if (typeof disable === 'undefined') { + disable = selectedPeriod == 'year'; + } + + // enable/disable month dropdown based on period == year + $('.ui-datepicker-month', datepickerElem).attr('disabled', disable); + }; + + var togglePeriodPickers = function (showSingle) { + $('#periodString').find('.period-date').toggle(showSingle); + $('#periodString').find('.period-range').toggle(!showSingle); + $('#calendarRangeApply').toggle(!showSingle); + }; + + // + // setup datepicker + // + + unhighlightAllDates(); + + // + // hook up event slots + // + + // highlight current period when mouse enters date + datepickerElem.on('mouseenter', 'tbody td', function () { + if ($(this).hasClass('ui-state-hover')) // if already highlighted, do nothing + { + return; + } + + // unhighlight if cell is disabled/blank, unless the period is year + if ($(this).hasClass('ui-state-disabled') && selectedPeriod != 'year') { + unhighlightAllDates(); + + // if period is week, then highlight the current week + if (selectedPeriod == 'week') { + highlightCurrentPeriod.call(this); + } + } + else { + highlightCurrentPeriod.call(this); + } + }); + + // make sure cell stays highlighted when mouse leaves cell (overrides jquery-ui behavior) + datepickerElem.on('mouseleave', 'tbody td', function () { + $('a', this).addClass('ui-state-hover'); + }); + + // unhighlight everything when mouse leaves table body (can't do event on tbody, for some reason + // that fails, so we do two events, one on the table & one on thead) + datepickerElem.on('mouseleave', 'table', unhighlightAllDates) + .on('mouseenter', 'thead', unhighlightAllDates); + + // make sure whitespace is clickable when the period makes it appropriate + datepickerElem.on('click', 'tbody td.ui-datepicker-other-month', function () { + if ($(this).hasClass('ui-state-hover')) { + var row = $(this).parent(), tbody = row.parent(); + + if (row.is(':first-child')) { + // click on first of the month + $('a', tbody).first().click(); + } + else { + // click on last of month + $('a', tbody).last().click(); + } + } + }); + + // Hack to get around firefox bug. When double clicking a label in firefox, the 'click' + // event of its associated input will not be fired twice. We want to change the period + // if clicking the select period's label OR input, so we catch the click event on the + // label & the input. + var reloading = false; + var changePeriodOnClick = function (periodInput) { + if (reloading) // if a click event resulted in reloading, don't reload again + { + return; + } + + var url = periodInput.val(), + period = broadcast.getValueFromUrl('period', url); + + // if clicking on the selected period, change the period but not the date + if (selectedPeriod == period && selectedPeriod != 'range') { + // only reload if current period is different from selected + if (piwik.period != selectedPeriod && !reloading) { + reloading = true; + selectedPeriod = period; + updateDate(piwik.currentDateString); + } + return true; + } + + return false; + }; + + $("#otherPeriods").find("label").on('click', function (e) { + var id = $(e.target).attr('for'); + changePeriodOnClick($('#' + id)); + }); + + // when non-range period is clicked, change the period & refresh the date picker + $("#otherPeriods").find("input").on('click', function (e) { + var request_URL = $(e.target).val(), + period = broadcast.getValueFromUrl('period', request_URL), + lastPeriod = selectedPeriod; + + if (changePeriodOnClick($(e.target))) { + return true; + } + + // switch the selected period + selectedPeriod = period; + + // remove tooltips from the period inputs + periodLabels.each(function () { $(this).attr('title', '').removeClass('selected-period-label'); }); + + // range periods are handled in an event handler below + if (period == 'range') { + return true; + } + + // set the tooltip of the current period + if (period != piwik.period) // don't add tooltip for current period + { + $(this).parent().find('label[for=period_id_' + period + ']') + .attr('title', periodTooltip).addClass('selected-period-label'); + } + + // toggle the right selector controls (show period selector datepicker & hide 'apply range' button) + togglePeriodPickers(true); + + // set months step to 12 for year period (or set back to 1 if leaving year period) + if (selectedPeriod == 'year' || lastPeriod == 'year') { + // setting stepMonths will change the month in view back to the selected date. to avoid + // we set the selected date to the month in view. + var currentMonth = $('.ui-datepicker-month', datepickerElem).val(), + currentYear = $('.ui-datepicker-year', datepickerElem).val(); + + datepickerElem + .datepicker('option', 'stepMonths', selectedPeriod == 'year' ? 12 : 1) + .datepicker('setDate', new Date(currentYear, currentMonth)); + } + + datepickerElem.datepicker('refresh'); // must be last datepicker call, otherwise cells get highlighted + + unhighlightAllDates(); + toggleMonthDropdown(); + + return true; + }); + + // clicking left/right re-enables the month dropdown, so we disable it again + $(datepickerElem).on('click', '.ui-datepicker-next,.ui-datepicker-prev', function () { + unhighlightAllDates(); // make sure today's date isn't highlighted & toggle extra year highlighting + toggleMonthDropdown(selectedPeriod == 'year'); + }); + + // reset date/period when opening calendar + $("#periodString").on('click', "#date,.calendar-icon", function () { + var periodMore = $("#periodMore").toggle(); + if (periodMore.is(":visible")) { + periodMore.find(".ui-state-highlight").removeClass('ui-state-highlight'); + } + }); + + $('body').on('click', function(e) { + var target = $(e.target); + if (target.closest('html').length && !target.closest('#periodString').length && !target.is('option') && $("#periodMore").is(":visible")) { + $("#periodMore").hide(); + } + }); + + function onDateRangeSelect(dateText, inst) { + var toOrFrom = inst.id == 'calendarFrom' ? 'From' : 'To'; + $('#inputCalendar' + toOrFrom).val(dateText); + } + + // this will trigger to change only the period value on search query and hash string. + $("#period_id_range").on('click', function (e) { + togglePeriodPickers(false); + + var options = getDatePickerOptions(); + + // Custom Date range callback + options.onSelect = onDateRangeSelect; + // Do not highlight the period + options.beforeShowDay = ''; + // Create both calendars + options.defaultDate = piwik.startDateString; + $('#calendarFrom').datepicker(options).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', piwik.startDateString)); + + // Technically we should trigger the onSelect event on the calendar, but I couldn't find how to do that + // So calling the onSelect bind function manually... + //$('#calendarFrom').trigger('dateSelected'); // or onSelect + onDateRangeSelect(piwik.startDateString, { "id": "calendarFrom" }); + + // Same code for the other calendar + options.defaultDate = piwik.endDateString; + $('#calendarTo').datepicker(options).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', piwik.endDateString)); + onDateRangeSelect(piwik.endDateString, { "id": "calendarTo" }); + + + // If not called, the first date appears light brown instead of dark brown + $('.ui-state-hover').removeClass('ui-state-hover'); + + // Apply date range button will reload the page with the selected range + $('#calendarRangeApply') + .on('click', function () { + var request_URL = $(e.target).val(); + var dateFrom = $('#inputCalendarFrom').val(), + dateTo = $('#inputCalendarTo').val(), + oDateFrom = $.datepicker.parseDate('yy-mm-dd', dateFrom), + oDateTo = $.datepicker.parseDate('yy-mm-dd', dateTo); + + if (!isValidDate(oDateFrom) + || !isValidDate(oDateTo) + || oDateFrom > oDateTo) { + $('#alert').find('h2').text(_pk_translate('General_InvalidDateRange')); + piwikHelper.modalConfirm('#alert', {}); + return false; + } + piwikHelper.showAjaxLoading('ajaxLoadingCalendar'); + broadcast.propagateNewPage('period=range&date=' + dateFrom + ',' + dateTo); + }) + .show(); + + + // Bind the input fields to update the calendar's date when date is manually changed + $('#inputCalendarFrom, #inputCalendarTo') + .keyup(function (e) { + var fromOrTo = this.id == 'inputCalendarFrom' ? 'From' : 'To'; + var dateInput = $(this).val(); + try { + var newDate = $.datepicker.parseDate('yy-mm-dd', dateInput); + } catch (e) { + return; + } + $("#calendar" + fromOrTo).datepicker("setDate", newDate); + if (e.keyCode == 13) { + $('#calendarRangeApply').click(); + } + }); + return true; + }); + function isValidDate(d) { + if (Object.prototype.toString.call(d) !== "[object Date]") + return false; + return !isNaN(d.getTime()); + } + + if (piwik.period == 'range') { + $("#period_id_range").click(); + } + }); + +}(jQuery)); diff --git a/www/analytics/plugins/CoreHome/javascripts/color_manager.js b/www/analytics/plugins/CoreHome/javascripts/color_manager.js new file mode 100644 index 00000000..2a5af41a --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/color_manager.js @@ -0,0 +1,225 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($) { + + var colorNames = {"aliceblue":"#f0f8ff","antiquewhite":"#faebd7","aqua":"#00ffff","aquamarine":"#7fffd4","azure":"#f0ffff", + "beige":"#f5f5dc","bisque":"#ffe4c4","black":"#000000","blanchedalmond":"#ffebcd","blue":"#0000ff","blueviolet":"#8a2be2","brown":"#a52a2a","burlywood":"#deb887", + "cadetblue":"#5f9ea0","chartreuse":"#7fff00","chocolate":"#d2691e","coral":"#ff7f50","cornflowerblue":"#6495ed","cornsilk":"#fff8dc","crimson":"#dc143c","cyan":"#00ffff", + "darkblue":"#00008b","darkcyan":"#008b8b","darkgoldenrod":"#b8860b","darkgray":"#a9a9a9","darkgreen":"#006400","darkkhaki":"#bdb76b","darkmagenta":"#8b008b","darkolivegreen":"#556b2f", + "darkorange":"#ff8c00","darkorchid":"#9932cc","darkred":"#8b0000","darksalmon":"#e9967a","darkseagreen":"#8fbc8f","darkslateblue":"#483d8b","darkslategray":"#2f4f4f","darkturquoise":"#00ced1", + "darkviolet":"#9400d3","deeppink":"#ff1493","deepskyblue":"#00bfff","dimgray":"#696969","dodgerblue":"#1e90ff", + "firebrick":"#b22222","floralwhite":"#fffaf0","forestgreen":"#228b22","fuchsia":"#ff00ff","gainsboro":"#dcdcdc","ghostwhite":"#f8f8ff","gold":"#ffd700","goldenrod":"#daa520","gray":"#808080","green":"#008000","greenyellow":"#adff2f", + "honeydew":"#f0fff0","hotpink":"#ff69b4","indianred ":"#cd5c5c","indigo ":"#4b0082","ivory":"#fffff0","khaki":"#f0e68c", + "lavender":"#e6e6fa","lavenderblush":"#fff0f5","lawngreen":"#7cfc00","lemonchiffon":"#fffacd","lightblue":"#add8e6","lightcoral":"#f08080","lightcyan":"#e0ffff","lightgoldenrodyellow":"#fafad2", + "lightgrey":"#d3d3d3","lightgreen":"#90ee90","lightpink":"#ffb6c1","lightsalmon":"#ffa07a","lightseagreen":"#20b2aa","lightskyblue":"#87cefa","lightslategray":"#778899","lightsteelblue":"#b0c4de", + "lightyellow":"#ffffe0","lime":"#00ff00","limegreen":"#32cd32","linen":"#faf0e6","magenta":"#ff00ff","maroon":"#800000","mediumaquamarine":"#66cdaa","mediumblue":"#0000cd","mediumorchid":"#ba55d3","mediumpurple":"#9370d8","mediumseagreen":"#3cb371","mediumslateblue":"#7b68ee", + "mediumspringgreen":"#00fa9a","mediumturquoise":"#48d1cc","mediumvioletred":"#c71585","midnightblue":"#191970","mintcream":"#f5fffa","mistyrose":"#ffe4e1","moccasin":"#ffe4b5", + "navajowhite":"#ffdead","navy":"#000080","oldlace":"#fdf5e6","olive":"#808000","olivedrab":"#6b8e23","orange":"#ffa500","orangered":"#ff4500","orchid":"#da70d6", + "palegoldenrod":"#eee8aa","palegreen":"#98fb98","paleturquoise":"#afeeee","palevioletred":"#d87093","papayawhip":"#ffefd5","peachpuff":"#ffdab9","peru":"#cd853f","pink":"#ffc0cb","plum":"#dda0dd","powderblue":"#b0e0e6","purple":"#800080", + "red":"#ff0000","rosybrown":"#bc8f8f","royalblue":"#4169e1","saddlebrown":"#8b4513","salmon":"#fa8072","sandybrown":"#f4a460","seagreen":"#2e8b57","seashell":"#fff5ee","sienna":"#a0522d","silver":"#c0c0c0","skyblue":"#87ceeb","slateblue":"#6a5acd","slategray":"#708090","snow":"#fffafa","springgreen":"#00ff7f","steelblue":"#4682b4", + "tan":"#d2b48c","teal":"#008080","thistle":"#d8bfd8","tomato":"#ff6347","turquoise":"#40e0d0","violet":"#ee82ee","wheat":"#f5deb3","white":"#ffffff","whitesmoke":"#f5f5f5","yellow":"#ffff00","yellowgreen":"#9acd32"}; + + /** + * The ColorManager class allows JS code to grab colors defined in CSS for + * components that don't manage HTML (like jqPlot or sparklines). Such components + * can't use CSS colors directly since the colors are used to generate images + * or by <canvas> elements. + * + * Colors obtained via ColorManager are defined in CSS like this: + * + * .my-color-namespace[data-name=color-name] { + * color: #fff + * } + * + * and can be accessed in JavaScript like this: + * + * piwik.ColorManager.getColor("my-color-namespace", "color-name"); + * + * The singleton instance of this class can be accessed via piwik.ColorManager. + */ + var ColorManager = function () { + // empty + }; + + ColorManager.prototype = { + + /** + * Returns the color for a namespace and name. + * + * @param {String} namespace The string identifier that groups related colors + * together. For example, 'sparkline-colors'. + * @param {String} name The name of the color to retrieve. For example, 'lineColor'. + * @return {String} A hex color, eg, '#fff'. + */ + getColor: function (namespace, name) { + var element = this._getElement(); + + element.attr('class', 'color-manager ' + namespace).attr('data-name', name); + + return this._normalizeColor(element.css('color')); + }, + + /** + * Returns the colors for a namespace and a list of names. + * + * @param {String} namespace The string identifier that groups related colors + * together. For example, 'sparkline-colors'. + * @param {Array} names An array of color names to retrieve. + * @param {Boolean} asArray Whether the result should be an array or an object. + * @return {Object|Array} An object mapping color names with color values or an + * array of colors. + */ + getColors: function (namespace, names, asArray) { + var colors = asArray ? [] : {}; + for (var i = 0; i != names.length; ++i) { + var name = names[i], + color = this.getColor(namespace, name); + if (color) { + if (asArray) { + colors.push(color); + } else { + colors[name] = color; + } + } + } + return colors; + }, + + /** + * Returns a color that is N % between two other colors. + * + * @param {String|Array} spectrumStart The start color. If percentFromStart is 0, this color will + * be returned. Can be either a hex color or RGB array. + * It will be converted to an RGB array if a hex color is supplied. + * @param {String|Array} spectrumEnd The end color. If percentFromStart is 1, this color will be + * returned. Can be either a hex color or RGB array. It will be + * converted to an RGB array if a hex color is supplied. + * @param {Number} percentFromStart The percent from spectrumStart and twoard spectrumEnd that the + * result color should be. Must be a value between 0.0 & 1.0. + * @return {String} A hex color. + */ + getSingleColorFromGradient: function (spectrumStart, spectrumEnd, percentFromStart) { + if (!(spectrumStart instanceof Array)) { + spectrumStart = this.getRgb(spectrumStart); + } + + if (!(spectrumEnd instanceof Array)) { + spectrumEnd = this.getRgb(spectrumEnd); + } + + var result = []; + for (var channel = 0; channel != spectrumStart.length; ++channel) { + var delta = (spectrumEnd[channel] - spectrumStart[channel]) * percentFromStart; + + result[channel] = Math.floor(spectrumStart[channel] + delta); + } + + return this.getHexColor(result); + }, + + /** + * Utility function that converts a hex color (ie, #fff or #1a1a1a) to an array of + * RGB values. + * + * @param {String} hexColor The color to convert. + * @return {Array} An array with three integers between 0 and 255. + */ + getRgb: function (hexColor) { + if (hexColor[0] == '#') { + hexColor = hexColor.substring(1); + } + + if (hexColor.length == 3) { + return [ + parseInt(hexColor[0], 16), + parseInt(hexColor[1], 16), + parseInt(hexColor[2], 16) + ]; + } else { + return [ + parseInt(hexColor.substring(0,2), 16), + parseInt(hexColor.substring(2,4), 16), + parseInt(hexColor.substring(4,6), 16) + ]; + } + }, + + /** + * Utility function that converts an RGB array to a hex color. + * + * @param {Array} rgbColor An array with three integers between 0 and 255. + * @return {String} The hex color, eg, #1a1a1a. + */ + getHexColor: function (rgbColor) { + // convert channels to hex with one leading 0 + for (var i = 0; i != rgbColor.length; ++i) { + rgbColor[i] = ("00" + rgbColor[i].toString(16)).slice(-2); + } + + // create hex string + return '#' + rgbColor.join(''); + }, + + /** + * Turns a color string that might be an rgb value rgb(12, 34, 56) into + * a hex color string. + */ + _normalizeColor: function (color) { + if (color == this._getTransparentColor()) { + return null; + } + + if (color && colorNames[color]) { + return colorNames[color]; + } + + if (color + && color[0] != '#' + ) { + // parse rgb(#, #, #) and get rgb numbers + var parts = color.split(/[()rgb,\s]+/); + parts = [+parts[1], +parts[2], +parts[3]]; + + // convert to hex + color = this.getHexColor(parts); + } + return color; + }, + + /** + * Returns the manufactured <div> element used to obtain color data. When + * getting color data the class and data-name attribute of this element are + * changed. + */ + _getElement: function () { + if (!this.$element) { + $('body').append('<div id="color-manager"></div>'); + this.$element = $('#color-manager'); + } + + return this.$element; + }, + + /** + * Returns this browser's representation of the 'transparent' color. Used to + * compare against colors obtained in getColor. If a color is 'transparent' + * it means there's no color for that namespace/name combination. + */ + _getTransparentColor: function () { + if (!this.transparentColor) { + this.transparentColor = + $('<div style="color:transparent;display:none;"></div>').appendTo($('body')).css('color'); + } + + return this.transparentColor; + } + }; + + piwik.ColorManager = new ColorManager(); + +}(jQuery)); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/javascripts/corehome.js b/www/analytics/plugins/CoreHome/javascripts/corehome.js new file mode 100755 index 00000000..dfb405c9 --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/corehome.js @@ -0,0 +1,160 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($) { + + $(function () { + + // + // 'check for updates' behavior + // + + var headerMessageParent = $('#header_message').parent(); + + // when 'check for updates...' link is clicked, force a check & display the result + headerMessageParent.on('click', '#updateCheckLinkContainer', function (e) { + e.preventDefault(); + + var headerMessage = $(this).closest('#header_message'); + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.setLoadingElement('#header_message .loadingPiwik'); + ajaxRequest.addParams({ + module: 'CoreHome', + action: 'checkForUpdates' + }, 'get'); + ajaxRequest.setCallback(function (response) { + headerMessage.fadeOut('slow', function () { + response = $(response); + + var newVersionAvailable = response.hasClass('header_alert'); + if (newVersionAvailable) { + headerMessage.replaceWith(response); + } + else { + headerMessage.html(_pk_translate('CoreHome_YouAreUsingTheLatestVersion')).show(); + setTimeout(function () { + headerMessage.fadeOut('slow', function () { + headerMessage.replaceWith(response); + }); + }, 4000); + } + }); + }); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + + return false; + }); + + // when clicking the header message, show the long message w/o needing to hover + headerMessageParent.on('click', '#header_message', function (e) { + if (e.target.tagName.toLowerCase() != 'a') { + $(this).toggleClass('active'); + } + }); + + // + // section toggler behavior + // + + var handleSectionToggle = function (self, showType, doHide) { + var sectionId = $(self).attr('data-section-id'), + section = $('#' + sectionId), + showText = _pk_translate('General_Show'), + hideText = _pk_translate('General_Hide'); + + if (typeof(doHide) == 'undefined') { + doHide = section.is(':visible'); + } + + if (doHide) { + var newText = $(self).text().replace(hideText, showText), + afterHide = function () { $(self).text(newText); }; + + if (showType == 'slide') { + section.slideUp(afterHide); + } + else if (showType == 'inline') { + section.hide(); + afterHide(); + } + else { + section.hide(afterHide); + } + } + else { + var newText = $(self).text().replace(showText, hideText); + $(self).text(newText); + + if (showType == 'slide') { + section.slideDown(); + } + else if (showType == 'inline') { + section.css('display', 'inline-block'); + } + else { + section.show(); + } + } + }; + + // when click section toggler link, toggle the visibility of the associated section + $('body').on('click', 'a.section-toggler-link', function (e) { + e.preventDefault(); + handleSectionToggle(this, 'slide'); + return false; + }); + + $('body').on('change', 'input.section-toggler-link', function (e) { + handleSectionToggle(this, 'inline', !$(this).is(':checked')); + }); + + // + // reports by dimension list behavior + // + + // when a report dimension is clicked, load the appropriate report + var currentWidgetLoading = null; + $('body').on('click', '.reportDimension', function (e) { + var view = $(this).closest('.reportsByDimensionView'), + report = $('.dimensionReport', view), + loading = $('.loadingPiwik', view); + + // make this dimension the active one + $('.activeDimension', view).removeClass('activeDimension'); + $(this).addClass('activeDimension'); + + // hide the visible report & show the loading elem + report.hide(); + loading.show(); + + // load the report using the data-url attribute (which holds the URL to the report) + var widgetParams = broadcast.getValuesFromUrl($(this).attr('data-url')); + for (var key in widgetParams) { + widgetParams[key] = decodeURIComponent(widgetParams[key]); + } + + var widgetUniqueId = widgetParams.module + widgetParams.action; + currentWidgetLoading = widgetUniqueId; + + widgetsHelper.loadWidgetAjax(widgetUniqueId, widgetParams, function (response) { + // if the widget that was loaded was not for the latest clicked link, do nothing w/ the response + if (widgetUniqueId != currentWidgetLoading) { + return; + } + + loading.hide(); + report.html($(response)).css('display', 'inline-block'); + + // scroll to report + piwikHelper.lazyScrollTo(report, 400); + }); + }); + }); + +}(jQuery)); diff --git a/www/analytics/plugins/CoreHome/javascripts/dataTable.js b/www/analytics/plugins/CoreHome/javascripts/dataTable.js new file mode 100644 index 00000000..7fb018cc --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/dataTable.js @@ -0,0 +1,1767 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +//----------------------------------------------------------------------------- +// DataTable +//----------------------------------------------------------------------------- + +(function ($, require) { + +var exports = require('piwik/UI'), + UIControl = exports.UIControl; + +/** + * This class contains the client side logic for viewing and interacting with + * Piwik datatables. + * + * The id attribute for DataTables is set dynamically by the initNewDataTables + * method, and this class instance is stored using the jQuery $.data function + * with the 'uiControlObject' key. + * + * To find a datatable element by report (ie, 'UserSettings.getBrowser'), + * use piwik.DataTable.getDataTableByReport. + * + * To get the dataTable JS instance (an instance of this class) for a + * datatable HTML element, use $(element).data('uiControlObject'). + * + * @constructor + */ +function DataTable(element) { + UIControl.call(this, element); + + this.init(); +} + +DataTable._footerIconHandlers = {}; + +DataTable.initNewDataTables = function () { + $('div.dataTable').each(function () { + if (!$(this).attr('id')) { + var tableType = $(this).attr('data-table-type') || 'DataTable', + klass = require('piwik/UI')[tableType] || require(tableType), + table = new klass(this); + } + }); +}; + +DataTable.registerFooterIconHandler = function (id, handler) { + var handlers = DataTable._footerIconHandlers; + + if (handlers[id]) { + setTimeout(function () { // fail gracefully + throw new Exception("DataTable footer icon handler '" + id + "' is already being used.") + }, 1); + return; + } + + handlers[id] = handler; +}; + +/** + * Returns the first datatable div displaying a specific report. + * + * @param {string} report The report, eg, UserSettings.getWideScreen + * @return {Element} The datatable div displaying the report, or undefined if + * it cannot be found. + */ +DataTable.getDataTableByReport = function (report) { + var result = undefined; + $('div.dataTable').each(function () { + if ($(this).attr('data-report') == report) { + result = this; + return false; + } + }); + return result; +}; + +$.extend(DataTable.prototype, UIControl.prototype, { + + _init: function (domElem) { + // initialize your dataTable in your plugin + }, + + //initialisation function + init: function () { + var domElem = this.$element; + + this.workingDivId = this._createDivId(); + domElem.attr('id', this.workingDivId); + + this.loadedSubDataTable = {}; + this.isEmpty = $('.pk-emptyDataTable', domElem).length > 0; + this.bindEventsAndApplyStyle(domElem); + this._init(domElem); + this.initialized = true; + }, + + //function triggered when user click on column sort + onClickSort: function (domElem) { + var self = this; + var newColumnToSort = $(domElem).attr('id'); + // we lookup if the column to sort was already this one, if it is the case then we switch from desc <-> asc + if (self.param.filter_sort_column == newColumnToSort) { + // toggle the sorted order + if (this.param.filter_sort_order == 'asc') { + self.param.filter_sort_order = 'desc'; + } + else { + self.param.filter_sort_order = 'asc'; + } + } + self.param.filter_offset = 0; + self.param.filter_sort_column = newColumnToSort; + self.reloadAjaxDataTable(); + }, + + setGraphedColumn: function (columnName) { + this.param.columns = columnName; + }, + + isWithinDialog: function (domElem) { + return !!$(domElem).parents('.ui-dialog').length; + }, + + isDashboard: function () { + return !!$('#dashboardWidgetsArea').length; + }, + + //Reset DataTable filters (used before a reload or view change) + resetAllFilters: function () { + var self = this; + var FiltersToRestore = {}; + var filters = [ + 'filter_column', + 'filter_pattern', + 'filter_column_recursive', + 'filter_pattern_recursive', + 'enable_filter_excludelowpop', + 'filter_offset', + 'filter_limit', + 'filter_sort_column', + 'filter_sort_order', + 'disable_generic_filters', + 'columns', + 'flat', + 'include_aggregate_rows', + 'totalRows' + ]; + + for (var key = 0; key < filters.length; key++) { + var value = filters[key]; + FiltersToRestore[value] = self.param[value]; + delete self.param[value]; + } + + return FiltersToRestore; + }, + + //Restores the filters to the values given in the array in parameters + restoreAllFilters: function (FiltersToRestore) { + var self = this; + + for (var key in FiltersToRestore) { + self.param[key] = FiltersToRestore[key]; + } + }, + + //Translate string parameters to javascript builtins + //'true' -> true, 'false' -> false + //it simplifies condition tests in the code + cleanParams: function () { + var self = this; + for (var key in self.param) { + if (self.param[key] == 'true') self.param[key] = true; + if (self.param[key] == 'false') self.param[key] = false; + } + }, + + // Function called to trigger the AJAX request + // The ajax request contains the function callback to trigger if the request is successful or failed + // displayLoading = false When we don't want to display the Loading... DIV .loadingPiwik + // for example when the script add a Loading... it self and doesn't want to display the generic Loading + reloadAjaxDataTable: function (displayLoading, callbackSuccess) { + var self = this; + + if (typeof displayLoading == "undefined") { + displayLoading = true; + } + if (typeof callbackSuccess == "undefined") { + callbackSuccess = function (response) { + self.dataTableLoaded(response, self.workingDivId); + }; + } + + if (displayLoading) { + $('#' + self.workingDivId + ' .loadingPiwik').last().css('display', 'block'); + } + + // when switching to display graphs, reset limit + if (self.param.viewDataTable && self.param.viewDataTable.indexOf('graph') === 0) { + delete self.param.filter_offset; + delete self.param.filter_limit; + } + + var container = $('#' + self.workingDivId + ' .piwik-graph'); + + + var params = {}; + for (var key in self.param) { + if (typeof self.param[key] != "undefined" && self.param[key] != '') + params[key] = self.param[key]; + } + + var ajaxRequest = new ajaxHelper(); + + ajaxRequest.addParams(params, 'get'); + + ajaxRequest.setCallback( + function (response) { + container.trigger('piwikDestroyPlot'); + container.off('piwikDestroyPlot'); + callbackSuccess(response); + } + ); + ajaxRequest.setFormat('html'); + + ajaxRequest.send(false); + + }, + + // Function called when the AJAX request is successful + // it looks for the ID of the response and replace the very same ID + // in the current page with the AJAX response + dataTableLoaded: function (response, workingDivId) { + var content = $(response); + + var idToReplace = workingDivId || $(content).attr('id'); + var dataTableSel = $('#' + idToReplace); + + // if the current dataTable is located inside another datatable + table = $(content).parents('table.dataTable'); + if (dataTableSel.parents('.dataTable').is('table')) { + // we add class to the table so that we can give a different style to the subtable + $(content).find('table.dataTable').addClass('subDataTable'); + $(content).find('.dataTableFeatures').addClass('subDataTable'); + + //we force the initialisation of subdatatables + dataTableSel.replaceWith(content); + } + else { + dataTableSel.find('object').remove(); + dataTableSel.replaceWith(content); + } + + content.trigger('piwik:dataTableLoaded'); + + piwikHelper.lazyScrollTo(content[0], 400); + + return content; + }, + + /* This method is triggered when a new DIV is loaded, which happens + - at the first loading of the page + - after any AJAX loading of a DataTable + + This method basically add features to the DataTable, + - such as column sorting, searching in the rows, displaying Next / Previous links, etc. + - add styles to the cells and rows (odd / even styles) + - modify some rows to add images if a span img is found, or add a link if a span urlLink is found + - bind new events onclick / hover / etc. to trigger AJAX requests, + nice hovertip boxes for truncated cells + */ + bindEventsAndApplyStyle: function (domElem) { + var self = this; + self.cleanParams(); + self.handleSort(domElem); + self.handleLimit(domElem); + self.handleSearchBox(domElem); + self.handleOffsetInformation(domElem); + self.handleAnnotationsButton(domElem); + self.handleEvolutionAnnotations(domElem); + self.handleExportBox(domElem); + self.applyCosmetics(domElem); + self.handleSubDataTable(domElem); + self.handleConfigurationBox(domElem); + self.handleColumnDocumentation(domElem); + self.handleRowActions(domElem); + self.handleCellTooltips(domElem); + self.handleRelatedReports(domElem); + self.handleTriggeredEvents(domElem); + self.handleColumnHighlighting(domElem); + self.handleExpandFooter(domElem); + self.setFixWidthToMakeEllipsisWork(domElem); + }, + + setFixWidthToMakeEllipsisWork: function (domElem) { + var self = this; + + function getTableWidth(domElem) { + var totalWidth = $(domElem).width(); + var totalWidthTable = $('table.dataTable', domElem).width(); // fixes tables in dbstats, referrers, ... + + if (totalWidthTable < totalWidth) { + totalWidth = totalWidthTable; + } + + if (!totalWidth) { + totalWidth = 0; + } + + return parseInt(totalWidth, 10); + } + + function getLabelWidth(domElem, tableWidth, minLabelWidth, maxLabelWidth) + { + var labelWidth = minLabelWidth; + + var columnsInFirstRow = $('tr:nth-child(1) td:not(.label)', domElem); + + var widthOfAllColumns = 0; + columnsInFirstRow.each(function (index, column) { + widthOfAllColumns += $(column).outerWidth(); + }); + + if (tableWidth - widthOfAllColumns >= minLabelWidth) { + labelWidth = tableWidth - widthOfAllColumns; + } else if (widthOfAllColumns >= tableWidth) { + labelWidth = tableWidth * 0.5; + } + + var isWidgetized = -1 !== location.search.indexOf('module=Widgetize'); + + if (labelWidth > maxLabelWidth + && !isWidgetized + && !self.isDashboard()) { + labelWidth = maxLabelWidth; // prevent for instance table in Actions-Pages is not too wide + } + + return parseInt(labelWidth, 10); + } + + function getLabelColumnMinWidth(domElem) + { + var minWidth = 0; + var minWidthHead = $('thead .first.label', domElem).css('minWidth'); + + if (minWidthHead) { + minWidth = parseInt(minWidthHead, 10); + } + + var minWidthBody = $('tbody tr:nth-child(1) td.label', domElem).css('minWidth'); + + if (minWidthBody) { + minWidthBody = parseInt(minWidthBody, 10); + if (minWidthBody && minWidthBody > minWidth) { + minWidth = minWidthBody; + } + } + + return parseInt(minWidth, 10); + } + + function removePaddingFromWidth(domElem, labelWidth) { + + var firstLabel = $('tbody tr:nth-child(1) td.label', domElem); + + var paddingLeft = firstLabel.css('paddingLeft'); + paddingLeft = paddingLeft ? parseInt(paddingLeft, 10) : 0; + + var paddingRight = firstLabel.css('paddingRight'); + paddingRight = paddingRight ? parseInt(paddingRight, 10) : 0; + + labelWidth = labelWidth - paddingLeft - paddingRight; + + return labelWidth; + } + + var minLabelWidth = 125; + var maxLabelWidth = 440; + + var tableWidth = getTableWidth(domElem); + var labelColumnMinWidth = getLabelColumnMinWidth(domElem); + var labelColumnWidth = getLabelWidth(domElem, tableWidth, 125, 440); + + if (labelColumnMinWidth > labelColumnWidth) { + labelColumnWidth = labelColumnMinWidth; + } + + labelColumnWidth = removePaddingFromWidth(domElem, labelColumnWidth); + + if (labelColumnWidth) { + $('td.label', domElem).width(labelColumnWidth); + } + + $('td span.label', domElem).each(function () { self.tooltip($(this)); }); + }, + + handleLimit: function (domElem) { + var tableRowLimits = [5, 10, 25, 50, 100, 250, 500], + evolutionLimits = + { + day: [30, 60, 90, 180, 365, 500], + week: [4, 12, 26, 52, 104, 500], + month: [3, 6, 12, 24, 36, 120], + year: [3, 5, 10] + }; + + var self = this; + if (typeof self.parentId != "undefined" && self.parentId != '') { + // no limit selector for subtables + $('.limitSelection', domElem).remove(); + return; + } + + // configure limit control + var setLimitValue, numbers, limitParamName; + if (self.param.viewDataTable == 'graphEvolution') { + limitParamName = 'evolution_' + self.param.period + '_last_n'; + numbers = evolutionLimits[self.param.period] || tableRowLimits; + + setLimitValue = function (params, limit) { + params[limitParamName] = limit; + }; + } + else { + numbers = tableRowLimits; + limitParamName = 'filter_limit'; + + setLimitValue = function (params, value) { + params.filter_limit = value; + params.filter_offset = 0; + }; + } + + // setup limit control + $('.limitSelection', domElem).append('<div><span>' + self.param[limitParamName] + '</span></div><ul></ul>'); + + if (self.props.show_limit_control) { + $('.limitSelection ul', domElem).hide(); + for (var i = 0; i < numbers.length; i++) { + $('.limitSelection ul', domElem).append('<li value="' + numbers[i] + '"><span>' + numbers[i] + '</span></li>'); + } + $('.limitSelection ul li:last', domElem).addClass('last'); + + if (!self.isEmpty) { + var show = function() { + $('.limitSelection ul', domElem).show(); + $('.limitSelection', domElem).addClass('visible'); + $(document).on('mouseup.limitSelection', function(e) { + if (!$(e.target).closest('.limitSelection').length) { + hide(); + } + }); + }; + var hide = function () { + $('.limitSelection ul', domElem).hide(); + $('.limitSelection', domElem).removeClass('visible'); + $(document).off('mouseup.limitSelection'); + }; + $('.limitSelection div', domElem).on('click', function () { + $('.limitSelection', domElem).is('.visible') ? hide() : show(); + }); + $('.limitSelection ul li', domElem).on('click', function (event) { + var limit = parseInt($(event.target).text()); + + hide(); + if (limit != self.param[limitParamName]) { + setLimitValue(self.param, limit); + $('.limitSelection>div>span', domElem).text(limit); + self.reloadAjaxDataTable(); + + var data = {}; + data[limitParamName] = self.param[limitParamName]; + self.notifyWidgetParametersChange(domElem, data); + } + }); + } + else { + $('.limitSelection', domElem).toggleClass('disabled'); + } + } + else { + $('.limitSelection', domElem).hide(); + } + }, + + // if sorting the columns is enabled, when clicking on a column, + // - if this column was already the one used for sorting, we revert the order desc<->asc + // - we send the ajax request with the new sorting information + handleSort: function (domElem) { + var self = this; + + function getSortImageSrc() { + var imageSortSrc = false; + if (currentIsSubDataTable) { + if (self.param.filter_sort_order == 'asc') { + imageSortSrc = 'plugins/Zeitgeist/images/sort_subtable_asc.png'; + } else { + imageSortSrc = 'plugins/Zeitgeist/images/sort_subtable_desc.png'; + } + } else { + if (self.param.filter_sort_order == 'asc') { + imageSortSrc = 'plugins/Zeitgeist/images/sortasc.png'; + } else { + imageSortSrc = 'plugins/Zeitgeist/images/sortdesc.png'; + } + } + return imageSortSrc; + } + + if (self.props.enable_sort) { + $('.sortable', domElem).off('click.dataTableSort').on('click.dataTableSort', + function () { + $(this).off('click.dataTableSort'); + self.onClickSort(this); + } + ); + } + + if (self.param.filter_sort_column) { + // are we in a subdatatable? + var currentIsSubDataTable = $(domElem).parent().hasClass('cellSubDataTable'); + var imageSortSrc = getSortImageSrc(); + var imageSortWidth = 16; + var imageSortHeight = 16; + + var sortOrder = self.param.filter_sort_order || 'desc'; + var ImageSortClass = sortOrder.charAt(0).toUpperCase() + sortOrder.substr(1); + + // we change the style of the column currently used as sort column + // adding an image and the class columnSorted to the TD + $("th#" + self.param.filter_sort_column + ' #thDIV', domElem).parent() + .addClass('columnSorted') + .prepend('<div class="sortIconContainer sortIconContainer' + ImageSortClass + '"><img class="sortIcon" width="' + imageSortWidth + '" height="' + imageSortHeight + '" src="' + imageSortSrc + '" /></div>'); + } + }, + + //behaviour for the DataTable 'search box' + handleSearchBox: function (domElem, callbackSuccess) { + var self = this; + + var currentPattern = self.param.filter_pattern; + if (typeof self.param.filter_pattern != "undefined" + && self.param.filter_pattern.length > 0) { + currentPattern = self.param.filter_pattern; + } + else if (typeof self.param.filter_pattern_recursive != "undefined" + && self.param.filter_pattern_recursive.length > 0) { + currentPattern = self.param.filter_pattern_recursive; + } + else { + currentPattern = ''; + } + currentPattern = piwikHelper.htmlDecode(currentPattern); + + $('.dataTableSearchPattern', domElem) + .css({display: 'block'}) + .each(function () { + // when enter is pressed in the input field we submit the form + $('.searchInput', this) + .on("keyup", + function (e) { + if (isEnterKey(e)) { + $(this).siblings(':submit').submit(); + } + } + ) + .val(currentPattern) + ; + + $(':submit', this).submit( + function () { + var keyword = $(this).siblings('.searchInput').val(); + self.param.filter_offset = 0; + + if (self.param.search_recursive) { + self.param.filter_column_recursive = 'label'; + self.param.filter_pattern_recursive = keyword; + } + else { + self.param.filter_column = 'label'; + self.param.filter_pattern = keyword; + } + + delete self.param.totalRows; + + self.reloadAjaxDataTable(true, callbackSuccess); + } + ); + + $(':submit', this) + .click(function () { $(this).submit(); }) + ; + + // in the case there is a searched keyword we display the RESET image + if (currentPattern) { + var target = this; + var clearImg = $('<span class="searchReset">\ + <img src="plugins/CoreHome/images/reset_search.png" title="Clear" />\ + </span>') + .click(function () { + $('.searchInput', target).val(''); + $(':submit', target).submit(); + }); + $('.searchInput', this).after(clearImg); + + } + } + ); + + if (this.isEmpty && !currentPattern) { + $('.dataTableSearchPattern', domElem).hide(); + } + }, + + //behaviour for '< prev' 'next >' links and page count + handleOffsetInformation: function (domElem) { + var self = this; + + $('.dataTablePages', domElem).each( + function () { + var offset = 1 + Number(self.param.filter_offset); + var offsetEnd = Number(self.param.filter_offset) + Number(self.param.filter_limit); + var totalRows = Number(self.param.totalRows); + var offsetEndDisp = offsetEnd; + + if (self.param.keep_summary_row == 1) --totalRows; + + if (offsetEnd > totalRows) offsetEndDisp = totalRows; + + // only show this string if there is some rows in the datatable + if (totalRows != 0) { + var str = sprintf(_pk_translate('CoreHome_PageOf'), offset + '-' + offsetEndDisp, totalRows); + $(this).text(str); + } else { + $(this).hide(); + } + } + ); + + // Display the next link if the total Rows is greater than the current end row + $('.dataTableNext', domElem) + .each(function () { + var offsetEnd = Number(self.param.filter_offset) + + Number(self.param.filter_limit); + var totalRows = Number(self.param.totalRows); + if (self.param.keep_summary_row == 1) --totalRows; + if (offsetEnd < totalRows) { + $(this).css('display', 'inline'); + } + }) + // bind the click event to trigger the ajax request with the new offset + .click(function () { + $(this).off('click'); + self.param.filter_offset = Number(self.param.filter_offset) + Number(self.param.filter_limit); + self.reloadAjaxDataTable(); + }) + ; + + // Display the previous link if the current offset is not zero + $('.dataTablePrevious', domElem) + .each(function () { + var offset = 1 + Number(self.param.filter_offset); + if (offset != 1) { + $(this).css('display', 'inline'); + } + } + ) + // bind the click event to trigger the ajax request with the new offset + // take care of the negative offset, we setup 0 + .click( + function () { + $(this).off('click'); + var offset = Number(self.param.filter_offset) - Number(self.param.filter_limit); + if (offset < 0) { offset = 0; } + self.param.filter_offset = offset; + self.param.previous = 1; + self.reloadAjaxDataTable(); + } + ); + }, + + handleEvolutionAnnotations: function (domElem) { + var self = this; + if (self.param.viewDataTable == 'graphEvolution' + && $('.annotationView', domElem).length > 0) { + // get dates w/ annotations across evolution period (have to do it through AJAX since we + // determine placement using the elements created by jqplot) + + $('.dataTableFeatures', domElem).addClass('hasEvolution'); + + piwik.annotations.api.getEvolutionIcons( + self.param.idSite, + self.param.date, + self.param.period, + self.param['evolution_' + self.param.period + '_last_n'], + function (response) { + var annotations = $(response), + datatableFeatures = $('.dataTableFeatures', domElem), + noteSize = 16, + annotationAxisHeight = 30 // css height + padding + margin + ; + + var annotationsCss = {left: 6}; // padding-left of .jqplot-graph element (in _dataTableViz_jqplotGraph.tpl) + if (!self.isDashboard() && !self.isWithinDialog(domElem)) { + annotationsCss['top'] = -datatableFeatures.height() - annotationAxisHeight + noteSize / 2; + } + + // set position of evolution annotation icons + annotations.css(annotationsCss); + + piwik.annotations.placeEvolutionIcons(annotations, domElem); + + // add new section under axis + if (self.isDashboard() || self.isWithinDialog(domElem)) { + annotations.insertAfter($('.datatableRelatedReports', domElem)); + } else { + datatableFeatures.append(annotations); + } + + // reposition annotation icons every time the graph is resized + $('.piwik-graph', domElem).on('resizeGraph', function () { + piwik.annotations.placeEvolutionIcons(annotations, domElem); + }); + + // on hover of x-axis, show note icon over correct part of x-axis + datatableFeatures.on('mouseenter', '.evolution-annotations>span', function () { + $(this).css('opacity', 1); + }); + + datatableFeatures.on('mouseleave', '.evolution-annotations>span', function () { + if ($(this).attr('data-count') == 0) // only hide if there are no annotations for this note + { + $(this).css('opacity', 0); + } + }); + + // when clicking an annotation, show the annotation viewer for that period + datatableFeatures.on('click', '.evolution-annotations>span', function () { + var spanSelf = $(this), + date = spanSelf.attr('data-date'), + oldDate = $('.annotation-manager', domElem).attr('data-date'); + if (date) { + var period = self.param.period; + if (period == 'range') { + period = 'day'; + } + + piwik.annotations.showAnnotationViewer( + domElem, + self.param.idSite, + date, + period, + undefined, // lastN + function (manager) { + manager.attr('data-is-range', 0); + $('.annotationView img', domElem) + .attr('title', _pk_translate('Annotations_IconDesc')); + + var viewAndAdd = _pk_translate('Annotations_ViewAndAddAnnotations'), + hideNotes = _pk_translate('Annotations_HideAnnotationsFor'); + + // change the tooltip of the previously clicked evolution icon (if any) + if (oldDate) { + $('span', annotations).each(function () { + if ($(this).attr('data-date') == oldDate) { + $(this).attr('title', viewAndAdd.replace("%s", oldDate)); + return false; + } + }); + } + + // change the tooltip of the clicked evolution icon + if (manager.is(':hidden')) { + spanSelf.attr('title', viewAndAdd.replace("%s", date)); + } + else { + spanSelf.attr('title', hideNotes.replace("%s", date)); + } + } + ); + } + }); + + // when hover over annotation in annotation manager, highlight the annotation + // icon + var runningAnimation = null; + domElem.on('mouseenter', '.annotation', function (e) { + var date = $(this).attr('data-date'); + + // find the icon for this annotation + var icon = $(); + $('span', annotations).each(function () { + if ($(this).attr('data-date') == date) { + icon = $('img', this); + return false; + } + }); + + if (icon[0] == runningAnimation) // if the animation is already running, do nothing + { + return; + } + + // stop ongoing animations + $('span', annotations).each(function () { + $('img', this).removeAttr('style'); + }); + + // start a bounce animation + icon.effect("bounce", {times: 1, distance: 10}, 1000); + runningAnimation = icon[0]; + }); + + // reset running animation item when leaving annotations list + domElem.on('mouseleave', '.annotations', function (e) { + runningAnimation = null; + }); + + self.$element.trigger('piwik:annotationsLoaded'); + } + ); + } + }, + + handleAnnotationsButton: function (domElem) { + var self = this; + if (self.param.idSubtable) // no annotations for subtables, just whole reports + { + return; + } + + // show the annotations view on click + $('.annotationView', domElem).click(function () { + var annotationManager = $('.annotation-manager', domElem); + + if (annotationManager.length > 0 + && annotationManager.attr('data-is-range') == 1) { + if (annotationManager.is(':hidden')) { + annotationManager.slideDown('slow'); // showing + $('img', this).attr('title', _pk_translate('Annotations_IconDescHideNotes')); + } + else { + annotationManager.slideUp('slow'); // hiding + $('img', this).attr('title', _pk_translate('Annotations_IconDesc')); + } + } + else { + // show the annotation viewer for the whole date range + var lastN = self.param['evolution_' + self.param.period + '_last_n']; + piwik.annotations.showAnnotationViewer( + domElem, + self.param.idSite, + self.param.date, + self.param.period, + lastN, + function (manager) { + manager.attr('data-is-range', 1); + } + ); + + // change the tooltip of the view annotation icon + $('img', this).attr('title', _pk_translate('Annotations_IconDescHideNotes')); + } + }); + }, + + // DataTable view box (simple table, all columns table, Goals table, pie graph, tag cloud, graph, ...) + handleExportBox: function (domElem) { + var self = this; + if (self.param.idSubtable) { + // no view box for subtables + return; + } + + // When the (+) image is hovered, the export buttons are displayed + $('.dataTableFooterIconsShow', domElem) + .show() + .hover(function () { + $(this).fadeOut('slow'); + $('.exportToFormatIcons', $(this).parent()).show('slow'); + }, function () {} + ); + + //footer arrow position element name + self.jsViewDataTable = self.param.viewDataTable; + + $('.tableAllColumnsSwitch a', domElem).show(); + + $('.dataTableFooterIcons .tableIcon', domElem).click(function () { + var id = $(this).attr('data-footer-icon-id'); + if (!id) { + return; + } + + var handler = DataTable._footerIconHandlers[id]; + if (!handler) { + handler = DataTable._footerIconHandlers['table']; + } + + handler(self, id); + }); + + //Graph icon Collapsed functionality + self.currentGraphViewIcon = 0; + self.graphViewEnabled = 0; + self.graphViewStartingThreads = 0; + self.graphViewStartingKeep = false; //show keep flag + + //define collapsed icons + $('.tableGraphCollapsed a', domElem) + .each(function (i) { + if (self.jsViewDataTable == $(this).attr('data-footer-icon-id')) { + self.currentGraphViewIcon = i; + self.graphViewEnabled = true; + } + }) + .each(function (i) { + if (self.currentGraphViewIcon != i) $(this).hide(); + }); + + $('.tableGraphCollapsed', domElem).hover( + function () { + //Graph icon onmouseover + if (self.graphViewStartingThreads > 0) return self.graphViewStartingKeep = true; //exit if animation is not finished + $(this).addClass('tableIconsGroupActive'); + $('a', this).each(function (i) { + if (self.currentGraphViewIcon != i || self.graphViewEnabled) { + self.graphViewStartingThreads++; + } + if (self.currentGraphViewIcon != i) { + //show other icons + $(this).show('fast', function () {self.graphViewStartingThreads--}); + } + else if (self.graphViewEnabled) { + self.graphViewStartingThreads--; + } + }); + self.exportToFormatHide(domElem); + }, + function() { + //Graph icon onmouseout + if (self.graphViewStartingKeep) return self.graphViewStartingKeep = false; //exit while icons animate + $('a', this).each(function (i) { + if (self.currentGraphViewIcon != i) { + //hide other icons + $(this).hide('fast'); + } + }); + $(this).removeClass('tableIconsGroupActive'); + } + ); + + //handle exportToFormat icons + self.exportToFormat = null; + $('.exportToFormatIcons a', domElem).click(function () { + self.exportToFormat = {}; + self.exportToFormat.lastActiveIcon = this; + self.exportToFormat.target = $(this).parent().siblings('.exportToFormatItems').show('fast'); + self.exportToFormat.obj = $(this).hide(); + }); + + //close exportToFormat onClickOutside + $('body').on('mouseup', function (e) { + if (self.exportToFormat) { + self.exportToFormatHide(domElem); + } + }); + + + $('.exportToFormatItems a', domElem) + // prevent click jacking attacks by dynamically adding the token auth when the link is clicked + .click(function () { + $(this).attr('href', function () { + var url = $(this).attr('href') + '&token_auth=' + piwik.token_auth; + + var limit = $('.limitSelection>div>span', domElem).text(); + var defaultLimit = $(this).attr('filter_limit'); + if (!limit || 'undefined' === limit || defaultLimit == -1) { + limit = defaultLimit; + } + url += '&filter_limit=' + limit; + + return url; + }) + }) + .attr('href', function () { + var format = $(this).attr('format'); + var method = $(this).attr('methodToCall'); + var segment = self.param.segment; + var label = self.param.label; + var idGoal = self.param.idGoal; + var param_date = self.param.date; + var date = $(this).attr('date'); + if (typeof date != 'undefined') { + param_date = date; + } + if (typeof self.param.dateUsedInGraph != 'undefined') { + param_date = self.param.dateUsedInGraph; + } + var period = self.param.period; + + var formatsUseDayNotRange = piwik.config.datatable_export_range_as_day.toLowerCase(); + if (formatsUseDayNotRange.indexOf(format.toLowerCase()) != -1 + && self.param.period == 'range') { + period = 'day'; + } + + // Below evolution graph, show daily exports + if(self.param.period == 'range' + && self.param.viewDataTable == "graphEvolution") { + period = 'day'; + } + var str = 'index.php?module=API' + + '&method=' + method + + '&format=' + format + + '&idSite=' + self.param.idSite + + '&period=' + period + + '&date=' + param_date + + ( typeof self.param.filter_pattern != "undefined" ? '&filter_pattern=' + self.param.filter_pattern : '') + + ( typeof self.param.filter_pattern_recursive != "undefined" ? '&filter_pattern_recursive=' + self.param.filter_pattern_recursive : ''); + + + if (typeof self.param.flat != "undefined") { + str += '&flat=' + (self.param.flat == 0 ? '0' : '1'); + if (typeof self.param.include_aggregate_rows != "undefined" && self.param.include_aggregate_rows) { + str += '&include_aggregate_rows=1'; + } + if (!self.param.flat + && typeof self.param.filter_pattern_recursive != "undefined" + && self.param.filter_pattern_recursive) { + str += '&expanded=1'; + } + + } else { + str += '&expanded=1'; + } + if (format == 'CSV' || format == 'TSV' || format == 'RSS') { + str += '&translateColumnNames=1&language=' + piwik.language; + } + if (typeof segment != 'undefined') { + str += '&segment=' + segment; + } + // Export Goals specific reports + if (typeof idGoal != 'undefined' + && idGoal != '-1') { + str += '&idGoal=' + idGoal; + } + if (label) { + label = label.split(','); + + if (label.length > 1) { + for (var i = 0; i != label.length; ++i) { + str += '&label[]=' + encodeURIComponent(label[i]); + } + } else { + str += '&label=' + encodeURIComponent(label[0]); + } + } + return str; + } + ); + }, + + exportToFormatHide: function (domElem, noAnimation) { + var self = this; + if (self.exportToFormat) { + var animationSpeed = noAnimation ? 0 : 'fast'; + self.exportToFormat.target.hide(animationSpeed); + self.exportToFormat.obj.show(animationSpeed); + self.exportToFormat = null; + } + }, + + handleConfigurationBox: function (domElem, callbackSuccess) { + var self = this; + + if (typeof self.parentId != "undefined" && self.parentId != '') { + // no manipulation when loading subtables + return; + } + if ((typeof self.numberOfSubtables == 'undefined' || self.numberOfSubtables == 0) + && (typeof self.param.flat == 'undefined' || self.param.flat != 1)) { + // if there are no subtables, remove the flatten action + $('.dataTableFlatten', domElem).parent().remove(); + } + + var ul = $('div.tableConfiguration ul', domElem); + function hideConfigurationIcon() { + // hide the icon when there are no actions available or we're not in a table view + $('div.tableConfiguration', domElem).remove(); + } + + if (ul.find('li').size() == 0) { + hideConfigurationIcon(); + return; + } + + var icon = $('a.tableConfigurationIcon', domElem); + icon.click(function () { return false; }); + var iconHighlighted = false; + + ul.find('li:first').addClass('first'); + ul.find('li:last').addClass('last'); + ul.prepend('<li class="firstDummy"></li>'); + + // open and close the box + var open = function () { + self.exportToFormatHide(domElem, true); + ul.addClass('open'); + icon.css('opacity', 1); + }; + var close = function () { + ul.removeClass('open'); + icon.css('opacity', icon.hasClass('highlighted') ? .85 : .6); + }; + $('div.tableConfiguration', domElem).hover(open, close); + + var generateClickCallback = function (paramName, callbackAfterToggle) { + return function () { + close(); + self.param[paramName] = 1 - self.param[paramName]; + self.param.filter_offset = 0; + delete self.param.totalRows; + if (callbackAfterToggle) callbackAfterToggle(); + self.reloadAjaxDataTable(true, callbackSuccess); + var data = {}; + data[paramName] = self.param[paramName]; + self.notifyWidgetParametersChange(domElem, data); + }; + }; + + var getText = function (text, addDefault) { + text = _pk_translate(text); + if (text.indexOf('%s') > 0) { + text = text.replace('%s', '<br /><span class="action">» '); + if (addDefault) text += ' (' + _pk_translate('CoreHome_Default') + ')'; + text += '</span>'; + } + return text; + }; + + var setText = function (el, paramName, textA, textB) { + if (typeof self.param[paramName] != 'undefined' && self.param[paramName] == 1) { + $(el).html(getText(textA, true)); + iconHighlighted = true; + } + else { + self.param[paramName] = 0; + $(el).html(getText(textB)); + } + }; + + // handle low population + $('.dataTableExcludeLowPopulation', domElem) + .each(function () { + // Set the text, either "Exclude low pop" or "Include all" + if (typeof self.param.enable_filter_excludelowpop == 'undefined') { + self.param.enable_filter_excludelowpop = 0; + } + if (Number(self.param.enable_filter_excludelowpop) != 0) { + var string = getText('CoreHome_IncludeRowsWithLowPopulation', true); + self.param.enable_filter_excludelowpop = 1; + iconHighlighted = true; + } + else { + var string = getText('CoreHome_ExcludeRowsWithLowPopulation'); + self.param.enable_filter_excludelowpop = 0; + } + $(this).html(string); + }) + .click(generateClickCallback('enable_filter_excludelowpop')); + + // handle flatten + $('.dataTableFlatten', domElem) + .each(function () { + setText(this, 'flat', 'CoreHome_UnFlattenDataTable', 'CoreHome_FlattenDataTable'); + }) + .click(generateClickCallback('flat')); + + $('.dataTableIncludeAggregateRows', domElem) + .each(function () { + setText(this, 'include_aggregate_rows', 'CoreHome_DataTableExcludeAggregateRows', + 'CoreHome_DataTableIncludeAggregateRows'); + }) + .click(generateClickCallback('include_aggregate_rows', function () { + if (self.param.include_aggregate_rows == 1) { + // when including aggregate rows is enabled, we remove the sorting + // this way, the aggregate rows appear directly before their children + self.param.filter_sort_column = ''; + self.notifyWidgetParametersChange(domElem, {filter_sort_column: ''}); + } + })); + + // handle highlighted icon + if (iconHighlighted) { + icon.addClass('highlighted'); + } + close(); + + if (!iconHighlighted + && !(self.param.viewDataTable == 'table' + || self.param.viewDataTable == 'tableAllColumns' + || self.param.viewDataTable == 'tableGoals')) { + hideConfigurationIcon(); + return; + } + + // fix a css bug of ie7 + if (document.all && !window.opera && window.XMLHttpRequest) { + window.setTimeout(function () { + open(); + var width = 0; + ul.find('li').each(function () { + width = Math.max(width, $(this).width()); + }).width(width); + close(); + }, 400); + } + }, + + // Tell parent widget that the parameters of this table was updated, + notifyWidgetParametersChange: function (domWidget, parameters) { + var widget = $(domWidget).closest('[widgetId]'); + // trigger setParameters event on base element + widget.trigger('setParameters', parameters); + }, + + tooltip: function (domElement) { + + function isTextEllipsized($element) + { + return !($element && $element[0] && $element.outerWidth() >= $element[0].scrollWidth); + } + + var $domElement = $(domElement); + + if ($domElement.data('tooltip') == 'enabled') { + return; + } + + $domElement.data('tooltip', 'enabled'); + + if (!isTextEllipsized($domElement)) { + return; + } + + var customToolTipText = $domElement.attr('title') || $domElement.text(); + + if (customToolTipText) { + $domElement.attr('title', customToolTipText); + } + + $domElement.tooltip({ + track: true, + show: false, + hide: false + }); + }, + + //Apply some miscelleaneous style to the DataTable + applyCosmetics: function (domElem) { + var self = this; + + // Add some styles on the cells even/odd + // label (first column of a data row) or not + $("th:first-child", domElem).addClass('label'); + $("td:first-child:odd", domElem).addClass('label labeleven'); + $("td:first-child:even", domElem).addClass('label labelodd'); + $("tr:odd td", domElem).slice(1).addClass('column columnodd'); + $("tr:even td", domElem).slice(1).addClass('column columneven'); + }, + + handleExpandFooter: function (domElem) { + if (!this.isDashboard() && !this.isWithinDialog(domElem)) { + return; + } + + var footerIcons = $('.dataTableFooterIcons', domElem); + + if (!footerIcons.length) { + return; + } + + var self = this; + function toggleFooter() + { + var icons = $('.dataTableFooterIcons', domElem); + $('.dataTableFeatures', domElem).toggleClass('expanded'); + + self.notifyWidgetParametersChange(domElem, { + isFooterExpandedInDashboard: icons.is(':visible') + }); + } + + var moveNode = $('.datatableFooterMessage', domElem); + if (!moveNode.length) { + moveNode = $('.datatableRelatedReports', domElem); + } + + footerIcons.after(moveNode); + + $('.expandDataTableFooterDrawer', domElem).after(footerIcons); + + var controls = $('.controls', domElem); + if (controls.length) { + $('.foldDataTableFooterDrawer', domElem).after(controls); + } + + var loadingPiwikBelow = $('.loadingPiwikBelow', domElem); + if (loadingPiwikBelow.length) { + loadingPiwikBelow.insertBefore(moveNode); + } + + if (this.param.isFooterExpandedInDashboard) { + toggleFooter(); + } + + $('.foldDataTableFooterDrawer, .expandDataTableFooterDrawer', domElem).on('click', toggleFooter); + }, + + handleColumnHighlighting: function (domElem) { + + var maxWidth = {}; + var currentNthChild = null; + var self = this; + + // higlight all columns on hover + $('td', domElem).hover( + function() { + var table = $(this).closest('table'); + var nthChild = $(this).parent('tr').children().index($(this)) + 1; + var rows = $('> tbody > tr', table); + + if (!maxWidth[nthChild]) { + maxWidth[nthChild] = 0; + rows.find("td:nth-child(" + (nthChild) + ") .column .value").each(function (index, element) { + var width = $(element).width(); + if (width > maxWidth[nthChild]) { + maxWidth[nthChild] = width; + } + }); + rows.find("td:nth-child(" + (nthChild) + ") .column .value").each(function (index, element) { + $(element).css({width: maxWidth[nthChild], display: 'inline-block'}); + }); + } + + if (currentNthChild === nthChild) { + return; + } + + currentNthChild = nthChild; + + rows.children("td:nth-child(" + (nthChild) + ")").addClass('highlight'); + self.repositionRowActions($(this).parent('tr')); + }, + function(event) { + + var table = $(this).closest('table'); + var tr = $(this).parent('tr').children(); + var nthChild = $(this).parent('tr').children().index($(this)); + var targetTd = $(event.relatedTarget).closest('td'); + var nthChildTarget = targetTd.parent('tr').children().index(targetTd); + + if (nthChild == nthChildTarget) { + return; + } + + currentNthChild = null; + + var rows = $('tr', table); + rows.find("td:nth-child(" + (nthChild + 1) + ")").removeClass('highlight'); + } + ); + }, + + //behaviour for 'nested DataTable' (DataTable loaded on a click on a row) + handleSubDataTable: function (domElem) { + var self = this; + // When the TR has a subDataTable class it means that this row has a link to a subDataTable + self.numberOfSubtables = $('tr.subDataTable', domElem) + .click( + function () { + // get the idSubTable + var idSubTable = $(this).attr('id'); + var divIdToReplaceWithSubTable = 'subDataTable_' + idSubTable; + + // if the subDataTable is not already loaded + if (typeof self.loadedSubDataTable[divIdToReplaceWithSubTable] == "undefined") { + var numberOfColumns = $(this).children().length; + + // at the end of the query it will replace the ID matching the new HTML table #ID + // we need to create this ID first + $(this).after( + '<tr>' + + '<td colspan="' + numberOfColumns + '" class="cellSubDataTable">' + + '<div id="' + divIdToReplaceWithSubTable + '">' + + '<span class="loadingPiwik" style="display:inline"><img src="plugins/Zeitgeist/images/loading-blue.gif" />' + _pk_translate('General_Loading') + '</span>' + + '</div>' + + '</td>' + + '</tr>' + ); + + var savedActionVariable = self.param.action; + + // reset all the filters from the Parent table + var filtersToRestore = self.resetAllFilters(); + // do not ignore the exclude low population click + self.param.enable_filter_excludelowpop = filtersToRestore.enable_filter_excludelowpop; + + self.param.idSubtable = idSubTable; + self.param.action = self.props.subtable_controller_action; + + delete self.param.totalRows; + + self.reloadAjaxDataTable(false, function(response) { + self.dataTableLoaded(response, divIdToReplaceWithSubTable); + }); + + self.param.action = savedActionVariable; + delete self.param.idSubtable; + self.restoreAllFilters(filtersToRestore); + + self.loadedSubDataTable[divIdToReplaceWithSubTable] = true; + + $(this).next().toggle(); + + // when "loading..." is displayed, hide actions + // repositioning after loading is not easily possible + $(this).find('div.dataTableRowActions').hide(); + } + + $(this).next().toggle(); + self.repositionRowActions($(this)); + } + ).size(); + }, + + // tooltip for column documentation + handleColumnDocumentation: function (domElem) { + if (this.isDashboard()) { + // don't display column documentation in dashboard + // it causes trouble in full screen view + return; + } + + $('th:has(.columnDocumentation)', domElem).each(function () { + var th = $(this); + var tooltip = th.find('.columnDocumentation'); + + tooltip.next().hover(function () { + var left = (-1 * tooltip.outerWidth() / 2) + th.width() / 2; + var top = -1 * (tooltip.outerHeight() + 10); + + if (th.next().size() == 0) { + left = (-1 * tooltip.outerWidth()) + th.width() + + parseInt(th.css('padding-right'), 10); + } + + tooltip.css({ + marginLeft: left, + marginTop: top + }); + + tooltip.stop(true, true).fadeIn(250); + }, + function () { + $(this).prev().stop(true, true).fadeOut(400); + }); + }); + }, + + handleRowActions: function (domElem) { + this.doHandleRowActions(domElem.find('table > tbody > tr')); + }, + + handleCellTooltips: function(domElem) { + domElem.find('span.cell-tooltip').tooltip({ + track: true, + items: 'span', + content: function() { + return $(this).parent().data('tooltip'); + }, + show: false, + hide: false, + tooltipClass: 'small' + }); + }, + + handleRelatedReports: function (domElem) { + var self = this, + hideShowRelatedReports = function (thisReport) { + $('span', $(thisReport).parent().parent()).each(function () { + if (thisReport == this) + $(this).hide(); + else + $(this).show(); + }); + }, + // 'this' report must be hidden in datatable output + thisReport = $('.datatableRelatedReports span:hidden', domElem)[0]; + + hideShowRelatedReports(thisReport); + + var relatedReports = $('.datatableRelatedReports span', domElem); + + if (!relatedReports.length) { + $('.datatableRelatedReports', domElem).hide(); + } + + relatedReports.each(function () { + var clicked = this; + $(this).unbind('click').click(function (e) { + var url = $(this).attr('href'); + + // if this url is also the url of a menu item, better to click that menu item instead of + // doing AJAX request + var menuItem = null; + $("#root").find(">.nav a").each(function () { + if ($(this).attr('href') == url) { + menuItem = this; + return false + } + }); + + if (menuItem) { + $(menuItem).click(); + return; + } + + // modify parameters + self.resetAllFilters(); + var newParams = broadcast.getValuesFromUrl(url); + + for (var key in newParams) { + self.param[key] = decodeURIComponent(newParams[key]); + } + + // do ajax request + self.reloadAjaxDataTable(true, function (newReport) { + var newDomElem = self.dataTableLoaded(newReport, self.workingDivId); + hideShowRelatedReports(clicked); + }); + }); + }); + }, + + /** + * Handle events that other code triggers on this table. + * + * You can trigger one of these events to get the datatable to do things, + * such as reload its data. + * + * Events handled: + * - reload: Triggering 'reload' on a datatable DOM element will + * reload the datatable's data. You can pass in an object mapping + * parameters to set before reloading data. + * + * $(datatableDomElem).trigger('reload', {columns: 'nb_visits,nb_actions', idSite: 2}); + */ + handleTriggeredEvents: function (domElem) { + var self = this; + + // reload datatable w/ new params if desired (NOTE: must use 'bind', not 'on') + $(domElem).bind('reload', function (e, paramOverride) { + paramOverride = paramOverride || {}; + for (var name in paramOverride) { + self.param[name] = paramOverride[name]; + } + + self.reloadAjaxDataTable(true); + }); + }, + + // also used in action data table + doHandleRowActions: function (trs) { + var self = this; + + var merged = $.extend({}, self.param, self.props); + var availableActionsForReport = DataTable_RowActions_Registry.getAvailableActionsForReport(merged); + + if (availableActionsForReport.length == 0) { + return; + } + + var actionInstances = {}; + for (var i = 0; i < availableActionsForReport.length; i++) { + var action = availableActionsForReport[i]; + actionInstances[action.name] = action.createInstance(self); + } + + trs.each(function () { + var tr = $(this); + var td = tr.find('td:first'); + + // call initTr on all actions that are available for the report + for (var i = 0; i < availableActionsForReport.length; i++) { + var action = availableActionsForReport[i]; + actionInstances[action.name].initTr(tr); + } + + // if there are row actions, make sure the first column is not too narrow + td.css('minWidth', '145px'); + + // show actions that are available for the row on hover + var actionsDom = null; + + tr.hover(function () { + if (actionsDom === null) { + // create dom nodes on the fly + actionsDom = self.createRowActions(availableActionsForReport, tr, actionInstances); + td.prepend(actionsDom); + } + // reposition and show the actions + self.repositionRowActions(tr); + if ($(window).width() >= 600) { + actionsDom.show(); + } + }, + function () { + if (actionsDom !== null) { + actionsDom.hide(); + } + }); + }); + }, + + createRowActions: function (availableActionsForReport, tr, actionInstances) { + var container = $(document.createElement('div')).addClass('dataTableRowActions'); + + for (var i = availableActionsForReport.length - 1; i >= 0; i--) { + var action = availableActionsForReport[i]; + + if (!action.isAvailableOnRow(this.param, tr)) { + continue; + } + + var actionEl = $(document.createElement('a')).attr({href: '#'}).addClass('action' + action.name); + actionEl.append($(document.createElement('img')).attr({src: action.dataTableIcon})); + container.append(actionEl); + + if (i == availableActionsForReport.length - 1) { + actionEl.addClass('leftmost'); + } + if (i == 0) { + actionEl.addClass('rightmost'); + } + + actionEl.click((function (action, el) { + return function (e) { + $(this).blur().tooltip('close'); + container.hide(); + if (typeof actionInstances[action.name].onClick == 'function') { + return actionInstances[action.name].onClick(el, tr, e); + } + actionInstances[action.name].trigger(tr, e); + return false; + } + })(action, actionEl)); + + if (typeof action.dataTableIconHover != 'undefined') { + actionEl.append($(document.createElement('img')).attr({src: action.dataTableIconHover}).hide()); + + actionEl.hover(function () { + var img = $(this).find('img'); + img.eq(0).hide(); + img.eq(1).show(); + }, + function () { + var img = $(this).find('img'); + img.eq(1).hide(); + img.eq(0).show(); + }); + } + + if (typeof action.dataTableIconTooltip != 'undefined') { + actionEl.tooltip({ + track: true, + items: 'a', + content: '<h3>'+action.dataTableIconTooltip[0]+'</h3>'+action.dataTableIconTooltip[1], + tooltipClass: 'rowActionTooltip', + show: false, + hide: false + }); + } + } + + return container; + }, + + repositionRowActions: function (tr) { + if (!tr) { + return; + } + + var td = tr.find('td:first'); + var actions = tr.find('div.dataTableRowActions'); + + if (!actions) { + return; + } + + actions.height(tr.innerHeight() - 2); + actions.css('marginLeft', (td.width() + 3 - actions.outerWidth()) + 'px'); + }, + + _findReportHeader: function (domElem) { + var h2 = false; + if (domElem.prev().is('h2')) { + h2 = domElem.prev(); + } + else if (this.param.viewDataTable == 'tableGoals') { + h2 = $('#titleGoalsByDimension'); + } + else if ($('h2', domElem)) { + h2 = $('h2', domElem); + } + return h2; + }, + + _createDivId: function () { + return 'dataTable_' + this._controlId; + } +}); + +// handle switch to All Columns/Goals/HtmlTable DataTable visualization +var switchToHtmlTable = function (dataTable, viewDataTable) { + // we only reset the limit filter, in case switch to table view from cloud view where limit is custom set to 30 + // this value is stored in config file General->datatable_default_limit but this is more an edge case so ok to set it to 10 + + dataTable.param.viewDataTable = viewDataTable; + + // when switching to display simple table, do not exclude low pop by default + delete dataTable.param.enable_filter_excludelowpop; + delete dataTable.param.filter_sort_column; + delete dataTable.param.filter_sort_order; + delete dataTable.param.columns; + dataTable.reloadAjaxDataTable(); + dataTable.notifyWidgetParametersChange(dataTable.$element, {viewDataTable: viewDataTable}); +}; + +DataTable.registerFooterIconHandler('table', switchToHtmlTable); +DataTable.registerFooterIconHandler('tableAllColumns', switchToHtmlTable); +DataTable.registerFooterIconHandler('tableGoals', switchToHtmlTable); +DataTable.registerFooterIconHandler('ecommerceOrder', switchToHtmlTable); +DataTable.registerFooterIconHandler('ecommerceAbandonedCart', switchToHtmlTable); + +// generic function to handle switch to graph visualizations +DataTable.switchToGraph = function (dataTable, viewDataTable) { + var filters = dataTable.resetAllFilters(); + dataTable.param.flat = filters.flat; + dataTable.param.columns = filters.columns; + + dataTable.param.viewDataTable = viewDataTable; + dataTable.reloadAjaxDataTable(); + dataTable.notifyWidgetParametersChange(dataTable.$element, {viewDataTable: viewDataTable}); +}; + +DataTable.registerFooterIconHandler('cloud', DataTable.switchToGraph); + +exports.DataTable = DataTable; + +})(jQuery, require); diff --git a/www/analytics/plugins/CoreHome/javascripts/dataTable_rowactions.js b/www/analytics/plugins/CoreHome/javascripts/dataTable_rowactions.js new file mode 100644 index 00000000..2325fded --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/dataTable_rowactions.js @@ -0,0 +1,386 @@ +/** + * Registry for row actions + * + * Plugins can call DataTable_RowActions_Registry.register() from their JS + * files in order to add new actions to arbitrary data tables. The register() + * method takes an object containing: + * - name: string identifying the action. must be short, no spaces. + * - dataTableIcon: path to the icon for the action + * - createInstance: a factory method to create an instance of the appropriate + * subclass of DataTable_RowAction + * - isAvailable: a method to determine whether the action is available in a + * given row of a data table + */ +var DataTable_RowActions_Registry = { + + registry: [], + + register: function (action) { + var createInstance = action.createInstance; + action.createInstance = function (dataTable, param) { + var instance = createInstance(dataTable, param); + instance.actionName = action.name; + return instance; + }; + + this.registry.push(action); + }, + + getAvailableActionsForReport: function (dataTableParams, tr) { + if (dataTableParams.disable_row_actions == '1') { + return []; + } + + var available = []; + for (var i = 0; i < this.registry.length; i++) { + if (this.registry[i].isAvailableOnReport(dataTableParams, tr)) { + available.push(this.registry[i]); + } + } + available.sort(function (a, b) { + return b.order - a.order; + }); + return available; + }, + + getActionByName: function (name) { + for (var i = 0; i < this.registry.length; i++) { + if (this.registry[i].name == name) { + return this.registry[i]; + } + } + return false; + } + +}; + +// Register Row Evolution (also servers as example) +DataTable_RowActions_Registry.register({ + + name: 'RowEvolution', + + dataTableIcon: 'plugins/Zeitgeist/images/row_evolution.png', + dataTableIconHover: 'plugins/Zeitgeist/images/row_evolution_hover.png', + + order: 50, + + dataTableIconTooltip: [ + _pk_translate('General_RowEvolutionRowActionTooltipTitle'), + _pk_translate('General_RowEvolutionRowActionTooltip') + ], + + createInstance: function (dataTable, param) { + if (dataTable !== null && typeof dataTable.rowEvolutionActionInstance != 'undefined') { + return dataTable.rowEvolutionActionInstance; + } + + if (dataTable === null && param) { + // when row evolution is triggered from the url (not a click on the data table) + // we look for the data table instance in the dom + var report = param.split(':')[0]; + var div = $(require('piwik/UI').DataTable.getDataTableByReport(report)); + if (div.size() > 0 && div.data('uiControlObject')) { + dataTable = div.data('uiControlObject'); + if (typeof dataTable.rowEvolutionActionInstance != 'undefined') { + return dataTable.rowEvolutionActionInstance; + } + } + } + + var instance = new DataTable_RowActions_RowEvolution(dataTable); + if (dataTable !== null) { + dataTable.rowEvolutionActionInstance = instance; + } + return instance; + }, + + isAvailableOnReport: function (dataTableParams) { + return ( + typeof dataTableParams.disable_row_evolution == 'undefined' + || dataTableParams.disable_row_evolution == "0" + ) && ( + typeof dataTableParams.flat == 'undefined' + || dataTableParams.flat == "0" + ); + }, + + isAvailableOnRow: function (dataTableParams, tr) { + return true; + } + +}); + + +/** + * DataTable Row Actions + * + * The lifecycle of an action is as follows: + * - for each data table, a new instance of the action is created using the factory + * - when the table is loaded, initTr is called for each tr + * - when the action icon is clicked, trigger is called + * - the label is put together and performAction is called + * - performAction must call openPopover on the base class + * - openPopover calls back doOpenPopover after doing general stuff + * + * The two template methods are performAction and doOpenPopover + */ + + +// +// BASE CLASS +// + +function DataTable_RowAction(dataTable) { + this.dataTable = dataTable; + + // has to be overridden in subclasses + this.trEventName = 'piwikTriggerRowAction'; + + // set in registry + this.actionName = 'RowAction'; +} + +/** Initialize a row when the table is loaded */ +DataTable_RowAction.prototype.initTr = function (tr) { + var self = this; + + // For subtables, we need to make sure that the actions are always triggered on the + // action instance connected to the root table. Otherwise sharing data (e.g. for + // for multi-row evolution) wouldn't be possible. Also, sub-tables might have different + // API actions. For the label filter to work, we need to use the parent action. + // We use jQuery events to let subtables access their parents. + tr.bind(self.trEventName, function (e, params) { + self.trigger($(this), params.originalEvent, params.label); + }); +}; + +/** + * This method is called from the click event and the tr event (see this.trEventName). + * It derives the label and calls performAction. + */ +DataTable_RowAction.prototype.trigger = function (tr, e, subTableLabel) { + var label = this.getLabelFromTr(tr); + + label = label.trim(); + // if we have received the event from the sub table, add the label + if (subTableLabel) { + var separator = ' > '; // LabelFilter::SEPARATOR_RECURSIVE_LABEL + label += separator + subTableLabel.trim(); + } + + // handle sub tables in nested reports: forward to parent + var subtable = tr.closest('table'); + if (subtable.is('.subDataTable')) { + subtable.closest('tr').prev().trigger(this.trEventName, { + label: label, + originalEvent: e + }); + return; + } + + // ascend in action reports + if (subtable.closest('div.dataTableActions').length) { + var allClasses = tr.attr('class'); + var matches = allClasses.match(/level[0-9]+/); + var level = parseInt(matches[0].substring(5, matches[0].length), 10); + if (level > 0) { + // .prev(.levelX) does not work for some reason => do it "by hand" + var findLevel = 'level' + (level - 1); + var ptr = tr; + while ((ptr = ptr.prev()).size() > 0) { + if (!ptr.hasClass(findLevel)) { + continue; + } + ptr.trigger(this.trEventName, { + label: label, + originalEvent: e + }); + return; + } + } + } + + this.performAction(label, tr, e); +}; + +/** Get the label string from a tr dom element */ +DataTable_RowAction.prototype.getLabelFromTr = function (tr) { + var label = tr.find('span.label'); + + // handle truncation + var value = label.data('originalText'); + + if (!value) { + value = label.text(); + } + value = value.trim(); + + return encodeURIComponent(value); +}; + +/** + * Base method for opening popovers. + * This method will remember the parameter in the url and call doOpenPopover(). + */ +DataTable_RowAction.prototype.openPopover = function (parameter) { + broadcast.propagateNewPopoverParameter('RowAction', this.actionName + ':' + parameter); +}; + +broadcast.addPopoverHandler('RowAction', function (param) { + var paramParts = param.split(':'); + var rowActionName = paramParts[0]; + paramParts.shift(); + param = paramParts.join(':'); + + var rowAction = DataTable_RowActions_Registry.getActionByName(rowActionName); + if (rowAction) { + rowAction.createInstance(null, param).doOpenPopover(param); + } +}); + +/** To be overridden */ +DataTable_RowAction.prototype.performAction = function (label, tr, e) { +}; +DataTable_RowAction.prototype.doOpenPopover = function (parameter) { +}; + + +// +// ROW EVOLUTION +// + +function DataTable_RowActions_RowEvolution(dataTable) { + this.dataTable = dataTable; + this.trEventName = 'piwikTriggerRowEvolution'; + + /** The rows to be compared in multi row evolution */ + this.multiEvolutionRows = []; +} + +/** Static helper method to launch row evolution from anywhere */ +DataTable_RowActions_RowEvolution.launch = function (apiMethod, label) { + var param = 'RowEvolution:' + apiMethod + ':0:' + label; + broadcast.propagateNewPopoverParameter('RowAction', param); +}; + +DataTable_RowActions_RowEvolution.prototype = new DataTable_RowAction; + +DataTable_RowActions_RowEvolution.prototype.performAction = function (label, tr, e) { + if (e.shiftKey) { + // only mark for multi row evolution if shift key is pressed + this.addMultiEvolutionRow(label); + return; + } + + // check whether we have rows marked for multi row evolution + var isMultiRowEvolution = '0'; + this.addMultiEvolutionRow(label); + if (this.multiEvolutionRows.length > 1) { + isMultiRowEvolution = '1'; + label = this.multiEvolutionRows.join(','); + } + + var apiMethod = this.dataTable.param.module + '.' + this.dataTable.param.action; + this.openPopover(apiMethod, isMultiRowEvolution, label); +}; + +DataTable_RowActions_RowEvolution.prototype.addMultiEvolutionRow = function (label) { + if ($.inArray(label, this.multiEvolutionRows) == -1) { + this.multiEvolutionRows.push(label); + } +}; + +DataTable_RowActions_RowEvolution.prototype.openPopover = function (apiMethod, multiRowEvolutionParam, label) { + var urlParam = apiMethod + ':' + multiRowEvolutionParam + ':' + label; + DataTable_RowAction.prototype.openPopover.apply(this, [urlParam]); +}; + +DataTable_RowActions_RowEvolution.prototype.doOpenPopover = function (urlParam) { + var urlParamParts = urlParam.split(':'); + + var apiMethod = urlParamParts[0]; + urlParamParts.shift(); + + var multiRowEvolutionParam = urlParamParts[0]; + urlParamParts.shift(); + + var label = urlParamParts.join(':'); + + this.showRowEvolution(apiMethod, label, multiRowEvolutionParam); +}; + +/** Open the row evolution popover */ +DataTable_RowActions_RowEvolution.prototype.showRowEvolution = function (apiMethod, label, multiRowEvolutionParam) { + + var self = this; + + // open the popover + var box = Piwik_Popover.showLoading('Row Evolution'); + box.addClass('rowEvolutionPopover'); + + // prepare loading the popover contents + var requestParams = { + apiMethod: apiMethod, + label: label, + disableLink: 1 + }; + + // derive api action and requested column from multiRowEvolutionParam + var action; + if (multiRowEvolutionParam == '0') { + action = 'getRowEvolutionPopover'; + } else if (multiRowEvolutionParam == '1') { + action = 'getMultiRowEvolutionPopover'; + } else { + action = 'getMultiRowEvolutionPopover'; + requestParams.column = multiRowEvolutionParam; + } + + var callback = function (html) { + Piwik_Popover.setContent(html); + + // use the popover title returned from the server + var title = box.find('div.popover-title'); + if (title.size() > 0) { + Piwik_Popover.setTitle(title.html()); + title.remove(); + } + + Piwik_Popover.onClose(function () { + // reset rows marked for multi row evolution on close + self.multiEvolutionRows = []; + }); + + if (self.dataTable !== null) { + // remember label for multi row evolution + box.find('a.rowevolution-startmulti').click(function () { + Piwik_Popover.onClose(false); // unbind listener that resets multiEvolutionRows + Piwik_Popover.close(); + return false; + }); + } else { + // when the popover is launched by copy&pasting a url, we don't have the data table. + // in this case, we can't remember the row marked for multi row evolution so + // we disable the picker. + box.find('.compare-container, .rowevolution-startmulti').remove(); + } + + // switch metric in multi row evolution + box.find('select.multirowevoltion-metric').change(function () { + var metric = $(this).val(); + Piwik_Popover.onClose(false); // unbind listener that resets multiEvolutionRows + self.openPopover(apiMethod, metric, label); + return true; + }); + }; + + requestParams.module = 'CoreHome'; + requestParams.action = action; + requestParams.colors = JSON.stringify(piwik.getSparklineColors()); + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(requestParams, 'get'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); +}; diff --git a/www/analytics/plugins/CoreHome/javascripts/donate.js b/www/analytics/plugins/CoreHome/javascripts/donate.js new file mode 100755 index 00000000..5d5fdeb3 --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/donate.js @@ -0,0 +1,142 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +(function ($) { + + $(document).ready(function () { + + var donateAmounts = [0, 30, 60, 90, 120]; + + // returns the space between each donation amount in the donation slider + var getTickWidth = function (slider) { + var effectiveSliderWidth = $('.slider-range', slider).width() - $('.slider-position', slider).width(); + return effectiveSliderWidth / (donateAmounts.length - 1); + }; + + // returns the position index on a slider based on a x coordinate value + var getPositionFromPageCoord = function (slider, pageX) { + return Math.round((pageX - $('.slider-range', slider).offset().left) / getTickWidth(slider)); + }; + + // set's the correct amount text & smiley face image based on the position of the slider + var setSmileyFaceAndAmount = function (slider, pos) { + // set text yearly amount + $('.slider-donate-amount', slider).text('$' + donateAmounts[pos] + '/' + _pk_translate('General_YearShort')); + + // set the right smiley face + $('.slider-smiley-face').attr('src', 'plugins/Zeitgeist/images/smileyprog_' + pos + '.png'); + + // set the hidden option input for paypal + var option = Math.max(1, pos); + $('.piwik-donate-call input[name=os0]').val("Option " + option); + }; + + // move's a slider's position to a specific spot + var moveSliderPosition = function (slider, to) { + // make sure 'to' is valid + if (to < 0) { + to = 0; + } + else if (to >= donateAmounts.length) { + to = donateAmounts.length - 1; + } + + // set the slider position + var left = to * getTickWidth(slider); + if (left == 0) { + left = -1; // at position 0 we move one pixel left to cover up some of slider bar + } + + $('.slider-position', slider).css({ + left: left + 'px' + }); + + // reset the smiley face & amount based on the new position + setSmileyFaceAndAmount(slider, to); + }; + + // when a slider is clicked, set the amount & smiley face appropriately + $('body').on('click', '.piwik-donate-slider>.slider-range', function (e) { + var slider = $(this).parent(), + currentPageX = $('.slider-position', this).offset().left, + currentPos = getPositionFromPageCoord(slider, currentPageX), + pos = getPositionFromPageCoord(slider, e.pageX); + + // if the closest position is the current one, use the other position since + // the user obviously wants to move the slider. + if (currentPos == pos) { + // if click is to right, go forward one, else backwards one + if (e.pageX > currentPageX) { + ++pos; + } + else { + --pos; + } + } + + moveSliderPosition(slider, pos); + }); + + // when the smiley icon is clicked, move the position up one to demonstrate how to use the slider + $('body').on('click', '.piwik-donate-slider .slider-smiley-face,.piwik-donate-slider .slider-donate-amount', + function (e) { + var slider = $(this).closest('.piwik-donate-slider'), + currentPageX = $('.slider-position', slider).offset().left, + currentPos = getPositionFromPageCoord(slider, currentPageX); + + moveSliderPosition(slider, currentPos + 1); + } + ); + + // stores the current slider being dragged + var draggingSlider = false; + + // start dragging on mousedown for a slider's position bar + $('body').on('mousedown', '.piwik-donate-slider .slider-position', function () { + draggingSlider = $(this).parent().parent(); + }); + + // move the slider position if currently dragging when the mouse moves anywhere over the entire page + $('body').on('mousemove', function (e) { + if (draggingSlider) { + var slider = draggingSlider.find('.slider-range'), + sliderPos = slider.find('.slider-position'), + left = e.pageX - slider.offset().left; + + // only move slider if the mouse x-coord is still on the slider (w/ some padding for borders) + if (left <= slider.width() - sliderPos.width() + 2 + && left >= -2) { + sliderPos.css({left: left + 'px'}); + + var closestPos = Math.round(left / getTickWidth(draggingSlider)); + setSmileyFaceAndAmount(draggingSlider, closestPos); + } + } + }); + + // stop dragging and normalize a slider's position on mouseup over the entire page + $('body').on('mouseup', function () { + if (draggingSlider) { + var sliderPos = $('.slider-position', draggingSlider), + slider = sliderPos.parent(); + + if (sliderPos.length) { + // move the slider to the nearest donation amount position + var pos = getPositionFromPageCoord(draggingSlider, sliderPos.offset().left); + moveSliderPosition(draggingSlider, pos); + } + + draggingSlider = false; // stop dragging + } + }); + + // event for programatically changing the position + $('body').on('piwik:changePosition', '.piwik-donate-slider', function (e, data) { + moveSliderPosition(this, data.position); + }); + }); + +}(jQuery)); diff --git a/www/analytics/plugins/CoreHome/javascripts/menu.js b/www/analytics/plugins/CoreHome/javascripts/menu.js new file mode 100644 index 00000000..a4ecb5ea --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/menu.js @@ -0,0 +1,110 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * @constructor + */ +function menu() { + this.param = {}; +} + +menu.prototype = +{ + resetTimer: null, + + adaptSubMenuHeight: function() { + var subNavHeight = $('.sfHover > ul').outerHeight(); + $('.nav_sep').height(subNavHeight); + }, + + overMainLI: function () { + var $this = $(this); + $this.siblings().removeClass('sfHover'); + $this.addClass('sfHover'); + menu.prototype.adaptSubMenuHeight(); + clearTimeout(menu.prototype.resetTimer); + }, + + outMainLI: function () { + clearTimeout(menu.prototype.resetTimer); + menu.prototype.resetTimer = setTimeout(function() { + $('.Menu-tabList > .sfHover', this.menuNode).removeClass('sfHover'); + $('.Menu-tabList > .sfActive', this.menuNode).addClass('sfHover'); + menu.prototype.adaptSubMenuHeight(); + }, 2000); + }, + + onItemClick: function (item) { + $('.Menu--dashboard').trigger('piwikSwitchPage', item); + broadcast.propagateAjax( $(item).attr('href').substr(1) ); + return false; + }, + + init: function () { + this.menuNode = $('.Menu--dashboard'); + + this.menuNode.find("li:has(ul)").hover(this.overMainLI, this.outMainLI); + + // add id to all li menu to support menu identification. + // for all sub menu we want to have a unique id based on their module and action + // for main menu we want to add just the module as its id. + this.menuNode.find('li').each(function () { + var url = $(this).find('a').attr('href').substr(1); + var module = broadcast.getValueFromUrl("module", url); + var action = broadcast.getValueFromUrl("action", url); + var moduleId = broadcast.getValueFromUrl("idGoal", url) || broadcast.getValueFromUrl("idDashboard", url); + var main_menu = $(this).parent().hasClass('Menu-tabList') ? true : false; + if (main_menu) { + $(this).attr({id: module}); + } + // if there's a idGoal or idDashboard, use this in the ID + else if (moduleId != '') { + $(this).attr({id: module + '_' + action + '_' + moduleId}); + } + else { + $(this).attr({id: module + '_' + action}); + } + }); + + menu.prototype.adaptSubMenuHeight(); + }, + + activateMenu: function (module, action, id) { + this.menuNode.find('li').removeClass('sfHover').removeClass('sfActive'); + var $li = this.getSubmenuID(module, id, action); + var mainLi = $("#" + module); + if (!mainLi.length) { + mainLi = $li.parents('li'); + } + + mainLi.addClass('sfActive').addClass('sfHover'); + + $li.addClass('sfHover'); + }, + + // getting the right li is a little tricky since goals uses idGoal, and overview is index. + getSubmenuID: function (module, id, action) { + var $li = ''; + // So, if module is Goals, id is present, and action is not Index, must be one of the goals + if (module == 'Goals' && id != '' && (action != 'index')) { + $li = $("#" + module + "_" + action + "_" + id); + // if module is Dashboard and id is present, must be one of the dashboards + } else if (module == 'Dashboard') { + if (!id) id = 1; + $li = $("#" + module + "_" + action + "_" + id); + } else { + $li = $("#" + module + "_" + action); + } + return $li; + }, + + loadFirstSection: function () { + if (broadcast.isHashExists() == false) { + $('li:first a:first', this.menuNode).click().addClass('sfHover').addClass('sfActive'); + } + } +}; diff --git a/www/analytics/plugins/CoreHome/javascripts/menu_init.js b/www/analytics/plugins/CoreHome/javascripts/menu_init.js new file mode 100644 index 00000000..490c8591 --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/menu_init.js @@ -0,0 +1,19 @@ +$(function () { + var isPageHasMenu = $('.Menu--dashboard').size(); + var isPageIsAdmin = $('#content.admin').size(); + if (isPageHasMenu) { + piwikMenu = new menu(); + piwikMenu.init(); + piwikMenu.loadFirstSection(); + } + + if(isPageIsAdmin) { + // don't use broadcast in admin pages + return; + } + if(isPageHasMenu) { + broadcast.init(); + } else { + broadcast.init(true); + } +}); diff --git a/www/analytics/plugins/CoreHome/javascripts/notification.js b/www/analytics/plugins/CoreHome/javascripts/notification.js new file mode 100644 index 00000000..6c68f71a --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/notification.js @@ -0,0 +1,198 @@ +/** + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'); + + /** + * Creates a new notifications. + * + * Example: + * var UI = require('piwik/UI'); + * var notification = new UI.Notification(); + * notification.show('My Notification Message', {title: 'Low space', context: 'warning'}); + */ + var Notification = function () { + this.$node = null; + }; + + /** + * Makes the notification visible. + * + * @param {string} message The actual message that will be displayed. Must be set. + * @param {Object} [options] + * @param {string} [options.id] Only needed for persistent notifications. The id will be sent to the + * frontend once the user closes the notifications. The notification has to + * be registered/notified under this name + * @param {string} [options.title] The title of the notification. For instance the plugin name. + * @param {bool} [options.animate=true] If enabled, the notification will be faded in. + * @param {string} [options.context=warning] Context of the notification: 'info', 'warning', 'success' or + * 'error' + * @param {string} [options.type=transient] The type of the notification: Either 'toast' or 'transitent' + * @param {bool} [options.noclear=false] If set, the close icon is not displayed. + * @param {object} [options.style] Optional style/css dictionary. For instance {'display': 'inline-block'} + * @param {string} [options.placeat] By default, the notification will be displayed in the "stats bar". + * You can specify any other CSS selector to place the notifications + * whereever you want. + */ + Notification.prototype.show = function (message, options) { + if (!message) { + throw new Error('No message given, cannot display notification'); + } + if (options && !$.isPlainObject(options)) { + throw new Error('Options has the wrong format, cannot display notification'); + } else if (!options) { + options = {}; + } + + if ('persistent' == options.type) { + // otherwise it is never possible to dismiss the notification + options.noclear = false; + } + + closeExistingNotificationHavingSameIdIfNeeded(options); + + var template = generateNotificationHtmlMarkup(options, message); + var $notificationNode = placeNotification(template, options); + this.$node = $notificationNode; + + if ('persistent' == options.type) { + addPersistentEvent($notificationNode); + } else if ('toast' == options.type) { + addToastEvent($notificationNode); + } + + if (!options.noclear) { + addCloseEvent($notificationNode); + } + }; + + Notification.prototype.scrollToNotification = function () { + if (this.$node) { + piwikHelper.lazyScrollTo(this.$node, 250); + } + }; + + exports.Notification = Notification; + + function closeExistingNotificationHavingSameIdIfNeeded(options) + { + if (!options.id) { + return; + } + + var $existingNode = $('.system.notification[data-id=' + options.id + ']'); + if ($existingNode && $existingNode.length) { + $existingNode.remove(); + } + } + + function generateNotificationHtmlMarkup(options, message) { + var template = buildNotificationStart(options); + + if (!options.noclear) { + template += buildClearButton(); + } + + if (options.title) { + template += buildTitle(options); + } + + template += message; + template += buildNotificationEnd(); + + return template; + } + + function buildNotificationStart(options) { + var template = '<div class="notification system'; + + if (options.context) { + template += ' notification-' + options.context; + } + + template += '"'; + + if (options.id) { + template += ' data-id="' + options.id + '"'; + } + + template += '>'; + + return template; + } + + function buildNotificationEnd() { + return '</div>'; + } + + function buildClearButton() { + return '<button type="button" class="close" data-dismiss="alert">×</button>'; + } + + function buildTitle(options) { + return '<strong>' + options.title + '</strong> '; + } + + function placeNotification(template, options) { + + var $notificationNode = $(template); + + if (options.style) { + $notificationNode.css(options.style); + } + + $notificationNode = $notificationNode.hide(); + $(options.placeat || '#notificationContainer').append($notificationNode); + + if (false === options.animate) { + $notificationNode.show(); + } else { + $notificationNode.fadeIn(1000); + } + + return $notificationNode; + } + + function addToastEvent($notificationNode) + { + setTimeout(function () { + $notificationNode.fadeOut( 'slow', function() { + $notificationNode.remove(); + $notificationNode = null; + }); + }, 12 * 1000); + } + + function addCloseEvent($notificationNode) { + $notificationNode.on('click', '.close', function (event) { + if (event && event.delegateTarget) { + $(event.delegateTarget).remove(); + } + }); + }; + + function addPersistentEvent($notificationNode) { + var notificationId = $notificationNode.data('id'); + + if (!notificationId) { + return; + } + + $notificationNode.on('click', '.close', function (event) { + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'CoreHome', + action: 'markNotificationAsRead' + }, 'GET'); + ajaxHandler.addParams({notificationId: notificationId}, 'POST'); + ajaxHandler.send(true); + }); + }; + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/javascripts/notification_parser.js b/www/analytics/plugins/CoreHome/javascripts/notification_parser.js new file mode 100644 index 00000000..d3ceeb5a --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/notification_parser.js @@ -0,0 +1,31 @@ +/** + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready((function ($, require) { + return function () { + + var UI = require('piwik/UI'); + + var $notificationNodes = $('[data-role="notification"]'); + + $notificationNodes.each(function (index, notificationNode) { + $notificationNode = $(notificationNode); + var attributes = $notificationNode.data(); + var message = $notificationNode.html(); + + if (message) { + var notification = new UI.Notification(); + attributes.animate = false; + notification.show(message, attributes); + } + + $notificationNodes.remove(); + }); + + } + +})(jQuery, require)); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/javascripts/popover.js b/www/analytics/plugins/CoreHome/javascripts/popover.js new file mode 100644 index 00000000..ea8c5c03 --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/popover.js @@ -0,0 +1,254 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +var Piwik_Popover = (function () { + + var container = false; + var isOpen = false; + var closeCallback = false; + + var createContainer = function () { + if (container === false) { + container = $(document.createElement('div')).attr('id', 'Piwik_Popover'); + } + }; + + var openPopover = function (title, dialogClass) { + createContainer(); + + var options = + { + title: title, + modal: true, + width: '950px', + position: ['center', 'center'], + resizable: false, + autoOpen: true, + open: function (event, ui) { + if (dialogClass) { + $(this).parent().addClass(dialogClass).attr('style', ''); + } + + $('.ui-widget-overlay').on('click.popover', function () { + container.dialog('close'); + }); + }, + close: function (event, ui) { + container.find('div.jqplot-target').trigger('piwikDestroyPlot'); + container[0].innerHTML = ''; // IE8 fix + container.dialog('destroy').remove(); + globalAjaxQueue.abort(); + $('.ui-widget-overlay').off('click.popover'); + isOpen = false; + broadcast.propagateNewPopoverParameter(false); + require('piwik/UI').UIControl.cleanupUnusedControls(); + if (typeof closeCallback == 'function') { + closeCallback(); + closeCallback = false; + } + } + }; + + container.dialog(options); + + // override the undocumented _title function to ensure that the title attribute is not escaped (according to jQueryUI bug #6016) + container.data( "uiDialog" )._title = function(title) { + title.html( this.options.title ); + }; + + isOpen = true; + }; + + var centerPopover = function () { + if (container !== false) { + container.dialog({position: ['center', 'center']}); + } + }; + + return { + + /** + * Open the popover with a loading message + * + * @param {string} popoverName name of the popover + * @param {string} [popoverSubject] subject of the popover (e.g. url, optional) + * @param {int} [height] height of the popover in px (optional) + * @param {string} [dialogClass] css class to add to dialog + */ + showLoading: function (popoverName, popoverSubject, height, dialogClass) { + var loading = $(document.createElement('div')).addClass('Piwik_Popover_Loading'); + + var loadingMessage = popoverSubject ? translations.General_LoadingPopoverFor : + translations.General_LoadingPopover; + + loadingMessage = loadingMessage.replace(/%s/, popoverName); + + var p1 = $(document.createElement('p')).addClass('Piwik_Popover_Loading_Name'); + loading.append(p1.text(loadingMessage)); + + var p2; + if (popoverSubject) { + popoverSubject = piwikHelper.addBreakpointsToUrl(popoverSubject); + p1.addClass('Piwik_Popover_Loading_NameWithSubject'); + p2 = $(document.createElement('p')).addClass('Piwik_Popover_Loading_Subject'); + loading.append(p2.html(popoverSubject)); + } + + if (height) { + loading.height(height); + } + + if (!isOpen) { + openPopover(null, dialogClass); + } + + this.setContent(loading); + this.setTitle(''); + + if (height) { + var offset = loading.height() - p1.outerHeight(); + if (popoverSubject) { + offset -= p2.outerHeight(); + } + var spacingEl = $(document.createElement('div')); + spacingEl.height(Math.round(offset / 2)); + loading.prepend(spacingEl); + } + + return container; + }, + + /** + * Add a help button to the current popover + * + * @param {string} helpUrl + */ + addHelpButton: function (helpUrl) { + if (!isOpen) { + return; + } + + var titlebar = container.parent().find('.ui-dialog-titlebar'); + + var button = $(document.createElement('a')).addClass('ui-dialog-titlebar-help'); + button.attr({href: helpUrl, target: '_blank'}); + + titlebar.append(button); + }, + + /** Set the title of the popover */ + setTitle: function (titleHtml) { + container.dialog('option', 'title', titleHtml); + }, + + /** Set inner HTML of the popover */ + setContent: function (html) { + if (typeof closeCallback == 'function') { + closeCallback(); + closeCallback = false; + } + + container[0].innerHTML = ''; // IE8 fix + container.html(html); + centerPopover(); + }, + + /** + * Show an error message. All params are HTML! + * + * @param {string} title + * @param {string} [message] + * @param {string} [backLabel] + */ + showError: function (title, message, backLabel) { + var error = $(document.createElement('div')).addClass('Piwik_Popover_Error'); + + var p = $(document.createElement('p')).addClass('Piwik_Popover_Error_Title'); + error.append(p.html(title)); + + if (message) { + p = $(document.createElement('p')).addClass('Piwik_Popover_Error_Message'); + error.append(p.html(message)); + } + + if (backLabel) { + var back = $(document.createElement('a')).addClass('Piwik_Popover_Error_Back'); + back.attr('href', '#').click(function () { + history.back(); + return false; + }); + error.append(back.html(backLabel)); + } + + if (!isOpen) { + openPopover(); + } + + this.setContent(error); + }, + + /** + * Add a callback for the next time the popover is closed or the content changes + * + * @param {function} callback + */ + onClose: function (callback) { + closeCallback = callback; + }, + + /** Close the popover */ + close: function () { + if (isOpen) { + container.dialog('close'); + } + }, + + /** + * Create a Popover and load the specified URL in it. + * + * Note: If you want the popover to be persisted in the URL (so if the URL is copy/pasted + * to a new window/tab it will be opened there), use broadcast.propagateNewPopoverParameter + * with a popover handler function that calls this one. + * + * @param {string} url + * @param {string} loadingName + * @param {string} [dialogClass] css class to add to dialog + */ + createPopupAndLoadUrl: function (url, loadingName, dialogClass) { + // make sure the minimum top position of the popover is 15px + var ensureMinimumTop = function () { + var popoverContainer = $('#Piwik_Popover').parent(); + if (popoverContainer.position().top < 106) { + popoverContainer.css('top', '15px'); + } + }; + + // open the popover + var box = Piwik_Popover.showLoading(loadingName, null, null, dialogClass); + ensureMinimumTop(); + + var callback = function (html) { + function setPopoverTitleIfOneFoundInContainer() { + var title = $('h1,h2', container); + if (title.length == 1) { + Piwik_Popover.setTitle(title.text()); + $(title).hide(); + } + } + + Piwik_Popover.setContent(html); + setPopoverTitleIfOneFoundInContainer(); + ensureMinimumTop(); + }; + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(piwikHelper.getArrayFromQueryString(url), 'get'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + } + }; +})(); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/javascripts/promo.js b/www/analytics/plugins/CoreHome/javascripts/promo.js new file mode 100644 index 00000000..06ef4860 --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/promo.js @@ -0,0 +1,12 @@ +$(function () { + $('#piwik-promo-thumbnail').click(function () { + var promoEmbed = $('#piwik-promo-embed'), + widgetWidth = $(this).closest('.widgetContent').width(), + height = (266 * widgetWidth) / 421, + embedHtml = '<iframe width="100%" height="' + height + '" src="http://www.youtube.com/embed/OslfF_EH81g?autoplay=1&vq=hd720&wmode=transparent" frameborder="0" wmode="Opaque"></iframe>'; + + $(this).hide(); + promoEmbed.height(height).html(embedHtml); + promoEmbed.show(); + }); +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/javascripts/require.js b/www/analytics/plugins/CoreHome/javascripts/require.js new file mode 100644 index 00000000..bee17024 --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/require.js @@ -0,0 +1,39 @@ +/** + * Piwik - Web Analytics + * + * Module creation & inclusion for Piwik. + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function (window) { + + var MODULE_SPLIT_REGEX = /[\/.\\]/; + + /** + * Returns a module for its ID. Empty modules are created if they does not exist. + * + * Modules are currently stored in the window object. + * + * @param {String} moduleId e.g. 'piwik/UserCountryMap' or 'myPlugin/Widgets/FancySchmancyThing'. + * The following characters can be used to separate individual modules: + * '/', '.' or '\'. + * @return {Object} The module object. + */ + window.require = function (moduleId) { + var parts = moduleId.split(MODULE_SPLIT_REGEX); + + // TODO: we use window objects for backwards compatibility. when rest of Piwik is rewritten to use + // require, we can switch simply holding the modules in a private variable. + var currentModule = window; + for (var i = 0; i != parts.length; ++i) { + var part = parts[i]; + + currentModule[part] = currentModule[part] || {}; + currentModule = currentModule[part]; + } + return currentModule; + }; + +})(window); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/javascripts/sparkline.js b/www/analytics/plugins/CoreHome/javascripts/sparkline.js new file mode 100644 index 00000000..4474f7ca --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/sparkline.js @@ -0,0 +1,83 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($) { + +var sparklineColorNames = ['backgroundColor', 'lineColor', 'minPointColor', 'maxPointColor', 'lastPointColor']; + +piwik.getSparklineColors = function () { + return piwik.ColorManager.getColors('sparkline-colors', sparklineColorNames); +}; + +// initializes each sparkline so they use colors defined in CSS +piwik.initSparklines = function() { + $('.sparkline > img').each(function () { + var $self = $(this); + + if ($self.attr('src')) { + return; + } + + var colors = JSON.stringify(piwik.getSparklineColors()); + var appendToSparklineUrl = '&colors=' + encodeURIComponent(colors); + + // Append the token_auth to the URL if it was set (eg. embed dashboard) + var token_auth = broadcast.getValueFromUrl('token_auth'); + if (token_auth.length) { + appendToSparklineUrl += '&token_auth=' + token_auth; + } + $self.attr('src', $self.attr('data-src') + appendToSparklineUrl); + }); +}; + +window.initializeSparklines = function () { + var sparklineUrlParamsToIgnore = ['module', 'action', 'idSite', 'period', 'date', 'viewDataTable']; + + $("[data-graph-id]").each(function () { + var graph = $(this); + + // try to find sparklines and add them clickable behaviour + graph.parent().find('div.sparkline').each(function () { + // find the sparkline and get it's src attribute + var sparklineUrl = $('img', this).attr('data-src'); + + if (sparklineUrl != "") { + var params = broadcast.getValuesFromUrl(sparklineUrl); + for (var i = 0; i != sparklineUrlParamsToIgnore.length; ++i) { + delete params[sparklineUrlParamsToIgnore[i]]; + } + for (var key in params) { + if (typeof params[key] == 'undefined') { + // this happens for example with an empty segment parameter + delete params[key]; + } else { + params[key] = decodeURIComponent(params[key]); + } + } + + // on click, reload the graph with the new url + $(this).click(function () { + var reportId = graph.attr('data-graph-id'), + dataTable = $(require('piwik/UI').DataTable.getDataTableByReport(reportId)); + + // when the metrics picker is used, the id of the data table might be updated (which is correct behavior). + // for example, in goal reports it might change from GoalsgetEvolutionGraph to GoalsgetEvolutionGraph1. + // if this happens, we can't find the graph using $('#'+idDataTable+"Chart"); + // instead, we just use the first evolution graph we can find. + if (dataTable.length == 0) { + dataTable = $('div.dataTableVizEvolution'); + } + + // reload the datatable w/ a new column & scroll to the graph + dataTable.trigger('reload', params); + }); + } + }); + }); +}; + +}(jQuery)); diff --git a/www/analytics/plugins/CoreHome/javascripts/top_controls.js b/www/analytics/plugins/CoreHome/javascripts/top_controls.js new file mode 100644 index 00000000..21f3c41f --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/top_controls.js @@ -0,0 +1,27 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +function initTopControls() { + var $topControlsContainer = $('.top_controls'), + left = 0; + + if ($topControlsContainer.length) { + $('.piwikTopControl').each(function () { + var $control = $(this); + if ($control.css('display') == 'none') { + return; + } + + $control.css('left', left); + + if (!$.contains($topControlsContainer[0], this)) { + $control.detach().appendTo($topControlsContainer); + } + + left += $control.outerWidth(true); + }); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/javascripts/uiControl.js b/www/analytics/plugins/CoreHome/javascripts/uiControl.js new file mode 100644 index 00000000..1c3c8aaf --- /dev/null +++ b/www/analytics/plugins/CoreHome/javascripts/uiControl.js @@ -0,0 +1,113 @@ +/** + * Piwik - Web Analytics + * + * Visitor profile popup control. + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'); + + /** + * Base type for Piwik UI controls. Provides functionality that all controls need (such as + * cleanup on destruction). + * + * @param {Element} element The root element of the control. + */ + var UIControl = function (element) { + if (!element) { + throw new Error("no element passed to UIControl constructor"); + } + + this._controlId = UIControl._controls.length; + UIControl._controls.push(this); + + var $element = this.$element = $(element); + $element.data('uiControlObject', this); + + var params = JSON.parse($element.attr('data-params') || '{}'); + for (var key in params) { // convert values in params that are arrays to comma separated string lists + if (params[key] instanceof Array) { + params[key] = params[key].join(','); + } + } + this.param = params; + + this.props = JSON.parse($element.attr('data-props') || '{}'); + }; + + /** + * Contains all active control instances. + */ + UIControl._controls = []; + + /** + * Utility method that will clean up all piwik UI controls whose elements are not attached + * to the DOM. + * + * TODO: instead of having other pieces of the UI manually calling cleanupUnusedControls, + * MutationObservers should be used + */ + UIControl.cleanupUnusedControls = function () { + var controls = UIControl._controls; + + for (var i = 0; i != controls.length; ++i) { + var control = controls[i]; + if (control + && control.$element + && !$.contains(document.documentElement, control.$element[0]) + ) { + controls[i] = null; + control._destroy(); + + if (!control._baseDestroyCalled) { + throw new Error("Error: " + control.constructor.name + "'s destroy method does not call " + + "UIControl.destroy. You may have a memory leak."); + } + } + } + }; + + UIControl.initElements = function (klass, selector) { + $(selector).each(function () { + if (!$(this).attr('data-inited')) { + var control = new klass(this); + $(this).attr('data-inited', 1); + } + }); + }; + + UIControl.prototype = { + + /** + * Perform cleanup. Called when the control has been removed from the DOM. Derived + * classes should overload this function to perform their own cleanup. + */ + _destroy: function () { + this.$element.removeData('uiControlObject'); + delete this.$element; + + this._baseDestroyCalled = true; + }, + + /** + * Handle the widget resize event, if we're currently in a widget. + * + * TODO: should use proper resize detection (see + * http://www.backalleycoder.com/2013/03/18/cross-browser-event-based-element-resize-detection/ ) + * with timeouts (since resizing widgets can be expensive) + */ + onWidgetResize: function (handler) { + var $widget = this.$element.closest('.widgetContent'); + $widget.on('widget:maximise', handler) + .on('widget:minimise', handler) + .on('widget:resize', handler); + } + }; + + exports.UIControl = UIControl; + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/_donate.less b/www/analytics/plugins/CoreHome/stylesheets/_donate.less new file mode 100755 index 00000000..57684fe5 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/_donate.less @@ -0,0 +1,125 @@ +.piwik-donate-call { + padding: 1em; + border: 1px solid #CCC; + border-radius: 4px; + max-width: 432px; + position: relative; +} + +#piwik-worth { + font-size: 1.2em; + font-weight: bold; + font-style: italic; + display: block; + margin: 0 1em 0 1em; +} + +.piwik-donate-slider { + margin: 1em 0 1em 1em; +} + +.piwik-donate-slider > .slider-range { + vertical-align: top; + position: relative; + display: inline-block; + border: 1px solid #999; + + background-color: #f7f7f7; + + border-radius: 6px; + + height: 14px; + width: 270px; + margin: 22px 8px 0 0; + cursor: pointer; +} + +.piwik-donate-slider .slider-position { + border: 1px solid #999; + background-color: #CCC; + + border-radius: 3px; + + height: 18px; + width: 10px; + + position: absolute; + top: -3px; + left: -1px; +} + +.piwik-donate-slider .slider-donate-amount { + display: inline-block; + + padding: .3em .5em .3em .5em; + margin: 16px 8px 0 0; + vertical-align: top; + width: 48px; + text-align: center; + background-color: #CCC; + color: #333; + cursor: pointer; +} + +.piwik-donate-slider .slider-smiley-face { + margin: 8px 0 8px 0; + display: inline-block; + cursor: pointer; +} + +.piwik-donate-call .donate-submit { + min-height: 55px; + position: relative; +} + +.piwik-donate-call .donate-submit input { + margin-left: 13px; + border-style: none; + background-image: none; + padding: 0; +} + +.piwik-donate-call .donate-submit a { + display: inline-block; + margin-left: 1.2em; + font-size: 1em; + font-style: italic; +} + +.piwik-donate-call .donate-submit a.donate-spacer { + margin-bottom:.5em; + visibility:hidden; +} + +.piwik-donate-call .donate-submit a.donate-one-time { + position: absolute; + bottom: .5em; + right: 1.2em; +} + +.piwik-donate-call > .piwik-donate-message { + margin-bottom: .5em; +} + +.piwik-donate-call > .piwik-donate-message p { + margin-left: 1em; +} + +.piwik-donate-call > .form-description { + margin-top: 1.25em; +} + +.donate-form-instructions { + font-size: .8em; + margin: 0 1.25em 0 1.25em; + color: #666; + font-style: italic; +} + +.widget .piwik-donate-call { + border-style: none; +} + +.widget .piwik-donate-slider > .slider-range { + width: 205px; +} diff --git a/www/analytics/plugins/CoreHome/stylesheets/cloud.less b/www/analytics/plugins/CoreHome/stylesheets/cloud.less new file mode 100644 index 00000000..2d490de3 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/cloud.less @@ -0,0 +1,55 @@ +.tagCloud { + padding: 10px; + + img { + border: 0; + } + + .word a { + text-decoration: none; + } + + .word { + padding: 4px 8px 4px 0; + white-space: nowrap; + } + + .valueIsZero { + text-decoration: line-through; + } + + span.size0, span.size0 a { + color: #255792; + font-size: 28px; + } + + span.size1, span.size1 a { + color: #255792; + font-size: 24px; + } + + span.size2, span.size2 a { + color: #255792; + font-size: 20px; + } + + span.size3, span.size3 a { + color: #255792; + font-size: 16px; + } + + span.size4, span.size4 a { + color: #255792; + font-size: 15px; + } + + span.size5, span.size5 a { + color: #255792; + font-size: 14px; + } + + span.size6, span.size6 a { + color: #255792; + font-size: 11px; + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/color_manager.css b/www/analytics/plugins/CoreHome/stylesheets/color_manager.css new file mode 100644 index 00000000..725d3df5 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/color_manager.css @@ -0,0 +1,3 @@ +.color-manager { + color: transparent; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/coreHome.less b/www/analytics/plugins/CoreHome/stylesheets/coreHome.less new file mode 100644 index 00000000..3a62b2e6 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/coreHome.less @@ -0,0 +1,260 @@ +h1 { + font-size: 18px; + font-weight: bold; + color: #7e7363; + padding: 3px 0 7px 0; + clear: both; +} + +h2 { + font-size: 18px; + font-weight: normal; + color: #7e7363; + padding: 12px 0 7px 0; + clear: both; +} + +h2 a { + text-decoration: none; + color: #7e7363; +} + +h3 { + font-size: 1.3em; + margin-top: 2em; + color: #1D3256; +} + +.home p { + padding-bottom: 1em; + margin-right: 1em; + margin-left: 1em; +} + +.nav_sep { + height: 39px; + border-radius: 0 4px 0 0; + border: 1px solid #DDD; +} + +.pageWrap { + border-left: 1px solid #DDDDDD; + border-right: 1px solid #DDDDDD; + min-height: 10px; + overflow: visible; + padding: 15px 15px 0; + position: relative; + font-size: 13px; +} + +/* Content */ +#content.home { + padding-top: 5px; + font-size: 14px; +} + +/* 2 columns reports */ +#leftcolumn { + float: left; + width: 50%; +} + +#rightcolumn { + float: right; + width: 45%; +} + +/* not in widget */ +.widget #leftcolumn, .widget #rightcolumn { + float: left; + padding: 0 10px; + width: auto; +} + +/* Calendar*/ +div.ui-datepicker { + font-size: 62.5%; +} + +.ui-datepicker-current-period a, .ui-datepicker-current-period a:link, .ui-datepicker-current-period a:visited { + border: 1px solid #2E85FF; + color: #2E85FF; +} + +#otherPeriods a { + text-decoration: none; +} + +#otherPeriods a:hover { + text-decoration: underline; +} + +#currentPeriod { + border-bottom: 1px dotted #520202; +} + +.hoverPeriod { + cursor: pointer; + font-weight: bold; + border-bottom: 1px solid #520202; +} + +#calendarRangeTo { + float: left; + margin-left: 20px; +} + +#calendarRangeFrom { + float: left; +} + +#inputCalendarFrom, #inputCalendarTo { + margin-left: 10px; + width: 95px; +} + +#calendarRangeApply { + display: none; + margin-top: 10px; + margin-left: 10px; +} + +#invalidDateRange { + display: none; +} + +div .sparkline { + float: left; + clear: both; + padding-bottom: 1px; + margin-top: 10px; + border-bottom: 1px solid white; +} + +.sparkline img { + vertical-align: middle; + padding-right: 10px; + margin-top: 0; +} + +div.pk-emptyGraph { + padding-top: 20px; + padding-bottom: 10px; + text-align: center; + font-style: italic; +} + +/** + * Popover + * @see popover.js + */ + +#Piwik_Popover { + font-family: Arial, Helvetica, sans-serif; +} + +.Piwik_Popover_Loading_Name { + padding: 50px 0 65px 0; + font-size: 16px; + line-height: 20px; + font-weight: normal; + text-align: center; + background: url(plugins/Zeitgeist/images/loading-blue.gif) no-repeat center 20px; +} + +.Piwik_Popover_Loading_NameWithSubject { + padding-bottom: 30px; +} + +.Piwik_Popover_Loading_Subject { + padding: 0 70px 55px 70px; + color: #7e7363; + text-align: center; + font-size: 14px; +} + +.Piwik_Popover_Error { + padding: 50px 20px 65px 20px; + text-align: center; +} + +.Piwik_Popover_Error_Title { + color: #E87500; + font-weight: bold; + font-size: 16px; +} + +.Piwik_Popover_Error_Title span { + color: #222; + font-weight: normal; + font-size: 16px; +} + +.Piwik_Popover_Error_Message { + color: #7e7363; + padding: 20px 0 0 0; + font-size: 14px; +} + +a.Piwik_Popover_Error_Back { + display: block; + margin: 20px 0 0 0; + color: #1D3256; + font-size: 14px; +} + +#alert.ui-confirm input { + display: block; + margin: 10px auto 5px !important; +} + +.header_short #updateCheckLinkContainer .icon { + display: none; +} + +.header_full #updateCheckLinkContainer { + margin-top: -2px; +} + +@-moz-document url-prefix() { + .header_full #updateCheckLinkContainer { + margin-top: -3px; + } +} + +#updateCheckLinkContainer { + opacity: 0.7; +} + +#updateCheckLinkContainer:hover { + opacity: 1; +} + +#updateCheckLinkContainer { + float: right; + cursor: pointer; +} + +#updateCheckLinkContainer>* { + vertical-align: middle; +} + +#header_message #checkForUpdates { + font-size: 10px; + line-height: 16px; + display: inline-block; + vertical-align: middle; + text-transform: uppercase; +} + +#header_message #checkForUpdates { + text-decoration: none; +} + +#header_message #checkForUpdates:hover { + text-decoration: underline; +} + +/* Used to link within content text, without adding visual clutter */ +.linkContent { color:#333; text-decoration:none} +.linkContent:hover { text-decoration:underline;} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/dataTable.less b/www/analytics/plugins/CoreHome/stylesheets/dataTable.less new file mode 100644 index 00000000..d3760872 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/dataTable.less @@ -0,0 +1,10 @@ +@dataTable-link-color: #255792; +@dataTable-header-background: #e4e2d7; +@dataTable-headerActive-background: #D5D3C8; + +@import "dataTable/_dataTable.less"; +@import "dataTable/_limitSelection.less"; +@import "dataTable/_reportDocumentation.less"; +@import "dataTable/_rowActions.less"; +@import "dataTable/_subDataTable.less"; +@import "dataTable/_tableConfiguration.less"; \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/dataTable/_dataTable.less b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_dataTable.less new file mode 100644 index 00000000..5edc6aa3 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_dataTable.less @@ -0,0 +1,613 @@ +/* main data table */ +.dataTable { + border: 0; + width: 100%; + padding: 0; + border-spacing: 0; + margin: 0; + + td .ratio { + color: #999999; + font-size: 12px; + display:none; + text-align: right; + min-width: 45px; + margin-left: 4px; + font-weight: normal; + } + + td.highlight > .ratio { + display: inline-block; + line-height: 15px; + } +} + +div.dataTable { + position:relative; +} + +table.dataTable td.label, +table.subDataTable td.label { + width: 100%; + white-space: nowrap; +} + +table.dataTable img, +table.subDataTable img { + vertical-align: middle; +} + +table.dataTable img { + border: 0; + margin-right: 1em; + margin-left: 0.5em; +} + +table.dataTable tr.subDataTable { + cursor: pointer; + + td.label span.label { + word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + width: inherit; + display: inline-block; + } +} + +table.dataTable th { + margin: 0; + color: @dataTable-link-color; + text-align: left; + padding: 6px 6px 6px 12px; + background: @dataTable-header-background; + font-size: 12px; + font-weight: normal; + border-left: 1px solid #d4d0c4; + vertical-align: top; +} + +table.dataTable th.sortable { + cursor: pointer; +} + +table.dataTable th.columnSorted { + font-weight: bold; + padding-right: 20px; + background: @dataTable-headerActive-background; +} + +table.dataTable td { + padding: 5px 5px 5px 12px; + background: #fff; + border-left: 1px solid #e7e7e7; +} + +table.dataTable td, +table.dataTable td a { + margin: 0; + text-decoration: none; + color: #444; +} + +table.dataTable tr:hover > td, +table.dataTable tr:hover > td .dataTableRowActions { + background-color: #FFFFF7; +} + +table.dataTable tr.subDataTable:hover > td, +table.dataTable tr.subDataTable:hover > td .dataTableRowActions { + background-color: #ffffcb; +} + +table.dataTable tr:hover > td.cellSubDataTable +table.dataTable tr:hover > td.cellSubDataTable .dataTableRowActions { + background-color: #fff; +} + +td.clean { + background-color: #fff; +} + +table.dataTable td.column { + white-space: nowrap; +} + +table.dataTable td.columneven { + background: #efeeec; +} + +table.dataTable td.columnodd { + background: #f6f5f3; +} + +.dataTable tr.highlight td { + background-color: #ECF9DD; + font-weight: bold; +} + +table.dataTable td.label, +table.subActionsDataTable td.label, +table.actionsDataTable td.label { + border-top: 0; + border-left: 0; +} + +table.dataTable th.label { + border-left: 0; +} + +.dataTableActions table.dataTable th.label { + /* Ensures tables have enough space to display subtable on click, and prevent the jumping effect */ + min-width: 250px; +} + +table.dataTable span.label.highlighted { + font-style: italic; +} + +/* the cell containing the subdatatable */ +table.dataTable .cellSubDataTable { + margin: 0; + border-left: 0; + padding: 6px 12px 6px; +} + +.cellSubDataTable > .dataTable { + padding: 6px 0 0; +} + +/* A link in a column in the DataTable */ +table.dataTable td #urlLink { + display: none; +} + +table.dataTable img { + margin-left: 0; +} + +.dataTable > .dataTableWrapper { + width: 450px; +} + +.subDataTable > .dataTableWrapper { + width: 95%; +} + +.sortIconContainer { + float: right; + position: relative; +} + +.sortIcon { + margin: 0; + position: absolute; +} + +.datatableFooterMessage { + color: #888; + text-align: left; + margin: 10px; +} + +.dataTablePages { + color: #BFBFBF; + font-weight: bold; + margin: 10px; + font-size: 12px; +} + +.dataTableSearchPattern { + margin: 5px 0 2px 0; + height: 20px; + display: block; + white-space: nowrap; + background: url(plugins/Zeitgeist/images/search_bg.png) no-repeat center 0; + text-align: center; +} + +.dataTableSearchPattern input { + vertical-align: top; + font-size: 10px; + color: #454545; + border: 0; + background: transparent; + width: 21px; + height: 17px; + overflow: hidden; + opacity: 0; + filter: Alpha(opacity=0); + cursor: pointer; +} + +.dataTableSearchPattern .searchInput { + width: 114px; + height: auto; + overflow: visible; + padding: 2px 6px; + opacity: 1; + cursor: text; + filter: Alpha(opacity=100); +} + +.dataTableNext, +.dataTablePrevious { + font-size: 12px; + color: #184A83; + cursor: pointer; +} + +.datatableRelatedReports { + color: #888; + font-size: 11px; + padding-bottom: 5px; + margin-top: 6px; +} + +#dashboard .datatableRelatedReports { + margin-top: 0px; +} + +.datatableRelatedReports span { + cursor: pointer; + font-weight: bold; +} + +.dataTableFeatures { + text-align: center; +} + +.dataTableFooterNavigation { + padding: 5px 0; +} + +.dataTableNext, +.dataTablePrevious, +.dataTableSearchPattern { + display: none; +} + +.dataTableFeatures .loadingPiwik { + font-size: 0.9em; +} + +.subDataTable .dataTableFooterIcons { + height: 0; +} + +.dataTable .loadingPiwikBelow { + padding-bottom: 5px; + display: block; + text-align: center; +} + +.dataTableFooterIcons div { + padding-bottom: 4px; +} + +#dashboard { + + .dataTableFeatures { + &.expanded { + .dataTableFooterIcons { + display: block; + } + + .expandDataTableFooterDrawer { + display: none; + } + } + + &.hasEvolution { + .dataTableFooterIcons { + margin-top: 17px; + } + .expandDataTableFooterDrawer { + margin-top: 20px; + } + } + + .expandDataTableFooterDrawer { + display: block; + margin-top: 5px; + margin-bottom: 0px; + margin-left: auto; + margin-right: auto; + background-color: #D9D9D9; + height: 7px; + width: 70px; + -webkit-border-top-left-radius: 10px; + -webkit-border-top-right-radius: 10px; + -moz-border-radius-topleft: 10px; + -moz-border-radius-topright: 10px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + line-height: 0px; + + img { + margin-bottom: 0px; + line-height: 0px; + } + } + } + + .dataTableFooterIcons { + + display: none; + margin-top: 5px; + height: auto; + + div { + padding-bottom: 2px; + } + + .foldDataTableFooterDrawer { + display: block; + padding-bottom: 0px; + margin-top: 0px; + margin-left: auto; + margin-right: auto; + background-color: #D9D9D9; + -webkit-border-top-left-radius: 10px; + -webkit-border-top-right-radius: 10px; + -moz-border-radius-topleft: 10px; + -moz-border-radius-topright: 10px; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + line-height: 0px; + height: 7px; + width: 70px; + clear: both; + + img { + margin-top: 2px; + margin-bottom: 0px; + line-height: 0px; + } + } + + .controls { + padding: 15px 0px; + text-align: left; + color: #333; + } + } + +} + +.dataTableFooterIcons .foldDataTableFooterDrawer, +.dataTableFeatures .expandDataTableFooterDrawer { + display: none; + cursor: pointer; +} + +@-moz-document url-prefix() { + + #dashboard .dataTableFeatures .expandDataTableFooterDrawer { + line-height: 1px; + + img { + margin-bottom: 1px; + line-height: 1px; + } + } +} + +.dataTableFooterIcons { + height: 20px; + white-space: nowrap; + font-size: 10px; + padding: 6px 5px; + border-top: 1px solid #B6B0A6; +} + +.dataTableFooterWrap { + position: relative; + float: left; +} + +.dataTableFooterWrap select { + float: left; + margin: 1px 0 1px 10px; +} + +.tableIcon { + background: #f2f1ed; + display: inline-block; + float: left; + margin: 0 1px 0 0; + padding: 2px; + border-radius: 2px; +} + +.tableIcon:hover { + background: #e9e8e1; +} + +.activeIcon { + background: #e9e8e1; +} + +.tableIconsGroup > span > span { + position:relative; + float:left; +} + +.dataTableFooterActiveItem { + position: absolute; + top: -6px; + left: 0; +} + +.exportToFormatItems { + background: #dcdacf; + float: left; + margin: 0 1px 0 0; + padding: 4px 6px 3px 6px; + color: #968d7f; + border-radius: 2px; +} + +.exportToFormatItems img { + vertical-align: middle; + margin: -4px -3px -2px 2px; +} + +.tableIconsGroup { + float: left; + padding-right: 4px; +} + +.tableIconsGroup .tableIcon span { + margin-right: 5px; + margin-left: 5px; +} + +.tableIconsGroup img { + vertical-align: bottom; +} + +.tableIconsGroupActive { + display: block; + float: left; + background: #dcdacf; + border-radius: 2px; +} + +.tableIconsGroupActive .tableIcon { + background: none; +} + +.tableIconsGroupActive .tableIcon:hover { + background: #e9e8e1; +} + +.exportToFormatIcons, +.dataTableFooterIconsShow { + float: left; +} + +.dataTableFooterIcons, +.dataTableFooterIcons a { + text-decoration: none; + color: @dataTable-link-color; +} + +.dataTableSpacer { + clear: both; +} + +/* Actions table */ +.dataTableActions table.dataTable tr td.labelodd { + background-image: none; +} + +/* levels higher than 4 have a default padding left */ +.actionsDataTable tr td.label { + padding-left: 7em; +} + +tr.level0 td.label { + padding-left: 1.5em; +} + +tr.level1 td.label { + padding-left: 2.5em; +} + +tr.level2 td.label { + padding-left: 3.5em; +} + +tr.level3 td.label { + padding-left: 4.5em; +} + +tr.level4 td.label { + padding-left: 5em; +} +tr.level5 td.label { + padding-left: 5.5em; +} +tr.level6 td.label { + padding-left: 6em; +} +tr.level7 td.label { + padding-left: 6.5em; +} +tr.level8 td.label { + padding-left: 7em; +} +tr.level9 td.label { + padding-left: 7.5em; +} +tr.level10 td.label { + padding-left: 8em; +} +tr.level11 td.label { + padding-left: 8.5em; +} +tr.level12 td.label { + padding-left: 9em; +} + +/* less right margins for the link image in the Pa*/ +.dataTableActions table.dataTable img.link { + margin-right: 0.5em; + margin-left: -0.5em; + margin-top: -8px; +} + +tr td.label img.plusMinus { + margin-left: -1em; + margin-right: 3px; + margin-top: -5px; +} + +.pk-emptyDataTable { + padding-top: 20px; + padding-bottom: 10px; + text-align: center; + font-style: italic; +} + +.helpDate { + color: #777777; + font-size: 11px; + font-style: italic; + padding: 4px; + text-align: right; + display: block; +} + +body .ui-tooltip.rowActionTooltip { + font-size: 11px; + padding: 3px 5px 3px 6px; +} + +table.dataTable span.cell-tooltip { + cursor: default; +} + +.dataTable .jqplot-graph { + padding-left: 6px; + > div { + position: relative; + } +} + +td.cellSubDataTable .loadingPiwik { + padding:0; +} + +.dataTable .searchReset { + position:relative; + + img { + position: absolute; + top: 4px; + left: -15px; + cursor: pointer; + display: inline; + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/dataTable/_limitSelection.less b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_limitSelection.less new file mode 100644 index 00000000..6d899d89 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_limitSelection.less @@ -0,0 +1,68 @@ +.limitSelection { + float: right; + position: relative; + margin-left: 5px; + min-height: 20px; + z-index: 1; +} + +.limitSelection.hidden { + display: none; +} + +.limitSelection > div { + border: 1px solid #DFDFDF; + border-radius: 4px; + background: url(plugins/Zeitgeist/images/sort_subtable_desc_light.png) no-repeat right 2px; + padding: 0 14px 0 4px; + display: block; + width: 24px; + height: 20px; + cursor: pointer; +} + +.limitSelection.disabled > div { + opacity: 0.5; + cursor: not-allowed; + filter: Alpha(opacity=50); +} + +.limitSelection.visible > div { + border-radius: 0 0 4px 4px; + background-image: url(plugins/Zeitgeist/images/sort_subtable_asc_light.png) +} + +.limitSelection > ul { + margin-top: 1px; + overflow: visible; + background-color: #fff; +} + +.limitSelection > ul > li { + cursor: pointer; + width: 28px; + padding: 0 10px 0 4px; + font-size: 1.1em; + font-weight: bold; + height: 20px; + margin-top: -40px; + background-color: #fff; + border-left: 1px solid #DFDFDF; + border-right: 1px solid #DFDFDF; + vertical-align: middle; + text-align: right; +} + +.limitSelection > ul > li.last { + border-top: 1px solid #DFDFDF; + border-radius: 4px 4px 0 0; +} + +.limitSelection > ul > li:hover { + background-color: #EBEAE6; +} + +.limitSelection span { + padding-top: 3px; + display: inline-block; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/dataTable/_reportDocumentation.less b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_reportDocumentation.less new file mode 100644 index 00000000..a1a8ae50 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_reportDocumentation.less @@ -0,0 +1,61 @@ +/* Documentation */ +table.dataTable th .columnDocumentation { + display: none; + width: 165px; + text-align: left; + background: #f7f7f7; + color: #444; + font-size: 11px; + font-weight: normal; + border: 1px solid #e4e5e4; + padding: 5px 10px 6px 10px; + border-radius: 4px; + z-index: 125; + position: absolute; + box-shadow: 0 0 4px #e4e5e4; + cursor: default; +} + +table.dataTable th .columnDocumentationTitle { + background: url(plugins/Zeitgeist/images/help.png) no-repeat; + line-height: 14px; + padding: 2px 0 3px 21px; + font-weight: bold; +} + +.reportDocumentation { + display: none; + background: #f7f7f7; + font-size: 12px; + font-weight: normal; + border: 1px solid #e4e5e4; + margin: 0 0 10px 0; + padding: 0; + border-radius: 4px; + max-width: 500px; +} + +.reportDocumentation p { + padding: 5px 10px 6px 10px; + margin: 0; + color: #444; + font-size: 12px; +} + +.reportDocumentationIcon { + display: block; + width: 16px; + height: 16px; + margin: 10px 0; + background: url(plugins/Zeitgeist/images/help.png) no-repeat; +} + +h2 .reportDocumentationIcon { + position: absolute; + margin: 4px 0 0 0; + display: none; +} + +h2 .reportDocumentationIcon.hidden { + background: none; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/dataTable/_rowActions.less b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_rowActions.less new file mode 100644 index 00000000..4ba2dbc8 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_rowActions.less @@ -0,0 +1,36 @@ + + +table.dataTable .dataTableRowActions { + position: absolute; + display: none; + overflow: hidden; + margin-top: -5px; + z-index: 1000; /* Work around FF bug to make sure it displays over ellipsis */ +} + +*+html table.dataTable .dataTableRowActions { + margin-top: -7px; +} + +table.dataTable .dataTableRowActions a { + display: block; + float: left; + padding: 6px 4px 6px 0; + margin: 0; +} + +table.dataTable .dataTableRowActions a.leftmost { + padding-left: 4px; +} + +table.dataTable .dataTableRowActions a.rightmost { + padding-right: 8px; +} + +table.dataTable .dataTableRowActions a img { + margin: 0; + padding: 0; + border: 0; + width: 20px; + height: 17px; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/dataTable/_subDataTable.less b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_subDataTable.less new file mode 100644 index 00000000..7e540c2b --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_subDataTable.less @@ -0,0 +1,46 @@ +/* SUBDATATABLE */ +/* a datatable inside another datatable */ +table.subDataTable td { + border: 0; +} + +table.subDataTable thead th { + font-weight: normal; + font-size: 12px; + text-align: left; + padding: .3em 1em; + border: 0; + border-top: 1px solid #e7e7e7; + border-bottom: 1px solid #e7e7e7; +} + +table.subDataTable td.labeleven, table.subDataTable td.labelodd { + background-image: none; +} + +table.subDataTable td { + border-bottom: 1px solid #e7e7e7; + border-left: 0; +} + +table.subDataTable td, table.subDataTable td a { + color: #615B53; +} + +table.subDataTable td.labeleven, table.subDataTable td.columneven { + color: #2D2A27; +} + +table.subDataTable td.label { + width: 80%; +} + +table.subDataTable td.label { + padding: 5px; +} + +/* are the following two supposed to be together? */ +.subDataTable.dataTableFeatures { + padding-top: 0; + padding-bottom: 5px; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/dataTable/_tableConfiguration.less b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_tableConfiguration.less new file mode 100644 index 00000000..fff7c3d1 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/dataTable/_tableConfiguration.less @@ -0,0 +1,82 @@ +.tableConfiguration { + float: right; + position: relative; + margin-left: 5px; + min-height: 20px; + min-width: 25px; +} + +a.tableConfigurationIcon { + display: block; + width: 30px; + height: 22px; + background: url(plugins/Zeitgeist/images/configure.png) no-repeat center 2px; + position: absolute; + z-index: 9; + right: 0; +} + +a.tableConfigurationIcon.highlighted { + display: block; + width: 30px; + height: 22px; + background-image: url(plugins/Zeitgeist/images/configure-highlight.png); + position: absolute; + z-index: 9; + right: 0; +} + +.tableConfiguration ul { + overflow: visible; + display: none; + position: relative; + z-index: 8; + text-align: left; +} + +.tableConfiguration ul.open { + display: block; +} + +.tableConfiguration ul li { + padding: 0; + font-size: 1.1em; + height: 40px; + margin-top: -80px; + background-color: #fff; + border: 1px solid #DFDFDF; + border-width: 0 1px; + vertical-align: middle; +} + +.tableConfiguration ul li.firstDummy { + border-bottom-width: 1px; + border-radius: 0 0 4px 4px; + height: 25px; + cursor: default; + margin-top: -4px; +} + +.tableConfiguration ul li.first { + margin-top: -65px; +} + +.tableConfiguration ul li.last { + border-top-width: 1px; + border-radius: 4px 4px 0 0; +} + +.tableConfiguration div.configItem { + cursor: pointer; + padding: 5px 10px; + line-height: 15px; + color: #444; +} + +.tableConfiguration div.configItem:hover { + background-color: #EBEAE6; +} + +.tableConfiguration div.configItem span.action { + color: @dataTable-link-color; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/jqplotColors.less b/www/analytics/plugins/CoreHome/stylesheets/jqplotColors.less new file mode 100644 index 00000000..01dd9b84 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/jqplotColors.less @@ -0,0 +1,150 @@ +// evolution graph colors +.evolution-graph-colors[data-name=grid-background] { + color: #fff; +} + +.evolution-graph-colors[data-name=grid-border] { + color: #f00; +} + +.evolution-graph-colors[data-name=series1] { + color: #5170AE; +} + +.evolution-graph-colors[data-name=series2] { + color: #F29007; +} + +.evolution-graph-colors[data-name=series3] { + color: #CC3399; +} + +.evolution-graph-colors[data-name=series4] { + color: #9933CC; +} + +.evolution-graph-colors[data-name=series5] { + color: #80a033; +} + +.evolution-graph-colors[data-name=series6] { + color: #246AD2; +} + +.evolution-graph-colors[data-name=series7] { + color: #FD16EA; +} + +.evolution-graph-colors[data-name=series8] { + color: #49C100; +} + +.evolution-graph-colors[data-name=ticks] { + color: #ccc; +} + +.evolution-graph-colors[data-name=single-metric-label] { + color: #666; +} + +// bar graph colors +.bar-graph-colors[data-name=grid-background] { + color: #fff; +} + +.bar-graph-colors[data-name=grid-border] { + color: #f00; +} + +.bar-graph-colors[data-name=series1] { + color: #5170AE; +} + +.bar-graph-colors[data-name=series2] { + color: #F3A010; +} + +.bar-graph-colors[data-name=series3] { + color: #CC3399; +} + +.bar-graph-colors[data-name=series4] { + color: #9933CC; +} + +.bar-graph-colors[data-name=series5] { + color: #80a033; +} + +.bar-graph-colors[data-name=series6] { + color: #246AD2; +} + +.bar-graph-colors[data-name=series7] { + color: #FD16EA; +} + +.bar-graph-colors[data-name=series8] { + color: #49C100; +} + +.bar-graph-colors[data-name=ticks] { + color: #ccc; +} + +.bar-graph-colors[data-name=single-metric-label] { + color: #666; +} + +// pie graph colors +.pie-graph-colors[data-name=grid-background] { + color: #fff; +} + +.pie-graph-colors[data-name=grid-border] { + color: #f00; +} + +.pie-graph-colors[data-name=series1] { + color: #59727F; +} + +.pie-graph-colors[data-name=series2] { + color: #7DAAC0; +} + +.pie-graph-colors[data-name=series3] { + color: #7F7259; +} + +.pie-graph-colors[data-name=series4] { + color: #C09E7D; +} + +.pie-graph-colors[data-name=series5] { + color: #9BB39B; +} + +.pie-graph-colors[data-name=series6] { + color: #B1D8B3; +} + +.pie-graph-colors[data-name=series7] { + color: #B39BA7; +} + +.pie-graph-colors[data-name=series8] { + color: #D8B1C5; +} + +.pie-graph-colors[data-name=series9] { + color: #A5A5A5; +} + +.pie-graph-colors[data-name=ticks] { + color: #ccc; +} + +.pie-graph-colors[data-name=single-metric-label] { + color: #666; +} diff --git a/www/analytics/plugins/CoreHome/stylesheets/jquery.ui.autocomplete.css b/www/analytics/plugins/CoreHome/stylesheets/jquery.ui.autocomplete.css new file mode 100644 index 00000000..c3077b7b --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/jquery.ui.autocomplete.css @@ -0,0 +1,70 @@ +/* Autocomplete +----------------------------------*/ +.ui-autocomplete { + position: absolute; + cursor: default; +} + +.ui-autocomplete-loading { + background: white; +} + +/* workarounds */ +* html .ui-autocomplete { + /* without this, the menu expands to 100% in IE6 */ + width: 1px; +} + +/* Menu +----------------------------------*/ +.ui-menu { + list-style: none; + padding: 6px; + margin: 0; + display: block; + position: relative; + font-family: Arial, Verdana, Arial, Helvetica, sans-serif; +} + +.ui-menu .ui-menu { + margin-top: -3px; + margin-bottom: 8px; +} + +.ui-menu .ui-menu-item { + line-height: 18px; + padding: 0; + height: auto; + display: block; + text-decoration: none; + white-space: nowrap; +} + +.ui-menu .ui-menu-item a { + line-height: 18px; + color: #255792; + font-size: 12px; + padding: 0 5px 0 5px; + position: relative; +} + +.ui-menu .ui-menu-item a.ui-state-focus, +.ui-menu .ui-menu-item a.ui-state-active { + font-weight: normal; + margin: 0; +} + +.ui-widget-content { + border: 0; +} + +.ui-corner-all { + border-radius: 4px; +} + +.ui-menu .ui-menu-item a.ui-state-focus { + background: #ebeae6; + border: 0; + border-radius: 0; +} + diff --git a/www/analytics/plugins/CoreHome/stylesheets/menu.less b/www/analytics/plugins/CoreHome/stylesheets/menu.less new file mode 100644 index 00000000..f4a28a49 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/menu.less @@ -0,0 +1,131 @@ +.Menu--dashboard { + position: relative; +} + +.Menu--dashboard > .Menu-tabList { + line-height: 1; + display: table; // The nav has the height og his children + margin-bottom: -1px; // Allow tabs to merge with the submenu +} + +.Menu--dashboard > .Menu-tabList ul { + background: #fff; /*IE6 needs this*/ + float: left; + position: relative; +} + +/* LEVEL1 NORMAL */ +.Menu--dashboard > .Menu-tabList > li { + background: #f1f1f1; + float: left; + list-style: none; + z-index: 49; + margin-right: -1px; + border: 1px solid #ddd; + border-bottom: 0; + border-radius: 4px 4px 0 0; +} + +.Menu--dashboard > .Menu-tabList a { + color: #444; + font-size: 18px; + display: block; + float: left; + padding: 8px 27px 0; + height: 25px; + text-decoration: none; + font-weight: normal; +} + +/* LEVEL1 HOVER */ +.Menu--dashboard > .Menu-tabList > li:hover, +.Menu--dashboard > .Menu-tabList > li.sfHover { + background: #fff; +} + +.Menu--dashboard > .Menu-tabList > li:hover > a, +.Menu--dashboard > .Menu-tabList > li.sfHover > a, +.Menu--dashboard > .Menu-tabList > li.sfActive > a, +.Menu--dashboard > .Menu-tabList a:hover { + color: #e87500; +} + +.Menu--dashboard > .Menu-tabList > li:hover > a { + text-decoration: underline; +} + +.Menu--dashboard > .Menu-tabList > li.sfHover > a { + border-bottom: 1px solid #fff; +} + +/* LEVEL2 NORMAL */ +.Menu--dashboard > .Menu-tabList > li > ul { + padding: 9px 0 5px 0; + left: 0; + top: -999em; + position: absolute; + min-height: 25px; + width: 100%; + background: none; +} + +.Menu--dashboard > .Menu-tabList > li li { + float: left; + background: none; + border: 0; + text-align: center; +} + +.Menu--dashboard > .Menu-tabList > li li > a { + padding: 5px 15px; + font-size: 14px; + border: 0; + float: none; + display: inline-block; + height: auto; + background: none; + color: #444; + text-decoration: none; +} + +/* LEVEL2 HOVER */ +.Menu--dashboard > .Menu-tabList > li.sfHover > ul, +.Menu--dashboard > .Menu-tabList > li:hover > ul { + z-index: 1; + top: 100%; + opacity: 1; + -webkit-transition: opacity 300ms ease-out 10ms; /* property duration timing-function delay */ + -moz-transition: opacity 300ms ease-out 10ms; + -o-transition: opacity 300ms ease-out 10ms; + transition: opacity 300ms ease-out 10ms; +} + +.Menu--dashboard > .Menu-tabList > li li:hover > a, +.Menu--dashboard > .Menu-tabList > li li.sfHover > a { + color: #e87500; +} + +.Menu--dashboard > .Menu-tabList > li li.sfHover > a { + font-weight: bold; + text-decoration: none !important; +} + +@media all and (max-width: 949px) { + .nav { + clear: right; + } +} + +@media all and (max-width: 749px) { + .Menu--dashboard > .Menu-tabList a { + padding-left: 8px; + padding-right: 8px; + } +} + +@media all and (max-width: 549px) { + .Menu--dashboard > ul.Menu-tabList > li.sfHover > a, + .Menu--dashboard > ul.Menu-tabList > li.sfActive.sfHover > a { + border-bottom: 0; + } +} diff --git a/www/analytics/plugins/CoreHome/stylesheets/notification.less b/www/analytics/plugins/CoreHome/stylesheets/notification.less new file mode 100644 index 00000000..23b29e1f --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/notification.less @@ -0,0 +1,93 @@ +#content.admin #notificationContainer { + width: 750px; + display: table-header-group; /* no overlap with About Piwik box */ + + .notification { + margin: 10px; + } +} + +.system.notification { + color: #9b7a44; + float: none; + + padding: 15px 35px 15px 15px; + text-shadow: 0 1px 0 rgba(255,255,255,.5); + background-color: #ffffe0; + border: 1px solid #e6db55; + border-radius: 3px; + font-size: 14px; + margin: 0px; + margin-bottom: 16px; + + a { + color: #9b7a44; + text-decoration: underline; + } + + .close { + position: relative; + top: -5px; + right: -28px; + line-height: 20px; + } + + button.close { + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + -webkit-appearance: none; + } + .close { + float: right; + font-size: 20px; + font-weight: bold; + line-height: 20px; + color: #000000; + text-shadow: 0 1px 0 #ffffff; + opacity: 0.2; + filter: alpha(opacity=20); + } + + &.notification-success { + background-color: #dff0d8; + border-color: #c3d6b7; + color: #468847; + + a { + color: #468847 + } + } + &.notification-danger, + &.notification-error { + background-color: #f2dede; + border-color: #d5bfc4; + color: #b94a48; + + a { + color: #b94a48 + } + } + &.notification-info { + background-color: #d9edf7; + border-color: #a7d3e3; + color: #3a87ad; + + a { + color: #3a87ad + } + } + + &.notification-block { + padding-top: 14px; + padding-bottom: 14px; + } + &.notification-block > p, + &.notification-block > ul { + margin-bottom: 0; + } + &.notification-block p + p { + margin-top: 5px; + } +} diff --git a/www/analytics/plugins/CoreHome/stylesheets/promo.less b/www/analytics/plugins/CoreHome/stylesheets/promo.less new file mode 100644 index 00000000..a40b36de --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/promo.less @@ -0,0 +1,72 @@ +#piwik-promo-thumbnail { + background: #fff url(plugins/CoreHome/images/promo_splash.png) no-repeat 0 0; + background-position: center; + width: 321px; + margin: 0 auto 0 auto; +} + +#piwik-promo-embed { + margin-left: 1px; +} + +#piwik-promo-embed>iframe { + z-index: 0; +} + +#piwik-promo-thumbnail { + height: 178px; +} + +#piwik-promo-thumbnail:hover { + opacity: .75; + cursor: pointer; +} + +#piwik-promo-thumbnail>img { + display: block; + position: relative; + top: 53px; + left: 125px; +} + +#piwik-promo-video { + margin: 2em 0 2em 0; +} + +#piwik-widget-footer { + margin: 0 1em 1em 1em; +} + +#piwik-promo-share { + margin: 0 2em 1em 0; + background-color: #CCC; + border: 1px solid #CCC; + border-radius: 6px; + display: inline-block; + padding: 0 .5em 0 .5em; + float: right; +} + +#piwik-promo-share > a { + margin-left: .5em; + margin-top: 4px; + display: inline-block; +} + +#piwik-promo-share>span { + display: inline-block; + vertical-align: top; + margin-top: 4px; +} + +#piwik-promo-videos-link { + font-size: .8em; + font-style: italic; + margin: 1em 0 0 1.25em; + color: #666; + display: inline-block; +} + +#piwik-promo-videos-link:hover { + text-decoration: none; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/stylesheets/sparklineColors.less b/www/analytics/plugins/CoreHome/stylesheets/sparklineColors.less new file mode 100644 index 00000000..9eef3ff9 --- /dev/null +++ b/www/analytics/plugins/CoreHome/stylesheets/sparklineColors.less @@ -0,0 +1,30 @@ +// sparkline styles +div.sparkline { + border-bottom: 1px solid white; +} + +div.sparkline:hover { + cursor: pointer; + border-bottom: 1px dashed #c3c3c3; +} + +// sparkline colors +.sparkline-colors[data-name=backgroundColor] { + color: white; +} + +.sparkline-colors[data-name=lineColor] { + color: rgb(22, 44, 74); +} + +.sparkline-colors[data-name=minPointColor] { + color: #ff7f7f; +} + +.sparkline-colors[data-name=lastPointColor] { + color: #55AAFF; +} + +.sparkline-colors[data-name=maxPointColor] { + color: #75BF7C; +} diff --git a/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportBody.twig b/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportBody.twig new file mode 100644 index 00000000..b168f9ef --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportBody.twig @@ -0,0 +1,80 @@ +<h2 id="{{ reportId }}" style="color: rgb({{ reportTitleTextColor }}); font-size: {{ reportTitleTextSize }}pt;"> + {{ reportName }} +</h2> + +{% if reportRows is empty %} + {{ 'CoreHome_ThereIsNoDataForThisReport'|translate }} +{% else %} + {% if displayGraph %} + <img alt="" + {% if renderImageInline %} + src="data:image/png;base64,{{ generatedImageGraph }}" + {% else %} + src="cid:{{ reportId }}" + {% endif %} + height="{{ graphHeight }}" + width="{{ graphWidth }}"/> + {% endif %} + + {% if displayGraph and displayTable %} + <br/> + <br/> + {% endif %} + + {% if displayTable %} + <table style="border-collapse:collapse; margin-left: 5px;"> + <thead style="background-color: rgb({{ tableHeaderBgColor }}); color: rgb({{ tableHeaderTextColor }}); font-size: {{ reportTableHeaderTextSize }}pt;"> + {% for columnName in reportColumns %} + <th style="padding: 6px 0;"> +  {{ columnName }}   + </th> + {% endfor %} + </thead> + <tbody> + {% set cycleValues=['','background-color: rgb('~tableBgColor~')'] %} + {% set cycleIndex=0 %} + {% for rowId,row in reportRows %} + {% set rowMetrics=row.columns %} + + {% if reportRowsMetadata[rowId] is defined %} + {% set rowMetadata=reportRowsMetadata[rowId].columns %} + {% else %} + {% set rowMetadata=null %} + {% endif %} + <tr style="{{ cycle(cycleValues, cycleIndex) }}"> + {% set cycleIndex=cycleIndex+1 %} + {% for columnId, columnName in reportColumns %} + <td style="font-size: {{ reportTableRowTextSize }}pt; border-bottom: 1px solid rgb({{ tableCellBorderColor }}); padding: 5px 0 5px 5px;"> + {% if columnId == 'label' %} + {% if rowMetrics[columnId] is defined %} + {% if rowMetadata.logo is defined %} + <img src='{{ currentPath }}{{ rowMetadata.logo }}'> +   + {% endif %} + {% if rowMetadata.url is defined %} + <a style="color: rgb({{ reportTextColor }});" href='{% if rowMetadata.url|slice(0,4) not in ['http','ftp:'] %}http://{% endif %}{{ rowMetadata.url }}'> + {% endif %} + {{ rowMetrics[columnId] | raw }}{# labels are escaped by SafeDecodeLabel filter in core/API/Response.php #} + {% if rowMetadata.url is defined %} + </a> + {% endif %} + {% endif %} + {% else %} + {% if rowMetrics[columnId] is empty %} + 0 + {% else %} + {{ rowMetrics[columnId] }} + {% endif %} + {% endif %} + </td> + {% endfor %} + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} + <br/> + <a style="text-decoration:none; color: rgb({{ reportTitleTextColor }}); font-size: {{ reportBackToTopTextSize }}pt;" href="#reportTop"> + {{ 'ScheduledReports_TopOfReport'|translate }} + </a> +{% endif %} diff --git a/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportFooter.twig b/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportFooter.twig new file mode 100644 index 00000000..691287b6 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportFooter.twig @@ -0,0 +1,2 @@ +</body> +</html> \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportHeader.twig b/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportHeader.twig new file mode 100644 index 00000000..e594377e --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/ReportRenderer/_htmlReportHeader.twig @@ -0,0 +1,36 @@ +<html> +<head> + <meta charset="utf-8"> +</head> +<body style="color: rgb({{ reportTextColor }});"> + +<a id="reportTop" target="_blank" href="{{ currentPath }}"><img title="{{ 'General_GoTo'|translate("Piwik") }}" border="0" alt="Piwik" src='{{ logoHeader }}'/></a> + +<h1 style="color: rgb({{ reportTitleTextColor }}); font-size: {{ reportTitleTextSize }}pt;"> + {{ reportTitle }} +</h1> + +<p> + {{ description }} - {{ 'General_DateRange'|translate }} {{ prettyDate }} +</p> + +{% if displaySegment %} +<p style="color: rgb({{ reportTitleTextColor }});"> + {{ 'ScheduledReports_CustomVisitorSegment'|translate("Piwik") }} {{ segmentName }} +</p> +{% endif %} + +{% if reportMetadata|length > 1 %} + <h2 style="color: rgb({{ reportTitleTextColor }}); font-size: {{ reportTitleTextSize }}pt;"> + {{ 'ScheduledReports_TableOfContent'|translate }} + </h2> + <ul> + {% for metadata in reportMetadata %} + <li> + <a href="#{{ metadata.uniqueId }}" style="text-decoration:none; color: rgb({{ reportTextColor }});"> + {{ metadata.name }} + </a> + </li> + {% endfor %} + </ul> +{% endif %} diff --git a/www/analytics/plugins/CoreHome/templates/ReportsByDimension/_reportsByDimension.twig b/www/analytics/plugins/CoreHome/templates/ReportsByDimension/_reportsByDimension.twig new file mode 100644 index 00000000..ef939fe3 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/ReportsByDimension/_reportsByDimension.twig @@ -0,0 +1,29 @@ +<div class="reportsByDimensionView"> + + <div class="entityList"> + {% for category, dimensions in dimensionCategories %} + {% set firstCategory = (loop.index0 == 0) %} + <div class='dimensionCategory'> + {{ category|translate }} + <ul class='listCircle'> + {% for idx, dimension in dimensions %} + <li class="reportDimension {% if idx == 0 and firstCategory %}activeDimension{% endif %}" + data-url="{{ dimension.url }}"> + <span class='dimension'>{{ dimension.title|translate }}</span> + </li> + {% endfor %} + </ul> + </div> + {% endfor %} + </div> + + <div style="float:left;max-width:900px;"> + <div class="loadingPiwik" style="display:none;"> + <img src="plugins/Zeitgeist/images/loading-blue.gif" alt=""/>{{ 'General_LoadingData'|translate }} + </div> + + <div class="dimensionReport">{{ firstReport|raw }}</div> + </div> + <div class="clear"></div> + +</div> diff --git a/www/analytics/plugins/CoreHome/templates/_dataTable.twig b/www/analytics/plugins/CoreHome/templates/_dataTable.twig new file mode 100644 index 00000000..234d90ea --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_dataTable.twig @@ -0,0 +1,40 @@ +{% if properties.show_visualization_only %} + {% include visualizationTemplate %} +{%- else -%} + +{% set summaryRowId = constant('Piwik\\DataTable::ID_SUMMARY_ROW') %}{# ID_SUMMARY_ROW #} +{% set isSubtable = javascriptVariablesToSet.idSubtable is defined and javascriptVariablesToSet.idSubtable != 0 %} +<div class="dataTable {{ visualizationCssClass }} {{ properties.datatable_css_class|default('') }} {% if isSubtable %}subDataTable{% endif %}" + data-table-type="{{ properties.datatable_js_type }}" + data-report="{{ properties.report_id }}" + data-props="{% if clientSideProperties is empty %}{}{% else %}{{ clientSideProperties|json_encode }}{% endif %}" + data-params="{% if clientSideParameters is empty %}{}{% else %}{{ clientSideParameters|json_encode }}{% endif %}"> + <div class="reportDocumentation"> + {% if properties.documentation|default is not empty %}<p>{{ properties.documentation|raw }}</p>{% endif %} + {% if reportLastUpdatedMessage is defined %}<span class='helpDate'>{{ reportLastUpdatedMessage }}</span>{% endif %} + </div> + <div class="dataTableWrapper"> + {% if error is defined %} + {{ error.message }} + {% else %} + {% if dataTable is empty or dataTableHasNoData|default(false) %} + <div class="pk-emptyDataTable"> + {% if showReportDataWasPurgedMessage is defined and showReportDataWasPurgedMessage %} + {{ 'CoreHome_DataForThisReportHasBeenPurged'|translate(deleteReportsOlderThan) }} + {% else %} + {{ 'CoreHome_ThereIsNoDataForThisReport'|translate }} + {% endif %} + </div> + {% else %} + {% include visualizationTemplate %} + {% endif %} + + {% if properties.show_footer %} + {% include "@CoreHome/_dataTableFooter.twig" %} + {% endif %} + {% include "@CoreHome/_dataTableJS.twig" %} + {% endif %} + </div> +</div> + +{%- endif %} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/_dataTableCell.twig b/www/analytics/plugins/CoreHome/templates/_dataTableCell.twig new file mode 100644 index 00000000..93331dda --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_dataTableCell.twig @@ -0,0 +1,49 @@ +{% spaceless %} +{% set tooltipIndex = column ~ '_tooltip' %} +{% if row.getMetadata(tooltipIndex) %}<span class="cell-tooltip" data-tooltip="{{ row.getMetadata(tooltipIndex) }}">{% endif %} +{% if not row.getIdSubDataTable() and column=='label' and row.getMetadata('url') %} + <a target="_blank" href='{% if row.getMetadata('url')|slice(0,4) not in ['http','ftp:'] %}http://{% endif %}{{ row.getMetadata('url')|raw }}'> + {% if not row.getMetadata('logo') %} + <img class="link" width="10" height="9" src="plugins/Zeitgeist/images/link.gif"/> + {% endif %} +{% endif %} +{% if column=='label' %} + {% import 'macros.twig' as piwik %} + + <span class='label{% if row.getMetadata('is_aggregate') %} highlighted{% endif %}' + {% if properties is defined and properties.tooltip_metadata_name is not empty %}title="{{ row.getMetadata(properties.tooltip_metadata_name) }}"{% endif %}> + {{ piwik.logoHtml(row.getMetadata(), row.getColumn('label')) }} + {% if row.getMetadata('html_label_prefix') %}<span class='label-prefix'>{{ row.getMetadata('html_label_prefix') | raw }} </span>{% endif -%} + {%- if row.getMetadata('html_label_suffix') %}<span class='label-suffix'>{{ row.getMetadata('html_label_suffix') | raw }}</span>{% endif -%} +{% endif %}<span class="value">{% if row.getColumn(column) %}{{- row.getColumn(column)|raw -}}{% else %}-{% endif %}</span> +{% if column=='label' %}</span>{% endif %} +{% if not row.getIdSubDataTable() and column=='label' and row.getMetadata('url') %} + </a> +{% endif %} +{% if row.getMetadata(tooltipIndex) %}</span>{% endif %} + +{% set totals = dataTable.getMetadata('totals') %} +{% if column in totals|keys -%} + {% set labelColumn = columns_to_display|first %} + {% set reportTotal = totals[column] %} + {% if siteSummary is defined and siteSummary is not empty and siteSummary.getFirstRow %} + {% set siteTotal = siteSummary.getFirstRow.getColumn(column) %} + {% else %} + {% set siteTotal = 0 %} + {% endif %} + {% set rowPercentage = row.getColumn(column)|percentage(reportTotal, 1) %} + {% set metricTitle = translations[column]|default(column) %} + {% set reportLabel = row.getColumn(labelColumn)|truncate(40)|raw %} + + {% set reportRatioTooltip = 'General_ReportRatioTooltip'|translate(reportLabel, rowPercentage|e('html_attr'), reportTotal|e('html_attr'), metricTitle|e('html_attr'), translations[labelColumn]|default(labelColumn)|e('html_attr')) %} + + {% if siteTotal and siteTotal > reportTotal %} + {% set totalPercentage = row.getColumn(column)|percentage(siteTotal, 1) %} + {% set totalRatioTooltip = 'General_TotalRatioTooltip'|translate(totalPercentage, siteTotal, metricTitle) %} + {% else %} + {% set totalRatioTooltip = '' %} + {% endif %} + + <span class="ratio" title="{{ reportRatioTooltip|raw }} {{ totalRatioTooltip|e('html_attr') }}"> {{ rowPercentage }}</span> +{%- endif %} +{% endspaceless %} diff --git a/www/analytics/plugins/CoreHome/templates/_dataTableFooter.twig b/www/analytics/plugins/CoreHome/templates/_dataTableFooter.twig new file mode 100644 index 00000000..ecfb83a5 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_dataTableFooter.twig @@ -0,0 +1,140 @@ +<div class="dataTableFeatures"> + + <div class="dataTableFooterNavigation"> + {% if properties.show_offset_information %} + <span> + <span class="dataTablePages"></span> + </span> + {% endif %} + + {% if properties.show_pagination_control %} + <span> + <span class="dataTablePrevious">‹ {% if clientSideParameters.dataTablePreviousIsFirst is defined %}{{ 'General_First'|translate }}{% else %}{{ 'General_Previous'|translate }}{% endif %} </span> + <span class="dataTableNext">{{ 'General_Next'|translate }} ›</span> + </span> + {% endif %} + + {% if properties.show_search %} + <span class="dataTableSearchPattern"> + <input type="text" class="searchInput" length="15" /> + <input type="submit" value="{{ 'General_Search'|translate }}" /> + </span> + {% endif %} + </div> + + <span class="loadingPiwik" style="display:none;"><img src="plugins/Zeitgeist/images/loading-blue.gif"/> {{ 'General_LoadingData'|translate }}</span> + + {% if properties.show_footer_icons %} + <div class="dataTableFooterIcons"> + <div class="dataTableFooterWrap"> + {% for footerIconGroup in footerIcons %} + <div class="tableIconsGroup"> + <span class="{{ footerIconGroup.class }}"> + {% for footerIcon in footerIconGroup.buttons %} + <span> + {% if properties.show_active_view_icon and clientSideParameters.viewDataTable == footerIcon.id %} + <img src="plugins/Zeitgeist/images/data_table_footer_active_item.png" class="dataTableFooterActiveItem"/> + {% endif %} + <a class="tableIcon {% if clientSideParameters.viewDataTable == footerIcon.id %}activeIcon{% endif %}" data-footer-icon-id="{{ footerIcon.id }}"> + <img width="16" height="16" title="{{ footerIcon.title }}" src="{{ footerIcon.icon }}"/> + {% if footerIcon.text is defined %}<span>{{ footerIcon.text }}</span>{% endif %} + </a> + </span> + {% endfor %} + </span> + </div> + {% endfor %} + <div class="tableIconsGroup"> + {% if footerIcons is empty %} + <img src="plugins/Zeitgeist/images/data_table_footer_active_item.png" class="dataTableFooterActiveItem"/> + {% endif %} + <span class="exportToFormatIcons"> + <a class="tableIcon" var="export"> + <img width="16" height="16" src="plugins/Zeitgeist/images/export.png" title="{{ 'General_ExportThisReport'|translate }}"/> + </a> + </span> + <span class="exportToFormatItems" style="display:none;"> + {{ 'General_Export'|translate }}: + <a target="_blank" methodToCall="{{ properties.apiMethodToRequestDataTable }}" format="CSV" filter_limit="{{ properties.export_limit }}">CSV</a> | + <a target="_blank" methodToCall="{{ properties.apiMethodToRequestDataTable }}" format="TSV" filter_limit="{{ properties.export_limit }}">TSV (Excel)</a> | + <a target="_blank" methodToCall="{{ properties.apiMethodToRequestDataTable }}" format="XML" filter_limit="{{ properties.export_limit }}">XML</a> | + <a target="_blank" methodToCall="{{ properties.apiMethodToRequestDataTable }}" format="JSON" filter_limit="{{ properties.export_limit }}">Json</a> | + <a target="_blank" methodToCall="{{ properties.apiMethodToRequestDataTable }}" format="PHP" filter_limit="{{ properties.export_limit }}">Php</a> + {% if properties.show_export_as_rss_feed %} + | + <a target="_blank" methodToCall="{{ properties.apiMethodToRequestDataTable }}" format="RSS" filter_limit="{{ properties.export_limit }}" date="last10"> + <img border="0" src="plugins/Zeitgeist/images/feed.png"/> + </a> + {% endif %} + </span> + {% if properties.show_export_as_image_icon %} + <span id="dataTableFooterExportAsImageIcon"> + <a class="tableIcon" href="#" onclick="$(this).closest('.dataTable').find('div.jqplot-target').trigger('piwikExportAsImage'); return false;"> + <img title="{{ 'General_ExportAsImage'|translate }}" src="plugins/Zeitgeist/images/image.png"/> + </a> + </span> + {% endif %} + </div> + + </div> + <div class="limitSelection {% if not properties.show_pagination_control and not properties.show_limit_control %} hidden{% endif %}" + title="{{ 'General_RowsToDisplay'|translate }}"></div> + <div class="tableConfiguration"> + <a class="tableConfigurationIcon" href="#"></a> + <ul> + {% if properties.show_flatten_table %} + {% if clientSideParameters.flat is defined and clientSideParameters.flat == 1 %} + <li> + <div class="configItem dataTableIncludeAggregateRows"></div> + </li> + {% endif %} + <li> + <div class="configItem dataTableFlatten"></div> + </li> + {% endif %} + {% if properties.show_exclude_low_population %} + <li> + <div class="configItem dataTableExcludeLowPopulation"></div> + </li> + {% endif %} + </ul> + </div> + {% if isPluginLoaded('Annotations') and not properties.hide_annotations_view %} + <div class="annotationView" title="{{ 'Annotations_IconDesc'|translate }}"> + <a class="tableIcon"> + <img width="16" height="16" src="plugins/Zeitgeist/images/annotations.png"/> + </a> + <span>{{ 'Annotations_Annotations'|translate }}</span> + </div> + {% endif %} + + <div class="foldDataTableFooterDrawer" title="{{ 'General_Close'|translate|e('html_attr') }}" + ><img width="7" height="4" src="plugins/Morpheus/images/sortasc_dark.png"></div> + + </div> + <div class="expandDataTableFooterDrawer" title="{{ 'General_ExpandDataTableFooter'|translate|e('html_attr') }}" + ><img width="7" height="4" src="plugins/Morpheus/images/sortdesc_dark.png" style=""></div> + {% endif %} + + <div class="datatableRelatedReports"> + {% if (properties.related_reports is not empty) and properties.show_related_reports %} + {{ properties.related_reports_title|raw }} + <ul style="list-style:none;{% if properties.related_reports|length == 1 %}display:inline-block;{% endif %}}"> + <li><span href="{{ properties.self_url }}" style="display:none;">{{ properties.title }}</span></li> + + {% for reportUrl,reportTitle in properties.related_reports %} + <li><span href="{{ reportUrl }}">{{ reportTitle }}</span></li> + {% endfor %} + </ul> + {% endif %} + </div> + + {% if properties.show_footer_message is defined and properties.show_footer_message is not empty %} + <div class='datatableFooterMessage'>{{ properties.show_footer_message | raw }}</div> + {% endif %} + +</div> + +<span class="loadingPiwikBelow" style="display:none;"><img src="plugins/Zeitgeist/images/loading-blue.gif"/> {{ 'General_LoadingData'|translate }}</span> + +<div class="dataTableSpacer"></div> diff --git a/www/analytics/plugins/CoreHome/templates/_dataTableHead.twig b/www/analytics/plugins/CoreHome/templates/_dataTableHead.twig new file mode 100644 index 00000000..43b87a0e --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_dataTableHead.twig @@ -0,0 +1,17 @@ +<thead> + <tr> + {% for column in properties.columns_to_display %} + <th class="{% if properties.enable_sort %}sortable{% endif %} {% if loop.first %}first{% elseif loop.last %}last{% endif %}" id="{{ column }}"> + {% if properties.metrics_documentation[column]|default is not empty %} + <div class="columnDocumentation"> + <div class="columnDocumentationTitle"> + {{ properties.translations[column]|default(column)|raw }} + </div> + {{ properties.metrics_documentation[column]|raw }} + </div> + {% endif %} + <div id="thDIV">{{ properties.translations[column]|default(column)|raw }}</div> + </th> + {% endfor %} + </tr> +</thead> diff --git a/www/analytics/plugins/CoreHome/templates/_dataTableJS.twig b/www/analytics/plugins/CoreHome/templates/_dataTableJS.twig new file mode 100644 index 00000000..975dca4b --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_dataTableJS.twig @@ -0,0 +1,5 @@ +<script type="text/javascript" defer="defer"> + $(document).ready(function () { + require('piwik/UI/DataTable').initNewDataTables(); + }); +</script> diff --git a/www/analytics/plugins/CoreHome/templates/_donate.twig b/www/analytics/plugins/CoreHome/templates/_donate.twig new file mode 100755 index 00000000..3140999f --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_donate.twig @@ -0,0 +1,63 @@ +<div class="piwik-donate-call"> + <div class="piwik-donate-message"> + {% if msg is defined %} + {{ msg }} + {% else %} + <p>{{ 'CoreHome_DonateCall1'|translate }}</p> + <p><strong><em>{{ 'CoreHome_DonateCall2'|translate }}</em></strong></p> + <p>{{ 'CoreHome_DonateCall3'|translate('<em><strong>','</strong></em>')|raw }}</p> + {% endif %} + </div> + + <span id="piwik-worth">{{ 'CoreHome_HowMuchIsPiwikWorth'|translate }}</span> + + <div class="donate-form-instructions">({{ 'CoreHome_DonateFormInstructions'|translate }})</div> + + <form action="index.php?module=CoreHome&action=redirectToPaypal&idSite=1" method="post" target="_blank"> + <input type="hidden" name="cmd" value="_s-xclick"/> + <input type="hidden" name="hosted_button_id" value="DVKLY73RS7JTE"/> + <input type="hidden" name="currency_code" value="USD"/> + <input type="hidden" name="on0" value="Piwik Supporter"/> + + <div class="piwik-donate-slider"> + <div class="slider-range"> + <div class="slider-position"></div> + </div> + <div style="display:inline-block;"> + <div class="slider-donate-amount">$30/{{ 'General_YearShort'|translate }}</div> + + <img class="slider-smiley-face" width="40" height="40" src="plugins/Zeitgeist/images/smileyprog_1.png"/> + </div> + + <input type="hidden" name="os0" value="Option 1"/> + </div> + + <div class="donate-submit"> + <input type="image" src="plugins/Zeitgeist/images/paypal_subscribe.gif" border="0" name="submit" + title="{{ 'CoreHome_SubscribeAndBecomePiwikSupporter'|translate }}"/> + <a class="donate-spacer">{{ 'CoreHome_MakeOneTimeDonation'|translate }}</a> + <a href="index.php?module=CoreHome&action=redirectToPaypal&idSite=1&cmd=_s-xclick&hosted_button_id=RPL23NJURMTFA&bb2_screener_=1357583494+83.233.186.82" + target="_blank" class="donate-one-time">{{ 'CoreHome_MakeOneTimeDonation'|translate }}</a> + </div> + + <!-- to cache images --> + <img style="display:none;" src="plugins/Zeitgeist/images/smileyprog_0.png"/> + <img style="display:none;" src="plugins/Zeitgeist/images/smileyprog_1.png"/> + <img style="display:none;" src="plugins/Zeitgeist/images/smileyprog_2.png"/> + <img style="display:none;" src="plugins/Zeitgeist/images/smileyprog_3.png"/> + <img style="display:none;" src="plugins/Zeitgeist/images/smileyprog_4.png"/> + </form> + {% if footerMessage is defined %} + <div class="form-description"> + {{ footerMessage }} + </div> + {% endif %} +</div> +<script type="text/javascript"> +$(document).ready(function () { + // Note: this will cause problems if more than one donate form is on the page + $('.piwik-donate-slider').each(function () { + $(this).trigger('piwik:changePosition', {position: 1}); + }); +}); +</script> diff --git a/www/analytics/plugins/CoreHome/templates/_headerMessage.twig b/www/analytics/plugins/CoreHome/templates/_headerMessage.twig new file mode 100644 index 00000000..2ca37857 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_headerMessage.twig @@ -0,0 +1,47 @@ +{# testing, remove test_ from var names #} +{% set test_latest_version_available="3.0" %} +{% set test_piwikUrl='http://demo.piwik.org/' %} +{% set isPiwikDemo %}{{ piwikUrl == 'http://demo.piwik.org/' or piwikUrl == 'https://demo.piwik.org/'}}{% endset %} + +{% set updateCheck %} +<div id="updateCheckLinkContainer"> + <span class='loadingPiwik' style="display:none;"><img src='plugins/Zeitgeist/images/loading-blue.gif'/></span> + <img class="icon" src="plugins/Zeitgeist/images/reload.png"/> + <a href="#" id="checkForUpdates"><em>{{ 'CoreHome_CheckForUpdates'|translate }}</em></a> +</div> +{% endset %} + +{% if isPiwikDemo or (latest_version_available and hasSomeViewAccess and not isUserIsAnonymous) or (isSuperUser and adminMenu is defined and adminMenu) %} +<span id="header_message" class="{% if isPiwikDemo or not latest_version_available %}header_info{% else %}header_alert{% endif %}"> + <span class="header_short"> + {% if isPiwikDemo %} + {{ 'General_YouAreViewingDemoShortMessage'|translate }} + {% elseif latest_version_available %} + {{ 'General_NewUpdatePiwikX'|translate(latest_version_available) }} + {% elseif isSuperUser and adminMenu is defined and adminMenu %} + {{ updateCheck|raw }} + {% endif %} + </span> + + <span class="header_full"> + {% if isPiwikDemo %} + {{ 'General_YouAreViewingDemoShortMessage'|translate }} + <br /> + {{ 'General_DownloadFullVersion'|translate("<a href='http://piwik.org/'>","</a>","<a href='http://piwik.org'>piwik.org</a>")|raw }} + <br/> + {% endif %} + {% if latest_version_available and isSuperUser %} + {{ 'General_PiwikXIsAvailablePleaseUpdateNow'|translate(latest_version_available,"<br /><a href='index.php?module=CoreUpdater&action=newVersionAvailable'>","</a>","<a href='?module=Proxy&action=redirect&url=http://piwik.org/changelog/' target='_blank'>","</a>")|raw }} + <br/> + {{ 'General_YouAreCurrentlyUsing'|translate(piwik_version) }} + {% elseif latest_version_available and not isPiwikDemo and hasSomeViewAccess and not isUserIsAnonymous %} + {% set updateSubject = 'General_NewUpdatePiwikX'|translate(latest_version_available)|e('url') %} + {{ 'General_PiwikXIsAvailablePleaseNotifyPiwikAdmin'|translate("<a href='?module=Proxy&action=redirect&url=http://piwik.org/' target='_blank'>Piwik</a> <a href='?module=Proxy&action=redirect&url=http://piwik.org/changelog/' target='_blank'>" ~ latest_version_available ~ "</a>", "<a href='mailto:" ~ superUserEmails ~ "?subject=" ~ updateSubject ~ "'>", "</a>")|raw }} + {% elseif isSuperUser and adminMenu is defined and adminMenu %} + {{ updateCheck|raw }} + <br /> + {{ 'General_YouAreCurrentlyUsing'|translate(piwik_version) }} + {% endif %} + </span> +</span> +{% endif %} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/_indexContent.twig b/www/analytics/plugins/CoreHome/templates/_indexContent.twig new file mode 100644 index 00000000..e115513a --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_indexContent.twig @@ -0,0 +1,20 @@ +{% import 'ajaxMacros.twig' as ajax %} +<div class="pageWrap"> + {% include "@CoreHome/_notifications.twig" %} + <div class="top_controls"> + {% include "@CoreHome/_periodSelect.twig" %} + {{ postEvent("Template.nextToCalendar") }} + {% render dashboardSettingsControl %} + {% include "@CoreHome/_headerMessage.twig" %} + {{ ajax.requestErrorDiv }} + </div> + + {{ ajax.loadingDiv() }} + + <div id="content" class="home"> + {% if content %}{{ content }}{% endif %} + </div> + <div class="clear"></div> +</div> + +<br/><br/> \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/_javaScriptDisabled.twig b/www/analytics/plugins/CoreHome/templates/_javaScriptDisabled.twig new file mode 100644 index 00000000..fb50e92d --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_javaScriptDisabled.twig @@ -0,0 +1,3 @@ +<noscript> + <div id="javascriptDisabled">{{ 'CoreHome_JavascriptDisabled'|translate('<a href="">','</a>')|raw }}</div> +</noscript> diff --git a/www/analytics/plugins/CoreHome/templates/_logo.twig b/www/analytics/plugins/CoreHome/templates/_logo.twig new file mode 100644 index 00000000..ad28f5b6 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_logo.twig @@ -0,0 +1,10 @@ +<span id="logo"> + <a href="index.php" title="{% if isCustomLogo %}{{ 'General_PoweredBy'|translate }} {% endif %}Piwik # {{ 'General_OpenSourceWebAnalytics'|translate }}"> + {% if hasSVGLogo %} + <img src='{{ logoSVG }}' alt="{% if isCustomLogo %}{{ 'General_PoweredBy'|translate }} {% endif %}Piwik" class="ie-hide {% if not isCustomLogo %}default-piwik-logo{% endif %}" /> + <!--[if lt IE 9]> + {% endif %} + <img src='{{ logoHeader }}' alt="{% if isCustomLogo %}{{ 'General_PoweredBy'|translate }} {% endif %}Piwik" /> + {% if hasSVGLogo %}<![endif]-->{% endif %} +</a> +</span> diff --git a/www/analytics/plugins/CoreHome/templates/_menu.twig b/www/analytics/plugins/CoreHome/templates/_menu.twig new file mode 100644 index 00000000..4437b90d --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_menu.twig @@ -0,0 +1,23 @@ +<div class="Menu--dashboard"> + <ul class="Menu-tabList"> + {% for level1,level2 in menu %} + <li id="{{ level2._url|urlRewriteWithParameters }}"> + <a href="#{{ level2._url|urlRewriteWithParameters|slice(1) }}" + onclick="return piwikMenu.onItemClick(this);">{{ level1|translate }}</a> + <ul> + {% for name,urlParameters in level2 %} + {% if name|slice(0,1) != '_' %} + <li> + <a href='#{{ urlParameters._url|urlRewriteWithParameters|slice(1) }}' + onclick='return piwikMenu.onItemClick(this);'> + {{ name|translate }} + </a> + </li> + {% endif %} + {% endfor %} + </ul> + </li> + {% endfor %} + </ul> +</div> +<div class="nav_sep"></div> diff --git a/www/analytics/plugins/CoreHome/templates/_notifications.twig b/www/analytics/plugins/CoreHome/templates/_notifications.twig new file mode 100644 index 00000000..0f3254e5 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_notifications.twig @@ -0,0 +1,9 @@ +<div id="notificationContainer"> + {% if notifications|length %} + {% for notificationId, n in notifications %} + + {{ n.message|notification({'id': notificationId, 'type': n.type, 'title': n.title, 'noclear': n.hasNoClear, 'context': n.context, 'raw': n.raw}, false) }} + + {% endfor %} + {% endif %} +</div> diff --git a/www/analytics/plugins/CoreHome/templates/_periodSelect.twig b/www/analytics/plugins/CoreHome/templates/_periodSelect.twig new file mode 100644 index 00000000..aa6732d6 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_periodSelect.twig @@ -0,0 +1,37 @@ +<div id="periodString" class="piwikTopControl periodSelector"> + <div id="date">{{ 'General_DateRange'|translate }} <strong>{{ prettyDate }}</strong></div> + <div class="calendar-icon"></div> + <div id="periodMore"> + <div class="period-date"> + <h6>{{ 'General_Date'|translate }}</h6> + + <div id="datepicker"></div> + </div> + <div class="period-range" style="display:none;"> + <div id="calendarRangeFrom"> + <h6>{{ 'General_DateRangeFrom'|translate }}<input tabindex="1" type="text" id="inputCalendarFrom" name="inputCalendarFrom"/></h6> + + <div id="calendarFrom"></div> + </div> + <div id="calendarRangeTo"> + <h6>{{ 'General_DateRangeTo'|translate }}<input tabindex="2" type="text" id="inputCalendarTo" name="inputCalendarTo"/></h6> + + <div id="calendarTo"></div> + </div> + </div> + <div class="period-type"> + <h6>{{ 'General_Period'|translate }}</h6> + <span id="otherPeriods"> + {% for label,thisPeriod in periodsNames %} + <input type="radio" name="period" id="period_id_{{ label }}" value="{{ linkTo( { 'period': label} ) }}"{% if label==period %} checked="checked"{% endif %} /> + <label for="period_id_{{ label }}">{{ thisPeriod.singular }}</label> + <br/> + {% endfor %} + </span> + <input tabindex="3" type="submit" value="{{ 'General_ApplyDateRange'|translate }}" id="calendarRangeApply"/> + {% import 'ajaxMacros.twig' as ajax %} + {{ ajax.loadingDiv('ajaxLoadingCalendar') }} + </div> + </div> + <div class="period-click-tooltip" style="display:none;">{{ 'General_ClickToChangePeriod'|translate }}</div> +</div> diff --git a/www/analytics/plugins/CoreHome/templates/_singleReport.twig b/www/analytics/plugins/CoreHome/templates/_singleReport.twig new file mode 100644 index 00000000..6d6ab8a4 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_singleReport.twig @@ -0,0 +1,2 @@ +<h2 piwik-enriched-headline>{{ title }}</h2> +{{ report|raw }} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/_siteSelectHeader.twig b/www/analytics/plugins/CoreHome/templates/_siteSelectHeader.twig new file mode 100644 index 00000000..547f2e41 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_siteSelectHeader.twig @@ -0,0 +1,5 @@ +<div class="top_bar_sites_selector {% if currentModule == 'CoreHome' %}sites_selector_in_dashboard{% endif %}"> + <label>{{ 'General_Website'|translate }}</label> + <div piwik-siteselector class="sites_autocomplete"></div> + +</div> \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/_topBar.twig b/www/analytics/plugins/CoreHome/templates/_topBar.twig new file mode 100644 index 00000000..29727e28 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_topBar.twig @@ -0,0 +1,5 @@ +{{ postEvent("Template.beforeTopBar", userAlias, userLogin, topMenu) }} +<div id="topBars"> + {% include "@CoreHome/_topBarHelloMenu.twig" %} + {% include "@CoreHome/_topBarTopMenu.twig" %} +</div> diff --git a/www/analytics/plugins/CoreHome/templates/_topBarHelloMenu.twig b/www/analytics/plugins/CoreHome/templates/_topBarHelloMenu.twig new file mode 100644 index 00000000..35f18771 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_topBarHelloMenu.twig @@ -0,0 +1,25 @@ +<div id="topRightBar"> + {% set helloAlias %} + {% if userAlias is not empty %} + <strong>{{ userAlias }}</strong> + {% else %} + <strong>{{ userLogin }}</strong> + {% endif %} + {% endset %} + <span class="topBarElem">{{ 'General_HelloUser'|translate(helloAlias|trim)|raw }}</span> + {% if userLogin != 'anonymous' %} + | + {% if isAdminLayout is defined %} + <span class="topBarElem topBarElemActive">{{ 'General_Settings'|translate }}</span> + {% else %} + <span class="topBarElem"><a href='index.php?module=CoreAdminHome'>{{ 'General_Settings'|translate }}</a></span> + {% endif %} + {% endif %} + | <span class="topBarElem"> + {% if userLogin == 'anonymous' %} + <a href='index.php?module={{ loginModule }}'>{{ 'Login_LogIn'|translate }}</a> + {% else %} + <a href='index.php?module={{ loginModule }}&action=logout'>{{ 'General_Logout'|translate }}</a> + {% endif %} + </span> +</div> diff --git a/www/analytics/plugins/CoreHome/templates/_topBarTopMenu.twig b/www/analytics/plugins/CoreHome/templates/_topBarTopMenu.twig new file mode 100644 index 00000000..a4dc16b0 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_topBarTopMenu.twig @@ -0,0 +1,13 @@ +<div id="topLeftBar"> + {% for label,menu in topMenu %} + {% if menu._html is defined %} + {{ menu._html|raw }} + {% elseif (menu._url.module == currentModule and (menu._url.action is empty or menu._url.action == currentAction)) %} + <span class="topBarElem topBarElemActive"><strong>{{ label|translate }}</strong></span>{% if not loop.last %} |{% endif %} + {% else %} + <span class="topBarElem" {% if menu._tooltip is defined %}title="{{ menu._tooltip }}"{% endif %}> + <a id="topmenu-{{ menu._url.module|lower }}" href="index.php{{ menu._url|urlRewriteWithParameters }}">{{ label|translate }}</a> + </span>{% if not loop.last %} | {% endif %} + {% endif %} + {% endfor %} +</div> \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/_topScreen.twig b/www/analytics/plugins/CoreHome/templates/_topScreen.twig new file mode 100644 index 00000000..1feec9e8 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_topScreen.twig @@ -0,0 +1,4 @@ +<div id="header"> + {% include "@CoreHome/_logo.twig" %} + {% include "@CoreHome/_topBar.twig" %} +</div> diff --git a/www/analytics/plugins/CoreHome/templates/_uiControl.twig b/www/analytics/plugins/CoreHome/templates/_uiControl.twig new file mode 100644 index 00000000..856f1afa --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_uiControl.twig @@ -0,0 +1,6 @@ +<div class="{{ cssIdentifier }} {{ cssClass }}" + data-props="{{ clientSideProperties|json_encode }}" + data-params="{{ clientSideParameters|json_encode }}"> + {% render implView with implOverride %} +</div> +<script>$(document).ready(function () { require('{{ jsNamespace }}').{{ jsClass }}.initElements(); });</script> \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/_warningInvalidHost.twig b/www/analytics/plugins/CoreHome/templates/_warningInvalidHost.twig new file mode 100644 index 00000000..3a5c603d --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/_warningInvalidHost.twig @@ -0,0 +1,21 @@ +{# untrusted host warning #} +{% if (isValidHost is defined and invalidHostMessage is defined and isValidHost == false) %} + {% set invalidHostText %} + <a style="float:right;" href="http://piwik.org/faq/troubleshooting/#faq_171" target="_blank"><img src="plugins/Zeitgeist/images/help.png"/></a> + <strong>{{ 'General_Warning'|translate }}: </strong>{{ invalidHostMessage|raw }} + + <br> + <br> + + <small>{{ invalidHostMessageHowToFix|raw }} + <br/><br/><a style="float:right;" href="http://piwik.org/faq/troubleshooting/#faq_171" target="_blank">{{ 'General_Help'|translate }} + <img style="vertical-align: bottom;" src="plugins/Zeitgeist/images/help.png"/></a><br/> + </small> + {% endset %} + + <div style="clear:both;width:800px;"> + {{ invalidHostText|notification({'noclear': true, 'raw': true, 'context': 'warning'}) }} + </div> + +{% endif %} + diff --git a/www/analytics/plugins/CoreHome/templates/checkForUpdates.twig b/www/analytics/plugins/CoreHome/templates/checkForUpdates.twig new file mode 100644 index 00000000..c611ae05 --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/checkForUpdates.twig @@ -0,0 +1 @@ +{% include "@CoreHome/_headerMessage.twig" %} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/getDefaultIndexView.twig b/www/analytics/plugins/CoreHome/templates/getDefaultIndexView.twig new file mode 100644 index 00000000..7b46ca3f --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/getDefaultIndexView.twig @@ -0,0 +1,14 @@ +{% extends "dashboard.twig" %} +{% block notification %}{% endblock %} +{% block content %} + +{% include "@CoreHome/_siteSelectHeader.twig" %} + +{% if (menu is defined and menu) %} + {% include "@CoreHome/_menu.twig" %} +{% endif %} + +{% include "@CoreHome/_indexContent.twig" %} + +{% endblock %} + diff --git a/www/analytics/plugins/CoreHome/templates/getDonateForm.twig b/www/analytics/plugins/CoreHome/templates/getDonateForm.twig new file mode 100644 index 00000000..69130cca --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/getDonateForm.twig @@ -0,0 +1 @@ +{% include "@CoreHome/_donate.twig" %} \ No newline at end of file diff --git a/www/analytics/plugins/CoreHome/templates/getMultiRowEvolutionPopover.twig b/www/analytics/plugins/CoreHome/templates/getMultiRowEvolutionPopover.twig new file mode 100644 index 00000000..0b1bd24e --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/getMultiRowEvolutionPopover.twig @@ -0,0 +1,40 @@ +{% set seriesColorCount = constant("Piwik\\Plugins\\CoreVisualizations\\Visualizations\\JqplotGraph\\Evolution::SERIES_COLOR_COUNT") %} +<div class="rowevolution multirowevolution"> + <div class="popover-title">{{ 'RowEvolution_MultiRowEvolutionTitle'|translate }}</div> + <div class="graph"> + {{ graph | raw }} + </div> + <div class="metrics-container"> + <h2>{{ availableRecordsText|translate }}</h2> + <table class="metrics" border="0" cellpadding="0" cellspacing="0"> + {% for i, metric in metrics %} + <tr> + <td class="sparkline"> + {{ metric.sparkline|raw }} + </td> + <td class="text"> + {% import 'macros.twig' as piwik %} + {{ piwik.logoHtml(metric, "") }} + <span class="evolution-graph-colors" data-name="series{{ (i % seriesColorCount) + 1 }}"> + {{- metric.label|raw -}} + </span> + <span class="details" title="{{ metric.minmax }}">{{ metric.details|raw }}</span> + </td> + </tr> + {% endfor %} + </table> + <a href="#" class="rowevolution-startmulti">» {{ 'RowEvolution_PickAnotherRow'|translate }}</a> + </div> + {% if availableMetrics|length > 1 %} + <div class="metric-selectbox"> + <h2>{{ 'RowEvolution_AvailableMetrics'|translate }}</h2> + <select name="metric" class="multirowevoltion-metric"> + {% for metric, metricName in availableMetrics %} + <option value="{{ metric }}"{% if selectedMetric == metric %} selected="selected"{% endif %}> + {{ metricName }} + </option> + {% endfor %} + </select> + </div> + {% endif %} +</div> diff --git a/www/analytics/plugins/CoreHome/templates/getPromoVideo.twig b/www/analytics/plugins/CoreHome/templates/getPromoVideo.twig new file mode 100755 index 00000000..ea70ffaa --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/getPromoVideo.twig @@ -0,0 +1,37 @@ +<div id="piwik-promo"> + <div id="piwik-promo-video"> + <div id="piwik-promo-thumbnail"> + <img src="plugins/Zeitgeist/images/video_play.png"/> + </div> + + <div id="piwik-promo-embed" style="display:none;"> + </div> + </div> + + <a id="piwik-promo-videos-link" href="http://piwik.org/blog/2012/12/piwik-how-to-videos/" target="_blank"> + {{ 'CoreHome_ViewAllPiwikVideoTutorials'|translate }} + </a> + + <div id="piwik-promo-share"> + <span>{{ 'CoreHome_ShareThis'|translate }}:</span> + + {# facebook #} + <a href="http://www.facebook.com/sharer.php?u={{ promoVideoUrl|url_encode }}" target="_blank"> + <img src="plugins/Referrers/images/socials/facebook.com.png" /> + </a> + + {# twitter #} + <a href="http://twitter.com/share?text={{ shareText|url_encode }}&url={{ promoVideoUrl|url_encode }}" target="_blank"> + <img src="plugins/Referrers/images/socials/twitter.com.png" /> + </a> + + {# email #} + <a href="mailto:?body={{ shareTextLong|url_encode(true) }}&subject={{ shareText|url_encode(true) }}" target="_blank"> + <img src="plugins/Zeitgeist/images/email.png" /> + </a> + </div> + + <div style="clear:both;"></div> + + <div id="piwik-widget-footer" style="color:#666;">{{ 'CoreHome_CloseWidgetDirections'|translate }}</div> +</div> diff --git a/www/analytics/plugins/CoreHome/templates/getRowEvolutionPopover.twig b/www/analytics/plugins/CoreHome/templates/getRowEvolutionPopover.twig new file mode 100644 index 00000000..a99e95dd --- /dev/null +++ b/www/analytics/plugins/CoreHome/templates/getRowEvolutionPopover.twig @@ -0,0 +1,39 @@ +{% set seriesColorCount = constant("Piwik\\Plugins\\CoreVisualizations\\Visualizations\\JqplotGraph\\Evolution::SERIES_COLOR_COUNT") %} +<div class="rowevolution"> + <div class="popover-title">{{ popoverTitle | raw }}</div> + <div class="graph"> + {{ graph|raw }} + </div> + <div class="metrics-container"> + <h2>{{ availableMetricsText|raw }}</h2> + + <div class="rowevolution-documentation"> + {{ 'RowEvolution_Documentation'|translate }} + </div> + <table class="metrics" border="0" cellpadding="0" cellspacing="0" data-thing="{{ seriesColorCount }}"> + {% for i, metric in metrics %} + <tr data-i="{{ i }}"> + <td class="sparkline"> + {{ metric.sparkline|raw }} + </td> + <td class="text"> + <span class="evolution-graph-colors" data-name="series{{ (i % seriesColorCount) + 1 }}"> + {{- metric.label|raw -}} + </span> + {% if metric.details %}: + <span class="details" title="{{ metric.minmax }}">{{ metric.details|raw }}</span> + {% endif %} + </td> + </tr> + {% endfor %} + </table> + </div> + <div class="compare-container"> + <h2>{{ 'RowEvolution_CompareRows'|translate }}</h2> + + <div class="rowevolution-documentation"> + {{ 'RowEvolution_CompareDocumentation'|translate|raw }} + </div> + <a href="#" class="rowevolution-startmulti">» {{ 'RowEvolution_PickARow'|translate }}</a> + </div> +</div> diff --git a/www/analytics/plugins/CorePluginsAdmin/Controller.php b/www/analytics/plugins/CorePluginsAdmin/Controller.php new file mode 100644 index 00000000..9d62fb03 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/Controller.php @@ -0,0 +1,481 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CorePluginsAdmin; + +use Piwik\API\Request; +use Piwik\Common; +use Piwik\Filechecks; +use Piwik\Filesystem; +use Piwik\Nonce; +use Piwik\Notification; +use Piwik\Piwik; +use Piwik\Plugin; +use Piwik\Settings\Manager as SettingsManager; +use Piwik\Url; +use Piwik\View; +use Piwik\Version; +use Exception; + +/** + */ +class Controller extends Plugin\ControllerAdmin +{ + const UPDATE_NONCE = 'CorePluginsAdmin.updatePlugin'; + const INSTALL_NONCE = 'CorePluginsAdmin.installPlugin'; + const ACTIVATE_NONCE = 'CorePluginsAdmin.activatePlugin'; + const DEACTIVATE_NONCE = 'CorePluginsAdmin.deactivatePlugin'; + const UNINSTALL_NONCE = 'CorePluginsAdmin.uninstallPlugin'; + + private $validSortMethods = array('popular', 'newest', 'alpha'); + private $defaultSortMethod = 'popular'; + + private function createUpdateOrInstallView($template, $nonceName) + { + static::dieIfMarketplaceIsDisabled(); + + $pluginName = $this->initPluginModification($nonceName); + $this->dieIfPluginsAdminIsDisabled(); + + $view = $this->configureView('@CorePluginsAdmin/' . $template); + + $view->plugin = array('name' => $pluginName); + + try { + $pluginInstaller = new PluginInstaller($pluginName); + $pluginInstaller->installOrUpdatePluginFromMarketplace(); + + } catch (\Exception $e) { + + $notification = new Notification($e->getMessage()); + $notification->context = Notification::CONTEXT_ERROR; + Notification\Manager::notify('CorePluginsAdmin_InstallPlugin', $notification); + + $this->redirectAfterModification(true); + return; + } + + $marketplace = new Marketplace(); + $view->plugin = $marketplace->getPluginInfo($pluginName); + + return $view; + } + + public function updatePlugin() + { + $view = $this->createUpdateOrInstallView('updatePlugin', static::UPDATE_NONCE); + return $view->render(); + } + + public function installPlugin() + { + $view = $this->createUpdateOrInstallView('installPlugin', static::INSTALL_NONCE); + $view->nonce = Nonce::getNonce(static::ACTIVATE_NONCE); + + return $view->render(); + } + + public function uploadPlugin() + { + static::dieIfPluginsAdminIsDisabled(); + Piwik::checkUserHasSuperUserAccess(); + + $nonce = Common::getRequestVar('nonce', null, 'string'); + + if (!Nonce::verifyNonce(static::INSTALL_NONCE, $nonce)) { + throw new \Exception(Piwik::translate('General_ExceptionNonceMismatch')); + } + + Nonce::discardNonce(static::INSTALL_NONCE); + + if (empty($_FILES['pluginZip'])) { + throw new \Exception('You did not specify a ZIP file.'); + } + + if (!empty($_FILES['pluginZip']['error'])) { + throw new \Exception('Something went wrong during the plugin file upload. Please try again.'); + } + + $file = $_FILES['pluginZip']['tmp_name']; + if (!file_exists($file)) { + throw new \Exception('Something went wrong during the plugin file upload. Please try again.'); + } + + $view = $this->configureView('@CorePluginsAdmin/uploadPlugin'); + + $pluginInstaller = new PluginInstaller('uploaded'); + $pluginMetadata = $pluginInstaller->installOrUpdatePluginFromFile($file); + + $view->nonce = Nonce::getNonce(static::ACTIVATE_NONCE); + $view->plugin = array( + 'name' => $pluginMetadata->name, + 'version' => $pluginMetadata->version, + 'isTheme' => !empty($pluginMetadata->theme), + 'isActivated' => \Piwik\Plugin\Manager::getInstance()->isPluginActivated($pluginMetadata->name) + ); + + return $view->render(); + } + + public function pluginDetails() + { + static::dieIfMarketplaceIsDisabled(); + + $pluginName = Common::getRequestVar('pluginName', null, 'string'); + $activeTab = Common::getRequestVar('activeTab', '', 'string'); + if ('changelog' !== $activeTab) { + $activeTab = ''; + } + + $view = $this->configureView('@CorePluginsAdmin/pluginDetails'); + + try { + $marketplace = new Marketplace(); + $view->plugin = $marketplace->getPluginInfo($pluginName); + $view->isSuperUser = Piwik::hasUserSuperUserAccess(); + $view->installNonce = Nonce::getNonce(static::INSTALL_NONCE); + $view->updateNonce = Nonce::getNonce(static::UPDATE_NONCE); + $view->activeTab = $activeTab; + } catch (\Exception $e) { + $view->errorMessage = $e->getMessage(); + } + + return $view->render(); + } + + private function dieIfMarketplaceIsDisabled() + { + if (!CorePluginsAdmin::isMarketplaceEnabled()) { + throw new \Exception('The Marketplace feature has been disabled. + You may enable the Marketplace by changing the config entry "enable_marketplace" to 1. + Please contact your Piwik admins with your request so they can assist.'); + } + + $this->dieIfPluginsAdminIsDisabled(); + } + + private function dieIfPluginsAdminIsDisabled() + { + if (!CorePluginsAdmin::isPluginsAdminEnabled()) { + throw new \Exception('Enabling, disabling and uninstalling plugins has been disabled by Piwik admins. + Please contact your Piwik admins with your request so they can assist you.'); + } + } + + private function createBrowsePluginsOrThemesView($template, $themesOnly) + { + static::dieIfMarketplaceIsDisabled(); + + $query = Common::getRequestVar('query', '', 'string', $_POST); + $sort = Common::getRequestVar('sort', $this->defaultSortMethod, 'string'); + + if (!in_array($sort, $this->validSortMethods)) { + $sort = $this->defaultSortMethod; + } + + $view = $this->configureView('@CorePluginsAdmin/' . $template); + + $marketplace = new Marketplace(); + $view->plugins = $marketplace->searchPlugins($query, $sort, $themesOnly); + + $view->query = $query; + $view->sort = $sort; + $view->installNonce = Nonce::getNonce(static::INSTALL_NONCE); + $view->updateNonce = Nonce::getNonce(static::UPDATE_NONCE); + $view->isSuperUser = Piwik::hasUserSuperUserAccess(); + + return $view; + } + + public function browsePlugins() + { + $view = $this->createBrowsePluginsOrThemesView('browsePlugins', $themesOnly = false); + return $view->render(); + } + + public function browseThemes() + { + $view = $this->createBrowsePluginsOrThemesView('browseThemes', $themesOnly = true); + return $view->render(); + } + + public function extend() + { + static::dieIfMarketplaceIsDisabled(); + + $view = $this->configureView('@CorePluginsAdmin/extend'); + $view->installNonce = Nonce::getNonce(static::INSTALL_NONCE); + $view->isSuperUser = Piwik::hasUserSuperUserAccess(); + + return $view->render(); + } + + private function createPluginsOrThemesView($template, $themesOnly) + { + Piwik::checkUserHasSuperUserAccess(); + + $view = $this->configureView('@CorePluginsAdmin/' . $template); + + $view->updateNonce = Nonce::getNonce(static::UPDATE_NONCE); + $view->activateNonce = Nonce::getNonce(static::ACTIVATE_NONCE); + $view->uninstallNonce = Nonce::getNonce(static::UNINSTALL_NONCE); + $view->deactivateNonce = Nonce::getNonce(static::DEACTIVATE_NONCE); + $view->pluginsInfo = $this->getPluginsInfo($themesOnly); + + $users = \Piwik\Plugins\UsersManager\API::getInstance()->getUsers(); + $view->otherUsersCount = count($users) - 1; + $view->themeEnabled = \Piwik\Plugin\Manager::getInstance()->getThemeEnabled()->getPluginName(); + + $view->pluginNamesHavingSettings = $this->getPluginNamesHavingSettingsForCurrentUser(); + $view->isMarketplaceEnabled = CorePluginsAdmin::isMarketplaceEnabled(); + $view->isPluginsAdminEnabled = CorePluginsAdmin::isPluginsAdminEnabled(); + + $view->pluginsHavingUpdate = array(); + $view->marketplacePluginNames = array(); + + if (CorePluginsAdmin::isMarketplaceEnabled()) { + try { + $marketplace = new Marketplace(); + $view->marketplacePluginNames = $marketplace->getAvailablePluginNames($themesOnly); + $view->pluginsHavingUpdate = $marketplace->getPluginsHavingUpdate($themesOnly); + } catch(Exception $e) { + // curl exec connection error (ie. server not connected to internet) + } + } + + return $view; + } + + public function plugins() + { + $view = $this->createPluginsOrThemesView('plugins', $themesOnly = false); + return $view->render(); + } + + public function themes() + { + $view = $this->createPluginsOrThemesView('themes', $themesOnly = true); + return $view->render(); + } + + protected function configureView($template) + { + Piwik::checkUserIsNotAnonymous(); + + $view = new View($template); + $this->setBasicVariablesView($view); + + // If user can manage plugins+themes, display a warning if config not writable + if (CorePluginsAdmin::isPluginsAdminEnabled()) { + $this->displayWarningIfConfigFileNotWritable(); + } + + $view->errorMessage = ''; + + return $view; + } + + protected function getPluginsInfo($themesOnly = false) + { + $pluginManager = \Piwik\Plugin\Manager::getInstance(); + $plugins = $pluginManager->returnLoadedPluginsInfo(); + + foreach ($plugins as $pluginName => &$plugin) { + + $plugin['isCorePlugin'] = $pluginManager->isPluginBundledWithCore($pluginName); + + if (!isset($plugin['info'])) { + + $suffix = Piwik::translate('CorePluginsAdmin_PluginNotWorkingAlternative'); + // If the plugin has been renamed, we do not show message to ask user to update plugin + if($pluginName != Request::renameModule($pluginName)) { + $suffix = "You may uninstall the plugin or manually delete the files in piwik/plugins/$pluginName/"; + } + + $description = '<strong><em>' + . Piwik::translate('CorePluginsAdmin_PluginNotCompatibleWith', array($pluginName, self::getPiwikVersion())) + . '</strong><br/>' + . $suffix + . '</em>'; + $plugin['info'] = array( + 'description' => $description, + 'version' => Piwik::translate('General_Unknown'), + 'theme' => false, + ); + } + } + + $pluginsFiltered = $this->keepPluginsOrThemes($themesOnly, $plugins); + return $pluginsFiltered; + } + + protected function keepPluginsOrThemes($themesOnly, $plugins) + { + $pluginsFiltered = array(); + foreach ($plugins as $name => $thisPlugin) { + + $isTheme = false; + if (!empty($thisPlugin['info']['theme'])) { + $isTheme = (bool)$thisPlugin['info']['theme']; + } + if (($themesOnly && $isTheme) + || (!$themesOnly && !$isTheme) + ) { + $pluginsFiltered[$name] = $thisPlugin; + } + } + return $pluginsFiltered; + } + + public function safemode($lastError = array()) + { + if (empty($lastError)) { + $lastError = array( + 'message' => Common::getRequestVar('error_message', null, 'string'), + 'file' => Common::getRequestVar('error_file', null, 'string'), + 'line' => Common::getRequestVar('error_line', null, 'integer') + ); + } + + $outputFormat = Common::getRequestVar('format', 'html', 'string'); + $outputFormat = strtolower($outputFormat); + + if (!empty($outputFormat) && 'html' !== $outputFormat) { + + $errorMessage = $lastError['message']; + + if (Piwik::isUserIsAnonymous()) { + $errorMessage = 'A fatal error occurred.'; + } + + $response = new \Piwik\API\ResponseBuilder($outputFormat); + $message = $response->getResponseException(new Exception($errorMessage)); + + return $message; + } + + if(Common::isPhpCliMode()) { + Piwik_ExitWithMessage("Error:" . var_export($lastError, true)); + } + + $view = new View('@CorePluginsAdmin/safemode'); + $view->lastError = $lastError; + $view->isSuperUser = Piwik::hasUserSuperUserAccess(); + $view->isAnonymousUser = Piwik::isUserIsAnonymous(); + $view->plugins = Plugin\Manager::getInstance()->returnLoadedPluginsInfo(); + $view->deactivateNonce = Nonce::getNonce(static::DEACTIVATE_NONCE); + $view->uninstallNonce = Nonce::getNonce(static::UNINSTALL_NONCE); + $view->emailSuperUser = implode(',', Piwik::getAllSuperUserAccessEmailAddresses()); + $view->piwikVersion = Version::VERSION; + $view->showVersion = !Common::getRequestVar('tests_hide_piwik_version', 0); + $view->pluginCausesIssue = ''; + + if (!empty($lastError['file'])) { + preg_match('/piwik\/plugins\/(.*)\//', $lastError['file'], $matches); + + if (!empty($matches[1])) { + $view->pluginCausesIssue = $matches[1]; + } + } + + return $view->render(); + } + + public function activate($redirectAfter = true) + { + $pluginName = $this->initPluginModification(static::ACTIVATE_NONCE); + $this->dieIfPluginsAdminIsDisabled(); + + \Piwik\Plugin\Manager::getInstance()->activatePlugin($pluginName); + + if ($redirectAfter) { + $plugin = \Piwik\Plugin\Manager::getInstance()->loadPlugin($pluginName); + + $actionToRedirect = 'plugins'; + if ($plugin->isTheme()) { + $actionToRedirect = 'themes'; + } + + $message = Piwik::translate('CorePluginsAdmin_SuccessfullyActicated', array($pluginName)); + if (SettingsManager::hasPluginSettingsForCurrentUser($pluginName)) { + $target = sprintf('<a href="index.php%s#%s">', + Url::getCurrentQueryStringWithParametersModified(array('module' => 'CoreAdminHome', 'action' => 'pluginSettings')), + $pluginName); + $message .= ' ' . Piwik::translate('CorePluginsAdmin_ChangeSettingsPossible', array($target, '</a>')); + } + + $notification = new Notification($message); + $notification->raw = true; + $notification->title = Piwik::translate('General_WellDone'); + $notification->context = Notification::CONTEXT_SUCCESS; + Notification\Manager::notify('CorePluginsAdmin_PluginActivated', $notification); + + $this->redirectToIndex('CorePluginsAdmin', $actionToRedirect); + } + } + + public function deactivate($redirectAfter = true) + { + $pluginName = $this->initPluginModification(static::DEACTIVATE_NONCE); + $this->dieIfPluginsAdminIsDisabled(); + + \Piwik\Plugin\Manager::getInstance()->deactivatePlugin($pluginName); + $this->redirectAfterModification($redirectAfter); + } + + public function uninstall($redirectAfter = true) + { + $pluginName = $this->initPluginModification(static::UNINSTALL_NONCE); + $this->dieIfPluginsAdminIsDisabled(); + + $uninstalled = \Piwik\Plugin\Manager::getInstance()->uninstallPlugin($pluginName); + + if (!$uninstalled) { + $path = Filesystem::getPathToPiwikRoot() . '/plugins/' . $pluginName . '/'; + $messagePermissions = Filechecks::getErrorMessageMissingPermissions($path); + + $messageIntro = Piwik::translate("Warning: \"%s\" could not be uninstalled. Piwik did not have enough permission to delete the files in $path. ", + $pluginName); + $exitMessage = $messageIntro . "<br/><br/>" . $messagePermissions; + $exitMessage .= "<br> Or manually delete this directory (using FTP or SSH access)"; + Piwik_ExitWithMessage($exitMessage, $optionalTrace = false, $optionalLinks = false, $optionalLinkBack = true); + } + + $this->redirectAfterModification($redirectAfter); + } + + protected function initPluginModification($nonceName) + { + Piwik::checkUserHasSuperUserAccess(); + + $nonce = Common::getRequestVar('nonce', null, 'string'); + + if (!Nonce::verifyNonce($nonceName, $nonce)) { + throw new \Exception(Piwik::translate('General_ExceptionNonceMismatch')); + } + + Nonce::discardNonce($nonceName); + + $pluginName = Common::getRequestVar('pluginName', null, 'string'); + + return $pluginName; + } + + protected function redirectAfterModification($redirectAfter) + { + if ($redirectAfter) { + Url::redirectToReferrer(); + } + } + + private function getPluginNamesHavingSettingsForCurrentUser() + { + return array_keys(SettingsManager::getPluginSettingsForCurrentUser()); + } + +} diff --git a/www/analytics/plugins/CorePluginsAdmin/CorePluginsAdmin.php b/www/analytics/plugins/CorePluginsAdmin/CorePluginsAdmin.php new file mode 100644 index 00000000..498729f3 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/CorePluginsAdmin.php @@ -0,0 +1,137 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CorePluginsAdmin; + +use Piwik\Config; +use Piwik\Menu\MenuAdmin; +use Piwik\Piwik; +use Piwik\Plugin; +use Piwik\ScheduledTask; +use Piwik\ScheduledTime; +use Piwik\Plugin\Manager as PluginManager; + +/** + * + */ +class CorePluginsAdmin extends \Piwik\Plugin +{ + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'Menu.Admin.addItems' => 'addMenu', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'TaskScheduler.getScheduledTasks' => 'getScheduledTasks', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys' + ); + } + + /** + * Gets all scheduled tasks executed by this plugin. + */ + public function getScheduledTasks(&$tasks) + { + $tasks[] = new ScheduledTask( + 'Piwik\Plugins\CorePluginsAdmin\MarketplaceApiClient', + 'clearAllCacheEntries', + null, + ScheduledTime::factory('daily'), + ScheduledTask::LOWEST_PRIORITY + ); + + if (self::isMarketplaceEnabled()) { + $sendUpdateNotification = new ScheduledTask ($this, + 'sendNotificationIfUpdatesAvailable', + null, + ScheduledTime::factory('daily'), + ScheduledTask::LOWEST_PRIORITY); + $tasks[] = $sendUpdateNotification; + } + } + + public function sendNotificationIfUpdatesAvailable() + { + $updateCommunication = new UpdateCommunication(); + if ($updateCommunication->isEnabled()) { + $updateCommunication->sendNotificationIfUpdatesAvailable(); + } + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/CorePluginsAdmin/stylesheets/marketplace.less"; + $stylesheets[] = "plugins/CorePluginsAdmin/stylesheets/plugins_admin.less"; + } + + function addMenu() + { + $pluginsUpdateMessage = ''; + $themesUpdateMessage = ''; + + if (Piwik::hasUserSuperUserAccess() && static::isMarketplaceEnabled()) { + $marketplace = new Marketplace(); + $pluginsHavingUpdate = $marketplace->getPluginsHavingUpdate($themesOnly = false); + $themesHavingUpdate = $marketplace->getPluginsHavingUpdate($themesOnly = true); + + if (!empty($pluginsHavingUpdate)) { + $pluginsUpdateMessage = sprintf(' (%d)', count($pluginsHavingUpdate)); + } + if (!empty($themesHavingUpdate)) { + $themesUpdateMessage = sprintf(' (%d)', count($themesHavingUpdate)); + } + } + + MenuAdmin::getInstance()->add('CorePluginsAdmin_MenuPlatform', null, "", !Piwik::isUserIsAnonymous(), $order = 7); + MenuAdmin::getInstance()->add('CorePluginsAdmin_MenuPlatform', Piwik::translate('General_Plugins') . $pluginsUpdateMessage, + array('module' => 'CorePluginsAdmin', 'action' => 'plugins', 'activated' => ''), + Piwik::hasUserSuperUserAccess(), + $order = 1); + MenuAdmin::getInstance()->add('CorePluginsAdmin_MenuPlatform', Piwik::translate('CorePluginsAdmin_Themes') . $themesUpdateMessage, + array('module' => 'CorePluginsAdmin', 'action' => 'themes', 'activated' => ''), + Piwik::hasUserSuperUserAccess(), + $order = 3); + + if (static::isMarketplaceEnabled()) { + + MenuAdmin::getInstance()->add('CorePluginsAdmin_MenuPlatform', 'CorePluginsAdmin_Marketplace', + array('module' => 'CorePluginsAdmin', 'action' => 'extend', 'activated' => ''), + !Piwik::isUserIsAnonymous(), + $order = 5); + + } + } + + public static function isMarketplaceEnabled() + { + return (bool) Config::getInstance()->General['enable_marketplace']; + } + + public static function isPluginsAdminEnabled() + { + return (bool) Config::getInstance()->General['enable_plugins_admin']; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/CoreHome/javascripts/popover.js"; + $jsFiles[] = "plugins/CorePluginsAdmin/javascripts/pluginDetail.js"; + $jsFiles[] = "plugins/CorePluginsAdmin/javascripts/pluginOverview.js"; + $jsFiles[] = "plugins/CorePluginsAdmin/javascripts/pluginExtend.js"; + $jsFiles[] = "plugins/CorePluginsAdmin/javascripts/plugins.js"; + } + + public function getClientSideTranslationKeys(&$translations) + { + $translations[] = 'CorePluginsAdmin_NoZipFileSelected'; + } + +} diff --git a/www/analytics/plugins/CorePluginsAdmin/Marketplace.php b/www/analytics/plugins/CorePluginsAdmin/Marketplace.php new file mode 100644 index 00000000..e50cdbbb --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/Marketplace.php @@ -0,0 +1,197 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CorePluginsAdmin; + +use Piwik\Date; +use Piwik\Piwik; +use Piwik\Plugin\Dependency as PluginDependency; + +/** + * + */ +class Marketplace +{ + /** + * @var MarketplaceApiClient + */ + private $client; + + public function __construct() + { + $this->client = new MarketplaceApiClient(); + } + + public function getPluginInfo($pluginName) + { + $marketplace = new MarketplaceApiClient(); + + $plugin = $marketplace->getPluginInfo($pluginName); + $plugin = $this->enrichPluginInformation($plugin); + + return $plugin; + } + + public function getAvailablePluginNames($themesOnly) + { + if ($themesOnly) { + $plugins = $this->client->searchForThemes('', '', ''); + } else { + $plugins = $this->client->searchForPlugins('', '', ''); + } + + $names = array(); + foreach ($plugins as $plugin) { + $names[] = $plugin['name']; + } + + return $names; + } + + public function getAllAvailablePluginNames() + { + return array_merge( + $this->getAvailablePluginNames(true), + $this->getAvailablePluginNames(false) + ); + } + + public function searchPlugins($query, $sort, $themesOnly) + { + if ($themesOnly) { + $plugins = $this->client->searchForThemes('', $query, $sort); + } else { + $plugins = $this->client->searchForPlugins('', $query, $sort); + } + + foreach ($plugins as $key => $plugin) { + $plugins[$key] = $this->enrichPluginInformation($plugin); + } + + return $plugins; + } + + private function getPluginUpdateInformation($plugin) + { + if (empty($plugin['name'])) { + return; + } + + $pluginsHavingUpdate = $this->getPluginsHavingUpdate($plugin['isTheme']); + + foreach ($pluginsHavingUpdate as $pluginHavingUpdate) { + if ($plugin['name'] == $pluginHavingUpdate['name']) { + return $pluginHavingUpdate; + } + } + } + + private function hasPluginUpdate($plugin) + { + $update = $this->getPluginUpdateInformation($plugin); + + return !empty($update); + } + + /** + * @param bool $themesOnly + * @return array + */ + public function getPluginsHavingUpdate($themesOnly) + { + $pluginManager = \Piwik\Plugin\Manager::getInstance(); + $pluginManager->returnLoadedPluginsInfo(); + $loadedPlugins = $pluginManager->getLoadedPlugins(); + + try { + $pluginsHavingUpdate = $this->client->getInfoOfPluginsHavingUpdate($loadedPlugins, $themesOnly); + + } catch (\Exception $e) { + $pluginsHavingUpdate = array(); + } + + foreach ($pluginsHavingUpdate as &$updatePlugin) { + foreach ($loadedPlugins as $loadedPlugin) { + + if (!empty($updatePlugin['name']) + && $loadedPlugin->getPluginName() == $updatePlugin['name'] + ) { + + $updatePlugin['currentVersion'] = $loadedPlugin->getVersion(); + $updatePlugin['isActivated'] = $pluginManager->isPluginActivated($updatePlugin['name']); + $updatePlugin = $this->addMissingRequirements($updatePlugin); + break; + } + } + } + + return $pluginsHavingUpdate; + } + + private function enrichPluginInformation($plugin) + { + $dateFormat = Piwik::translate('CoreHome_ShortDateFormatWithYear'); + + $plugin['canBeUpdated'] = $this->hasPluginUpdate($plugin); + $plugin['isInstalled'] = \Piwik\Plugin\Manager::getInstance()->isPluginLoaded($plugin['name']); + $plugin['lastUpdated'] = Date::factory($plugin['lastUpdated'])->getLocalized($dateFormat); + + if ($plugin['canBeUpdated']) { + $pluginUpdate = $this->getPluginUpdateInformation($plugin); + $plugin['repositoryChangelogUrl'] = $pluginUpdate['repositoryChangelogUrl']; + $plugin['currentVersion'] = $pluginUpdate['currentVersion']; + } + + if (!empty($plugin['activity']['lastCommitDate']) + && false === strpos($plugin['activity']['lastCommitDate'], '0000')) { + + $dateFormat = Piwik::translate('CoreHome_DateFormat'); + $plugin['activity']['lastCommitDate'] = Date::factory($plugin['activity']['lastCommitDate'])->getLocalized($dateFormat); + } else { + $plugin['activity']['lastCommitDate'] = null; + } + + if (!empty($plugin['versions'])) { + + $dateFormat = Piwik::translate('CoreHome_DateFormat'); + + foreach ($plugin['versions'] as $index => $version) { + $plugin['versions'][$index]['release'] = Date::factory($version['release'])->getLocalized($dateFormat); + } + } + + $plugin = $this->addMissingRequirements($plugin); + + return $plugin; + } + + /** + * @param $plugin + */ + private function addMissingRequirements($plugin) + { + $plugin['missingRequirements'] = array(); + + if (empty($plugin['versions']) || !is_array($plugin['versions'])) { + return $plugin; + } + + $latestVersion = $plugin['versions'][count($plugin['versions']) - 1]; + + if (empty($latestVersion['requires'])) { + return $plugin; + } + + $requires = $latestVersion['requires']; + + $dependency = new PluginDependency(); + $plugin['missingRequirements'] = $dependency->getMissingDependencies($requires); + + return $plugin; + } +} diff --git a/www/analytics/plugins/CorePluginsAdmin/MarketplaceApiClient.php b/www/analytics/plugins/CorePluginsAdmin/MarketplaceApiClient.php new file mode 100644 index 00000000..034a501b --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/MarketplaceApiClient.php @@ -0,0 +1,202 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CorePluginsAdmin; + +use Piwik\CacheFile; +use Piwik\Http; +use Piwik\Version; + +/** + * + */ +class MarketplaceApiClient +{ + const CACHE_TIMEOUT_IN_SECONDS = 1200; + const HTTP_REQUEST_TIMEOUT = 3; + + private $domain = 'http://plugins.piwik.org'; + + /** + * @var CacheFile + */ + private $cache = null; + + public function __construct() + { + $this->cache = new CacheFile('marketplace', self::CACHE_TIMEOUT_IN_SECONDS); + } + + public static function clearAllCacheEntries() + { + $cache = new CacheFile('marketplace'); + $cache->deleteAll(); + } + + public function getPluginInfo($name) + { + $action = sprintf('plugins/%s/info', $name); + + return $this->fetch($action, array()); + } + + public function download($pluginOrThemeName, $target) + { + $downloadUrl = $this->getDownloadUrl($pluginOrThemeName); + + if (empty($downloadUrl)) { + return false; + } + + $success = Http::fetchRemoteFile($downloadUrl, $target, 0, static::HTTP_REQUEST_TIMEOUT); + + return $success; + } + + /** + * @param \Piwik\Plugin[] $plugins + * @return array|mixed + */ + public function checkUpdates($plugins) + { + $params = array(); + + foreach ($plugins as $plugin) { + $pluginName = $plugin->getPluginName(); + if (!\Piwik\Plugin\Manager::getInstance()->isPluginBundledWithCore($pluginName)) { + $params[] = array('name' => $plugin->getPluginName(), 'version' => $plugin->getVersion()); + } + } + + if (empty($params)) { + return array(); + } + + $params = array('plugins' => $params); + + $hasUpdates = $this->fetch('plugins/checkUpdates', array('plugins' => json_encode($params))); + + if (empty($hasUpdates)) { + return array(); + } + + return $hasUpdates; + } + + /** + * @param \Piwik\Plugin[] $plugins + * @param bool $themesOnly + * @return array + */ + public function getInfoOfPluginsHavingUpdate($plugins, $themesOnly) + { + $hasUpdates = $this->checkUpdates($plugins); + + $pluginDetails = array(); + + foreach ($hasUpdates as $pluginHavingUpdate) { + $plugin = $this->getPluginInfo($pluginHavingUpdate['name']); + $plugin['repositoryChangelogUrl'] = $pluginHavingUpdate['repositoryChangelogUrl']; + + if (!empty($plugin['isTheme']) == $themesOnly) { + $pluginDetails[] = $plugin; + } + } + + return $pluginDetails; + } + + public function searchForPlugins($keywords, $query, $sort) + { + $response = $this->fetch('plugins', array('keywords' => $keywords, 'query' => $query, 'sort' => $sort)); + + if (!empty($response['plugins'])) { + return $response['plugins']; + } + + return array(); + } + + public function searchForThemes($keywords, $query, $sort) + { + $response = $this->fetch('themes', array('keywords' => $keywords, 'query' => $query, 'sort' => $sort)); + + if (!empty($response['plugins'])) { + return $response['plugins']; + } + + return array(); + } + + private function fetch($action, $params) + { + ksort($params); + $query = http_build_query($params); + $result = $this->getCachedResult($action, $query); + + if (false === $result) { + $endpoint = $this->domain . '/api/1.0/'; + $url = sprintf('%s%s?%s', $endpoint, $action, $query); + $response = Http::sendHttpRequest($url, static::HTTP_REQUEST_TIMEOUT); + $result = json_decode($response, true); + + if (is_null($result)) { + $message = sprintf('There was an error reading the response from the Marketplace: %s. Please try again later.', + substr($response, 0, 50)); + throw new MarketplaceApiException($message); + } + + if (!empty($result['error'])) { + throw new MarketplaceApiException($result['error']); + } + + $this->cacheResult($action, $query, $result); + } + + return $result; + } + + private function getCachedResult($action, $query) + { + $cacheKey = $this->getCacheKey($action, $query); + + return $this->cache->get($cacheKey); + } + + private function cacheResult($action, $query, $result) + { + $cacheKey = $this->getCacheKey($action, $query); + + $this->cache->set($cacheKey, $result); + } + + private function getCacheKey($action, $query) + { + return sprintf('api.1.0.%s.%s', str_replace('/', '.', $action), md5($query)); + } + + /** + * @param $pluginOrThemeName + * @throws MarketplaceApiException + * @return string + */ + public function getDownloadUrl($pluginOrThemeName) + { + $plugin = $this->getPluginInfo($pluginOrThemeName); + + if (empty($plugin['versions'])) { + throw new MarketplaceApiException('Plugin has no versions.'); + } + + $latestVersion = array_pop($plugin['versions']); + $downloadUrl = $latestVersion['download']; + + return $this->domain . $downloadUrl . '?coreVersion=' . Version::VERSION; + } + +} diff --git a/www/analytics/plugins/CorePluginsAdmin/MarketplaceApiException.php b/www/analytics/plugins/CorePluginsAdmin/MarketplaceApiException.php new file mode 100644 index 00000000..9da7582b --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/MarketplaceApiException.php @@ -0,0 +1,17 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ + +namespace Piwik\Plugins\CorePluginsAdmin; + +/** + */ +class MarketplaceApiException extends \Exception +{ + +} diff --git a/www/analytics/plugins/CorePluginsAdmin/PluginInstaller.php b/www/analytics/plugins/CorePluginsAdmin/PluginInstaller.php new file mode 100644 index 00000000..f3a1d2f7 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/PluginInstaller.php @@ -0,0 +1,294 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CorePluginsAdmin; + +use Piwik\Filechecks; +use Piwik\Filesystem; +use Piwik\Piwik; +use Piwik\SettingsPiwik; +use Piwik\Unzip; +use Piwik\Plugin\Dependency as PluginDependency; + +/** + * + */ +class PluginInstaller +{ + const PATH_TO_DOWNLOAD = '/tmp/latest/plugins/'; + const PATH_TO_EXTRACT = '/plugins/'; + + private $pluginName; + + public function __construct($pluginName) + { + $this->pluginName = $pluginName; + } + + public function installOrUpdatePluginFromMarketplace() + { + $tmpPluginZip = PIWIK_USER_PATH . self::PATH_TO_DOWNLOAD . $this->pluginName . '.zip'; + $tmpPluginFolder = PIWIK_USER_PATH . self::PATH_TO_DOWNLOAD . $this->pluginName; + + $tmpPluginZip = SettingsPiwik::rewriteTmpPathWithHostname($tmpPluginZip); + $tmpPluginFolder = SettingsPiwik::rewriteTmpPathWithHostname($tmpPluginFolder); + + try { + $this->makeSureFoldersAreWritable(); + $this->makeSurePluginNameIsValid(); + $this->downloadPluginFromMarketplace($tmpPluginZip); + $this->extractPluginFiles($tmpPluginZip, $tmpPluginFolder); + $this->makeSurePluginJsonExists($tmpPluginFolder); + $metadata = $this->getPluginMetadataIfValid($tmpPluginFolder); + $this->makeSureThereAreNoMissingRequirements($metadata); + $this->copyPluginToDestination($tmpPluginFolder); + + } catch (\Exception $e) { + + $this->removeFileIfExists($tmpPluginZip); + $this->removeFolderIfExists($tmpPluginFolder); + + throw $e; + } + + $this->removeFileIfExists($tmpPluginZip); + $this->removeFolderIfExists($tmpPluginFolder); + } + + public function installOrUpdatePluginFromFile($pathToZip) + { + $tmpPluginFolder = PIWIK_USER_PATH . self::PATH_TO_DOWNLOAD . $this->pluginName; + $tmpPluginFolder = SettingsPiwik::rewriteTmpPathWithHostname($tmpPluginFolder); + + try { + $this->makeSureFoldersAreWritable(); + $this->extractPluginFiles($pathToZip, $tmpPluginFolder); + + $this->makeSurePluginJsonExists($tmpPluginFolder); + $metadata = $this->getPluginMetadataIfValid($tmpPluginFolder); + $this->makeSureThereAreNoMissingRequirements($metadata); + + $this->pluginName = $metadata->name; + + $this->fixPluginFolderIfNeeded($tmpPluginFolder); + $this->copyPluginToDestination($tmpPluginFolder); + + } catch (\Exception $e) { + + $this->removeFileIfExists($pathToZip); + $this->removeFolderIfExists($tmpPluginFolder); + + throw $e; + } + + $this->removeFileIfExists($pathToZip); + $this->removeFolderIfExists($tmpPluginFolder); + + return $metadata; + } + + private function makeSureFoldersAreWritable() + { + Filechecks::dieIfDirectoriesNotWritable(array(self::PATH_TO_DOWNLOAD, self::PATH_TO_EXTRACT)); + } + + private function downloadPluginFromMarketplace($pluginZipTargetFile) + { + $this->removeFileIfExists($pluginZipTargetFile); + + $marketplace = new MarketplaceApiClient(); + + try { + $marketplace->download($this->pluginName, $pluginZipTargetFile); + } catch (\Exception $e) { + + try { + $downloadUrl = $marketplace->getDownloadUrl($this->pluginName); + $errorMessage = sprintf('Failed to download plugin from %s: %s', $downloadUrl, $e->getMessage()); + + } catch (\Exception $ex) { + $errorMessage = sprintf('Failed to download plugin: %s', $e->getMessage()); + } + + throw new PluginInstallerException($errorMessage); + } + } + + /** + * @param $pluginZipFile + * @param $pathExtracted + * @throws \Exception + */ + private function extractPluginFiles($pluginZipFile, $pathExtracted) + { + $archive = Unzip::factory('PclZip', $pluginZipFile); + + $this->removeFolderIfExists($pathExtracted); + + if (0 == ($pluginFiles = $archive->extract($pathExtracted))) { + throw new PluginInstallerException(Piwik::translate('CoreUpdater_ExceptionArchiveIncompatible', $archive->errorInfo())); + } + + if (0 == count($pluginFiles)) { + throw new PluginInstallerException(Piwik::translate('Plugin Zip File Is Empty')); + } + } + + private function makeSurePluginJsonExists($tmpPluginFolder) + { + $pluginJsonPath = $this->getPathToPluginJson($tmpPluginFolder); + + if (!file_exists($pluginJsonPath)) { + throw new PluginInstallerException('Plugin is not valid, it is missing the plugin.json file.'); + } + } + + private function makeSureThereAreNoMissingRequirements($metadata) + { + $requires = array(); + if(!empty($metadata->require)) { + $requires = (array) $metadata->require; + } + + $dependency = new PluginDependency(); + $missingDependencies = $dependency->getMissingDependencies($requires); + + if (!empty($missingDependencies)) { + $message = ''; + foreach ($missingDependencies as $dep) { + $params = array(ucfirst($dep['requirement']), $dep['actualVersion'], $dep['requiredVersion']); + $message .= Piwik::translate('CorePluginsAdmin_MissingRequirementsNotice', $params); + } + + throw new PluginInstallerException($message); + } + } + + private function getPluginMetadataIfValid($tmpPluginFolder) + { + $pluginJsonPath = $this->getPathToPluginJson($tmpPluginFolder); + + $metadata = file_get_contents($pluginJsonPath); + $metadata = json_decode($metadata); + + if (empty($metadata)) { + throw new PluginInstallerException('Plugin is not valid, plugin.json is empty or does not contain valid JSON.'); + } + + if (empty($metadata->name)) { + throw new PluginInstallerException('Plugin is not valid, the plugin.json file does not specify the plugin name.'); + } + + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $metadata->name)) { + throw new PluginInstallerException('The plugin name specified in plugin.json contains illegal characters. ' . + 'Plugin name can only contain following characters: [a-zA-Z0-9-_].'); + } + + if (empty($metadata->version)) { + throw new PluginInstallerException('Plugin is not valid, the plugin.json file does not specify the plugin version.'); + } + + if (empty($metadata->description)) { + throw new PluginInstallerException('Plugin is not valid, the plugin.json file does not specify a description.'); + } + + return $metadata; + } + + private function getPathToPluginJson($tmpPluginFolder) + { + $firstSubFolder = $this->getNameOfFirstSubfolder($tmpPluginFolder); + $path = $tmpPluginFolder . DIRECTORY_SEPARATOR . $firstSubFolder . DIRECTORY_SEPARATOR . 'plugin.json'; + + return $path; + } + + /** + * @param $pluginDir + * @throws PluginInstallerException + * @return string + */ + private function getNameOfFirstSubfolder($pluginDir) + { + if (!($dir = opendir($pluginDir))) { + return false; + } + $firstSubFolder = ''; + + while ($file = readdir($dir)) { + if ($file[0] != '.' && is_dir($pluginDir . DIRECTORY_SEPARATOR . $file)) { + $firstSubFolder = $file; + break; + } + } + + if (empty($firstSubFolder)) { + throw new PluginInstallerException('The plugin ZIP file does not contain a subfolder, but Piwik expects plugin files to be within a subfolder in the Zip archive.'); + } + + return $firstSubFolder; + } + + private function fixPluginFolderIfNeeded($tmpPluginFolder) + { + $firstSubFolder = $this->getNameOfFirstSubfolder($tmpPluginFolder); + + if ($firstSubFolder === $this->pluginName) { + return; + } + + $from = $tmpPluginFolder . DIRECTORY_SEPARATOR . $firstSubFolder; + $to = $tmpPluginFolder . DIRECTORY_SEPARATOR . $this->pluginName; + rename($from, $to); + } + + private function copyPluginToDestination($tmpPluginFolder) + { + $pluginTargetPath = PIWIK_USER_PATH . self::PATH_TO_EXTRACT . $this->pluginName; + + $this->removeFolderIfExists($pluginTargetPath); + + Filesystem::copyRecursive($tmpPluginFolder, PIWIK_USER_PATH . self::PATH_TO_EXTRACT); + } + + /** + * @param $pathExtracted + */ + private function removeFolderIfExists($pathExtracted) + { + Filesystem::unlinkRecursive($pathExtracted, true); + } + + /** + * @param $targetTmpFile + */ + private function removeFileIfExists($targetTmpFile) + { + if (file_exists($targetTmpFile)) { + unlink($targetTmpFile); + } + } + + /** + * @throws PluginInstallerException + */ + private function makeSurePluginNameIsValid() + { + try { + $marketplace = new MarketplaceApiClient(); + $pluginDetails = $marketplace->getPluginInfo($this->pluginName); + } catch (\Exception $e) { + throw new PluginInstallerException($e->getMessage()); + } + + if (empty($pluginDetails)) { + throw new PluginInstallerException('This plugin was not found in the Marketplace.'); + } + } + +} diff --git a/www/analytics/plugins/CorePluginsAdmin/PluginInstallerException.php b/www/analytics/plugins/CorePluginsAdmin/PluginInstallerException.php new file mode 100644 index 00000000..2dbfc74b --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/PluginInstallerException.php @@ -0,0 +1,17 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CorePluginsAdmin; + +/** + * PluginInstallerException + */ +class PluginInstallerException extends \Exception +{ + +} diff --git a/www/analytics/plugins/CorePluginsAdmin/UpdateCommunication.php b/www/analytics/plugins/CorePluginsAdmin/UpdateCommunication.php new file mode 100644 index 00000000..c340d842 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/UpdateCommunication.php @@ -0,0 +1,210 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CorePluginsAdmin; + +use Piwik\Config; +use Piwik\Mail; +use Piwik\Option; +use Piwik\Piwik; +use Piwik\Plugins\UsersManager\API as UsersManagerApi; +use Piwik\SettingsPiwik; + +/** + * Class to check and notify users via email if there are plugin updates available. + */ +class UpdateCommunication +{ + private $enabledOptionName = 'enableUpdateCommunicationPlugins'; + + /** + * Checks whether plugin update notification is enabled or not. If the marketplace is disabled or if update + * communication is disabled in general, it will return false as well. + * + * @return bool + */ + public function isEnabled() + { + if (!$this->canBeEnabled()) { + return false; + } + + $isEnabled = Option::get($this->enabledOptionName); + + return !empty($isEnabled); + } + + /** + * Checks whether a plugin update notification can be enabled or not. It cannot be enabled if for instance the + * Marketplace is disabled or if update notifications are disabled in general. + * + * @return bool + */ + public function canBeEnabled() + { + $isEnabled = Config::getInstance()->General['enable_update_communication']; + + return CorePluginsAdmin::isMarketplaceEnabled() && !empty($isEnabled); + } + + /** + * Enable plugin update notifications. + */ + public function enable() + { + Option::set($this->enabledOptionName, 1); + } + + /** + * Disable plugin update notifications. + */ + public function disable() + { + Option::set($this->enabledOptionName, 0); + } + + /** + * Sends an email to all super users if there is an update available for any plugins from the Marketplace. + * For each update we send an email only once. + * + * @return bool + */ + public function sendNotificationIfUpdatesAvailable() + { + $pluginsHavingUpdate = $this->getPluginsHavingUpdate(); + + if (empty($pluginsHavingUpdate)) { + return; + } + + $pluginsToBeNotified = array(); + + foreach ($pluginsHavingUpdate as $plugin) { + if ($this->hasNotificationAlreadyReceived($plugin)) { + continue; + } + + $this->setHasLatestUpdateNotificationReceived($plugin); + + $pluginsToBeNotified[] = $plugin; + } + + if (!empty($pluginsToBeNotified)) { + $this->sendNotifications($pluginsToBeNotified); + } + } + + protected function sendNotifications($pluginsToBeNotified) + { + $hasThemeUpdate = false; + $hasPluginUpdate = false; + + foreach ($pluginsToBeNotified as $plugin) { + $hasThemeUpdate = $hasThemeUpdate || $plugin['isTheme']; + $hasPluginUpdate = $hasPluginUpdate || !$plugin['isTheme']; + } + + $subject = Piwik::translate('CoreUpdater_NotificationSubjectAvailablePluginUpdate'); + $message = Piwik::translate('ScheduledReports_EmailHello'); + $message .= "\n\n"; + $message .= Piwik::translate('CoreUpdater_ThereIsNewPluginVersionAvailableForUpdate'); + $message .= "\n\n"; + + foreach ($pluginsToBeNotified as $plugin) { + $message .= sprintf(' * %s %s', $plugin['name'], $plugin['latestVersion']); + $message .= "\n"; + } + + $message .= "\n"; + + $host = SettingsPiwik::getPiwikUrl(); + if ($hasThemeUpdate) { + $message .= Piwik::translate('CoreUpdater_NotificationClickToUpdateThemes') . "\n"; + $message .= $host. 'index.php?module=CorePluginsAdmin&action=themes'; + } + if ($hasPluginUpdate) { + if ($hasThemeUpdate) { + $message .= "\n\n"; + } + $message .= Piwik::translate('CoreUpdater_NotificationClickToUpdatePlugins') . "\n"; + $message .= $host. 'index.php?module=CorePluginsAdmin&action=plugins'; + } + + $message .= "\n\n"; + $message .= Piwik::translate('Installation_HappyAnalysing'); + + $this->sendEmailNotification($subject, $message); + } + + /** + * Send an email notification to all super users. + * + * @param $subject + * @param $message + */ + protected function sendEmailNotification($subject, $message) + { + $superUsers = UsersManagerApi::getInstance()->getUsersHavingSuperUserAccess(); + + foreach ($superUsers as $superUser) { + $mail = new Mail(); + $mail->setDefaultFromPiwik(); + $mail->addTo($superUser['email']); + $mail->setSubject($subject); + $mail->setBodyText($message); + $mail->send(); + } + } + + private function setHasLatestUpdateNotificationReceived($plugin) + { + $latestVersion = $this->getLatestVersion($plugin); + + Option::set($this->getNotificationSentOptionName($plugin), $latestVersion); + } + + private function getLatestVersionSent($plugin) + { + return Option::get($this->getNotificationSentOptionName($plugin)); + } + + private function getLatestVersion($plugin) + { + return $plugin['latestVersion']; + } + + private function hasNotificationAlreadyReceived($plugin) + { + $latestVersion = $this->getLatestVersion($plugin); + $lastVersionSent = $this->getLatestVersionSent($plugin); + + if (!empty($lastVersionSent) + && ($latestVersion == $lastVersionSent + || version_compare($latestVersion, $lastVersionSent) == -1)) { + return true; + } + + return false; + } + + private function getNotificationSentOptionName($plugin) + { + return 'last_update_communication_sent_plugin_' . $plugin['name']; + } + + protected function getPluginsHavingUpdate() + { + $marketplace = new Marketplace(); + $pluginsHavingUpdate = $marketplace->getPluginsHavingUpdate($themesOnly = false); + $themesHavingUpdate = $marketplace->getPluginsHavingUpdate($themesOnly = true); + + $plugins = array_merge($pluginsHavingUpdate, $themesHavingUpdate); + + return $plugins; + } +} diff --git a/www/analytics/plugins/CorePluginsAdmin/images/plugins.png b/www/analytics/plugins/CorePluginsAdmin/images/plugins.png new file mode 100644 index 00000000..22059faf Binary files /dev/null and b/www/analytics/plugins/CorePluginsAdmin/images/plugins.png differ diff --git a/www/analytics/plugins/CorePluginsAdmin/images/rating_important.png b/www/analytics/plugins/CorePluginsAdmin/images/rating_important.png new file mode 100755 index 00000000..662df523 Binary files /dev/null and b/www/analytics/plugins/CorePluginsAdmin/images/rating_important.png differ diff --git a/www/analytics/plugins/CorePluginsAdmin/images/themes.png b/www/analytics/plugins/CorePluginsAdmin/images/themes.png new file mode 100644 index 00000000..be3b6139 Binary files /dev/null and b/www/analytics/plugins/CorePluginsAdmin/images/themes.png differ diff --git a/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginDetail.js b/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginDetail.js new file mode 100755 index 00000000..01baff6a --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginDetail.js @@ -0,0 +1,88 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready(function () { + + function syncMaxHeight (selector) { + + if (!selector) { + return; + } + + var $nodes = $(selector); + + if (!$nodes) { + return; + } + + var max = {}; + $nodes.each(function (index, node) { + var $node = $(node); + var top = $node.position().top; + + var height = $node.height(); + + if (!max[top]) { + max[top] = height; + } else if (max[top] < height) { + max[top] = height; + } else { + $node.height(max[top] + 'px'); + } + }); + + $nodes.each(function (index, node) { + var $node = $(node); + var top = $node.position().top; + + $node.height(max[top] + 'px'); + }); + } + + syncMaxHeight('.pluginslist .plugin'); + syncMaxHeight('.themeslist .plugin'); + + $('.pluginslist, #plugins, .themeslist').on('click', '[data-pluginName]', function (event) { + if ($(event.target).hasClass('install') || $(event.target).hasClass('uninstall')) { + return; + } + + var pluginName = $(this).attr('data-pluginName'); + + if (!pluginName) { + return; + } + + var activeTab = $(event.target).attr('data-activePluginTab'); + if (activeTab) { + pluginName += '!' + activeTab; + } + + broadcast.propagateNewPopoverParameter('browsePluginDetail', pluginName); + }); + + var showPopover = function (value) { + var pluginName = value; + var activeTab = null; + + if (-1 !== value.indexOf('!')) { + activeTab = value.substr(value.indexOf('!') + 1); + pluginName = value.substr(0, value.indexOf('!')); + } + + var url = 'module=CorePluginsAdmin&action=pluginDetails&pluginName=' + encodeURIComponent(pluginName); + + if (activeTab) { + url += '&activeTab=' + encodeURIComponent(activeTab); + } + + Piwik_Popover.createPopupAndLoadUrl(url, 'details'); + }; + + broadcast.addPopoverHandler('browsePluginDetail', showPopover); + +}); \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginExtend.js b/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginExtend.js new file mode 100644 index 00000000..560cbc17 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginExtend.js @@ -0,0 +1,31 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready(function () { + + $('.extendPlatform .uploadPlugin').click(function (event) { + event.preventDefault(); + + piwikHelper.modalConfirm('#installPluginByUpload', { + yes: function () { + window.location = link; + } + }); + }); + + $('#uploadPluginForm').submit(function (event) { + + var $zipFile = $('[name=pluginZip]'); + var fileName = $zipFile.val(); + + if (!fileName || '.zip' != fileName.slice(-4)) { + event.preventDefault(); + alert(_pk_translate('CorePluginsAdmin_NoZipFileSelected')); + } + }); + +}); \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginOverview.js b/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginOverview.js new file mode 100644 index 00000000..8160ddf1 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/javascripts/pluginOverview.js @@ -0,0 +1,37 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready(function () { + + var uninstallConfirmMessage = ''; + + $('#plugins .uninstall').click(function (event) { + event.preventDefault(); + + var link = $(this).attr('href'); + var pluginName = $(this).attr('data-pluginName'); + + if (!link || !pluginName) { + return; + } + + if (!uninstallConfirmMessage) { + uninstallConfirmMessage = $('#uninstallPluginConfirm').text(); + } + + var messageToDisplay = uninstallConfirmMessage.replace('%s', pluginName); + + $('#uninstallPluginConfirm').text(messageToDisplay); + + piwikHelper.modalConfirm('#confirmUninstallPlugin', { + yes: function () { + window.location = link; + } + }); + }); + +}); \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/javascripts/plugins.js b/www/analytics/plugins/CorePluginsAdmin/javascripts/plugins.js new file mode 100644 index 00000000..5114fc18 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/javascripts/plugins.js @@ -0,0 +1,93 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready(function () { + + updateAllNumbersOfMatchingPluginsInFilter(); + + function filterPlugins() + { + var filterOrigin = getCurrentFilterOrigin(); + var filterStatus = getCurrentFilterStatus(); + + var $nodesToEnable = getMatchingNodes(filterOrigin, filterStatus); + + $('#plugins tr[data-filter-origin][data-filter-status]').css('display', 'none'); + $nodesToEnable.css('display', 'table-row'); + + updateAllNumbersOfMatchingPluginsInFilter(); + } + + function updateAllNumbersOfMatchingPluginsInFilter() + { + var filterOrigin = getCurrentFilterOrigin(); + var filterStatus = getCurrentFilterStatus(); + + updateNumberOfMatchingPluginsInFilter('[data-filter-status="all"]', filterOrigin, 'all'); + updateNumberOfMatchingPluginsInFilter('[data-filter-status="active"]', filterOrigin, 'active'); + updateNumberOfMatchingPluginsInFilter('[data-filter-status="inactive"]', filterOrigin, 'inactive'); + + updateNumberOfMatchingPluginsInFilter('[data-filter-origin="all"]', 'all', filterStatus); + updateNumberOfMatchingPluginsInFilter('[data-filter-origin="core"]', 'core', filterStatus); + updateNumberOfMatchingPluginsInFilter('[data-filter-origin="noncore"]', 'noncore', filterStatus); + } + + function updateNumberOfMatchingPluginsInFilter(selectorFilterToUpdate, filterOrigin, filterStatus) + { + var numMatchingNodes = getMatchingNodes(filterOrigin, filterStatus).length; + var updatedCounterText = ' (' + numMatchingNodes + ')'; + + $('.pluginsFilter ' + selectorFilterToUpdate + ' .counter').text(updatedCounterText); + } + + function getCurrentFilterOrigin() + { + return $('.pluginsFilter .origin a.active').data('filter-origin'); + } + + function getCurrentFilterStatus() + { + return $('.pluginsFilter .status a.active').data('filter-status'); + } + + function getMatchingNodes(filterOrigin, filterStatus) + { + var query = '#plugins tr'; + + if ('all' == filterOrigin) { + query += '[data-filter-origin]'; + } else { + query += '[data-filter-origin=' + filterOrigin + ']'; + } + + if ('all' == filterStatus) { + query += '[data-filter-status]'; + } else { + query += '[data-filter-status=' + filterStatus + ']'; + } + + return $(query); + } + + $('.pluginsFilter .status').on('click', 'a', function (event) { + event.preventDefault(); + + $(this).siblings().removeClass('active'); + $(this).addClass('active'); + + filterPlugins(); + }); + + $('.pluginsFilter .origin').on('click', 'a', function (event) { + event.preventDefault(); + + $(this).siblings().removeClass('active'); + $(this).addClass('active'); + + filterPlugins(); + }); +}); \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/stylesheets/marketplace.less b/www/analytics/plugins/CorePluginsAdmin/stylesheets/marketplace.less new file mode 100644 index 00000000..1bacfa28 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/stylesheets/marketplace.less @@ -0,0 +1,367 @@ +.extendPlatform { + min-width: 580px; + + .introduction { max-width:980px; } + .byPlugins { width:50%;float:left; } + .byThemes { width:50%;float:left; } + .teaserImage { width: 128px; height: 128px; margin: 64px; } + .header { font-size: 1.6em; } + .callToAction { font-size: 1.1em;line-height: 2em; } +} + +#plugins { + + .desc .missingRequirementsNotice { + color: red; + } + + .plugin-desc-missingrequirements { + font-weight:bold; + font-style: italic; + a { + text-decoration: underline !important; + color: black; + } + } + + .settingsLink { + text-align: right; + width: 100%; + display: inline-block; + font-style: italic; + } +} + +.admin .pluginsFilter { + color: #666; + .active { + font-weight: bold; + } + + a { + color: #255792; + text-decoration: none; + } + + a .counter { + color: #999999; + font-weight: normal; + } + + a:hover { + text-decoration: underline; + } + + .status { + display: inline-block; + margin-left: 20px; + } + + .getNewPlugins { + float: right; + } +} + +#installPluginByUpload { + .description { + margin-top: 30px; + margin-bottom: 20px; + } + + .startUpload { + margin-top: 20px; + margin-bottom: 20px; + } +} + +.pluginslist { + margin-top: 20px; + max-width: 980px; + clear: right; + + .plugin { + width: 280px; + float: left; + border: 1px solid #dadada; + padding: 15px; + background-color: #F6F5F3; + margin-right: 14px; + margin-bottom: 15px; + position: relative; + + .missingRequirementsNotice, + .updateAvailableNotice { + font-size: 14px; + padding: 10px; + color: #9b7a44; + display: inline-block; + background-color: #ffffe0; + border-radius: 3px; + margin-top: 1px; + margin-bottom: 16px; + + a { + color: #9b7a44; + font-weight: bold; + } + } + + &:hover { + background-color: #EFEEEC; + } + + li { + display: inline-block; + padding-right: 50px; + font-size: 90%; + + &.even { + padding-right: 0px; + width: 140px; + overflow: hidden; + white-space: nowrap; + } + &.odd { + padding-right: 0px; + width: 130px; + overflow: hidden; + white-space: nowrap; + } + } + + ul { + list-style: none; + margin-left: 0; + line-height: 140%; + } + + .header { + margin-top: 0px; + margin-bottom: 15px; + } + + .description { + padding-bottom: 10px; + } + .install { + float: right; + } + .update { + .install + } + .more { + font-weight: bold; + text-decoration: none; + color: #255792; + } + .content { + margin-bottom: 46px; + cursor: pointer; + } + + .featuredIcon { + margin-right: 3px; + margin-bottom: 3px; + height: 24px; + width: 24px; + position: absolute; + right: 1px; + margin-top: -22px; + } + + .footer { + position: absolute; + bottom: 4px; + left: 0px; + right: 0px; + cursor: pointer; + } + .metadataSeparator { + background-color: lightgray; + color: #333; + border: 0px; + height: 1px; + width: 100%; + } + .metadata { + margin-top: 10px; + margin-left: 15px; + margin-right: 15px; + } + } + + &.themes .plugin { + .header { + display: inline; + } + .content { + margin-bottom: 57px; + } + .preview { + width: 250px; + height: 250px; + } + .footer { + height: 55px; + position: absolute; + bottom: 7px; + left: 0px; + right: 0px; + } + } +} + +.pluginslistNonSuperUserHint { + margin-top: 30px; + margin-bottom: 30px; + width: 500px; +} + +.pluginslistActionBar { + min-width: 650px; + max-width: 980px; + + form { + display: inline; + } + + .sort { + .active { + font-weight: bold; + } + } + + .infoBox { + margin: 0px 0px 20px 0px; + } +} + +.pluginDetails { + font-size: 13px; + text-align: left; + font-family: Arial, Helvetica, sans-serif; + line-height: 20px; + + h3, h4, h5, h6 { + margin: 20px 0px 10px 0px; + color: #000000; + } + + .ui-tabs-panel ul, .ui-tabs-panel ol { + list-style: initial; + padding-left: 20px; + } + + .content .missingRequirementsNotice, + .content .updateAvailableNotice { + font-size: 14px; + padding: 10px; + color: #9b7a44; + display: inline-block; + background-color: #ffffe0; + border-radius: 3px; + + a { + color: #9b7a44; + font-weight: bold; + } + a:hover { + text-decoration: underline; + } + } + + p, .ui-tabs-panel ul, .ui-tabs-panel li { + font-family: Arial, Helvetica, sans-serif; + text-align: left; + line-height: 20px; + } + + .header .intro { + margin-bottom: 15px; + } + + .content p { + margin: 0 0 10px; + } + + .description { + padding-right: 25px; + } + + .ui-tabs { + padding: 0em; + } + + .ui-tabs .ui-tabs-nav { + padding: 0em; + border-bottom: 1px solid #cccccc; + margin-right: 25px; + border-radius: 0px; + font-size: 15px; + } + + .ui-tabs .ui-tabs-panel { + padding: 1.4em 3em 0em 0em; + } + + .content a { + color: #255792; + text-decoration: none; + } + + .metadata dl { + padding-right: 25px; + } + + .metadata a:hover { + text-decoration: underline; + } + + .ui-state-default { + border: 0px !important; + } + + .ui-state-active { + padding-bottom: 0px !important; + } + + .ui-state-active.ui-state-default { + border: 1px solid #cccccc !important; + } + + .ui-state-default:hover { + background-color: #eeeeee !important; + } + + .install { + padding: 11px 19px; + font-size: 17.5px; + -webkit-border-radius: 6px; + -moz-border-radius: 6px; + border-radius: 6px; + color: #ffffff; + background-color: #5bb75b; + display: inline-block; + text-decoration: none; + } + + .install:hover { + text-decoration: underline; + } + + dt { + font-weight: bold; + line-height: 20px; + } + dd { + margin-left: 10px; + line-height: 20px; + } + + .featuredIcon { + height: 16px; + width: 16px; + margin-right: 5px; + } + +} \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/stylesheets/plugins_admin.less b/www/analytics/plugins/CorePluginsAdmin/stylesheets/plugins_admin.less new file mode 100644 index 00000000..edcc9120 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/stylesheets/plugins_admin.less @@ -0,0 +1,54 @@ +table.dataTable tr.active-plugin > td { + background-color:#fff !important; +} + +table.dataTable tr.active-plugin:hover > td { + background-color:#fff !important; +} + +table.dataTable tr.inactive-plugin > td { + background-color:#ddd !important; +} + +table.dataTable tr.inactive-plugin:hover > td { + background-color:#ddd !important; +} + +.plugin-desc-text { + margin-top:0em; + margin-bottom:1.5em; +} + +.plugin-author { + float:left; +} + +.plugin-license { + float:right; + font-style:italic; +} + +.plugin-homepage { + font-size:.8em; + font-style:italic; +} + +table.entityTable tr td .plugin-homepage a { + text-decoration:none; +} + +table.entityTable tr td .plugin-homepage a:hover { + text-decoration:underline; +} + +table.entityTable tr td a.uninstall { + color:red; + text-decoration:none; + font-weight:bold; +} + +.plugin-version { + font-size:80%; + font-style:italic; + color:#777; +} \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/browsePlugins.twig b/www/analytics/plugins/CorePluginsAdmin/templates/browsePlugins.twig new file mode 100644 index 00000000..63845bf7 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/browsePlugins.twig @@ -0,0 +1,50 @@ +{% extends 'admin.twig' %} +{% import '@CorePluginsAdmin/macros.twig' as pluginsMacro %} + +{% block content %} + + <div class="pluginslistActionBar"> + + <h2 piwik-enriched-headline + feature-name="{{ 'CorePluginsAdmin_Marketplace'|translate }}" + >{{ 'CorePluginsAdmin_TeaserExtendPiwikByPlugin'|translate }}</h2> + + <div class="infoBox"> + {{ 'CorePluginsAdmin_BeCarefulUsingPlugins'|translate }} + </div> + + {% include "@CorePluginsAdmin/browsePluginsActions.twig" %} + </div> + + {% if not isSuperUser %} + <div class="pluginslistNonSuperUserHint"> + {{ 'CorePluginsAdmin_NotAllowedToBrowseMarketplacePlugins'|translate }} + </div> + {% endif %} + + <div class="pluginslist"> + + {% if plugins|length %} + + {% for plugin in plugins %} + + <div class="plugin"> + <div class="content" data-pluginName="{{ plugin.name }}"> + {% include "@CorePluginsAdmin/pluginOverview.twig" %} + </div> + + <div class="footer" data-pluginName="{{ plugin.name }}"> + {% if plugin.featured %} + {{ pluginsMacro.featuredIcon('right') }} + {% endif %} + {% include "@CorePluginsAdmin/pluginMetadata.twig" %} + </div> + </div> + + {% endfor %} + + {% else %} + {{ 'CorePluginsAdmin_NoPluginsFound'|translate }} + {% endif %} + </div> +{% endblock %} diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/browsePluginsActions.twig b/www/analytics/plugins/CorePluginsAdmin/templates/browsePluginsActions.twig new file mode 100644 index 00000000..be1a69c7 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/browsePluginsActions.twig @@ -0,0 +1,12 @@ +<div class="sort"> + <a href="{{ linkTo({'sort': 'popular', 'query': ''}) }}" {% if 'popular' == sort %}class="active"{% endif %}>{{ 'CorePluginsAdmin_SortByPopular'|translate }}</a> + | + <a href="{{ linkTo({'sort': 'newest', 'query': ''}) }}" {% if 'newest' == sort %}class="active"{% endif %}>{{ 'CorePluginsAdmin_SortByNewest'|translate }}</a> + | + <a href="{{ linkTo({'sort': 'alpha', 'query': ''}) }}" {% if 'alpha' == sort %}class="active"{% endif %}>{{ 'CorePluginsAdmin_SortByAlpha'|translate }}</a> + | + <form action="{{ linkTo({'sort': ''}) }}" method="POST"> + <input value="{{ query }}" placeholder="{{ 'General_Search'|translate }}" type="text" name="query"/> + <button type="submit">{{ 'General_Search'|translate }}</button> + </form> +</div> diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/browseThemes.twig b/www/analytics/plugins/CorePluginsAdmin/templates/browseThemes.twig new file mode 100644 index 00000000..2f834247 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/browseThemes.twig @@ -0,0 +1,45 @@ +{% extends 'admin.twig' %} + +{% block content %} + + <div class="pluginslistActionBar"> + + <h2 piwik-enriched-headline + feature-name="{{ 'CorePluginsAdmin_Marketplace'|translate }}" + >{{ 'CorePluginsAdmin_TeaserExtendPiwikByTheme'|translate }}</h2> + + <div class="infoBox"> + {{ 'CorePluginsAdmin_BeCarefulUsingThemes'|translate }} + </div> + + {% include "@CorePluginsAdmin/browsePluginsActions.twig" %} + </div> + + {% if not isSuperUser %} + <div class="pluginslistNonSuperUserHint"> + {{ 'CorePluginsAdmin_NotAllowedToBrowseMarketplaceThemes'|translate }} + </div> + {% endif %} + + <div class="pluginslist themes"> + + {% if plugins|length %} + {% for plugin in plugins %} + + <div class="plugin"> + <div class="content" data-pluginName="{{ plugin.name }}"> + {% include "@CorePluginsAdmin/themeOverview.twig" %} + </div> + + <div class="footer" data-pluginName="{{ plugin.name }}"> + {% include "@CorePluginsAdmin/pluginMetadata.twig" %} + </div> + </div> + + {% endfor %} + {% else %} + {{ 'CorePluginsAdmin_NoThemesFound'|translate }} + {% endif %} + + </div> +{% endblock %} diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/extend.twig b/www/analytics/plugins/CorePluginsAdmin/templates/extend.twig new file mode 100644 index 00000000..b95b6018 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/extend.twig @@ -0,0 +1,70 @@ +{% extends 'admin.twig' %} + +{% import '@CorePluginsAdmin/macros.twig' as plugins %} + +{% block content %} + <div class="extendPlatform"> + + <div class="ui-confirm" id="installPluginByUpload"> + <h2>{{ 'CorePluginsAdmin_TeaserExtendPiwikByUpload'|translate }}</h2> + + <p class="description"> {{ 'CorePluginsAdmin_AllowedUploadFormats'|translate }} </p> + + <form enctype="multipart/form-data" + method="post" + id="uploadPluginForm" + action="{{ linkTo({'action':'uploadPlugin', 'nonce': installNonce}) }}"> + <input type="file" name="pluginZip"> + <br /> + <input class="startUpload" type="submit" value="{{ 'CorePluginsAdmin_UploadZipFile'|translate }}"> + </form> + </div> + + <div class="introduction"> + + <h2 piwik-enriched-headline + feature-name="{{ 'CorePluginsAdmin_Marketplace'|translate }}" + >{{ 'CorePluginsAdmin_TeaserExtendPiwik'|translate }}</h2> + + <p>{{ 'CorePluginsAdmin_DownloadAndInstallPluginsFromMarketplace'|translate("<a href='?module=Proxy&action=redirect&url=http://plugins.piwik.org/' target='_blank'>", "</a>")|raw }}</p> + + {% set marketplaceSellPluginSubject = 'CorePluginsAdmin_MarketplaceSellPluginSubject'|translate %} + <em>{{ 'CorePluginsAdmin_GetEarlyAccessForPaidPlugins'|translate("<a href='mailto:hello@piwik.org?subject=" ~ marketplaceSellPluginSubject ~ "'>", "</a>")|raw }}</em> + </div> + + <div> + <div class="byPlugins"> + <h3 class="header">{{ 'CorePluginsAdmin_GetNewFunctionality'|translate }}</h3> + <span class="callToAction">{{ 'CorePluginsAdmin_ByInstallingNewPluginFromMarketplace'|translate("<a href=" ~ linkTo({'action':'browsePlugins', 'sort': ''}) ~ ">", "</a>")|raw }}</span> + + <p> + <a href="{{ linkTo({'action':'browsePlugins', 'sort': ''}) }}"><img class="teaserImage" title="{{ 'CorePluginsAdmin_InstallNewPlugins'|translate }}" alt="{{ 'CorePluginsAdmin_InstallNewPlugins'|translate }}" src="plugins/CorePluginsAdmin/images/plugins.png"/></a> + </p> + + <span class="callToAction"> + {{ 'CorePluginsAdmin_ByWritingOwnPlugin'|translate('<a href="http://developer.piwik.org/guides/getting-started-part-1" target="_blank">', '</a>')|raw }} + {% if isSuperUser %} + <br/>{{ 'CorePluginsAdmin_OrByUploadingAPlugin'|translate('<a href="#" class="uploadPlugin">', '</a>')|raw }} + {% endif %} + </span> + + </div> + + <div class="byThemes"> + <h3 class="header">{{ 'CorePluginsAdmin_EnjoyAnotherLookAndFeelOfThemes'|translate }}</h3> + <span class="callToAction">{{ 'CorePluginsAdmin_ByInstallingNewThemeFromMarketplace'|translate("<a href=" ~ linkTo({'action':'browseThemes', 'sort': ''}) ~ ">", "</a>")|raw }}</span> + + <p> + <a href="{{ linkTo({'action':'browseThemes', 'sort': ''}) }}"><img class="teaserImage" alt="{{ 'CorePluginsAdmin_InstallNewThemes'|translate }}" title="{{ 'CorePluginsAdmin_InstallNewThemes'|translate }}" src="plugins/CorePluginsAdmin/images/themes.png"/></a> + </p> + + <span class="callToAction"> + {{ 'CorePluginsAdmin_ByDesigningOwnTheme'|translate('<a href="http://developer.piwik.org/guides/theming" target="_blank">', '</a>')|raw }} + {% if isSuperUser %} + <br />{{ 'CorePluginsAdmin_OrByUploadingATheme'|translate('<a href="#" class="uploadPlugin">', '</a>')|raw }} + {% endif %} + </span> + </div> + </div> + </div> +{% endblock %} diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/installPlugin.twig b/www/analytics/plugins/CorePluginsAdmin/templates/installPlugin.twig new file mode 100644 index 00000000..9f557776 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/installPlugin.twig @@ -0,0 +1,41 @@ +{% extends 'admin.twig' %} + +{% block content %} + + <div style="max-width:980px;"> + + <h2>{{ 'CorePluginsAdmin_InstallingPlugin'|translate(plugin.name) }}</h2> + + <div> + + {% if plugin.isTheme %} + + <p>{{ 'CorePluginsAdmin_StepDownloadingThemeFromMarketplace'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepUnzippingTheme'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepThemeSuccessfullyInstalled'|translate(plugin.name, plugin.latestVersion) }}</p> + + <p><strong><a href="{{ linkTo({'action': 'activate', 'pluginName': plugin.name, 'nonce': nonce}) }}">{{ 'CorePluginsAdmin_ActionActivateTheme'|translate }}</a></strong> + + | + <a href="{{ linkTo({'action': 'browseThemes'}) }}">{{ 'CorePluginsAdmin_BackToExtendPiwik'|translate }}</a></p> + + {% else %} + + <p>{{ 'CorePluginsAdmin_StepDownloadingPluginFromMarketplace'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepUnzippingPlugin'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepPluginSuccessfullyInstalled'|translate(plugin.name, plugin.latestVersion) }}</p> + + <p><strong><a href="{{ linkTo({'action': 'activate', 'pluginName': plugin.name, 'nonce': nonce}) }}">{{ 'CorePluginsAdmin_ActionActivatePlugin'|translate }}</a></strong> + + | + <a href="{{ linkTo({'action': 'browsePlugins'}) }}">{{ 'CorePluginsAdmin_BackToExtendPiwik'|translate }}</a></p> + + {% endif %} + </div> + </div> + +{% endblock %} diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/macros.twig b/www/analytics/plugins/CorePluginsAdmin/templates/macros.twig new file mode 100644 index 00000000..df42eba8 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/macros.twig @@ -0,0 +1,256 @@ +{% macro tablePluginUpdates(pluginsHavingUpdate, nonce, isTheme) %} + + <div class='entityContainer'> + <table class="dataTable entityTable"> + <thead> + <tr> + <th>{% if isTheme %}{{ 'CorePluginsAdmin_Theme'|translate }}{% else %}{{ 'General_Plugin'|translate }}{% endif %}</th> + <th class="num">{{ 'CorePluginsAdmin_Version'|translate }}</th> + <th>{{ 'General_Description'|translate }}</th> + <th class="status">{{ 'CorePluginsAdmin_Status'|translate }}</th> + <th class="action-links">{{ 'General_Action'|translate }}</th> + </tr> + </thead> + <tbody id="plugins"> + {% for name,plugin in pluginsHavingUpdate %} + <tr {% if plugin.isActivated %}class="active-plugin"{% else %}class="inactive-plugin"{% endif %}> + <td class="name"> + <a href="javascript:void(0);" data-pluginName="{{ plugin.name|e('html_attr') }}"> + {{ plugin.name }} + </a> + </td> + <td class="vers"> + {% if plugin.repositoryChangelogUrl %} + <a href="javascript:void(0);" title="{{ 'CorePluginsAdmin_Changelog'|translate }}" data-activePluginTab="changelog" data-pluginName="{{ plugin.name|e('html_attr') }}">{{ plugin.currentVersion }} => {{ plugin.latestVersion }}</a> + {% else %} + {{ plugin.currentVersion }} => {{ plugin.latestVersion }} + {% endif %} + </td> + <td class="desc"> + {{ plugin.description }} + {{ _self.missingRequirementsPleaseUpdateNotice(plugin) }} + </td> + <td class="status"> + {% if plugin.isActivated %} + {{ 'CorePluginsAdmin_Active'|translate }} + {% else %} + {{ 'CorePluginsAdmin_Inactive'|translate }} + {% endif %} + </td> + <td class="togl action-links"> + {% if 0 == plugin.missingRequirements|length %} + <a href="{{ linkTo({'action':'updatePlugin', 'pluginName': plugin.name, 'nonce': nonce}) }}">Update</a> + {% else %} + - + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + +{% endmacro %} + +{% macro pluginDeveloper(owner) %} + {% if 'piwik' == owner %}<img title="Piwik" alt="Piwik" style="padding-bottom:2px;height:11px;" src="plugins/Zeitgeist/images/logo-marketplace.png"/>{% else %}{{ owner }}{% endif %} +{% endmacro %} + +{% macro featuredIcon(align='') %} + <img class="featuredIcon" + title="{{ 'CorePluginsAdmin_FeaturedPlugin'|translate }}" + src="plugins/CorePluginsAdmin/images/rating_important.png" + align="{{ align }}" /> +{% endmacro %} + +{% macro pluginsFilter(isTheme, isMarketplaceEnabled) %} + + <p class="pluginsFilter entityContainer"> + <span class="origin"> + <strong>{{ 'CorePluginsAdmin_Origin'|translate }}</strong> + <a data-filter-origin="all" href="#" class="active">{{ 'General_All'|translate }}<span class="counter"></span></a> | + <a data-filter-origin="core" href="#">{{ 'CorePluginsAdmin_OriginCore'|translate }}<span class="counter"></span></a> | + <a data-filter-origin="noncore" href="#">{{ 'CorePluginsAdmin_OriginThirdParty'|translate }}<span class="counter"></span></a> + </span> + + <span class="status"> + <strong>{{ 'CorePluginsAdmin_Status'|translate }}</strong> + <a data-filter-status="all" href="#" class="active">{{ 'General_All'|translate }}<span class="counter"></span></a> | + <a data-filter-status="active" href="#">{{ 'CorePluginsAdmin_Active'|translate }}<span class="counter"></span></a> | + <a data-filter-status="inactive" href="#">{{ 'CorePluginsAdmin_Inactive'|translate }}<span class="counter"></span></a> + </span> + + {% if isMarketplaceEnabled %} + <span class="getNewPlugins"> + {% if isTheme %} + <a href="{{ linkTo({'action':'browseThemes', 'sort': ''}) }}">{{ 'CorePluginsAdmin_InstallNewThemes'|translate }}</a> + {% else %} + <a href="{{ linkTo({'action':'browsePlugins', 'sort': ''}) }}">{{ 'CorePluginsAdmin_InstallNewPlugins'|translate }}</a> + {% endif %} + </span> + {% endif %} + </p> + +{% endmacro %} + +{% macro missingRequirementsPleaseUpdateNotice(plugin) %} + {% if plugin.missingRequirements and 0 < plugin.missingRequirements|length %} + {% for req in plugin.missingRequirements -%} + <p class="missingRequirementsNotice"> + {% set requirement = req.requirement|capitalize %} + {% if 'Php' == requirement %} + {% set requirement = 'PHP' %} + {% endif %} + {{ 'CorePluginsAdmin_MissingRequirementsNotice'|translate(requirement, req.actualVersion, req.requiredVersion) }} + </p> + {%- endfor %} + {% endif %} +{% endmacro %} + +{% macro missingRequirementsInfo(pluginName, metadata, missingRequirements, marketplacePluginNames) %} + {% set causedBy = '' %} + {% for dependency in missingRequirements %} + {% set causedBy = causedBy ~ dependency.requirement|capitalize ~ ' ' ~ dependency.causedBy %} + {% if not loop.last %} + {% set causedBy = causedBy ~ ', ' %} + {% endif %} + {% endfor %} + + {{ 'CorePluginsAdmin_PluginRequirement'|translate(pluginName, causedBy) }} + + {% if metadata is defined + and metadata.support is defined + and metadata.support.email + and pluginName not in marketplacePluginNames %} + {{ 'CorePluginsAdmin_EmailToEnquireUpdatedVersion'|translate('<a href="mailto:' ~ metadata.support.email|e('html_attr') ~'">' ~ metadata.support.email ~ '</a>', pluginName)|raw }} + {% endif %} +{% endmacro %} + + +{% macro tablePlugins(pluginsInfo, pluginNamesHavingSettings, activateNonce, deactivateNonce, uninstallNonce, isTheme, marketplacePluginNames, displayAdminLinks) %} + +<div id="confirmUninstallPlugin" class="ui-confirm"> + + <h2 id="uninstallPluginConfirm">{{ 'CorePluginsAdmin_UninstallConfirm'|translate }}</h2> + <input role="yes" type="button" value="{{ 'General_Yes'|translate }}"/> + <input role="no" type="button" value="{{ 'General_No'|translate }}"/> + +</div> + +<div class='entityContainer'> + <table class="dataTable entityTable"> + <thead> + <tr> + <th>{% if isTheme %}{{ 'CorePluginsAdmin_Theme'|translate }}{% else %}{{ 'General_Plugin'|translate }}{% endif %}</th> + <th>{{ 'General_Description'|translate }}</th> + <th class="status">{{ 'CorePluginsAdmin_Status'|translate }}</th> + {% if (displayAdminLinks) %} + <th class="action-links">{{ 'General_Action'|translate }}</th> + {% endif %} + </tr> + </thead> + <tbody id="plugins"> + {% for name,plugin in pluginsInfo %} + {% set isZeitgeist = isTheme and name == 'Zeitgeist' %} + {% if (plugin.alwaysActivated is defined and not plugin.alwaysActivated) or isTheme %} + <tr {% if plugin.activated %}class="active-plugin"{% else %}class="inactive-plugin"{% endif %} data-filter-status="{% if plugin.activated %}active{% else %}inactive{% endif %}" data-filter-origin="{% if plugin.isCorePlugin %}core{% else %}noncore{% endif %}"> + <td class="name" style="white-space:nowrap;"> + <a name="{{ name|e('html_attr') }}"></a> + {% if not plugin.isCorePlugin and name in marketplacePluginNames -%} + <a href="javascript:void(0);" + data-pluginName="{{ name|e('html_attr') }}" + >{{ name }}</a> + {%- else %} + {{ name }} + {% endif %} + <span class="plugin-version" {% if plugin.isCorePlugin %}title="{{ 'CorePluginsAdmin_CorePluginTooltip'|translate }}"{% endif %}>({% if plugin.isCorePlugin %}{{ 'CorePluginsAdmin_OriginCore'|translate }}{% else %}v{{ plugin.info.version }}{% endif %})</span> + + {% if name in pluginNamesHavingSettings %} + <br /><br /> + <a href="{{ linkTo({'module':'CoreAdminHome', 'action': 'pluginSettings'}) }}#{{ name|e('html_attr') }}" class="settingsLink">{{ 'General_Settings'|translate }}</a> + {% endif %} + </td> + <td class="desc"> + <div class="plugin-desc-missingrequirements"> + {% if plugin.missingRequirements is defined and plugin.missingRequirements %} + {{ _self.missingRequirementsInfo(name, plugin.info, plugin.missingRequirements, marketplacePluginNames) }} + <br /> + {% endif %} + </div> + <div class="plugin-desc-text"> + + {{ plugin.info.description|raw|nl2br }} + + {% if plugin.info.homepage|default is not empty and plugin.info.homepage not in [ + 'http://piwik.org', 'http://www.piwik.org', 'http://piwik.org/', 'http://www.piwik.org/' + ] %} + <span class="plugin-homepage"> + <a href="{{ plugin.info.homepage }}">({{ 'CorePluginsAdmin_PluginHomepage'|translate|replace({' ': ' '})|raw }})</a> + </span> + {% endif %} + </div> + {% if plugin.info.license is defined %} + <div class="plugin-license"> + {% if plugin.info.license_homepage is defined %}<a title="{{ 'CorePluginsAdmin_LicenseHomepage'|translate }}" target="_blank" href="{{ plugin.info.license_homepage }}">{% endif %}{{ plugin.info.license }}{% if plugin.info.license_homepage is defined %}</a>{% endif %} + </div> + {% endif %} + {% if plugin.info.authors is defined %} + <div class="plugin-author"> + <cite>By + {% if plugin.info.authors is defined -%} + {% spaceless %} + {% for author in plugin.info.authors if author.name %} + {% if author.homepage is defined %} + <a title="{{ 'CorePluginsAdmin_AuthorHomepage'|translate }}" href="{{ author.homepage }}" target="_blank">{{ author.name }}</a> + {% else %} + {{ author.name }} + {% endif %} + {% if loop.index < plugin.info.authors|length %} + , + {% endif %} + {% endfor %} + {% endspaceless %} + {%- endif %}.</cite> + </div> + {% endif %} + </td> + <td class="status" {% if isZeitgeist %}style="border-left-width:0px;"{% endif %}> + {% if not isZeitgeist -%} + + {% if plugin.activated %} + {{ 'CorePluginsAdmin_Active'|translate }} + {% else %} + {{ 'CorePluginsAdmin_Inactive'|translate }} + {% if plugin.uninstallable and displayAdminLinks %} <br/> - <a data-pluginName="{{ name|escape('html_attr') }}" class="uninstall" href='index.php?module=CorePluginsAdmin&action=uninstall&pluginName={{ name }}&nonce={{ uninstallNonce }}'>{{ 'CorePluginsAdmin_ActionUninstall'|translate }}</a>{% endif %} + {% endif %} + + {%- endif %} + </td> + + {% if displayAdminLinks %} + <td class="togl action-links" {% if isZeitgeist %}style="border-left-width:0px;"{% endif %}> + {% if not isZeitgeist -%} + + {% if plugin.invalid is defined or plugin.alwaysActivated %} + - + {% else %} + {% if plugin.activated %} + <a href='index.php?module=CorePluginsAdmin&action=deactivate&pluginName={{ name }}&nonce={{ deactivateNonce }}'>{{ 'CorePluginsAdmin_Deactivate'|translate }}</a> + {% elseif plugin.missingRequirements %} + - + {% else %} + <a href='index.php?module=CorePluginsAdmin&action=activate&pluginName={{ name }}&nonce={{ activateNonce }}'>{{ 'CorePluginsAdmin_Activate'|translate }}</a> + {% endif %} + {% endif %} + + {%- endif %} + </td> + {% endif %} + </tr> + {% endif %} + {% endfor %} + </tbody> + </table> +</div> + +{% endmacro %} diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/pluginDetails.twig b/www/analytics/plugins/CorePluginsAdmin/templates/pluginDetails.twig new file mode 100644 index 00000000..2487ef41 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/pluginDetails.twig @@ -0,0 +1,204 @@ +{% import '@CorePluginsAdmin/macros.twig' as pluginsMacro %} + +{% block content %} + + <div class="pluginDetails"> + {% if errorMessage %} + {{ errorMessage }} + {% elseif plugin %} + + {% set latestVersion = plugin.versions[plugin.versions|length - 1] %} + + <div class="header"> + <div class="intro" style="width:75%;float:left;"> + <h2>{{ plugin.name }}</h2> + <p class="description"> + {% if plugin.featured %} + {{ pluginsMacro.featuredIcon('left') }} + {% endif %} + {{ plugin.description }} + </p> + </div> + <div class="width:25%;float:left;"> + + {% if isSuperUser %} + {% if plugin.canBeUpdated and 0 == plugin.missingRequirements|length %} + <a class="install update" + href="{{ linkTo({'action':'updatePlugin', 'pluginName': plugin.name, 'nonce': updateNonce}) }}" + >{{ 'CoreUpdater_UpdateTitle'|translate }}</a> + {% elseif plugin.isInstalled %} + {% elseif 0 < plugin.missingRequirements|length %} + {% else %} + <a href="{{ linkTo({'action': 'installPlugin', 'pluginName': plugin.name, 'nonce': installNonce}) }}" + class="install">{{ 'CorePluginsAdmin_ActionInstall'|translate }}</a> + {% endif %} + {% endif %} + </div> + </div> + + <div class="content"> + <div style="width:75%;float:left;"> + + <div id="pluginDetailsTabs"> + <ul> + <li><a href="#tabs-description">{{ 'General_Description'|translate }}</a></li> + {% if latestVersion.readmeHtml.faq %} + <li><a href="#tabs-faq">{{ 'General_Faq'|translate }}</a></li> + {% endif %} + <li><a href="#tabs-changelog">{{ 'CorePluginsAdmin_Changelog'|translate }}</a></li> + {% if plugin.screenshots|length %} + <li><a href="#tabs-screenshots">{{ 'CorePluginsAdmin_Screenshots'|translate }}</a></li> + {% endif %} + {% if latestVersion.readmeHtml.support %} + <li><a href="#tabs-support">{{ 'CorePluginsAdmin_Support'|translate }}</a></li> + {% endif %} + </ul> + + <div id="tabs-description"> + {{ pluginsMacro.missingRequirementsPleaseUpdateNotice(plugin) }} + {{ latestVersion.readmeHtml.description|raw }} + </div> + + {% if latestVersion.readmeHtml.faq %} + <div id="tabs-faq"> + {{ latestVersion.readmeHtml.faq|raw }} + </div> + {% endif %} + + <div id="tabs-changelog"> + {{ pluginsMacro.missingRequirementsPleaseUpdateNotice(plugin) }} + {% if plugin.canBeUpdated %} + <p class="updateAvailableNotice">{{ 'CorePluginsAdmin_PluginUpdateAvailable'|translate(plugin.currentVersion, plugin.latestVersion) }} + {% if plugin.repositoryChangelogUrl %}<a target="_blank" href="{{ plugin.repositoryChangelogUrl }}">{{ 'CorePluginsAdmin_ViewRepositoryChangelog'|translate }}</a>{% endif %} + </p> + {% endif %} + + {% if latestVersion.readmeHtml.changelog %} + {{ latestVersion.readmeHtml.changelog|raw }} + {% endif %} + + <h3>{{ 'CorePluginsAdmin_History'|translate }}</h3> + + <ul> + {% for version in plugin.versions|reverse %} + <li> + {% set versionName %} + <strong> + {% if version.repositoryChangelogUrl %} + <a target="_blank" title="{{ 'CorePluginsAdmin_Changelog'|translate }}" href="{{ version.repositoryChangelogUrl }}">{{ version.name }}</a> + {% else %} + {{ version.name }} + {% endif %} + </strong> + {% endset %} + {{ 'CorePluginsAdmin_PluginVersionInfo'|translate(versionName, version.release)|raw }} + </li> + {% endfor %} + </ul> + </div> + + {% if plugin.screenshots|length %} + <div id="tabs-screenshots"> + <div class="thumbnails"> + {% for screenshot in plugin.screenshots %} + <div class="thumbnail"> + <a href="{{ screenshot }}" target="_blank"><img src="{{ screenshot }}?w=400" width="400" alt=""></a> + <p> + {{ screenshot|split('/')|last|replace({'_': ' ', '.png': '', '.jpg': '', '.jpeg': ''}) }} + </p> + </div> + {% endfor %} + </div> + </div> + {% endif %} + + {% if latestVersion.readmeHtml.support %} + <div id="tabs-support"> + + {{ latestVersion.readmeHtml.support|raw }} + + </div> + {% endif %} + </div> + + </div> + <div class="metadata" style="width:25%;float:left;"> + <p><br /></p> + <dl> + <dt>{{ 'CorePluginsAdmin_Version'|translate }}</dt> + <dd>{{ plugin.latestVersion }}</dd> + <dt>{{ 'CorePluginsAdmin_PluginKeywords'|translate }}</dt> + <dd>{{ plugin.keywords|join(', ') }}</dd> + <dt>{{ 'CorePluginsAdmin_LastUpdated'|translate }}</dt> + <dd>{{ plugin.lastUpdated }}</dd> + <dt>{{ 'General_Downloads'|translate }}</dt> + <dd title="{{ 'CorePluginsAdmin_NumDownloadsLatestVersion'|translate(latestVersion.numDownloads|number_format) }}">{{ plugin.numDownloads }}</dd> + <dt>{{ 'CorePluginsAdmin_Developer'|translate }}</dt> + <dd>{{ pluginsMacro.pluginDeveloper(plugin.owner) }}</dd> + <dt>{{ 'CorePluginsAdmin_Authors'|translate }}</dt> + <dd>{% for author in plugin.authors if author.name %} + + {% spaceless %} + {% if author.homepage %} + <a target="_blank" href="{{ author.homepage }}">{{ author.name }}</a> + {% elseif author.email %} + <a href="mailto:{{ author.email|escape('url') }}">{{ author.name }}</a> + {% else %} + {{ author.name }} + {% endif %} + + {% if loop.index < plugin.authors|length %} + , + {% endif %} + {% endspaceless %} + + {% endfor %} + </dd> + <dt>{{ 'CorePluginsAdmin_Websites'|translate }}</dt> + <dd> + {% if plugin.homepage %} + <a target="_blank" href="{{ plugin.homepage }}">{{ 'CorePluginsAdmin_PluginWebsite'|translate }}</a>, + {% endif %} + <a target="_blank" href="{{ plugin.repositoryUrl }}">GitHub</a></dd> + {% if plugin.activity %} + <dt>{{ 'CorePluginsAdmin_Activity'|translate }}</dt> + <dd> + {{ plugin.activity.numCommits }} commits + + {% if plugin.activity.numContributors > 1 %} + {{ 'CorePluginsAdmin_ByXDevelopers'|translate(plugin.activity.numContributors) }} + {% endif %} + {% if plugin.activity.lastCommitDate %} + {{ 'CorePluginsAdmin_LastCommitTime'|translate(plugin.activity.lastCommitDate) }} + {% endif %}</dd> + {% endif %} + </dl> + <br /> + </div> + </div> + <script type="text/javascript"> + $(function() { + + var active = 0; + {% if activeTab %} + var $activeTab = $('#tabs-{{ activeTab|e('js') }}'); + if ($activeTab) { + active = $activeTab.index() - 1; + } + {% endif %} + + $( "#pluginDetailsTabs" ).tabs({active: active >= 0 ? active : 0}); + + $('.pluginDetails a').each(function (index, a) { + var link = $(a).attr('href'); + + if (link && 0 === link.indexOf('http')) { + $(a).attr('target', '_blank'); + } + }); + }); + </script> + {% endif %} + </div> + +{% endblock %} diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/pluginMetadata.twig b/www/analytics/plugins/CorePluginsAdmin/templates/pluginMetadata.twig new file mode 100644 index 00000000..2c488687 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/pluginMetadata.twig @@ -0,0 +1,9 @@ +{% import '@CorePluginsAdmin/macros.twig' as plugins %} + +<hr class="metadataSeparator"/> +<ul class="metadata"> + <li class="odd">{{ 'CorePluginsAdmin_Version'|translate }}: <strong>{{ plugin.latestVersion }}</strong></li> + <li class="even">{{ 'CorePluginsAdmin_Updated'|translate }}: <strong>{{ plugin.lastUpdated }}</strong></li> + <li class="odd">{{ 'General_Downloads'|translate }}: <strong>{{ plugin.numDownloads }}</strong></li> + <li class="even">{{ 'CorePluginsAdmin_Developer'|translate }}: <strong>{{ plugins.pluginDeveloper(plugin.owner) }}</strong></li> +</ul> \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/pluginOverview.twig b/www/analytics/plugins/CorePluginsAdmin/templates/pluginOverview.twig new file mode 100644 index 00000000..cbe4ca5a --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/pluginOverview.twig @@ -0,0 +1,30 @@ +{% import '@CorePluginsAdmin/macros.twig' as plugins %} + +{% if isSuperUser %} + {% if plugin.canBeUpdated and 0 == plugin.missingRequirements|length %} + <a class="update" + href="{{ linkTo({'action':'updatePlugin', 'pluginName': plugin.name, 'nonce': updateNonce}) }}" + >{{ 'CoreUpdater_UpdateTitle'|translate }}</a> + {% elseif plugin.isInstalled %} + <span class="install">{{ 'General_Installed'|translate }}</span> + {% elseif 0 < plugin.missingRequirements|length %} + {% else %} + <a href="{{ linkTo({'action': 'installPlugin', 'pluginName': plugin.name, 'nonce': installNonce}) }}" + class="install">{{ 'CorePluginsAdmin_ActionInstall'|translate }}</a> + {% endif %} +{% endif %} + + +<h3 class="header" title="{{ 'General_MoreDetails'|translate }}"> + <a href="javascript:void(0);" class="more">{{ plugin.name }}</a> +</h3> +<p class="description">{{ plugin.description }} + <br /> + <a href="javascript:void(0);" title="{{ 'General_MoreDetails'|translate }}" class="more">>> {{ 'General_MoreLowerCase'|translate }}</a> +</p> + +{% if plugin.canBeUpdated %} + <p class="updateAvailableNotice" data-activePluginTab="changelog">{{ 'CorePluginsAdmin_PluginUpdateAvailable'|translate(plugin.currentVersion, plugin.latestVersion) }}</p> +{% endif %} + +{{ plugins.missingRequirementsPleaseUpdateNotice(plugin) }} \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/plugins.twig b/www/analytics/plugins/CorePluginsAdmin/templates/plugins.twig new file mode 100644 index 00000000..4eeb7ee0 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/plugins.twig @@ -0,0 +1,31 @@ +{% extends 'admin.twig' %} + +{% import '@CorePluginsAdmin/macros.twig' as plugins %} + +{% block content %} +<div style="max-width:980px;"> + + {% if pluginsHavingUpdate|length %} + <h2>{{ pluginsHavingUpdate|length }} Update(s) available</h2> + + <p>{{ 'CorePluginsAdmin_InfoPluginUpdateIsRecommended'|translate }}</p> + + {{ plugins.tablePluginUpdates(pluginsHavingUpdate, updateNonce, activateNonce, 0) }} + {% endif %} + + <h2 piwik-enriched-headline>{{ 'CorePluginsAdmin_PluginsManagement'|translate }}</h2> + + <p>{{ 'CorePluginsAdmin_MainDescription'|translate }} + + {% if not isPluginsAdminEnabled %} + <br/>{{ 'CorePluginsAdmin_DoMoreContactPiwikAdmins'|translate }} + {% endif %} + + </p> + + {{ plugins.pluginsFilter(false, isMarketplaceEnabled) }} + + {{ plugins.tablePlugins(pluginsInfo, pluginNamesHavingSettings, activateNonce, deactivateNonce, uninstallNonce, false, marketplacePluginNames, isPluginsAdminEnabled) }} + +</div> +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/safemode.twig b/www/analytics/plugins/CorePluginsAdmin/templates/safemode.twig new file mode 100644 index 00000000..7fc5e138 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/safemode.twig @@ -0,0 +1,114 @@ +<html> + <head> + <style type="text/css"> + html, body { + background-color: white; + } + td { + border: 1px solid #ccc; + border-collapse: collapse; + padding: 5px; + } + table { + border-collapse: collapse; + border: 0px; + } + a { + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + </style> + </head> + <body> + + <h1>A fatal error occurred</h1> + + <div style="width: 640px"> + + {% if not isAnonymousUser %} + <p> + The following error just broke Piwik{% if showVersion %} (v{{ piwikVersion }}){% endif %}: + <pre>{{ lastError.message }}</pre> + in + <pre>{{ lastError.file }} line {{ lastError.line }}</pre> + </p> + {% endif %} + + {% if isSuperUser %} + <p> + If this error continues to happen, there is a good chance to fix this issue by disabling one or more of + the Third-Party plugins. You can enable them again in the + <a target="_blank" href="index.php?module=CorePluginsAdmin&action=plugins">Plugins</a> or <a target="_blank" href="index.php?module=CorePluginsAdmin&action=themes">Themes</a> page under + settings at any time. + + {% if pluginCausesIssue %} + Based on the error message, the issue is probably caused by the plugin <strong>{{ pluginCausesIssue }}</strong>. + {% endif %} + </p> + + <table> + {% for pluginName, plugin in plugins if plugin.uninstallable and plugin.activated %} + <tr {% if loop.index is divisibleby(2) %}style="background-color: #eeeeee"{% endif %}> + <td style="min-width:200px;"> + {{ pluginName }} + </td> + <td> + <a href="index.php?module=CorePluginsAdmin&action=deactivate&pluginName={{ pluginName }}&nonce={{ deactivateNonce }}" + target="_blank">deactivate</a> + </td> + </tr> + {% endfor %} + </table> + + {% set uninstalledPluginsFound = false %} + {% for pluginName, plugin in plugins if plugin.uninstallable and not plugin.activated %} + {% set uninstalledPluginsFound = true %} + {% endfor %} + + {% if uninstalledPluginsFound %} + + <p> + If this error still occurs after disabling all plugins, you might want to consider uninstalling some + plugins. Keep in mind: The plugin will be completely removed from your platform. + </p> + + <table> + {% for pluginName, plugin in plugins if plugin.uninstallable and not plugin.activated %} + <tr {% if loop.index is divisibleby(2) %}style="background-color: #eeeeee"{% endif %}> + <td style="min-width:200px;"> + {{ pluginName }} + </td> + <td> + <a href="index.php?module=CorePluginsAdmin&action=uninstall&pluginName={{ pluginName }}&nonce={{ uninstallNonce }}" + target="_blank" onclick="return confirm('{{ 'CorePluginsAdmin_UninstallConfirm'|translate(pluginName)|e('js') }}')">uninstall</a> + </td> + </tr> + {% endfor %} + </table> + {% endif %} + + <p> + <br /> + We appreciate if you send the + <a href="mailto:hello@piwik.org?subject={{ 'Fatal error in Piwik ' ~ piwikVersion|e('url') }}&body={{ lastError.message|e('url') }}%20in%20{{ lastError.file|e('url') }}%20{{ lastError.line|e('url') }}%20using%20PHP%20{{ constant('PHP_VERSION') }}">error report</a> + to the Piwik team. + </p> + + {% elseif isAnonymousUser %} + + <p>Please contact the system administrator.</p> + + {% else %} + <p> + If this error continues to happen you may want to send an + <a href="mailto:{{ emailSuperUser }}?subject={{ 'Fatal error in Piwik ' ~ piwikVersion|e('url') }}&body={{ lastError.message|e('url') }}%20in%20{{ lastError.file|e('url') }}%20{{ lastError.line|e('url') }}%20using%20PHP%20{{ constant('PHP_VERSION') }}">error report</a> + to your system administrator. + </p> + {% endif %} + + </div> + + </body> +</html> \ No newline at end of file diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/themeOverview.twig b/www/analytics/plugins/CorePluginsAdmin/templates/themeOverview.twig new file mode 100644 index 00000000..3458b309 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/themeOverview.twig @@ -0,0 +1,30 @@ +{% import '@CorePluginsAdmin/macros.twig' as plugins %} + +{% if isSuperUser %} + {% if plugin.canBeUpdated and 0 == plugin.missingRequirements|length %} + <a href="{{ linkTo({'action':'updatePlugin', 'pluginName': plugin.name, 'nonce': updateNonce}) }}" + class="update" + >{{ 'CoreUpdater_UpdateTitle'|translate }}</a> + {% elseif plugin.isInstalled %} + <span class="install">{{ 'General_Installed'|translate }}</span> + {% elseif 0 < plugin.missingRequirements|length %} + {% else %} + <a href="{{ linkTo({'action': 'installPlugin', 'pluginName': plugin.name, 'nonce': installNonce}) }}" + class="install">{{ 'CorePluginsAdmin_ActionInstall'|translate }}</a> + {% endif %} +{% endif %} + +<h3 class="header" title="{{ 'General_MoreDetails'|translate }}"> + <a href="javascript:void(0);" class="more">{{ plugin.name }}</a> +</h3> + +<p class="description">{% if plugin.featured %}{{ plugins.featuredIcon('right') }}{% endif %}{{ plugin.description }}</p> + +{% if plugin.canBeUpdated %} + <p class="updateAvailableNotice">{{ 'CorePluginsAdmin_PluginUpdateAvailable'|translate(plugin.currentVersion, plugin.latestVersion) }}</p> +{% endif %} + +{{ plugins.missingRequirementsPleaseUpdateNotice(plugin) }} + +<a href="javascript:void(0);" class="more"><img title="{{ 'General_MoreDetails'|translate }}" + class="preview" src="{{ plugin.screenshots|first }}?w=250&h=250"/></a> diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/themes.twig b/www/analytics/plugins/CorePluginsAdmin/templates/themes.twig new file mode 100644 index 00000000..5ac8ff6d --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/themes.twig @@ -0,0 +1,33 @@ +{% extends 'admin.twig' %} + +{% import '@CorePluginsAdmin/macros.twig' as plugins %} + +{% block content %} +<div style="max-width:980px;"> + + {% if pluginsHavingUpdate|length %} + <h2>{{ 'CorePluginsAdmin_NumUpdatesAvailable'|translate(pluginsHavingUpdate|length) }}</h2> + + <p>{{ 'CorePluginsAdmin_InfoThemeUpdateIsRecommended'|translate }}</p> + + {{ plugins.tablePluginUpdates(pluginsHavingUpdate, updateNonce, true) }} + {% endif %} + + <h2 piwik-enriched-headline>{{ 'CorePluginsAdmin_ThemesManagement'|translate }}</h2> + + <p>{{ 'CorePluginsAdmin_ThemesDescription'|translate }} + {% if otherUsersCount > 0 %} + <br/> {{ 'CorePluginsAdmin_InfoThemeIsUsedByOtherUsersAsWell'|translate(otherUsersCount, themeEnabled) }} + {% endif %} + {% if not isPluginsAdminEnabled %} + <br/>{{ 'CorePluginsAdmin_DoMoreContactPiwikAdmins'|translate }} + {% endif %} + + </p> + + {{ plugins.pluginsFilter(true, isMarketplaceEnabled) }} + + {{ plugins.tablePlugins(pluginsInfo, pluginNamesHavingSettings, activateNonce, deactivateNonce, uninstallNonce, true, marketplacePluginNames, isPluginsAdminEnabled ) }} + +</div> +{% endblock %} diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/updatePlugin.twig b/www/analytics/plugins/CorePluginsAdmin/templates/updatePlugin.twig new file mode 100644 index 00000000..ab8d5507 --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/updatePlugin.twig @@ -0,0 +1,41 @@ +{% extends 'admin.twig' %} + +{% block content %} + + <div style="max-width:980px;"> + + <h2>{{ 'CorePluginsAdmin_UpdatingPlugin'|translate(plugin.name) }}</h2> + + <div> + + {% if plugin.isTheme %} + + <p>{{ 'CorePluginsAdmin_StepDownloadingThemeFromMarketplace'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepUnzippingTheme'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepReplaceExistingTheme'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepThemeSuccessfullyUpdated'|translate(plugin.name, plugin.latestVersion) }}</p> + + {% else %} + + <p>{{ 'CorePluginsAdmin_StepDownloadingPluginFromMarketplace'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepUnzippingPlugin'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepReplaceExistingPlugin'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepPluginSuccessfullyUpdated'|translate(plugin.name, plugin.latestVersion) }}</p> + + {% endif %} + + <p><a href="{{ linkTo({'action': 'plugins'}) }}">{{ 'General_Plugins'|translate }}</a> + | + <a href="{{ linkTo({'action': 'themes'}) }}">{{ 'CorePluginsAdmin_Themes'|translate }}</a> + | + <a href="{{ linkTo({'action': 'extend'}) }}">{{ 'CorePluginsAdmin_Marketplace'|translate }}</a></p> + </div> + </div> + +{% endblock %} diff --git a/www/analytics/plugins/CorePluginsAdmin/templates/uploadPlugin.twig b/www/analytics/plugins/CorePluginsAdmin/templates/uploadPlugin.twig new file mode 100644 index 00000000..d345512e --- /dev/null +++ b/www/analytics/plugins/CorePluginsAdmin/templates/uploadPlugin.twig @@ -0,0 +1,44 @@ +{% extends 'admin.twig' %} + +{% block content %} + + <div style="max-width:980px;"> + + <div> + <h2>{{ 'CorePluginsAdmin_InstallingPlugin'|translate(plugin.name) }}</h2> + + {% if plugin.isTheme %} + + <p>{{ 'CorePluginsAdmin_StepUnzippingTheme'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepThemeSuccessfullyInstalled'|translate(plugin.name, plugin.version) }}</p> + + <p> + {% if not plugin.isActivated %} + <strong><a href="{{ linkTo({'action': 'activate', 'pluginName': plugin.name, 'nonce': nonce}) }}">{{ 'CorePluginsAdmin_ActionActivateTheme'|translate }}</a></strong> + + | + {% endif %} + <a href="{{ linkTo({'action': 'extend'}) }}">{{ 'CorePluginsAdmin_BackToExtendPiwik'|translate }}</a> + </p> + + {% else %} + + <p>{{ 'CorePluginsAdmin_StepUnzippingPlugin'|translate }}</p> + + <p>{{ 'CorePluginsAdmin_StepPluginSuccessfullyInstalled'|translate(plugin.name, plugin.version) }}</p> + + <p> + {% if not plugin.isActivated %} + <strong><a href="{{ linkTo({'action': 'activate', 'pluginName': plugin.name, 'nonce': nonce}) }}">{{ 'CorePluginsAdmin_ActionActivatePlugin'|translate }}</a></strong> + + | + {% endif %} + <a href="{{ linkTo({'action': 'extend'}) }}">{{ 'CorePluginsAdmin_BackToExtendPiwik'|translate }}</a> + </p> + + {% endif %} + </div> + </div> + +{% endblock %} diff --git a/www/analytics/plugins/CoreUpdater/Commands/Update.php b/www/analytics/plugins/CoreUpdater/Commands/Update.php new file mode 100644 index 00000000..04171407 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/Commands/Update.php @@ -0,0 +1,62 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreUpdater\Commands; + +use Piwik\Plugin\ConsoleCommand; +use Piwik\Plugins\CoreUpdater\Controller; +use Piwik\Plugins\CoreUpdater\NoUpdatesFoundException; +use Piwik\Plugins\UserCountry\LocationProvider; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @package CloudAdmin + */ +class Update extends ConsoleCommand +{ + protected function configure() + { + $this->setName('core:update'); + + $this->setDescription('Triggers upgrades. Use it after Piwik core or any plugin files have been updated.'); + + $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only prints out the SQL requests that would be executed during the upgrade'); + } + + /** + * Execute command like: ./console core:update --dry-run + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $doDryRun = $input->getOption('dry-run'); + + try { + $this->makeUpdate($input, $output, $doDryRun); + } catch(NoUpdatesFoundException $e) { + // Do not fail if no updates were found + $output->writeln("<info>".$e->getMessage()."</info>"); + } catch (\Exception $e) { + // Fail in case of any other error during upgrade + $output->writeln("<error>" . $e->getMessage() . "</error>"); + throw $e; + } + } + + protected function makeUpdate(InputInterface $input, OutputInterface $output, $doDryRun) + { + $this->checkAllRequiredOptionsAreNotEmpty($input); + + $updateController = new Controller(); + echo $updateController->runUpdaterAndExit($doDryRun); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreUpdater/Controller.php b/www/analytics/plugins/CoreUpdater/Controller.php new file mode 100644 index 00000000..3d4b5811 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/Controller.php @@ -0,0 +1,432 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreUpdater; + +use Exception; +use Piwik\ArchiveProcessor\Rules; +use Piwik\Common; +use Piwik\Config; +use Piwik\DbHelper; +use Piwik\Filechecks; +use Piwik\Filesystem; +use Piwik\Http; +use Piwik\Option; +use Piwik\Piwik; +use Piwik\Plugin; +use Piwik\Plugins\CorePluginsAdmin\Marketplace; +use Piwik\Plugins\LanguagesManager\LanguagesManager; +use Piwik\SettingsPiwik; +use Piwik\SettingsServer; +use Piwik\Unzip; +use Piwik\UpdateCheck; +use Piwik\Updater; +use Piwik\Version; +use Piwik\View; +use Piwik\View\OneClickDone; +use Piwik\Plugin\Manager as PluginManager; + +/** + * + */ +class Controller extends \Piwik\Plugin\Controller +{ + const CONFIG_FILE_BACKUP = '/config/global.ini.auto-backup-before-update.php'; + const PATH_TO_EXTRACT_LATEST_VERSION = '/tmp/latest/'; + + private $coreError = false; + private $warningMessages = array(); + private $errorMessages = array(); + private $deactivatedPlugins = array(); + private $pathPiwikZip = false; + private $newVersion; + + static protected function getLatestZipUrl($newVersion) + { + if (@Config::getInstance()->Debug['allow_upgrades_to_beta']) { + return 'http://builds.piwik.org/piwik-' . $newVersion . '.zip'; + } + return Config::getInstance()->General['latest_version_url']; + } + + public function newVersionAvailable() + { + Piwik::checkUserHasSuperUserAccess(); + + $newVersion = $this->checkNewVersionIsAvailableOrDie(); + + $view = new View('@CoreUpdater/newVersionAvailable'); + + $view->piwik_version = Version::VERSION; + $view->piwik_new_version = $newVersion; + + $incompatiblePlugins = $this->getIncompatiblePlugins($newVersion); + + $marketplacePlugins = array(); + try { + if (!empty($incompatiblePlugins)) { + $marketplace = new Marketplace(); + $marketplacePlugins = $marketplace->getAllAvailablePluginNames(); + } + } catch (\Exception $e) {} + + $view->marketplacePlugins = $marketplacePlugins; + $view->incompatiblePlugins = $incompatiblePlugins; + $view->piwik_latest_version_url = self::getLatestZipUrl($newVersion); + $view->can_auto_update = Filechecks::canAutoUpdate(); + $view->makeWritableCommands = Filechecks::getAutoUpdateMakeWritableMessage(); + + return $view->render(); + } + + public function oneClickUpdate() + { + Piwik::checkUserHasSuperUserAccess(); + $this->newVersion = $this->checkNewVersionIsAvailableOrDie(); + + SettingsServer::setMaxExecutionTime(0); + + $url = self::getLatestZipUrl($this->newVersion); + $steps = array( + array('oneClick_Download', Piwik::translate('CoreUpdater_DownloadingUpdateFromX', $url)), + array('oneClick_Unpack', Piwik::translate('CoreUpdater_UnpackingTheUpdate')), + array('oneClick_Verify', Piwik::translate('CoreUpdater_VerifyingUnpackedFiles')), + array('oneClick_CreateConfigFileBackup', Piwik::translate('CoreUpdater_CreatingBackupOfConfigurationFile', self::CONFIG_FILE_BACKUP)) + ); + $incompatiblePlugins = $this->getIncompatiblePlugins($this->newVersion); + if (!empty($incompatiblePlugins)) { + $namesToDisable = array(); + foreach ($incompatiblePlugins as $incompatiblePlugin) { + $namesToDisable[] = $incompatiblePlugin->getPluginName(); + } + $steps[] = array('oneClick_DisableIncompatiblePlugins', Piwik::translate('CoreUpdater_DisablingIncompatiblePlugins', implode(', ', $namesToDisable))); + } + + $steps[] = array('oneClick_Copy', Piwik::translate('CoreUpdater_InstallingTheLatestVersion')); + $steps[] = array('oneClick_Finished', Piwik::translate('CoreUpdater_PiwikUpdatedSuccessfully')); + + $errorMessage = false; + $messages = array(); + foreach ($steps as $step) { + try { + $method = $step[0]; + $message = $step[1]; + $this->$method(); + $messages[] = $message; + } catch (Exception $e) { + $errorMessage = $e->getMessage(); + break; + } + } + + $view = new OneClickDone(Piwik::getCurrentUserTokenAuth()); + $view->coreError = $errorMessage; + $view->feedbackMessages = $messages; + return $view->render(); + } + + public function oneClickResults() + { + $view = new View('@CoreUpdater/oneClickResults'); + $view->coreError = Common::getRequestVar('error', '', 'string', $_POST); + $view->feedbackMessages = safe_unserialize(Common::unsanitizeInputValue(Common::getRequestVar('messages', '', 'string', $_POST))); + return $view->render(); + } + + protected function redirectToDashboardWhenNoError($updater) + { + if (count($updater->getSqlQueriesToExecute()) == 1 + && !$this->coreError + && empty($this->warningMessages) + && empty($this->errorMessages) + && empty($this->deactivatedPlugins) + ) { + Piwik::redirectToModule('CoreHome'); + } + } + + protected static function clearPhpCaches() + { + if (function_exists('apc_clear_cache')) { + apc_clear_cache(); // clear the system (aka 'opcode') cache + } + + if (function_exists('opcache_reset')) { + opcache_reset(); // reset the opcode cache (php 5.5.0+) + } + } + + private function checkNewVersionIsAvailableOrDie() + { + $newVersion = UpdateCheck::isNewestVersionAvailable(); + if (!$newVersion) { + throw new Exception(Piwik::translate('CoreUpdater_ExceptionAlreadyLatestVersion', Version::VERSION)); + } + return $newVersion; + } + + private function oneClick_Download() + { + $pathPiwikZip = PIWIK_USER_PATH . self::PATH_TO_EXTRACT_LATEST_VERSION . 'latest.zip'; + $this->pathPiwikZip = SettingsPiwik::rewriteTmpPathWithHostname($pathPiwikZip); + + Filechecks::dieIfDirectoriesNotWritable(array(self::PATH_TO_EXTRACT_LATEST_VERSION)); + + // we catch exceptions in the caller (i.e., oneClickUpdate) + $url = self::getLatestZipUrl($this->newVersion) . '?cb=' . $this->newVersion; + + Http::fetchRemoteFile($url, $this->pathPiwikZip); + } + + private function oneClick_Unpack() + { + $pathExtracted = PIWIK_USER_PATH . self::PATH_TO_EXTRACT_LATEST_VERSION; + $pathExtracted = SettingsPiwik::rewriteTmpPathWithHostname($pathExtracted); + + $this->pathRootExtractedPiwik = $pathExtracted . 'piwik'; + + if (file_exists($this->pathRootExtractedPiwik)) { + Filesystem::unlinkRecursive($this->pathRootExtractedPiwik, true); + } + + $archive = Unzip::factory('PclZip', $this->pathPiwikZip); + + if (0 == ($archive_files = $archive->extract($pathExtracted))) { + throw new Exception(Piwik::translate('CoreUpdater_ExceptionArchiveIncompatible', $archive->errorInfo())); + } + + if (0 == count($archive_files)) { + throw new Exception(Piwik::translate('CoreUpdater_ExceptionArchiveEmpty')); + } + unlink($this->pathPiwikZip); + } + + private function oneClick_Verify() + { + $someExpectedFiles = array( + '/config/global.ini.php', + '/index.php', + '/core/Piwik.php', + '/piwik.php', + '/plugins/API/API.php' + ); + foreach ($someExpectedFiles as $file) { + if (!is_file($this->pathRootExtractedPiwik . $file)) { + throw new Exception(Piwik::translate('CoreUpdater_ExceptionArchiveIncomplete', $file)); + } + } + } + + private function oneClick_CreateConfigFileBackup() + { + $configFileBefore = PIWIK_USER_PATH . '/config/global.ini.php'; + $configFileAfter = PIWIK_USER_PATH . self::CONFIG_FILE_BACKUP; + Filesystem::copy($configFileBefore, $configFileAfter); + } + + private function oneClick_DisableIncompatiblePlugins() + { + $plugins = $this->getIncompatiblePlugins($this->newVersion); + + foreach ($plugins as $plugin) { + PluginManager::getInstance()->deactivatePlugin($plugin->getPluginName()); + } + } + + private function oneClick_Copy() + { + /* + * Make sure the execute bit is set for this shell script + */ + if (!Rules::isBrowserTriggerEnabled()) { + @chmod($this->pathRootExtractedPiwik . '/misc/cron/archive.sh', 0755); + } + + /* + * Copy all files to PIWIK_INCLUDE_PATH. + * These files are accessed through the dispatcher. + */ + Filesystem::copyRecursive($this->pathRootExtractedPiwik, PIWIK_INCLUDE_PATH); + + /* + * These files are visible in the web root and are generally + * served directly by the web server. May be shared. + */ + if (PIWIK_INCLUDE_PATH !== PIWIK_DOCUMENT_ROOT) { + /* + * Copy PHP files that expect to be in the document root + */ + $specialCases = array( + '/index.php', + '/piwik.php', + '/js/index.php', + ); + + foreach ($specialCases as $file) { + Filesystem::copy($this->pathRootExtractedPiwik . $file, PIWIK_DOCUMENT_ROOT . $file); + } + + /* + * Copy the non-PHP files (e.g., images, css, javascript) + */ + Filesystem::copyRecursive($this->pathRootExtractedPiwik, PIWIK_DOCUMENT_ROOT, true); + } + + /* + * Config files may be user (account) specific + */ + if (PIWIK_INCLUDE_PATH !== PIWIK_USER_PATH) { + Filesystem::copyRecursive($this->pathRootExtractedPiwik . '/config', PIWIK_USER_PATH . '/config'); + } + + Filesystem::unlinkRecursive($this->pathRootExtractedPiwik, true); + + self::clearPhpCaches(); + } + + private function oneClick_Finished() + { + } + + public function index() + { + $language = Common::getRequestVar('language', ''); + if (!empty($language)) { + LanguagesManager::setLanguageForSession($language); + } + + try { + return $this->runUpdaterAndExit(); + } catch(NoUpdatesFoundException $e) { + Piwik::redirectToModule('CoreHome'); + } + } + + public function runUpdaterAndExit($doDryRun = null) + { + $updater = new Updater(); + $componentsWithUpdateFile = CoreUpdater::getComponentUpdates($updater); + if (empty($componentsWithUpdateFile)) { + throw new NoUpdatesFoundException("Everything is already up to date."); + } + + SettingsServer::setMaxExecutionTime(0); + + $cli = Common::isPhpCliMode() ? '_cli' : ''; + $welcomeTemplate = '@CoreUpdater/runUpdaterAndExit_welcome' . $cli; + $doneTemplate = '@CoreUpdater/runUpdaterAndExit_done' . $cli; + $viewWelcome = new View($welcomeTemplate); + $viewDone = new View($doneTemplate); + $doExecuteUpdates = Common::getRequestVar('updateCorePlugins', 0, 'integer') == 1; + + if(is_null($doDryRun)) { + $doDryRun = !$doExecuteUpdates; + } + + if($doDryRun) { + $viewWelcome->queries = $updater->getSqlQueriesToExecute(); + $viewWelcome->isMajor = $updater->hasMajorDbUpdate(); + $this->doWelcomeUpdates($viewWelcome, $componentsWithUpdateFile); + return $viewWelcome->render(); + } + + // CLI + if (Common::isPhpCliMode()) { + $this->doWelcomeUpdates($viewWelcome, $componentsWithUpdateFile); + $output = $viewWelcome->render(); + + // Proceed with upgrade in CLI only if user specifically asked for it, or if running console command + $isUpdateRequested = Common::isRunningConsoleCommand() || Piwik::getModule() == 'CoreUpdater'; + + if (!$this->coreError && $isUpdateRequested) { + $this->doExecuteUpdates($viewDone, $updater, $componentsWithUpdateFile); + $output .= $viewDone->render(); + } + return $output; + } + + // Web + if ($doExecuteUpdates) { + $this->warningMessages = array(); + $this->doExecuteUpdates($viewDone, $updater, $componentsWithUpdateFile); + + $this->redirectToDashboardWhenNoError($updater); + + return $viewDone->render(); + } + + exit; + } + + private function doWelcomeUpdates($view, $componentsWithUpdateFile) + { + $view->new_piwik_version = Version::VERSION; + $view->commandUpgradePiwik = "<br /><code>php " . Filesystem::getPathToPiwikRoot() . "/console core:update </code>"; + $pluginNamesToUpdate = array(); + $coreToUpdate = false; + + // handle case of existing database with no tables + if (!DbHelper::isInstalled()) { + $this->errorMessages[] = Piwik::translate('CoreUpdater_EmptyDatabaseError', Config::getInstance()->database['dbname']); + $this->coreError = true; + $currentVersion = 'N/A'; + } else { + $this->errorMessages = array(); + try { + $currentVersion = Option::get('version_core'); + } catch (Exception $e) { + $currentVersion = '<= 0.2.9'; + } + + foreach ($componentsWithUpdateFile as $name => $filenames) { + if ($name == 'core') { + $coreToUpdate = true; + } else { + $pluginNamesToUpdate[] = $name; + } + } + } + + // check file integrity + $integrityInfo = Filechecks::getFileIntegrityInformation(); + if (isset($integrityInfo[1])) { + if ($integrityInfo[0] == false) { + $this->warningMessages[] = Piwik::translate('General_FileIntegrityWarningExplanation'); + } + $this->warningMessages = array_merge($this->warningMessages, array_slice($integrityInfo, 1)); + } + Filesystem::deleteAllCacheOnUpdate(); + + $view->coreError = $this->coreError; + $view->warningMessages = $this->warningMessages; + $view->errorMessages = $this->errorMessages; + $view->current_piwik_version = $currentVersion; + $view->pluginNamesToUpdate = $pluginNamesToUpdate; + $view->coreToUpdate = $coreToUpdate; + } + + private function doExecuteUpdates($view, $updater, $componentsWithUpdateFile) + { + $result = CoreUpdater::updateComponents($updater, $componentsWithUpdateFile); + + $this->coreError = $result['coreError']; + $this->warningMessages = $result['warnings']; + $this->errorMessages = $result['errors']; + $this->deactivatedPlugins = $result['deactivatedPlugins']; + $view->coreError = $this->coreError; + $view->warningMessages = $this->warningMessages; + $view->errorMessages = $this->errorMessages; + $view->deactivatedPlugins = $this->deactivatedPlugins; + } + + private function getIncompatiblePlugins($piwikVersion) + { + return PluginManager::getInstance()->getIncompatiblePlugins($piwikVersion); + } + +} diff --git a/www/analytics/plugins/CoreUpdater/CoreUpdater.php b/www/analytics/plugins/CoreUpdater/CoreUpdater.php new file mode 100644 index 00000000..3be95d66 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/CoreUpdater.php @@ -0,0 +1,160 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreUpdater; + +use Exception; +use Piwik\Common; +use Piwik\Filesystem; +use Piwik\FrontController; +use Piwik\Piwik; +use Piwik\ScheduledTask; +use Piwik\ScheduledTime; +use Piwik\UpdateCheck; +use Piwik\Updater; +use Piwik\UpdaterErrorException; +use Piwik\Version; +use Piwik\Access; + +/** + * + */ +class CoreUpdater extends \Piwik\Plugin +{ + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + $hooks = array( + 'Request.dispatchCoreAndPluginUpdatesScreen' => 'dispatch', + 'Platform.initialized' => 'updateCheck', + 'TaskScheduler.getScheduledTasks' => 'getScheduledTasks', + ); + return $hooks; + } + + public function getScheduledTasks(&$tasks) + { + $sendUpdateNotification = new ScheduledTask($this, + 'sendNotificationIfUpdateAvailable', + null, + ScheduledTime::factory('daily'), + ScheduledTask::LOWEST_PRIORITY); + $tasks[] = $sendUpdateNotification; + } + + public function sendNotificationIfUpdateAvailable() + { + $coreUpdateCommunication = new UpdateCommunication(); + if ($coreUpdateCommunication->isEnabled()) { + $coreUpdateCommunication->sendNotificationIfUpdateAvailable(); + } + } + + public static function updateComponents(Updater $updater, $componentsWithUpdateFile) + { + $warnings = array(); + $errors = array(); + $deactivatedPlugins = array(); + $coreError = false; + + if (!empty($componentsWithUpdateFile)) { + $currentAccess = Access::getInstance(); + $hasSuperUserAccess = $currentAccess->hasSuperUserAccess(); + + if (!$hasSuperUserAccess) { + $currentAccess->setSuperUserAccess(true); + } + + // if error in any core update, show message + help message + EXIT + // if errors in any plugins updates, show them on screen, disable plugins that errored + CONTINUE + // if warning in any core update or in any plugins update, show message + CONTINUE + // if no error or warning, success message + CONTINUE + foreach ($componentsWithUpdateFile as $name => $filenames) { + try { + $warnings = array_merge($warnings, $updater->update($name)); + } catch (UpdaterErrorException $e) { + $errors[] = $e->getMessage(); + if ($name == 'core') { + $coreError = true; + break; + } else { + \Piwik\Plugin\Manager::getInstance()->deactivatePlugin($name); + $deactivatedPlugins[] = $name; + } + } + } + + if (!$hasSuperUserAccess) { + $currentAccess->setSuperUserAccess(false); + } + } + + Filesystem::deleteAllCacheOnUpdate(); + + $result = array( + 'warnings' => $warnings, + 'errors' => $errors, + 'coreError' => $coreError, + 'deactivatedPlugins' => $deactivatedPlugins + ); + + return $result; + } + + public static function getComponentUpdates(Updater $updater) + { + $updater->addComponentToCheck('core', Version::VERSION); + $plugins = \Piwik\Plugin\Manager::getInstance()->getLoadedPlugins(); + foreach ($plugins as $pluginName => $plugin) { + $updater->addComponentToCheck($pluginName, $plugin->getVersion()); + } + + $componentsWithUpdateFile = $updater->getComponentsWithUpdateFile(); + if (count($componentsWithUpdateFile) == 0 && !$updater->hasNewVersion('core')) { + return null; + } + + return $componentsWithUpdateFile; + } + + public function dispatch() + { + $module = Common::getRequestVar('module', '', 'string'); + $action = Common::getRequestVar('action', '', 'string'); + + $updater = new Updater(); + $updater->addComponentToCheck('core', Version::VERSION); + $updates = $updater->getComponentsWithNewVersion(); + if (!empty($updates)) { + Filesystem::deleteAllCacheOnUpdate(); + } + if (self::getComponentUpdates($updater) !== null + && $module != 'CoreUpdater' + // Proxy module is used to redirect users to piwik.org, should still work when Piwik must be updated + && $module != 'Proxy' + // Do not show update page during installation. + && $module != 'Installation' + && !($module == 'LanguagesManager' + && $action == 'saveLanguage') + ) { + if (FrontController::shouldRethrowException()) { + throw new Exception("Piwik and/or some plugins have been upgraded to a new version. \n" . + "--> Please run the update process first. See documentation: http://piwik.org/docs/update/ \n"); + } else { + Piwik::redirectToModule('CoreUpdater'); + } + } + } + + public function updateCheck() + { + UpdateCheck::check(); + } +} diff --git a/www/analytics/plugins/CoreUpdater/NoUpdatesFoundException.php b/www/analytics/plugins/CoreUpdater/NoUpdatesFoundException.php new file mode 100644 index 00000000..1fd3ef11 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/NoUpdatesFoundException.php @@ -0,0 +1,13 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreUpdater; + +class NoUpdatesFoundException extends \Exception { + +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreUpdater/UpdateCommunication.php b/www/analytics/plugins/CoreUpdater/UpdateCommunication.php new file mode 100644 index 00000000..5b4253f8 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/UpdateCommunication.php @@ -0,0 +1,157 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Plugins\CoreUpdater; + +use Piwik\Config; +use Piwik\Mail; +use Piwik\Option; +use Piwik\Piwik; +use Piwik\SettingsPiwik; +use Piwik\UpdateCheck; +use Piwik\Plugins\UsersManager\API as UsersManagerApi; + +/** + * Class to check and notify users via email if there is a core update available. + */ +class UpdateCommunication +{ + + /** + * Checks whether update communciation in general is enabled or not. + * + * @return bool + */ + public function isEnabled() + { + $isEnabled = Config::getInstance()->General['enable_update_communication']; + + return !empty($isEnabled); + } + + /** + * Sends a notification email to all super users if there is a core update available but only if we haven't notfied + * them about a specific new version yet. + * + * @return bool + */ + public function sendNotificationIfUpdateAvailable() + { + if (!$this->isNewVersionAvailable()) { + return; + } + + if ($this->hasNotificationAlreadyReceived()) { + return; + } + + $this->setHasLatestUpdateNotificationReceived(); + $this->sendNotifications(); + } + + protected function sendNotifications() + { + $latestVersion = $this->getLatestVersion(); + + $host = SettingsPiwik::getPiwikUrl(); + + $subject = Piwik::translate('CoreUpdater_NotificationSubjectAvailableCoreUpdate', $latestVersion); + $message = Piwik::translate('ScheduledReports_EmailHello'); + $message .= "\n\n"; + $message .= Piwik::translate('CoreUpdater_ThereIsNewVersionAvailableForUpdate'); + $message .= "\n\n"; + $message .= Piwik::translate('CoreUpdater_YouCanUpgradeAutomaticallyOrDownloadPackage', $latestVersion); + $message .= "\n\n"; + $message .= $host . 'index.php?module=CoreUpdater&action=newVersionAvailable'; + $message .= "\n\n"; + $message .= Piwik::translate('CoreUpdater_FeedbackRequest'); + $message .= "\n"; + $message .= 'http://piwik.org/contact/'; + + $this->sendEmailNotification($subject, $message); + } + + private function isVersionLike($latestVersion) + { + return strlen($latestVersion) < 18; + } + + /** + * Send an email notification to all super users. + * + * @param $subject + * @param $message + */ + protected function sendEmailNotification($subject, $message) + { + $superUsers = UsersManagerApi::getInstance()->getUsersHavingSuperUserAccess(); + + foreach ($superUsers as $superUser) { + $mail = new Mail(); + $mail->setDefaultFromPiwik(); + $mail->addTo($superUser['email']); + $mail->setSubject($subject); + $mail->setBodyText($message); + $mail->send(); + } + } + + private function isNewVersionAvailable() + { + UpdateCheck::check(); + + $hasUpdate = UpdateCheck::isNewestVersionAvailable(); + + if (!$hasUpdate) { + return false; + } + + $latestVersion = self::getLatestVersion(); + if (!$this->isVersionLike($latestVersion)) { + return false; + } + + return $hasUpdate; + } + + private function hasNotificationAlreadyReceived() + { + $latestVersion = $this->getLatestVersion(); + $lastVersionSent = $this->getLatestVersionSent(); + + if (!empty($lastVersionSent) + && ($latestVersion == $lastVersionSent + || version_compare($latestVersion, $lastVersionSent) == -1)) { + return true; + } + + return false; + } + + private function getLatestVersion() + { + return UpdateCheck::getLatestVersion(); + } + + private function getLatestVersionSent() + { + return Option::get($this->getNotificationSentOptionName()); + } + + private function setHasLatestUpdateNotificationReceived() + { + $latestVersion = $this->getLatestVersion(); + + Option::set($this->getNotificationSentOptionName(), $latestVersion); + } + + private function getNotificationSentOptionName() + { + return 'last_update_communication_sent_core'; + } +} diff --git a/www/analytics/plugins/CoreUpdater/javascripts/updateLayout.js b/www/analytics/plugins/CoreUpdater/javascripts/updateLayout.js new file mode 100644 index 00000000..e112657d --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/javascripts/updateLayout.js @@ -0,0 +1,8 @@ +$(document).ready(function () { + $('#showSql').click(function () { + $('#sqlQueries').toggle(); + }); + $('#upgradeCorePluginsForm').submit(function () { + $('input[type=submit]', this).prop('disabled', 'disabled'); + }); +}); \ No newline at end of file diff --git a/www/analytics/plugins/CoreUpdater/stylesheets/updateLayout.css b/www/analytics/plugins/CoreUpdater/stylesheets/updateLayout.css new file mode 100644 index 00000000..1975a4b4 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/stylesheets/updateLayout.css @@ -0,0 +1,38 @@ +* { + margin: 0; + padding: 0; +} + +.topBarElem { + font-family: arial, sans-serif !important; + font-size: 13px; + line-height: 1.33; +} + +#donate-form-container { + margin: 0 0 2em 2em; +} + +code { + background-color: #F0F7FF; + border: 1px dashed #00008B; + border-left: 5px solid; + direction: ltr; + display: block; + margin: 2px 2px 20px; + padding: 4px; + text-align: left; +} + +li { + margin-top: 10px; + margin-left: 30px; +} + +#oneclickupdate .submit { + clear:none; + float:left; +} +form#oneclickupdate { + min-height: 50px; +} \ No newline at end of file diff --git a/www/analytics/plugins/CoreUpdater/templates/layout.twig b/www/analytics/plugins/CoreUpdater/templates/layout.twig new file mode 100644 index 00000000..5455e579 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/templates/layout.twig @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>Piwik › {{ 'CoreUpdater_UpdateTitle'|translate }} + + + + + + + + + + + + + + + {% if 'General_LayoutDirection'|translate =='rtl' %} + + {% endif %} + + + + +
    +
    + Piwik + # {{ 'General_OpenSourceWebAnalytics'|translate }} +
    + {% block content %} + {% endblock %} +
    + + diff --git a/www/analytics/plugins/CoreUpdater/templates/newVersionAvailable.twig b/www/analytics/plugins/CoreUpdater/templates/newVersionAvailable.twig new file mode 100644 index 00000000..cf87a949 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/templates/newVersionAvailable.twig @@ -0,0 +1,42 @@ +{% extends '@CoreUpdater/layout.twig' %} +{% import '@CorePluginsAdmin/macros.twig' as pluginsMacro %} + +{% block content %} +
    +

    {{ 'CoreUpdater_ThereIsNewVersionAvailableForUpdate'|translate }}

    + +{% if can_auto_update %} +

    {{ 'CoreUpdater_YouCanUpgradeAutomaticallyOrDownloadPackage'|translate(piwik_new_version) }}

    +{% else %} +

    {{ 'Installation_SystemCheckAutoUpdateHelp'|translate }}

    +

    {{ 'CoreUpdater_YouMustDownloadPackageOrFixPermissions'|translate(piwik_new_version) }} + {{ makeWritableCommands|raw }} +

    +{% endif %} + +{% if incompatiblePlugins %} +

    {{ 'CoreUpdater_IncompatbilePluginsWillBeDisabledInfo'|translate(piwik_new_version) }}

    + +
      + {% for plugin in incompatiblePlugins %} +
    • {{ pluginsMacro.missingRequirementsInfo(plugin.getPluginName, plugin.getInformation, plugin.getMissingDependencies(piwik_new_version), marketplacePlugins) }}
    • + {% endfor %} +
    +

    +{% endif %} + +{% if can_auto_update %} +
    + + + + {% endif %} + {{ 'CoreUpdater_DownloadX'|translate(piwik_new_version) }}
    + {% if can_auto_update %} +
    +{% endif %} +
    +« {{ 'General_BackToPiwik'|translate }} +{% endblock %} + diff --git a/www/analytics/plugins/CoreUpdater/templates/oneClickResults.twig b/www/analytics/plugins/CoreUpdater/templates/oneClickResults.twig new file mode 100644 index 00000000..3ec19a7c --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/templates/oneClickResults.twig @@ -0,0 +1,26 @@ +{% extends '@CoreUpdater/layout.twig' %} + +{% block content %} +
    +{% for message in feedbackMessages %} +

    {{ message }}

    +{% endfor %} + +{% if coreError %} +
    +
    +
    {{ coreError }}
    +
    +
    +
    + + {{ 'CoreUpdater_UpdateHasBeenCancelledExplanation'|translate("

    ","","")|raw }} +
    +
    +
    +{% endif %} + +
    + +
    +{% endblock %} diff --git a/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_done.twig b/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_done.twig new file mode 100644 index 00000000..fb6b9bad --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_done.twig @@ -0,0 +1,78 @@ +{% extends '@CoreUpdater/layout.twig' %} +{% set helpMessage %} + {{ 'CoreUpdater_HelpMessageContent'|translate('','','
  • ')|raw }} +{% endset %} + +{% block content %} +{% if coreError %} +
    +
    +
    + {{ 'CoreUpdater_CriticalErrorDuringTheUpgradeProcess'|translate }} + {% for message in errorMessages %} +
    {{ message }}
    +
    + {% endfor %} +
    +
    +

    {{ 'CoreUpdater_HelpMessageIntroductionWhenError'|translate }} +

      +
    • {{ helpMessage }}
    • +
    +

    +

    {{ 'CoreUpdater_ErrorDIYHelp'|translate }} +

      +
    • {{ 'CoreUpdater_ErrorDIYHelp_1'|translate }}
    • +
    • {{ 'CoreUpdater_ErrorDIYHelp_2'|translate }}
    • +
    • {{ 'CoreUpdater_ErrorDIYHelp_3'|translate }} (see FAQ)
    • +
    • {{ 'CoreUpdater_ErrorDIYHelp_4'|translate }}
    • +
    • {{ 'CoreUpdater_ErrorDIYHelp_5'|translate }}
    • +
    +

    +{% else %} + {% if warningMessages|length > 0 %} +
    +

    {{ 'CoreUpdater_WarningMessages'|translate }}

    + {% for message in warningMessages %} +
    {{ message }}
    +
    + {% endfor %} +
    + {% endif %} + + {% if errorMessages|length > 0 %} +
    +

    {{ 'CoreUpdater_ErrorDuringPluginsUpdates'|translate }}

    + {% for message in errorMessages %} +
    {{ message }}
    +
    + {% endfor %} + + {% if deactivatedPlugins is defined and deactivatedPlugins|length > 0 %} + {% set listOfDeactivatedPlugins=deactivatedPlugins|join(', ') %} +

    + + {{ 'CoreUpdater_WeAutomaticallyDeactivatedTheFollowingPlugins'|translate(listOfDeactivatedPlugins) }} +

    + {% endif %} +
    + {% endif %} + + {% if errorMessages|length > 0 or warningMessages|length > 0 %} +
    +

    {{ 'CoreUpdater_HelpMessageIntroductionWhenWarning'|translate }} +

      +
    • {{ helpMessage }}
    • +
    +

    + {% else %} +

    {{ 'CoreUpdater_PiwikHasBeenSuccessfullyUpgraded'|translate }}

    + + {% endif %} +
    + +
    +{% endif %} +{% endblock %} diff --git a/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_done_cli.twig b/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_done_cli.twig new file mode 100644 index 00000000..3f6f2cd6 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_done_cli.twig @@ -0,0 +1,55 @@ +{% autoescape false %} +{% set helpMessage %}{{- 'CoreUpdater_HelpMessageContent'|translate('[',']',"\n\n *") }}{% endset %} +{% if coreError %} + [X] {{ 'CoreUpdater_CriticalErrorDuringTheUpgradeProcess'|translate }} + + {% for message in errorMessages %} + * {{ message }} + {% endfor %} + + {{ 'CoreUpdater_HelpMessageIntroductionWhenError'|translate }} + + * {{ helpMessage }} + + {{ 'CoreUpdater_ErrorDIYHelp'|translate }} + * {{ 'CoreUpdater_ErrorDIYHelp_1'|translate }} + * {{ 'CoreUpdater_ErrorDIYHelp_2'|translate }} + * {{ 'CoreUpdater_ErrorDIYHelp_3'|translate }} + * {{ 'CoreUpdater_ErrorDIYHelp_4'|translate }} + * {{ 'CoreUpdater_ErrorDIYHelp_5'|translate }} + +{% else %} +{% if warningMessages|length > 0 %} + [!] {{ 'CoreUpdater_WarningMessages'|translate }} + + {% for message in warningMessages -%} + * {{ message }} + {%- endfor %} +{%- endif %} +{% if errorMessages|length > 0 -%} + + [X] {{ 'CoreUpdater_ErrorDuringPluginsUpdates'|translate }} + + {% for message in errorMessages %} + * {{ message }} + {% endfor %} + + {% if deactivatedPlugins|length > 0 -%} + {% set listOfDeactivatedPlugins %}{{ deactivatedPlugins|implode(', ') }}{% endset %} + + [!] {{ 'CoreUpdater_WeAutomaticallyDeactivatedTheFollowingPlugins'|translate(listOfDeactivatedPlugins) }} + {% endif %} + +{% endif %} + +{% if errorMessages|length > 0 or warningMessages|length > 0 %} + {{ 'CoreUpdater_HelpMessageIntroductionWhenWarning'|translate }} + + * {{ helpMessage }} +{% else %} +*** {{ 'CoreUpdater_PiwikHasBeenSuccessfullyUpgraded'|translate }} *** +{% endif %} + +{% endif %} +{% endautoescape %} + diff --git a/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome.twig b/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome.twig new file mode 100644 index 00000000..212a8247 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome.twig @@ -0,0 +1,100 @@ +{% extends '@CoreUpdater/layout.twig' %} + +{% block content %} +{% spaceless %} +{{ postEvent('Template.topBar')|raw }} +{% set helpMessage %} + {{ 'CoreUpdater_HelpMessageContent'|translate('','','
  • ')|raw }} +{% endset %} + +{% if coreError %} +
    +
    +
    + {{ 'CoreUpdater_CriticalErrorDuringTheUpgradeProcess'|translate }} + {% for message in errorMessages %} +
    {{ message|raw }}
    + {% endfor %} +
    +
    +

    {{ 'CoreUpdater_HelpMessageIntroductionWhenError'|translate }} +

      +
    • {{ helpMessage|raw }}
    • +
    +

    +{% else %} + {% if coreToUpdate or pluginNamesToUpdate|length > 0 %} +

    {{ 'CoreUpdater_DatabaseUpgradeRequired'|translate }}

    +

    {{ 'CoreUpdater_YourDatabaseIsOutOfDate'|translate }}

    + {% if coreToUpdate %} +

    {{ 'CoreUpdater_PiwikWillBeUpgradedFromVersionXToVersionY'|translate(current_piwik_version,new_piwik_version) }}

    + {% endif %} + + {% if pluginNamesToUpdate|length > 0 %} + {% set listOfPlugins=pluginNamesToUpdate|join(', ') %} +

    {{ 'CoreUpdater_TheFollowingPluginsWillBeUpgradedX'|translate(listOfPlugins) }}

    + {% endif %} +

    {{ 'CoreUpdater_NoteForLargePiwikInstances'|translate }}

    + {% if isMajor %} +

    + {{ 'CoreUpdater_MajorUpdateWarning1'|translate }}
    + {{ 'CoreUpdater_MajorUpdateWarning2'|translate }} +

    + {% endif %} +
      +
    • {{ 'CoreUpdater_TheUpgradeProcessMayFailExecuteCommand'|translate(commandUpgradePiwik)|raw }}
    • +
    • {{ 'CoreUpdater_HighTrafficPiwikServerEnableMaintenance'|translate('', '')|raw }}
    • +
    • {{ 'CoreUpdater_YouCouldManuallyExecuteSqlQueries'|translate }}
      + › {{ 'CoreUpdater_ClickHereToViewSqlQueries'|translate }} + + +
    • +
    +
    +
    +

    {{ 'CoreUpdater_ReadyToGo'|translate }}

    +

    {{ 'CoreUpdater_TheUpgradeProcessMayTakeAWhilePleaseBePatient'|translate }}

    + {% endif %} + + {% if warningMessages|length > 0 %} +

    {{ warningMessages[0] }} + {% if warningMessages|length > 1 %} + + {% endif %} +

    + {% endif %} + + {% if coreToUpdate or pluginNamesToUpdate|length > 0 %} +
    +
    + + {% if queries|length == 1 %} + + {% else %} + + {% endif %} +
    + {% else %} + {% if warningMessages|length == 0 %} +

    {{ 'CoreUpdater_PiwikHasBeenSuccessfullyUpgraded'|translate }}

    + {% endif %} +
    +
    + +
    + {% endif %} +{% endif %} + +{% include "@Installation/_integrityDetails.twig" %} + +{% endspaceless %} +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome_cli.twig b/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome_cli.twig new file mode 100644 index 00000000..b9126329 --- /dev/null +++ b/www/analytics/plugins/CoreUpdater/templates/runUpdaterAndExit_welcome_cli.twig @@ -0,0 +1,48 @@ +{% autoescape false %} +{% set helpMessage %} +{{- 'CoreUpdater_HelpMessageContent'|translate('[',']','\n\n *') }} +{% endset %} + +*** {{ 'CoreUpdater_UpdateTitle'|translate }} *** +{% if coreError %} + + [X] {{ 'CoreUpdater_CriticalErrorDuringTheUpgradeProcess'|translate }} + + {% for message in errorMessages %} + {{- message }} + {% endfor %} + + {{ 'CoreUpdater_HelpMessageIntroductionWhenError'|translate }} + + * {{ helpMessage }} + +{% elseif coreToUpdate or pluginNamesToUpdate|length > 0 %} + + {{ 'CoreUpdater_DatabaseUpgradeRequired'|translate }} + + {{ 'CoreUpdater_YourDatabaseIsOutOfDate'|translate }} + +{% if coreToUpdate %} + {{ 'CoreUpdater_PiwikWillBeUpgradedFromVersionXToVersionY'|translate(current_piwik_version, new_piwik_version) }} +{% endif %} + +{%- if pluginNamesToUpdate|length > 0 %} + {%- set listOfPlugins %}{{ pluginNamesToUpdate|implode(', ') }}{% endset %} + {{ 'CoreUpdater_TheFollowingPluginsWillBeUpgradedX'|translate( listOfPlugins) }} +{% endif %} + +{# dry run #} +{% if queries is defined and queries is not empty %} +*** Note: this is a Dry Run *** + + {% for query in queries %}{{ query|trim }} + {% endfor %} + +*** End of Dry Run *** +{% else %} + {{ 'CoreUpdater_TheUpgradeProcessMayTakeAWhilePleaseBePatient'|translate }} +{% endif %} + +{% endif %} +{% endautoescape %} + diff --git a/www/analytics/plugins/CoreVisualizations/CoreVisualizations.php b/www/analytics/plugins/CoreVisualizations/CoreVisualizations.php new file mode 100644 index 00000000..db3b3f15 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/CoreVisualizations.php @@ -0,0 +1,71 @@ + 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'ViewDataTable.addViewDataTable' => 'getAvailableDataTableVisualizations', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys' + ); + } + + public function getAvailableDataTableVisualizations(&$visualizations) + { + $visualizations[] = 'Piwik\\Plugins\\CoreVisualizations\\Visualizations\\Sparkline'; + $visualizations[] = 'Piwik\\Plugins\\CoreVisualizations\\Visualizations\\HtmlTable'; + $visualizations[] = 'Piwik\\Plugins\\CoreVisualizations\\Visualizations\\HtmlTable\\AllColumns'; + $visualizations[] = 'Piwik\\Plugins\\CoreVisualizations\\Visualizations\\Cloud'; + $visualizations[] = 'Piwik\\Plugins\\CoreVisualizations\\Visualizations\\JqplotGraph\\Pie'; + $visualizations[] = 'Piwik\\Plugins\\CoreVisualizations\\Visualizations\\JqplotGraph\\Bar'; + $visualizations[] = 'Piwik\\Plugins\\CoreVisualizations\\Visualizations\\JqplotGraph\\Evolution'; + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/CoreVisualizations/stylesheets/dataTableVisualizations.less"; + $stylesheets[] = "plugins/CoreVisualizations/stylesheets/jqplot.css"; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/CoreVisualizations/javascripts/seriesPicker.js"; + $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplot.js"; + $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplotBarGraph.js"; + $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplotPieGraph.js"; + $jsFiles[] = "plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js"; + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = 'General_MetricsToPlot'; + $translationKeys[] = 'General_MetricToPlot'; + $translationKeys[] = 'General_RecordsToPlot'; + $translationKeys[] = 'General_SaveImageOnYourComputer'; + $translationKeys[] = 'General_ExportAsImage'; + $translationKeys[] = 'General_NoDataForGraph'; + } +} diff --git a/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator.php b/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator.php new file mode 100644 index 00000000..debc7167 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator.php @@ -0,0 +1,160 @@ +properties = $properties; + $this->graphType = $graphType; + } + + /** + * Generates JSON graph data and returns it. + * + * @param DataTable|DataTable\Map $dataTable + * @return string + */ + public function generate($dataTable) + { + $visualization = new Chart(); + + if ($dataTable->getRowsCount() > 0) { + // if addTotalRow was called in GenerateGraphHTML, add a row containing totals of + // different metrics + if ($this->properties['add_total_row']) { + $dataTable->queueFilter('AddSummaryRow', Piwik::translate('General_Total')); + } + + $dataTable->applyQueuedFilters(); + $this->initChartObjectData($dataTable, $visualization); + } + + return $visualization->render(); + } + + /** + * @param DataTable|DataTable\Map $dataTable + * @param $visualization + */ + protected function initChartObjectData($dataTable, $visualization) + { + // We apply a filter to the DataTable, decoding the label column (useful for keywords for example) + $dataTable->filter('ColumnCallbackReplace', array('label', 'urldecode')); + + $xLabels = $dataTable->getColumn('label'); + + $columnNames = $this->properties['columns_to_display']; + if (($labelColumnIndex = array_search('label', $columnNames)) !== false) { + unset($columnNames[$labelColumnIndex]); + } + + $columnNameToTranslation = $columnNameToValue = array(); + foreach ($columnNames as $columnName) { + $columnNameToTranslation[$columnName] = @$this->properties['translations'][$columnName]; + + $columnNameToValue[$columnName] = $dataTable->getColumn($columnName); + } + + $visualization->dataTable = $dataTable; + $visualization->properties = $this->properties; + + $visualization->setAxisXLabels($xLabels); + $visualization->setAxisYValues($columnNameToValue); + $visualization->setAxisYLabels($columnNameToTranslation); + + $units = $this->getUnitsForColumnsToDisplay(); + $visualization->setAxisYUnits($units); + } + + protected function getUnitsForColumnsToDisplay() + { + // derive units from column names + $units = $this->deriveUnitsFromRequestedColumnNames(); + if (!empty($this->properties['y_axis_unit'])) { + $units = array_fill(0, count($units), $this->properties['y_axis_unit']); + } + + // the bar charts contain the labels a first series + // this series has to be removed from the units + if ($this->graphType == 'bar') { + array_shift($units); + } + + return $units; + } + + private function deriveUnitsFromRequestedColumnNames() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + + $units = array(); + foreach ($this->properties['columns_to_display'] as $columnName) { + $derivedUnit = Metrics::getUnit($columnName, $idSite); + $units[$columnName] = empty($derivedUnit) ? false : $derivedUnit; + } + return $units; + } +} diff --git a/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator/Chart.php b/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator/Chart.php new file mode 100644 index 00000000..87f98e77 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator/Chart.php @@ -0,0 +1,119 @@ +properties['x_axis_step_size']; + $showAllTicks = $this->properties['show_all_ticks']; + + $this->axes['xaxis']['labels'] = array_values($xLabels); + + $ticks = array_values($xLabels); + + if (!$showAllTicks) { + // unset labels so there are $xSteps number of blank ticks between labels + foreach ($ticks as $i => &$label) { + if ($i % $xSteps != 0) { + $label = ' '; + } + } + } + $this->axes['xaxis']['ticks'] = $ticks; + } + + public function setAxisXOnClick(&$onClick) + { + $this->axes['xaxis']['onclick'] = & $onClick; + } + + public function setAxisYValues(&$values) + { + foreach ($values as $label => &$data) { + $this->series[] = array( + 'label' => $label, + 'internalLabel' => $label + ); + + array_walk($data, function (&$v) { + $v = (float)$v; + }); + $this->data[] = & $data; + } + } + + public function setAxisYUnits($yUnits) + { + $yUnits = array_values(array_map('strval', $yUnits)); + + // generate axis IDs for each unique y unit + $axesIds = array(); + foreach ($yUnits as $idx => $unit) { + if (!isset($axesIds[$unit])) { + // handle axes ids: first y[]axis, then y[2]axis, y[3]axis... + $nextAxisId = empty($axesIds) ? '' : count($axesIds) + 1; + + $axesIds[$unit] = 'y' . $nextAxisId . 'axis'; + } + } + + // generate jqplot axes config + foreach ($axesIds as $unit => $axisId) { + $this->axes[$axisId]['tickOptions']['formatString'] = '%s' . $unit; + } + + // map each series to appropriate yaxis + foreach ($yUnits as $idx => $unit) { + $this->series[$idx]['yaxis'] = $axesIds[$unit]; + } + } + + public function setAxisYLabels($labels) + { + foreach ($this->series as &$series) { + $label = $series['internalLabel']; + if (isset($labels[$label])) { + $series['label'] = $labels[$label]; + } + } + } + + public function render() + { + ProxyHttp::overrideCacheControlHeaders(); + + // See http://www.jqplot.com/docs/files/jqPlotOptions-txt.html + $data = array( + 'params' => array( + 'axes' => &$this->axes, + 'series' => &$this->series + ), + 'data' => &$this->data + ); + + return $data; + } +} diff --git a/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php b/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php new file mode 100644 index 00000000..ebf705de --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/JqplotDataGenerator/Evolution.php @@ -0,0 +1,188 @@ +getDataTables() as $metadataDataTable) { + $xLabels[] = $metadataDataTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getLocalizedShortString(); // eg. "Aug 2009" + } + + $units = $this->getUnitsForColumnsToDisplay(); + + // if rows to display are not specified, default to all rows (TODO: perhaps this should be done elsewhere?) + $rowsToDisplay = $this->properties['rows_to_display'] + ? : array_unique($dataTable->getColumn('label')) + ? : array(false) // make sure that a series is plotted even if there is no data + ; + + // collect series data to show. each row-to-display/column-to-display permutation creates a series. + $allSeriesData = array(); + $seriesUnits = array(); + foreach ($rowsToDisplay as $rowLabel) { + foreach ($this->properties['columns_to_display'] as $columnName) { + $seriesLabel = $this->getSeriesLabel($rowLabel, $columnName); + $seriesData = $this->getSeriesData($rowLabel, $columnName, $dataTable); + + $allSeriesData[$seriesLabel] = $seriesData; + $seriesUnits[$seriesLabel] = $units[$columnName]; + } + } + + $visualization->dataTable = $dataTable; + $visualization->properties = $this->properties; + + $visualization->setAxisXLabels($xLabels); + $visualization->setAxisYValues($allSeriesData); + $visualization->setAxisYUnits($seriesUnits); + + $dataTables = $dataTable->getDataTables(); + + if ($this->isLinkEnabled()) { + $idSite = Common::getRequestVar('idSite', null, 'int'); + $periodLabel = reset($dataTables)->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getLabel(); + + $axisXOnClick = array(); + $queryStringAsHash = $this->getQueryStringAsHash(); + foreach ($dataTable->getDataTables() as $idDataTable => $metadataDataTable) { + $dateInUrl = $metadataDataTable->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getDateStart(); + $parameters = array( + 'idSite' => $idSite, + 'period' => $periodLabel, + 'date' => $dateInUrl->toString(), + 'segment' => \Piwik\API\Request::getRawSegmentFromRequest() + ); + $hash = ''; + if (!empty($queryStringAsHash)) { + $hash = '#' . Url::getQueryStringFromParameters($queryStringAsHash + $parameters); + } + $link = 'index.php?' . + Url::getQueryStringFromParameters(array( + 'module' => 'CoreHome', + 'action' => 'index', + ) + $parameters) + . $hash; + $axisXOnClick[] = $link; + } + $visualization->setAxisXOnClick($axisXOnClick); + } + } + + private function getSeriesData($rowLabel, $columnName, DataTable\Map $dataTable) + { + $seriesData = array(); + foreach ($dataTable->getDataTables() as $childTable) { + // get the row for this label (use the first if $rowLabel is false) + if ($rowLabel === false) { + $row = $childTable->getFirstRow(); + } else { + $row = $childTable->getRowFromLabel($rowLabel); + } + + // get series data point. defaults to 0 if no row or no column value. + if ($row === false) { + $seriesData[] = 0; + } else { + $seriesData[] = $row->getColumn($columnName) ? : 0; + } + } + return $seriesData; + } + + /** + * Derive the series label from the row label and the column name. + * If the row label is set, both the label and the column name are displayed. + * @param string $rowLabel + * @param string $columnName + * @return string + */ + private function getSeriesLabel($rowLabel, $columnName) + { + $metricLabel = @$this->properties['translations'][$columnName]; + + if ($rowLabel !== false) { + // eg. "Yahoo! (Visits)" + $label = "$rowLabel ($metricLabel)"; + } else { + // eg. "Visits" + $label = $metricLabel; + } + + return $label; + } + + /** + * We link the graph dots to the same report as currently being displayed (only the date would change). + * + * In some cases the widget is loaded within a report that doesn't exist as such. + * For example, the dashboards loads the 'Last visits graph' widget which can't be directly linked to. + * Instead, the graph must link back to the dashboard. + * + * In other cases, like Visitors>Overview or the Goals graphs, we can link the graph clicks to the same report. + * + * To detect whether or not we can link to a report, we simply check if the current URL from which it was loaded + * belongs to the menu or not. If it doesn't belong to the menu, we do not append the hash to the URL, + * which results in loading the dashboard. + * + * @return array Query string array to append to the URL hash or false if the dashboard should be displayed + */ + private function getQueryStringAsHash() + { + $queryString = Url::getArrayFromCurrentQueryString(); + $piwikParameters = array('idSite', 'date', 'period', 'XDEBUG_SESSION_START', 'KEY'); + foreach ($piwikParameters as $parameter) { + unset($queryString[$parameter]); + } + if (MenuMain::getInstance()->isUrlFound($queryString)) { + return $queryString; + } + return false; + } + + private function isLinkEnabled() + { + static $linkEnabled; + if (!isset($linkEnabled)) { + // 1) Custom Date Range always have link disabled, otherwise + // the graph data set is way too big and fails to display + // 2) disableLink parameter is set in the Widgetize "embed" code + $linkEnabled = !Common::getRequestVar('disableLink', 0, 'int') + && Common::getRequestVar('period', 'day') != 'range'; + } + return $linkEnabled; + } +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/Cloud.php b/www/analytics/plugins/CoreVisualizations/Visualizations/Cloud.php new file mode 100644 index 00000000..19f79c38 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/Cloud.php @@ -0,0 +1,195 @@ +config->show_exclude_low_population = false; + $this->config->show_offset_information = false; + $this->config->show_limit_control = false; + } + + public function afterAllFiltersAreApplied() + { + if ($this->dataTable->getRowsCount() == 0) { + return; + } + + $columnToDisplay = isset($this->config->columns_to_display[1]) ? $this->config->columns_to_display[1] : 'nb_visits'; + $labelMetadata = array(); + + foreach ($this->dataTable->getRows() as $row) { + $logo = false; + if ($this->config->display_logo_instead_of_label) { + $logo = $row->getMetadata('logo'); + } + + $label = $row->getColumn('label'); + + $labelMetadata[$label] = array( + 'logo' => $logo, + 'url' => $row->getMetadata('url'), + ); + + $this->addWord($label, $row->getColumn($columnToDisplay)); + } + + $cloudValues = $this->getCloudValues(); + foreach ($cloudValues as &$value) { + $value['logoWidth'] = round(max(16, $value['percent'])); + } + + $this->assignTemplateVar('labelMetadata', $labelMetadata); + $this->assignTemplateVar('cloudValues', $cloudValues); + } + + /** + * Assign word to array + * @param string $word + * @param int $value + * @return string + */ + public function addWord($word, $value = 1) + { + if (isset($this->wordsArray[$word])) { + $this->wordsArray[$word] += $value; + } else { + $this->wordsArray[$word] = $value; + } + } + + private function getCloudValues() + { + $this->shuffleCloud(); + + if (empty($this->wordsArray)) { + return array(); + } + + $return = array(); + $maxValue = max($this->wordsArray); + + foreach ($this->wordsArray as $word => $popularity) { + + $wordTruncated = $this->truncateWordIfNeeded($word); + $percent = $this->getPercentage($popularity, $maxValue); + $sizeRange = $this->getClassFromPercent($percent); + + $return[$word] = array( + 'word' => $word, + 'wordTruncated' => $wordTruncated, + 'value' => $popularity, + 'size' => $sizeRange, + 'percent' => $percent, + ); + } + + return $return; + } + + /** + * Shuffle associated names in array + */ + protected function shuffleCloud() + { + if (self::$debugDisableShuffle) { + return; + } + + $keys = array_keys($this->wordsArray); + + shuffle($keys); + + if (count($keys) && is_array($keys)) { + + $tmpArray = $this->wordsArray; + + $this->wordsArray = array(); + foreach ($keys as $key => $value) { + $this->wordsArray[$value] = $tmpArray[$value]; + } + + } + } + + /** + * Get the class range using a percentage + * + * @param $percent + * + * @return int class + */ + protected function getClassFromPercent($percent) + { + $mapping = array(95, 70, 50, 30, 15, 5, 0); + foreach ($mapping as $key => $value) { + if ($percent >= $value) { + return $key; + } + } + return 0; + } + + /** + * @param $word + * @return string + */ + private function truncateWordIfNeeded($word) + { + if (Common::mb_strlen($word) > $this->truncatingLimit) { + return Common::mb_substr($word, 0, $this->truncatingLimit - 3) . '...'; + } + + return $word; + } + + private function getPercentage($popularity, $maxValue) + { + // case hideFutureHoursWhenToday=1 shows hours with no visits + if ($maxValue == 0) { + return 0; + } + + $percent = ($popularity / $maxValue) * 100; + + return $percent; + } +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/Cloud/Config.php b/www/analytics/plugins/CoreVisualizations/Visualizations/Cloud/Config.php new file mode 100644 index 00000000..4a3b3e16 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/Cloud/Config.php @@ -0,0 +1,35 @@ +addPropertiesThatCanBeOverwrittenByQueryParams(array('display_logo_instead_of_label')); + } + +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/Graph.php b/www/analytics/plugins/CoreVisualizations/Visualizations/Graph.php new file mode 100644 index 00000000..6d90aab1 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/Graph.php @@ -0,0 +1,157 @@ +addPropertiesThatShouldBeAvailableClientSide(array('columns')); + + return $config; + } + + public function beforeRender() + { + if ($this->config->show_goals) { + $this->config->translations['nb_conversions'] = Piwik::translate('Goals_ColumnConversions'); + $this->config->translations['revenue'] = Piwik::translate('General_TotalRevenue'); + } + } + + public function beforeLoadDataTable() + { + // TODO: this should not be required here. filter_limit should not be a view property, instead HtmlTable should use 'limit' or something, + // and manually set request_parameters_to_modify['filter_limit'] based on that. (same for filter_offset). + $this->requestConfig->request_parameters_to_modify['filter_limit'] = false; + + if ($this->config->max_graph_elements) { + $this->requestConfig->request_parameters_to_modify['filter_truncate'] = $this->config->max_graph_elements - 1; + } + + $this->requestConfig->request_parameters_to_modify['disable_queued_filters'] = 1; + } + + /** + * Determines what rows are selectable and stores them in the selectable_rows property in + * a format the SeriesPicker JavaScript class can use. + */ + public function determineWhichRowsAreSelectable() + { + if ($this->config->row_picker_match_rows_by === false) { + return; + } + + // collect all selectable rows + $self = $this; + + $this->dataTable->filter(function ($dataTable) use ($self) { + /** @var DataTable $dataTable */ + + foreach ($dataTable->getRows() as $row) { + $rowLabel = $row->getColumn('label'); + + if (false === $rowLabel) { + continue; + } + + // build config + if (!isset($self->selectableRows[$rowLabel])) { + $self->selectableRows[$rowLabel] = array( + 'label' => $rowLabel, + 'matcher' => $rowLabel, + 'displayed' => $self->isRowVisible($rowLabel) + ); + } + } + }); + } + + public function isRowVisible($rowLabel) + { + $isVisible = true; + if ('label' == $this->config->row_picker_match_rows_by) { + $isVisible = in_array($rowLabel, $this->config->rows_to_display); + } + + return $isVisible; + } + + /** + * Defaults the selectable_columns property if it has not been set and then transforms + * it into something the SeriesPicker JavaScript class can use. + */ + public function afterAllFiltersAreApplied() + { + $this->determineWhichRowsAreSelectable(); + + $this->config->selectable_rows = array_values($this->selectableRows); + + if ($this->config->add_total_row) { + $totalTranslation = Piwik::translate('General_Total'); + $this->config->selectable_rows[] = array( + 'label' => $totalTranslation, + 'matcher' => $totalTranslation, + 'displayed' => $this->isRowVisible($totalTranslation) + ); + } + + if ($this->config->show_goals) { + $this->config->addTranslations(array( + 'nb_conversions' => Piwik::translate('Goals_ColumnConversions'), + 'revenue' => Piwik::translate('General_TotalRevenue') + )); + } + + // set default selectable columns, if none specified + $selectableColumns = $this->config->selectable_columns; + if (false === $selectableColumns) { + $selectableColumns = array('nb_visits', 'nb_actions', 'nb_uniq_visitors'); + + if ($this->config->show_goals) { + $goalMetrics = array('nb_conversions', 'revenue'); + $selectableColumns = array_merge($selectableColumns, $goalMetrics); + } + } + + $transformed = array(); + foreach ($selectableColumns as $column) { + $transformed[] = array( + 'column' => $column, + 'translation' => @$this->config->translations[$column], + 'displayed' => in_array($column, $this->config->columns_to_display) + ); + } + + $this->config->selectable_columns = $transformed; + } +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/Graph/Config.php b/www/analytics/plugins/CoreVisualizations/Visualizations/Graph/Config.php new file mode 100644 index 00000000..4c09b913 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/Graph/Config.php @@ -0,0 +1,122 @@ +show_limit_control = false; + + $this->addPropertiesThatShouldBeAvailableClientSide(array( + 'show_series_picker', + 'allow_multi_select_series_picker', + 'selectable_columns', + 'selectable_rows', + 'display_percentage_in_tooltip' + )); + + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array( + 'show_all_ticks', + 'show_series_picker' + )); + } + +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable.php b/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable.php new file mode 100644 index 00000000..c4f64767 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable.php @@ -0,0 +1,77 @@ +requestConfig->idSubtable + && $this->config->show_embedded_subtable) { + + $this->config->show_visualization_only = true; + } + + // we do not want to get a datatable\map + $period = Common::getRequestVar('period', 'day', 'string'); + if (Period\Range::parseDateRange($period)) { + $period = 'range'; + } + + if ($this->dataTable->getRowsCount()) { + + $request = new ApiRequest(array( + 'method' => 'API.get', + 'module' => 'API', + 'action' => 'get', + 'format' => 'original', + 'filter_limit' => '-1', + 'disable_generic_filters' => 1, + 'expanded' => 0, + 'flat' => 0, + 'filter_offset' => 0, + 'period' => $period, + 'showColumns' => implode(',', $this->config->columns_to_display), + 'columns' => implode(',', $this->config->columns_to_display) + )); + + $dataTable = $request->process(); + $this->assignTemplateVar('siteSummary', $dataTable); + } + + + } + +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/AllColumns.php b/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/AllColumns.php new file mode 100644 index 00000000..b203f991 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/AllColumns.php @@ -0,0 +1,66 @@ +config->show_extra_columns = true; + $this->config->datatable_css_class = 'dataTableVizAllColumns'; + $this->config->show_exclude_low_population = true; + + parent::beforeRender(); + } + + public function beforeGenericFiltersAreAppliedToLoadedDataTable() + { + $this->dataTable->filter('AddColumnsProcessedMetrics'); + + $properties = $this->config; + + $this->dataTable->filter(function ($dataTable) use ($properties) { + $columnsToDisplay = array('label', 'nb_visits'); + + if (in_array('nb_uniq_visitors', $dataTable->getColumns())) { + $columnsToDisplay[] = 'nb_uniq_visitors'; + } + + $columnsToDisplay = array_merge( + $columnsToDisplay, array('nb_actions', 'nb_actions_per_visit', 'avg_time_on_site', 'bounce_rate') + ); + + // only display conversion rate for the plugins that do not provide "per goal" metrics + // otherwise, conversion rate is meaningless as a whole (since we don't process 'cross goals' conversions) + if (!$properties->show_goals) { + $columnsToDisplay[] = 'conversion_rate'; + } + + $properties->columns_to_display = $columnsToDisplay; + }); + } + + public function afterGenericFiltersAreAppliedToLoadedDataTable() + { + $prettifyTime = array('\Piwik\MetricsFormatter', 'getPrettyTimeFromSeconds'); + + $this->dataTable->filter('ColumnCallbackReplace', array('avg_time_on_site', $prettifyTime)); + } +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php b/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php new file mode 100644 index 00000000..d03ecaf2 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/Config.php @@ -0,0 +1,119 @@ +enable_sort = true; + $this->datatable_js_type = 'DataTable'; + + $this->addPropertiesThatShouldBeAvailableClientSide(array( + 'show_extra_columns', + 'show_goals_columns', + 'disable_row_evolution', + 'disable_row_actions', + 'enable_sort', + 'keep_summary_row', + 'subtable_controller_action', + )); + + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array( + 'show_expanded', + 'disable_row_actions', + 'disable_row_evolution', + 'show_extra_columns', + 'show_goals_columns', + 'disable_subtable_when_show_goals', + 'keep_summary_row', + 'highlight_summary_row', + )); + } + +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php b/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php new file mode 100644 index 00000000..a982c55a --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/HtmlTable/RequestConfig.php @@ -0,0 +1,54 @@ +filter_limit = PiwikConfig::getInstance()->General['datatable_default_limit']; + + if (Common::getRequestVar('enable_filter_excludelowpop', false) == '1') { + $this->filter_excludelowpop = 'nb_visits'; + $this->filter_excludelowpop_value = false; + } + + $this->addPropertiesThatShouldBeAvailableClientSide(array( + 'search_recursive', + 'filter_limit', + 'filter_offset', + 'filter_sort_column', + 'filter_sort_order', + 'keep_summary_row' + )); + + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array( + 'keep_summary_row', + )); + } + +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph.php b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph.php new file mode 100644 index 00000000..93ac0595 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph.php @@ -0,0 +1,48 @@ +makeDataGenerator($properties); + + return $dataGenerator->generate($dataTable); + } + + /** + * @param $properties + * @return JqplotDataGenerator + */ + abstract protected function makeDataGenerator($properties); +} + +require_once PIWIK_INCLUDE_PATH . '/plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php'; +require_once PIWIK_INCLUDE_PATH . '/plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php'; +require_once PIWIK_INCLUDE_PATH . '/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php'; diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php new file mode 100644 index 00000000..34a2c306 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Bar.php @@ -0,0 +1,43 @@ +config->datatable_js_type = 'JqplotBarGraphDataTable'; + } + + public static function getDefaultConfig() + { + $config = new Config(); + $config->max_graph_elements = 6; + + return $config; + } + + protected function makeDataGenerator($properties) + { + return JqplotDataGenerator::factory('bar', $properties); + } +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Config.php b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Config.php new file mode 100644 index 00000000..2efcfa82 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Config.php @@ -0,0 +1,65 @@ +show_exclude_low_population = false; + $this->show_offset_information = false; + $this->show_pagination_control = false; + $this->show_exclude_low_population = false; + $this->show_search = false; + $this->show_export_as_image_icon = true; + $this->y_axis_unit = ''; + + $this->addPropertiesThatShouldBeAvailableClientSide(array( + 'external_series_toggle', + 'external_series_toggle_show_all' + )); + + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('x_axis_step_size')); + } + +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php new file mode 100644 index 00000000..07cff899 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution.php @@ -0,0 +1,200 @@ +config->datatable_js_type = 'JqplotEvolutionGraphDataTable'; + } + + public function beforeLoadDataTable() + { + $this->calculateEvolutionDateRange(); + + parent::beforeLoadDataTable(); + + // period will be overridden when 'range' is requested in the UI + // but the graph will display for each day of the range. + // Default 'range' behavior is to return the 'sum' for the range + if (Common::getRequestVar('period', false) == 'range') { + $this->requestConfig->request_parameters_to_modify['period'] = 'day'; + } + + $this->config->custom_parameters['columns'] = $this->config->columns_to_display; + } + + public function afterAllFiltersAreApplied() + { + parent::afterAllFiltersAreApplied(); + + if (false === $this->config->x_axis_step_size) { + $rowCount = $this->dataTable->getRowsCount(); + + $this->config->x_axis_step_size = $this->getDefaultXAxisStepSize($rowCount); + } + } + + protected function makeDataGenerator($properties) + { + return JqplotDataGenerator::factory('evolution', $properties); + } + + /** + * Based on the period, date and evolution_{$period}_last_n query parameters, + * calculates the date range this evolution chart will display data for. + */ + private function calculateEvolutionDateRange() + { + $period = Common::getRequestVar('period'); + + $defaultLastN = self::getDefaultLastN($period); + $originalDate = Common::getRequestVar('date', 'last' . $defaultLastN, 'string'); + + if ('range' != $period) { // show evolution limit if the period is not a range + $this->config->show_limit_control = true; + + // set the evolution_{$period}_last_n query param + if (Range::parseDateRange($originalDate)) { + // if a multiple period + + // overwrite last_n param using the date range + $oPeriod = new Range($period, $originalDate); + $lastN = count($oPeriod->getSubperiods()); + + } else { + + // if not a multiple period + list($newDate, $lastN) = self::getDateRangeAndLastN($period, $originalDate, $defaultLastN); + $this->requestConfig->request_parameters_to_modify['date'] = $newDate; + $this->config->custom_parameters['dateUsedInGraph'] = $newDate; + } + + $lastNParamName = self::getLastNParamName($period); + $this->config->custom_parameters[$lastNParamName] = $lastN; + } + } + + /** + * Returns the entire date range and lastN value for the current request, based on + * a period type and end date. + * + * @param string $period The period type, 'day', 'week', 'month' or 'year' + * @param string $endDate The end date. + * @param int|null $defaultLastN The default lastN to use. If null, the result of + * getDefaultLastN is used. + * @return array An array w/ two elements. The first is a whole date range and the second + * is the lastN number used, ie, array('2010-01-01,2012-01-02', 2). + */ + public static function getDateRangeAndLastN($period, $endDate, $defaultLastN = null) + { + if ($defaultLastN === null) { + $defaultLastN = self::getDefaultLastN($period); + } + + $lastNParamName = self::getLastNParamName($period); + $lastN = Common::getRequestVar($lastNParamName, $defaultLastN, 'int'); + + $site = new Site(Common::getRequestVar('idSite')); + + $dateRange = Range::getRelativeToEndDate($period, 'last' . $lastN, $endDate, $site); + + return array($dateRange, $lastN); + } + + /** + * Returns the default last N number of dates to display for a given period. + * + * @param string $period 'day', 'week', 'month' or 'year' + * @return int + */ + public static function getDefaultLastN($period) + { + switch ($period) { + case 'week': + return 26; + case 'month': + return 24; + case 'year': + return 5; + case 'day': + default: + return 30; + } + } + + /** + * Returns the query parameter that stores the lastN number of periods to get for + * the evolution graph. + * + * @param string $period The period type, 'day', 'week', 'month' or 'year'. + * @return string + */ + public static function getLastNParamName($period) + { + return "evolution_{$period}_last_n"; + } + + public function getDefaultXAxisStepSize($countGraphElements) + { + // when the number of elements plotted can be small, make sure the X legend is useful + if ($countGraphElements <= 7) { + return 1; + } + + $periodLabel = Common::getRequestVar('period'); + + switch ($periodLabel) { + case 'day': + case 'range': + $steps = 5; + break; + case 'week': + $steps = 4; + break; + case 'month': + $steps = 5; + break; + case 'year': + $steps = 5; + break; + default: + $steps = 5; + break; + } + + $paddedCount = $countGraphElements + 2; // pad count so last label won't be cut off + + return ceil($paddedCount / $steps); + } +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php new file mode 100644 index 00000000..0d7b61b7 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Evolution/Config.php @@ -0,0 +1,41 @@ +show_all_views_icons = false; + $this->show_table = false; + $this->show_table_all_columns = false; + $this->hide_annotations_view = false; + $this->x_axis_step_size = false; + $this->show_line_graph = true; + + $this->addPropertiesThatShouldBeAvailableClientSide(array('show_line_graph')); + $this->addPropertiesThatCanBeOverwrittenByQueryParams(array('show_line_graph')); + } + +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php new file mode 100644 index 00000000..d110336c --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/JqplotGraph/Pie.php @@ -0,0 +1,58 @@ +max_graph_elements = 6; + $config->allow_multi_select_series_picker = false; + + return $config; + } + + public function beforeRender() + { + parent::beforeRender(); + + $this->config->show_all_ticks = true; + $this->config->datatable_js_type = 'JqplotPieGraphDataTable'; + } + + public function afterAllFiltersAreApplied() + { + parent::afterAllFiltersAreApplied(); + + $metricColumn = reset($this->config->columns_to_display); + + if ($metricColumn == 'label') { + $metricColumn = next($this->config->columns_to_display); + } + + $this->config->columns_to_display = array($metricColumn ? : 'nb_visits'); + } + + protected function makeDataGenerator($properties) + { + return JqplotDataGenerator::factory('pie', $properties); + } +} diff --git a/www/analytics/plugins/CoreVisualizations/Visualizations/Sparkline.php b/www/analytics/plugins/CoreVisualizations/Visualizations/Sparkline.php new file mode 100644 index 00000000..886d4839 --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/Visualizations/Sparkline.php @@ -0,0 +1,128 @@ +loadDataTableFromAPI(); + + // then revert the hack for potentially subsequent getRequestVar + $_GET['period'] = $period; + + $values = $this->getValuesFromDataTable($this->dataTable); + if (empty($values)) { + $values = array_fill(0, 30, 0); + } + + $graph = new \Piwik\Visualization\Sparkline(); + $graph->setValues($values); + + $height = Common::getRequestVar('height', 0, 'int'); + if (!empty($height)) { + $graph->setHeight($height); + } + + $width = Common::getRequestVar('width', 0, 'int'); + if (!empty($width)) { + $graph->setWidth($width); + } + + $graph->main(); + + return $graph; + } + + /** + * @param DataTable\Map $dataTableMap + * @param string $columnToPlot + * + * @return array + * @throws \Exception + */ + protected function getValuesFromDataTableMap($dataTableMap, $columnToPlot) + { + $dataTableMap->applyQueuedFilters(); + + $values = array(); + + foreach ($dataTableMap->getDataTables() as $table) { + + if ($table->getRowsCount() > 1) { + throw new Exception("Expecting only one row per DataTable"); + } + + $value = 0; + $onlyRow = $table->getFirstRow(); + + if (false !== $onlyRow) { + if (!empty($columnToPlot)) { + $value = $onlyRow->getColumn($columnToPlot); + } // if not specified, we load by default the first column found + // eg. case of getLastDistinctCountriesGraph + else { + $columns = $onlyRow->getColumns(); + $value = current($columns); + } + } + + $values[] = $value; + } + + return $values; + } + + protected function getValuesFromDataTable($dataTable) + { + $columns = $this->config->columns_to_display; + + $columnToPlot = false; + + if (!empty($columns)) { + $columnToPlot = reset($columns); + if ($columnToPlot == 'label') { + $columnToPlot = next($columns); + } + } + + // a Set is returned when using the normal code path to request data from Archives, in all core plugins + // however plugins can also return simple datatable, hence why the sparkline can accept both data types + if ($this->dataTable instanceof DataTable\Map) { + $values = $this->getValuesFromDataTableMap($dataTable, $columnToPlot); + } elseif ($this->dataTable instanceof DataTable) { + $values = $this->dataTable->getColumn($columnToPlot); + } else { + $values = false; + } + + return $values; + } +} diff --git a/www/analytics/plugins/CoreVisualizations/javascripts/jqplot.js b/www/analytics/plugins/CoreVisualizations/javascripts/jqplot.js new file mode 100644 index 00000000..825440cc --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/javascripts/jqplot.js @@ -0,0 +1,1100 @@ +/** + * Piwik - Web Analytics + * + * DataTable UI class for JqplotGraph. + * + * @link http://www.jqplot.com + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'), + DataTable = exports.DataTable, + dataTablePrototype = DataTable.prototype; + + /** + * DataTable UI class for jqPlot graph datatable visualizations. + * + * @constructor + */ + exports.JqplotGraphDataTable = function (element) { + DataTable.call(this, element); + }; + + $.extend(exports.JqplotGraphDataTable.prototype, dataTablePrototype, { + + /** + * Initializes this class. + */ + init: function () { + dataTablePrototype.init.call(this); + + var graphElement = $('.piwik-graph', this.$element); + if (!graphElement.length) { + return; + } + + this._lang = { + noData: _pk_translate('General_NoDataForGraph'), + exportTitle: _pk_translate('General_ExportAsImage'), + exportText: _pk_translate('General_SaveImageOnYourComputer'), + metricsToPlot: _pk_translate('General_MetricsToPlot'), + metricToPlot: _pk_translate('General_MetricToPlot'), + recordsToPlot: _pk_translate('General_RecordsToPlot') + }; + + // set a unique ID for the graph element (required by jqPlot) + this.targetDivId = this.workingDivId + 'Chart'; + graphElement.attr('id', this.targetDivId); + + try { + var graphData = JSON.parse(graphElement.attr('data-data')); + } catch (e) { + console.error('JSON.parse Error: "' + e + "\" in:\n" + graphElement.attr('data-data')); + return; + } + + this.data = graphData.data; + this._setJqplotParameters(graphData.params); + + if (this.props.display_percentage_in_tooltip) { + this._setTooltipPercentages(); + } + + this._bindEvents(); + + // add external series toggle if it should be added + if (this.props.external_series_toggle) { + this.addExternalSeriesToggle( + window[this.props.external_series_toggle], // get the function w/ string name + this.props.external_series_toggle_show_all == 1 + ); + } + + // render the graph (setTimeout is required, otherwise the graph will not + // render initially) + var self = this; + setTimeout(function () { self.render(); }, 1); + }, + + _setJqplotParameters: function (params) { + defaultParams = { + grid: { + drawGridLines: false, + borderWidth: 0, + shadow: false + }, + title: { + show: false + }, + axesDefaults: { + pad: 1.0, + tickRenderer: $.jqplot.CanvasAxisTickRenderer, + tickOptions: { + showMark: false, + fontSize: '11px', + fontFamily: window.piwik.jqplotLabelFont || 'Arial' + }, + rendererOptions: { + drawBaseline: false + } + }, + axes: { + yaxis: { + tickOptions: { + formatString: '%d' + } + } + } + }; + + this.jqplotParams = $.extend(true, {}, defaultParams, params); + + this._setColors(); + }, + + _setTooltipPercentages: function () { + this.tooltip = {percentages: []}; + + for (var seriesIdx = 0; seriesIdx != this.data.length; ++seriesIdx) { + var series = this.data[seriesIdx]; + var sum = 0; + + $.each(series, function(index, value) { + if ($.isArray(value) && value[1]) { + sum = sum + value[1]; + } else { + sum = sum + value; + } + }); + + var percentages = this.tooltip.percentages[seriesIdx] = []; + for (var valueIdx = 0; valueIdx != series.length; ++valueIdx) { + var value = series[valueIdx]; + if ($.isArray(value) && value[1]) { + value = value[1]; + } + + percentages[valueIdx] = sum > 0 ? Math.round(100 * value / sum) : 0; + } + } + }, + + _bindEvents: function () { + var self = this; + var target = $('#' + this.targetDivId); + + // tooltip show/hide + target.on('jqplotDataHighlight', function (e, seriesIndex, valueIndex) { + self._showDataPointTooltip(this, seriesIndex, valueIndex); + }) + .on('jqplotDataUnhighlight', function () { + self._destroyDataPointTooltip($(this)); + }); + + // handle window resize + this._plotWidth = target.innerWidth(); + target.on('resizeGraph', function () { // TODO: shouldn't be a triggerable event. + self._resizeGraph(); + }); + + // export as image + target.on('piwikExportAsImage', function () { + self.exportAsImage(target, self._lang); + }); + + // manage resources + target.on('piwikDestroyPlot', function () { + $(window).off('resize', this._resizeListener); + self._plot.destroy(); + for (var i = 0; i < $.jqplot.visiblePlots.length; i++) { + if ($.jqplot.visiblePlots[i] == self._plot) { + $.jqplot.visiblePlots[i] = null; + } + } + }); + + this.$element.closest('.widgetContent').on('widget:resize', function () { + self._resizeGraph(); + }); + }, + + _resizeGraph: function () { + var width = $('#' + this.targetDivId).innerWidth(); + if (width > 0 && Math.abs(this._plotWidth - width) >= 5) { + this._plotWidth = width; + this.render(); + } + }, + + _setWindowResizeListener: function () { + var self = this; + + var timeout = false; + this._resizeListener = function () { + if (timeout) { + window.clearTimeout(timeout); + } + + timeout = window.setTimeout(function () { $('#' + self.targetDivId).trigger('resizeGraph'); }, 300); + }; + $(window).on('resize', this._resizeListener); + }, + + _destroyDataPointTooltip: function ($element) { + if ($element.is( ":data('ui-tooltip')" )) { + $element.tooltip('destroy'); + } + }, + + _showDataPointTooltip: function (element, seriesIndex, valueIndex) { + // empty + }, + + changeSeries: function (columns, rows) { + this.showLoading(); + + columns = columns || []; + if (typeof columns == 'string') { + columns = columns.split(','); + } + + rows = rows || []; + if (typeof rows == 'string') { + rows = rows.split(','); + } + + var dataTable = $('#' + this.workingDivId).data('uiControlObject'); + dataTable.param.columns = columns.join(','); + dataTable.param.rows = rows.join(','); + delete dataTable.param.filter_limit; + delete dataTable.param.totalRows; + if (dataTable.param.filter_sort_column != 'label') { + dataTable.param.filter_sort_column = columns[0]; + } + dataTable.param.disable_generic_filters = '0'; + dataTable.reloadAjaxDataTable(false); + }, + + destroyPlot: function () { + var target = $('#' + this.targetDivId); + + target.trigger('piwikDestroyPlot'); + if (target.data('oldHeight') > 0) { + // handle replot after empty report + target.height(target.data('oldHeight')); + target.data('oldHeight', 0); + target.innerHTML = ''; + } + }, + + showLoading: function () { + var target = $('#' + this.targetDivId); + + var loading = $(document.createElement('div')).addClass('jqplot-loading'); + loading.css({ + width: target.innerWidth() + 'px', + height: target.innerHeight() + 'px', + opacity: 0 + }); + target.prepend(loading); + loading.css({opacity: .7}); + }, + + /** Generic render function */ + render: function () { + if (this.data.length == 0) { // sanity check + return; + } + + var targetDivId = this.workingDivId + 'Chart'; + var lang = this._lang; + var dataTableDiv = $('#' + this.workingDivId); + + // if the plot has already been rendered, get rid of the existing plot + var target = $('#' + targetDivId); + if (target.find('canvas').length > 0) { + this.destroyPlot(); + } + + // handle replot + // this has be bound before the check for an empty graph. + // otherwise clicking on sparklines won't work anymore after an empty + // report has been displayed. + var self = this; + + // create jqplot chart + try { + var plot = self._plot = $.jqplot(targetDivId, this.data, this.jqplotParams); + } catch (e) { + // this is thrown when refreshing piwik in the browser + if (e != "No plot target specified") { + throw e; + } + } + + self._setWindowResizeListener(); + + var self = this; + + // TODO: this code destroys plots when a page is switched. there must be a better way of managing memory. + if (typeof $.jqplot.visiblePlots == 'undefined') { + $.jqplot.visiblePlots = []; + $('.nav').on('piwikSwitchPage', function () { + for (var i = 0; i < $.jqplot.visiblePlots.length; i++) { + if ($.jqplot.visiblePlots[i] == null) { + continue; + } + $.jqplot.visiblePlots[i].destroy(); + } + $.jqplot.visiblePlots = []; + }); + } + + if (typeof plot != 'undefined') { + $.jqplot.visiblePlots.push(plot); + } + }, + + /** Export the chart as an image */ + exportAsImage: function (container, lang) { + var exportCanvas = document.createElement('canvas'); + exportCanvas.width = container.width(); + exportCanvas.height = container.height(); + + if (!exportCanvas.getContext) { + alert("Sorry, not supported in your browser. Please upgrade your browser :)"); + return; + } + var exportCtx = exportCanvas.getContext('2d'); + + var canvases = container.find('canvas'); + + for (var i = 0; i < canvases.length; i++) { + var canvas = canvases.eq(i); + var position = canvas.position(); + var parent = canvas.parent(); + if (parent.hasClass('jqplot-axis')) { + var addPosition = parent.position(); + position.left += addPosition.left; + position.top += addPosition.top + parseInt(parent.css('marginTop'), 10); + } + exportCtx.drawImage(canvas[0], Math.round(position.left), Math.round(position.top)); + } + + var exported = exportCanvas.toDataURL("image/png"); + + var img = document.createElement('img'); + img.src = exported; + + img = $(img).css({ + width: exportCanvas.width + 'px', + height: exportCanvas.height + 'px' + }); + + var popover = $(document.createElement('div')); + + popover.append('
    ' + + lang.exportText + '
    ').append($(img)); + + popover.dialog({ + title: lang.exportTitle, + modal: true, + width: 'auto', + position: ['center', 'center'], + resizable: false, + autoOpen: true, + open: function (event, ui) { + $('.ui-widget-overlay').on('click.popover', function () { + popover.dialog('close'); + }); + }, + close: function (event, ui) { + $(this).dialog("destroy").remove(); + } + }); + }, + + // ------------------------------------------------------------ + // HELPER METHODS + // ------------------------------------------------------------ + + /** Generate ticks in y direction */ + setYTicks: function () { + // default axis + this.setYTicksForAxis('yaxis', this.jqplotParams.axes.yaxis); + // other axes: y2axis, y3axis... + for (var i = 2; typeof this.jqplotParams.axes['y' + i + 'axis'] != 'undefined'; i++) { + this.setYTicksForAxis('y' + i + 'axis', this.jqplotParams.axes['y' + i + 'axis']); + } + }, + + setYTicksForAxis: function (axisName, axis) { + // calculate maximum x value of all data sets + var maxCrossDataSets = 0; + for (var i = 0; i < this.data.length; i++) { + if (this.jqplotParams.series[i].yaxis == axisName) { + var maxValue = Math.max.apply(Math, this.data[i]); + if (maxValue > maxCrossDataSets) { + maxCrossDataSets = maxValue; + } + maxCrossDataSets = parseFloat(maxCrossDataSets); + } + } + + // add little padding on top + maxCrossDataSets += Math.max(1, Math.round(maxCrossDataSets * .03)); + + // round to the nearest multiple of ten + if (maxCrossDataSets > 15) { + maxCrossDataSets = maxCrossDataSets + 10 - maxCrossDataSets % 10; + } + + if (maxCrossDataSets == 0) { + maxCrossDataSets = 1; + } + + // make sure percent axes don't go above 100% + if (axis.tickOptions.formatString.substring(2, 3) == '%' && maxCrossDataSets > 100) { + maxCrossDataSets = 100; + } + + // calculate y-values for ticks + var ticks = []; + var numberOfTicks = 2; + var tickDistance = Math.ceil(maxCrossDataSets / numberOfTicks); + for (var i = 0; i <= numberOfTicks; i++) { + ticks.push(i * tickDistance); + } + axis.ticks = ticks; + }, + + /** Get a formatted y values (with unit) */ + formatY: function (value, seriesIndex) { + var floatVal = parseFloat(value); + var intVal = parseInt(value, 10); + if (Math.abs(floatVal - intVal) >= 0.005) { + value = Math.round(floatVal * 100) / 100; + } else if (parseFloat(intVal) == floatVal) { + value = intVal; + } else { + value = floatVal; + } + + var axisId = this.jqplotParams.series[seriesIndex].yaxis; + var formatString = this.jqplotParams.axes[axisId].tickOptions.formatString; + + return formatString.replace('%s', value); + }, + + /** + * Add an external series toggle. + * As opposed to addSeriesPicker, the external series toggle can only show/hide + * series that are already loaded. + * + * @param seriesPickerClass a subclass of JQPlotExternalSeriesToggle + * @param initiallyShowAll + */ + addExternalSeriesToggle: function (seriesPickerClass, initiallyShowAll) { + new seriesPickerClass(this.targetDivId, this, initiallyShowAll); + + if (!initiallyShowAll) { + // initially, show only the first series + this.data = [this.data[0]]; + this.jqplotParams.series = [this.jqplotParams.series[0]]; + this.setYTicks(); + } + }, + + /** + * Sets the colors used to render this graph. + */ + _setColors: function () { + var colorManager = piwik.ColorManager, + seriesColorNames = ['series1', 'series2', 'series3', 'series4', 'series5', + 'series6', 'series7', 'series8', 'series9', 'series10']; + + var viewDataTable = $('#' + this.workingDivId).data('uiControlObject').param['viewDataTable']; + + var graphType; + if (viewDataTable == 'graphEvolution') { + graphType = 'evolution'; + } else if (viewDataTable == 'graphPie') { + graphType = 'pie'; + } else if (viewDataTable == 'graphVerticalBar') { + graphType = 'bar'; + } + + var namespace = graphType + '-graph-colors'; + + this.jqplotParams.seriesColors = colorManager.getColors(namespace, seriesColorNames, true); + this.jqplotParams.grid.background = colorManager.getColor(namespace, 'grid-background'); + this.jqplotParams.grid.borderColor = colorManager.getColor(namespace, 'grid-border'); + this.tickColor = colorManager.getColor(namespace, 'ticks'); + this.singleMetricColor = colorManager.getColor(namespace, 'single-metric-label') + } + }); + + DataTable.registerFooterIconHandler('graphPie', DataTable.switchToGraph); + DataTable.registerFooterIconHandler('graphVerticalBar', DataTable.switchToGraph); + DataTable.registerFooterIconHandler('graphEvolution', DataTable.switchToGraph); + +})(jQuery, require); + +// ---------------------------------------------------------------- +// EXTERNAL SERIES TOGGLE +// Use external dom elements and their events to show/hide series +// ---------------------------------------------------------------- + +function JQPlotExternalSeriesToggle(targetDivId, jqplotObject, initiallyShowAll) { + this.init(targetDivId, originalConfig, initiallyShowAll); +} + +JQPlotExternalSeriesToggle.prototype = { + + init: function (targetDivId, jqplotObject, initiallyShowAll) { + this.targetDivId = targetDivId; + this.jqplotObject = jqplotObject; + this.originalData = jqplotObject.data; + this.originalSeries = jqplotObject.jqplotParams.series; + this.originalAxes = jqplotObject.jqplotParams.axes; + this.originalParams = jqplotObject.jqplotParams; + this.originalSeriesColors = jqplotObject.jqplotParams.seriesColors; + this.initiallyShowAll = initiallyShowAll; + + this.activated = []; + this.target = $('#' + targetDivId); + + this.attachEvents(); + }, + + // can be overridden + attachEvents: function () {}, + + // show a single series + showSeries: function (i) { + for (var j = 0; j < this.activated.length; j++) { + this.activated[j] = (i == j); + } + this.replot(); + }, + + // toggle a series (make plotting multiple series possible) + toggleSeries: function (i) { + var activatedCount = 0; + for (var k = 0; k < this.activated.length; k++) { + if (this.activated[k]) { + activatedCount++; + } + } + if (activatedCount == 1 && this.activated[i]) { + // prevent removing the only visible metric + return; + } + + this.activated[i] = !this.activated[i]; + this.replot(); + }, + + replot: function () { + this.beforeReplot(); + + // build new config and replot + var usedAxes = []; + var config = {data: this.originalData, params: this.originalParams}; + config.data = []; + config.params.series = []; + config.params.axes = {xaxis: this.originalAxes.xaxis}; + config.params.seriesColors = []; + for (var j = 0; j < this.activated.length; j++) { + if (!this.activated[j]) { + continue; + } + config.data.push(this.originalData[j]); + config.params.seriesColors.push(this.originalSeriesColors[j]); + config.params.series.push($.extend(true, {}, this.originalSeries[j])); + // build array of used axes + var axis = this.originalSeries[j].yaxis; + if ($.inArray(axis, usedAxes) == -1) { + usedAxes.push(axis); + } + } + + // build new axes config + var replaceAxes = {}; + for (j = 0; j < usedAxes.length; j++) { + var originalAxisName = usedAxes[j]; + var newAxisName = (j == 0 ? 'yaxis' : 'y' + (j + 1) + 'axis'); + replaceAxes[originalAxisName] = newAxisName; + config.params.axes[newAxisName] = this.originalAxes[originalAxisName]; + } + + // replace axis names in series config + for (j = 0; j < config.params.series.length; j++) { + var series = config.params.series[j]; + series.yaxis = replaceAxes[series.yaxis]; + } + + this.jqplotObject.data = config.data; + this.jqplotObject.jqplotParams = config.params; + this.jqplotObject.setYTicks(); + this.jqplotObject.render(); + }, + + // can be overridden + beforeReplot: function () {} + +}; + + +// ROW EVOLUTION SERIES TOGGLE + +function RowEvolutionSeriesToggle(targetDivId, jqplotData, initiallyShowAll) { + this.init(targetDivId, jqplotData, initiallyShowAll); +} + +RowEvolutionSeriesToggle.prototype = JQPlotExternalSeriesToggle.prototype; + +RowEvolutionSeriesToggle.prototype.attachEvents = function () { + var self = this; + this.seriesPickers = this.target.closest('.rowevolution').find('table.metrics tr'); + + this.seriesPickers.each(function (i) { + var el = $(this); + el.click(function (e) { + if (e.shiftKey) { + self.toggleSeries(i); + + document.getSelection().removeAllRanges(); // make sure chrome doesn't select text + } else { + self.showSeries(i); + } + return false; + }); + + if (i == 0 || self.initiallyShowAll) { + // show the active series + // if initiallyShowAll, all are active; otherwise only the first one + self.activated.push(true); + } else { + // fade out the others + el.find('td').css('opacity', .5); + self.activated.push(false); + } + + // prevent selecting in ie & opera (they don't support doing this via css) + if ($.browser.msie) { + this.ondrag = function () { return false; }; + this.onselectstart = function () { return false; }; + } else if ($.browser.opera) { + $(this).attr('unselectable', 'on'); + } + }); +}; + +RowEvolutionSeriesToggle.prototype.beforeReplot = function () { + // fade out if not activated + for (var i = 0; i < this.activated.length; i++) { + if (this.activated[i]) { + this.seriesPickers.eq(i).find('td').css('opacity', 1); + } else { + this.seriesPickers.eq(i).find('td').css('opacity', .5); + } + } +}; + + +// ------------------------------------------------------------ +// PIWIK TICKS PLUGIN FOR JQPLOT +// Handle ticks the piwik way... +// ------------------------------------------------------------ + +(function ($) { + + $.jqplot.PiwikTicks = function (options) { + // canvas for the grid + this.piwikTicksCanvas = null; + // canvas for the highlight + this.piwikHighlightCanvas = null; + // renderer used to draw the marker of the highlighted point + this.markerRenderer = new $.jqplot.MarkerRenderer({ + shadow: false + }); + // the x tick the mouse is over + this.currentXTick = false; + // show the highlight around markers + this.showHighlight = false; + // show the grid + this.showGrid = false; + // show the ticks + this.showTicks = false; + + $.extend(true, this, options); + }; + + $.jqplot.PiwikTicks.init = function (target, data, opts) { + // add plugin as an attribute to the plot + var options = opts || {}; + this.plugins.piwikTicks = new $.jqplot.PiwikTicks(options.piwikTicks); + + if (typeof $.jqplot.PiwikTicks.init.eventsBound == 'undefined') { + $.jqplot.PiwikTicks.init.eventsBound = true; + $.jqplot.eventListenerHooks.push(['jqplotMouseMove', handleMouseMove]); + $.jqplot.eventListenerHooks.push(['jqplotMouseLeave', handleMouseLeave]); + } + }; + + // draw the grid + // called with context of plot + $.jqplot.PiwikTicks.postDraw = function () { + var c = this.plugins.piwikTicks; + + // highligh canvas + if (c.showHighlight) { + c.piwikHighlightCanvas = new $.jqplot.GenericCanvas(); + + this.eventCanvas._elem.before(c.piwikHighlightCanvas.createElement( + this._gridPadding, 'jqplot-piwik-highlight-canvas', this._plotDimensions, this)); + c.piwikHighlightCanvas.setContext(); + } + + // grid canvas + if (c.showTicks) { + var dimensions = this._plotDimensions; + dimensions.height += 6; + c.piwikTicksCanvas = new $.jqplot.GenericCanvas(); + this.series[0].shadowCanvas._elem.before(c.piwikTicksCanvas.createElement( + this._gridPadding, 'jqplot-piwik-ticks-canvas', dimensions, this)); + c.piwikTicksCanvas.setContext(); + + var ctx = c.piwikTicksCanvas._ctx; + + var ticks = this.data[0]; + var totalWidth = ctx.canvas.width; + var tickWidth = totalWidth / ticks.length; + + var xaxisLabels = this.axes.xaxis.ticks; + + for (var i = 0; i < ticks.length; i++) { + var pos = Math.round(i * tickWidth + tickWidth / 2); + var full = xaxisLabels[i] && xaxisLabels[i] != ' '; + drawLine(ctx, pos, full, c.showGrid, c.tickColor); + } + } + }; + + $.jqplot.preInitHooks.push($.jqplot.PiwikTicks.init); + $.jqplot.postDrawHooks.push($.jqplot.PiwikTicks.postDraw); + + // draw a 1px line + function drawLine(ctx, x, full, showGrid, color) { + ctx.save(); + ctx.strokeStyle = color; + + ctx.beginPath(); + ctx.lineWidth = 2; + var top = 0; + if ((full && !showGrid) || !full) { + top = ctx.canvas.height - 5; + } + ctx.moveTo(x, top); + ctx.lineTo(x, full ? ctx.canvas.height : ctx.canvas.height - 2); + ctx.stroke(); + + // canvas renders line slightly too large + ctx.clearRect(x, 0, x + 1, ctx.canvas.height); + + ctx.restore(); + } + + // tigger the event jqplotPiwikTickOver when the mosue enters + // and new tick. this is used for tooltips. + function handleMouseMove(ev, gridpos, datapos, neighbor, plot) { + var c = plot.plugins.piwikTicks; + + var tick = Math.floor(datapos.xaxis + 0.5) - 1; + if (tick !== c.currentXTick) { + c.currentXTick = tick; + plot.target.trigger('jqplotPiwikTickOver', [tick]); + highlight(plot, tick); + } + } + + function handleMouseLeave(ev, gridpos, datapos, neighbor, plot) { + unHighlight(plot); + plot.plugins.piwikTicks.currentXTick = false; + } + + // highlight a marker + function highlight(plot, tick) { + var c = plot.plugins.piwikTicks; + + if (!c.showHighlight) { + return; + } + + unHighlight(plot); + + for (var i = 0; i < plot.series.length; i++) { + var series = plot.series[i]; + var seriesMarkerRenderer = series.markerRenderer; + + c.markerRenderer.style = seriesMarkerRenderer.style; + c.markerRenderer.size = seriesMarkerRenderer.size + 5; + + var rgba = $.jqplot.getColorComponents(seriesMarkerRenderer.color); + var newrgb = [rgba[0], rgba[1], rgba[2]]; + var alpha = rgba[3] * .4; + c.markerRenderer.color = 'rgba(' + newrgb[0] + ',' + newrgb[1] + ',' + newrgb[2] + ',' + alpha + ')'; + c.markerRenderer.init(); + + var position = series.gridData[tick]; + c.markerRenderer.draw(position[0], position[1], c.piwikHighlightCanvas._ctx); + } + } + + function unHighlight(plot) { + var canvas = plot.plugins.piwikTicks.piwikHighlightCanvas; + if (canvas !== null) { + var ctx = canvas._ctx; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + } + } + +})(jQuery); + + +// ------------------------------------------------------------ +// LEGEND PLUGIN FOR JQPLOT +// Render legend on canvas +// ------------------------------------------------------------ + +(function ($) { + + $.jqplot.CanvasLegendRenderer = function (options) { + // canvas for the legend + this.legendCanvas = null; + // is it a legend for a single metric only (pie chart)? + this.singleMetric = false; + // render the legend? + this.show = false; + + $.extend(true, this, options); + }; + + $.jqplot.CanvasLegendRenderer.init = function (target, data, opts) { + // add plugin as an attribute to the plot + var options = opts || {}; + this.plugins.canvasLegend = new $.jqplot.CanvasLegendRenderer(options.canvasLegend); + + // add padding above the grid + // legend will be put there + if (this.plugins.canvasLegend.show) { + options.gridPadding = { + top: 21 + }; + } + + }; + + // render the legend + $.jqplot.CanvasLegendRenderer.postDraw = function () { + var plot = this; + var legend = plot.plugins.canvasLegend; + + if (!legend.show) { + return; + } + + // initialize legend canvas + var padding = {top: 0, right: this._gridPadding.right, bottom: 0, left: this._gridPadding.left}; + var dimensions = {width: this._plotDimensions.width, height: this._gridPadding.top}; + var width = this._plotDimensions.width - this._gridPadding.left - this._gridPadding.right; + + legend.legendCanvas = new $.jqplot.GenericCanvas(); + this.eventCanvas._elem.before(legend.legendCanvas.createElement( + padding, 'jqplot-legend-canvas', dimensions, plot)); + legend.legendCanvas.setContext(); + + var ctx = legend.legendCanvas._ctx; + ctx.save(); + ctx.font = '11px ' + (window.piwik.jqplotLabelFont || 'Arial'); + + // render series names + var x = 0; + var series = plot.legend._series; + for (var i = 0; i < series.length; i++) { + var s = series[i]; + var label; + if (legend.labels && legend.labels[i]) { + label = legend.labels[i]; + } else { + label = s.label.toString(); + } + + ctx.fillStyle = s.color; + if (legend.singleMetric) { + ctx.fillStyle = legend.singleMetricColor; + } + + ctx.fillRect(x, 10, 10, 2); + x += 15; + + var nextX = x + ctx.measureText(label).width + 20; + + if (nextX + 70 > width) { + ctx.fillText("[...]", x, 15); + x += ctx.measureText("[...]").width + 20; + break; + } + + ctx.fillText(label, x, 15); + x = nextX; + } + + legend.width = x; + + ctx.restore(); + }; + + $.jqplot.preInitHooks.push($.jqplot.CanvasLegendRenderer.init); + $.jqplot.postDrawHooks.push($.jqplot.CanvasLegendRenderer.postDraw); + +})(jQuery); + + +// ------------------------------------------------------------ +// SERIES PICKER +// ------------------------------------------------------------ + +(function ($, require) { + $.jqplot.preInitHooks.push(function (target, data, options) { + // create the series picker + var dataTable = $('#' + target).closest('.dataTable').data('uiControlObject'); + if (!dataTable) { // if we're not dealing w/ a DataTable visualization, don't add the series picker + return; + } + + var SeriesPicker = require('piwik/DataTableVisualizations/Widgets').SeriesPicker; + var seriesPicker = new SeriesPicker(dataTable); + + // handle placeSeriesPicker event + var plot = this; + $(seriesPicker).bind('placeSeriesPicker', function () { + this.domElem.css('margin-left', (plot._gridPadding.left + plot.plugins.canvasLegend.width - 1) + 'px'); + plot.baseCanvas._elem.before(this.domElem); + }); + + // handle seriesPicked event + $(seriesPicker).bind('seriesPicked', function (e, columns, rows) { + dataTable.changeSeries(columns, rows); + }); + + this.plugins.seriesPicker = seriesPicker; + }); + + $.jqplot.postDrawHooks.push(function () { + this.plugins.seriesPicker.init(); + }); +})(jQuery, require); + +// ------------------------------------------------------------ +// PIE CHART LEGEND PLUGIN FOR JQPLOT +// Render legend inside the pie graph +// ------------------------------------------------------------ + +(function ($) { + + $.jqplot.PieLegend = function (options) { + // canvas for the legend + this.pieLegendCanvas = null; + // render the legend? + this.show = false; + + $.extend(true, this, options); + }; + + $.jqplot.PieLegend.init = function (target, data, opts) { + // add plugin as an attribute to the plot + var options = opts || {}; + this.plugins.pieLegend = new $.jqplot.PieLegend(options.pieLegend); + }; + + // render the legend + $.jqplot.PieLegend.postDraw = function () { + var plot = this; + var legend = plot.plugins.pieLegend; + + if (!legend.show) { + return; + } + + var series = plot.series[0]; + var angles = series._sliceAngles; + var radius = series._diameter / 2; + var center = series._center; + var colors = this.seriesColors; + + // concentric line angles + var lineAngles = []; + for (var i = 0; i < angles.length; i++) { + lineAngles.push((angles[i][0] + angles[i][1]) / 2 + Math.PI / 2); + } + + // labels + var labels = []; + var data = series._plotData; + for (i = 0; i < data.length; i++) { + labels.push(data[i][0]); + } + + // initialize legend canvas + legend.pieLegendCanvas = new $.jqplot.GenericCanvas(); + plot.series[0].canvas._elem.before(legend.pieLegendCanvas.createElement( + plot._gridPadding, 'jqplot-pie-legend-canvas', plot._plotDimensions, plot)); + legend.pieLegendCanvas.setContext(); + + var ctx = legend.pieLegendCanvas._ctx; + ctx.save(); + + ctx.font = '11px ' + (window.piwik.jqplotLabelFont || 'Arial'); + + // render labels + var height = legend.pieLegendCanvas._elem.height(); + var x1, x2, y1, y2, lastY2 = false, right, lastRight = false; + for (i = 0; i < labels.length; i++) { + var label = labels[i]; + + ctx.strokeStyle = colors[i % colors.length]; + ctx.lineCap = 'round'; + ctx.lineWidth = 1; + + // concentric line + x1 = center[0] + Math.sin(lineAngles[i]) * (radius); + y1 = center[1] - Math.cos(lineAngles[i]) * (radius); + + x2 = center[0] + Math.sin(lineAngles[i]) * (radius + 7); + y2 = center[1] - Math.cos(lineAngles[i]) * (radius + 7); + + right = x2 > center[0]; + + // move close labels + if (lastY2 !== false && lastRight == right && ( + (right && y2 - lastY2 < 13) || + (!right && lastY2 - y2 < 13))) { + + if (x1 > center[0]) { + // move down if the label is in the right half of the graph + y2 = lastY2 + 13; + } else { + // move up if in left halt + y2 = lastY2 - 13; + } + } + + if (y2 < 4 || y2 + 4 > height) { + continue; + } + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + + ctx.closePath(); + ctx.stroke(); + + // horizontal line + ctx.beginPath(); + ctx.moveTo(x2, y2); + if (right) { + ctx.lineTo(x2 + 5, y2); + } else { + ctx.lineTo(x2 - 5, y2); + } + + ctx.closePath(); + ctx.stroke(); + + lastY2 = y2; + lastRight = right; + + // text + if (right) { + var x = x2 + 9; + } else { + var x = x2 - 9 - ctx.measureText(label).width; + } + + ctx.fillStyle = legend.labelColor; + ctx.fillText(label, x, y2 + 3); + } + + ctx.restore(); + }; + + $.jqplot.preInitHooks.push($.jqplot.PieLegend.init); + $.jqplot.postDrawHooks.push($.jqplot.PieLegend.postDraw); + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/CoreVisualizations/javascripts/jqplotBarGraph.js b/www/analytics/plugins/CoreVisualizations/javascripts/jqplotBarGraph.js new file mode 100644 index 00000000..4af18aaf --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/javascripts/jqplotBarGraph.js @@ -0,0 +1,80 @@ +/** + * Piwik - Web Analytics + * + * DataTable UI class for JqplotGraph/Bar. + * + * @link http://www.jqplot.com + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'), + JqplotGraphDataTable = exports.JqplotGraphDataTable; + + exports.JqplotBarGraphDataTable = function (element) { + JqplotGraphDataTable.call(this, element); + }; + + $.extend(exports.JqplotBarGraphDataTable.prototype, JqplotGraphDataTable.prototype, { + + _setJqplotParameters: function (params) { + JqplotGraphDataTable.prototype._setJqplotParameters.call(this, params); + + this.jqplotParams.seriesDefaults = { + renderer: $.jqplot.BarRenderer, + rendererOptions: { + shadowOffset: 1, + shadowDepth: 2, + shadowAlpha: .2, + fillToZero: true, + barMargin: this.data[0].length > 10 ? 2 : 10 + } + }; + + this.jqplotParams.piwikTicks = { + showTicks: true, + showGrid: false, + showHighlight: false, + tickColor: this.tickColor + }; + + this.jqplotParams.axes.xaxis.renderer = $.jqplot.CategoryAxisRenderer; + this.jqplotParams.axes.xaxis.tickOptions = { + showGridline: false + }; + + this.jqplotParams.canvasLegend = { + show: true + }; + }, + + _bindEvents: function () { + this.setYTicks(); + JqplotGraphDataTable.prototype._bindEvents.call(this); + }, + + _showDataPointTooltip: function (element, seriesIndex, valueIndex) { + var value = this.formatY(this.data[seriesIndex][valueIndex], seriesIndex); + var series = this.jqplotParams.series[seriesIndex].label; + + var percentage = ''; + if (typeof this.tooltip.percentages != 'undefined') { + percentage = this.tooltip.percentages[seriesIndex][valueIndex]; + percentage = ' (' + percentage + '%)'; + } + + var label = this.jqplotParams.axes.xaxis.labels[valueIndex]; + var text = '' + value + ' ' + series + percentage; + $(element).tooltip({ + track: true, + items: '*', + content: '

    ' + label + '

    ' + text, + show: false, + hide: false + }).trigger('mouseover'); + } + }); + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js b/www/analytics/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js new file mode 100644 index 00000000..13abe04b --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/javascripts/jqplotEvolutionGraph.js @@ -0,0 +1,135 @@ +/** + * Piwik - Web Analytics + * + * DataTable UI class for JqplotGraph/Evolution. + * + * @link http://www.jqplot.com + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'), + JqplotGraphDataTable = exports.JqplotGraphDataTable, + JqplotGraphDataTablePrototype = JqplotGraphDataTable.prototype; + + exports.JqplotEvolutionGraphDataTable = function (element) { + JqplotGraphDataTable.call(this, element); + }; + + $.extend(exports.JqplotEvolutionGraphDataTable.prototype, JqplotGraphDataTablePrototype, { + + _setJqplotParameters: function (params) { + JqplotGraphDataTablePrototype._setJqplotParameters.call(this, params); + + var defaultParams = { + axes: { + xaxis: { + pad: 1.0, + renderer: $.jqplot.CategoryAxisRenderer, + tickOptions: { + showGridline: false + } + } + }, + piwikTicks: { + showTicks: true, + showGrid: true, + showHighlight: true, + tickColor: this.tickColor + } + }; + + if (this.props.show_line_graph) { + defaultParams.seriesDefaults = { + lineWidth: 1, + markerOptions: { + style: "filledCircle", + size: 6, + shadow: false + } + }; + } else { + defaultParams.seriesDefaults = { + renderer: $.jqplot.BarRenderer, + rendererOptions: { + shadowOffset: 1, + shadowDepth: 2, + shadowAlpha: .2, + fillToZero: true, + barMargin: this.data[0].length > 10 ? 2 : 10 + } + }; + } + + var overrideParams = { + legend: { + show: false + }, + canvasLegend: { + show: true + } + }; + this.jqplotParams = $.extend(true, {}, defaultParams, this.jqplotParams, overrideParams); + }, + + _bindEvents: function () { + JqplotGraphDataTablePrototype._bindEvents.call(this); + + var self = this; + var lastTick = false; + + $('#' + this.targetDivId) + .on('jqplotMouseLeave', function (e, s, i, d) { + $(this).css('cursor', 'default'); + JqplotGraphDataTablePrototype._destroyDataPointTooltip.call(this, $(this)); + }) + .on('jqplotClick', function (e, s, i, d) { + if (lastTick !== false && typeof self.jqplotParams.axes.xaxis.onclick != 'undefined' + && typeof self.jqplotParams.axes.xaxis.onclick[lastTick] == 'string') { + var url = self.jqplotParams.axes.xaxis.onclick[lastTick]; + piwikHelper.redirectToUrl(url); + } + }) + .on('jqplotPiwikTickOver', function (e, tick) { + lastTick = tick; + var label; + if (typeof self.jqplotParams.axes.xaxis.labels != 'undefined') { + label = self.jqplotParams.axes.xaxis.labels[tick]; + } else { + label = self.jqplotParams.axes.xaxis.ticks[tick]; + } + + var text = []; + for (var d = 0; d < self.data.length; d++) { + var value = self.formatY(self.data[d][tick], d); + var series = self.jqplotParams.series[d].label; + text.push('' + value + ' ' + series); + } + $(this).tooltip({ + track: true, + items: 'div', + content: '

    '+label+'

    '+text.join('
    '), + show: false, + hide: false + }).trigger('mouseover'); + if (typeof self.jqplotParams.axes.xaxis.onclick != 'undefined' + && typeof self.jqplotParams.axes.xaxis.onclick[lastTick] == 'string') { + $(this).css('cursor', 'pointer'); + } + }); + + this.setYTicks(); + }, + + _destroyDataPointTooltip: function () { + // do nothing, tooltips are destroyed in the jqplotMouseLeave event + }, + + render: function () { + JqplotGraphDataTablePrototype.render.call(this); + } + }); + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/CoreVisualizations/javascripts/jqplotPieGraph.js b/www/analytics/plugins/CoreVisualizations/javascripts/jqplotPieGraph.js new file mode 100644 index 00000000..61f0a22c --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/javascripts/jqplotPieGraph.js @@ -0,0 +1,81 @@ +/** + * Piwik - Web Analytics + * + * DataTable UI class for JqplotGraph/Pie. + * + * @link http://www.jqplot.com + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'), + JqplotGraphDataTable = exports.JqplotGraphDataTable; + + exports.JqplotPieGraphDataTable = function (element) { + JqplotGraphDataTable.call(this, element); + }; + + $.extend(exports.JqplotPieGraphDataTable.prototype, JqplotGraphDataTable.prototype, { + + _setJqplotParameters: function (params) { + JqplotGraphDataTable.prototype._setJqplotParameters.call(this, params); + + this.jqplotParams.seriesDefaults = { + renderer: $.jqplot.PieRenderer, + rendererOptions: { + shadow: false, + showDataLabels: false, + sliceMargin: 1, + startAngle: 35 + } + }; + + this.jqplotParams.piwikTicks = { + showTicks: false, + showGrid: false, + showHighlight: false, + tickColor: this.tickColor + }; + + this.jqplotParams.legend = { + show: false + }; + this.jqplotParams.pieLegend = { + show: true, + labelColor: this.singleMetricColor + }; + this.jqplotParams.canvasLegend = { + show: true, + singleMetric: true, + singleMetricColor: this.singleMetricColor + }; + + // pie charts have a different data format + if (!(this.data[0][0] instanceof Array)) { // check if already in different format + for (var i = 0; i < this.data[0].length; i++) { + this.data[0][i] = [this.jqplotParams.axes.xaxis.ticks[i], this.data[0][i]]; + } + } + }, + + _showDataPointTooltip: function (element, seriesIndex, valueIndex) { + var value = this.formatY(this.data[0][valueIndex][1], 0); + var series = this.jqplotParams.series[0].label; + var percentage = this.tooltip.percentages[0][valueIndex]; + + var label = this.data[0][valueIndex][0]; + + var text = '' + percentage + '% (' + value + ' ' + series + ')'; + $(element).tooltip({ + track: true, + items: '*', + content: '

    ' + label + '

    ' + text, + show: false, + hide: false + }).trigger('mouseover'); + } + }); + +})(jQuery, require); diff --git a/www/analytics/plugins/CoreVisualizations/javascripts/seriesPicker.js b/www/analytics/plugins/CoreVisualizations/javascripts/seriesPicker.js new file mode 100644 index 00000000..dbd2df0a --- /dev/null +++ b/www/analytics/plugins/CoreVisualizations/javascripts/seriesPicker.js @@ -0,0 +1,366 @@ +/** + * Piwik - Web Analytics + * + * Series Picker control addition for DataTable visualizations. + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, doc, require) { + + /** + * This class creates and manages the Series Picker for certain DataTable visualizations. + * + * To add the series picker to your DataTable visualization, create a SeriesPicker instance + * and after your visualization has been rendered, call the 'init' method. + * + * To customize SeriesPicker placement and behavior, you can bind callbacks to the following + * events before calling 'init': + * + * 'placeSeriesPicker': Triggered after the DOM element for the series picker link is created. + * You must use this event to add the link to the dataTable. YOu can also + * use this event to position the link however you want. + * + * Callback Signature: function () {} + * + * 'seriesPicked': Triggered when the user selects one or more columns/rows. + * + * Callback Signature: function (eventInfo, columns, rows) {} + * + * Events are triggered via jQuery, so you bind callbacks to them like this: + * + * var picker = new SeriesPicker(dataTable); + * $(picker).bind('placeSeriesPicker', function () { + * $(this.domElem).doSomething(...); + * }); + * + * @param {dataTable} dataTable The dataTable instance to add a series picker to. + * @constructor + */ + var SeriesPicker = function (dataTable) { + this.domElem = null; + this.dataTableId = dataTable.workingDivId; + + // the columns that can be selected + this.selectableColumns = dataTable.props.selectable_columns; + + // the rows that can be selected + this.selectableRows = dataTable.props.selectable_rows; + + // render the picker? + this.show = !! dataTable.props.show_series_picker + && (this.selectableColumns || this.selectableRows); + + // can multiple rows we selected? + this.multiSelect = !! dataTable.props.allow_multi_select_series_picker; + + // language strings + this.lang = + { + metricsToPlot: _pk_translate('General_MetricsToPlot'), + metricToPlot: _pk_translate('General_MetricToPlot'), + recordsToPlot: _pk_translate('General_RecordsToPlot') + }; + + this._pickerState = null; + this._pickerPopover = null; + }; + + SeriesPicker.prototype = { + + /** + * Initializes the series picker by creating the element. Must be called when + * the datatable the picker is being attached to is ready for it to be drawn. + */ + init: function () { + if (!this.show) { + return; + } + + var self = this; + + // initialize dom element + this.domElem = $(doc.createElement('a')) + .addClass('jqplot-seriespicker') + .attr('href', '#') + .html('+') + + // set opacity on 'hide' + .on('hide', function () { + $(this).css('opacity', .55); + }) + .trigger('hide') + + // show picker on hover + .hover( + function () { + var $this = $(this); + + $this.css('opacity', 1); + if (!$this.hasClass('open')) { + $this.addClass('open'); + self._showPicker(); + } + }, + function () { + // do nothing on mouseout because using this event doesn't work properly. + // instead, the timeout check beneath is used (_bindCheckPickerLeave()). + } + ) + .click(function (e) { + e.preventDefault(); + return false; + }); + + $(this).trigger('placeSeriesPicker'); + }, + + /** + * Returns the translation of a metric that can be selected. + * + * @param {String} metric The name of the metric, ie, 'nb_visits' or 'nb_actions'. + * @return {String} The metric translation. If one cannot be found, the metric itself + * is returned. + */ + getMetricTranslation: function (metric) { + for (var i = 0; i != this.selectableColumns.length; ++i) { + if (this.selectableColumns[i].column == metric) { + return this.selectableColumns[i].translation; + } + } + return metric; + }, + + /** + * Creates the popover DOM element, binds event handlers to it, and then displays it. + */ + _showPicker: function () { + this._pickerState = {manipulated: false}; + this._pickerPopover = this._createPopover(); + + this._positionPopover(); + + // hide and replot on mouse leave + var self = this; + this._bindCheckPickerLeave(function () { + var replot = self._pickerState.manipulated; + self._hidePicker(replot); + }); + }, + + /** + * Creates a checkbox and related elements for a selectable column or selectable row. + */ + _createPickerPopupItem: function (config, type) { + var self = this; + + if (type == 'column') { + var columnName = config.column, + columnLabel = config.translation, + cssClass = 'pickColumn'; + } else { + var columnName = config.matcher, + columnLabel = config.label, + cssClass = 'pickRow'; + } + + var checkbox = $(document.createElement('input')).addClass('select') + .attr('type', this.multiSelect ? 'checkbox' : 'radio'); + + if (config.displayed && !(!this.multiSelect && this._pickerState.oneChecked)) { + checkbox.prop('checked', true); + this._pickerState.oneChecked = true; + } + + // if we are rendering a column, remember the column name + // if it's a row, remember the string that can be used to match the row + checkbox.data('name', columnName); + + var el = $(document.createElement('p')) + .append(checkbox) + .append($('
  • ').attr('id', 'Dashboard_embeddedIndex_' + dashboards[i].iddashboard) + .addClass('dashboardMenuItem').append($link); + items.push($li); + + if (dashboards[i].iddashboard == dashboardId) { + dashboardName = dashboards[i].name; + $li.addClass('sfHover'); + } + } + dashboardMenuList.prepend(items); + } else { + dashboardMenuList.hide(); + } + + dashboardMenuList.find('a[data-idDashboard]').click(function (e) { + e.preventDefault(); + + var idDashboard = $(this).attr('data-idDashboard'); + + if (typeof piwikMenu != 'undefined') { + piwikMenu.activateMenu('Dashboard', 'embeddedIndex'); + } + $('#Dashboard ul li').removeClass('sfHover'); + if ($(dashboardElement).length) { + $(dashboardElement).dashboard('loadDashboard', idDashboard); + } else { + broadcast.propagateAjax('module=Dashboard&action=embeddedIndex&idDashboard=' + idDashboard); + } + $(this).closest('li').addClass('sfHover'); + }); + }; + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams({ + module: 'Dashboard', + action: 'getAllDashboards' + }, 'get'); + ajaxRequest.setCallback(success); + ajaxRequest.send(false); + } + + /** + * Save the current layout in database if it has changed + * @param {string} [action] action to perform (defaults to saveLayout) + */ + function saveLayout(action) { + var columns = []; + + var columnNumber = 0; + $('.col').each(function () { + columns[columnNumber] = []; + var items = $('[widgetId]', this); + for (var j = 0; j < items.size(); j++) { + columns[columnNumber][j] = $(items[j]).dashboardWidget('getWidgetObject'); + + // Do not store segment in the dashboard layout + delete columns[columnNumber][j].parameters.segment; + + } + columnNumber++; + }); + + if (JSON.stringify(dashboardLayout.columns) != JSON.stringify(columns) || dashboardChanged || action) { + + dashboardLayout.columns = JSON.parse(JSON.stringify(columns)); + columns = null; + + if (!action) { + action = 'saveLayout'; + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams({ + module: 'Dashboard', + action: action, + idDashboard: dashboardId + }, 'get'); + ajaxRequest.addParams({ + layout: JSON.stringify(dashboardLayout), + name: dashboardName + }, 'post'); + ajaxRequest.setCallback( + function () { + if (dashboardChanged) { + dashboardChanged = false; + buildMenu(); + } + } + ); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + } + } + + /** + * Removes the current dashboard + */ + function removeDashboard() { + if (dashboardId == 1) { + return; // dashboard with id 1 should never be deleted, as it is the default + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.setLoadingElement(); + ajaxRequest.addParams({ + module: 'Dashboard', + action: 'removeDashboard', + idDashboard: dashboardId + }, 'get'); + ajaxRequest.setCallback( + function () { + methods.loadDashboard.apply(this, [1]); + } + ); + ajaxRequest.setFormat('html'); + ajaxRequest.send(true); + } + + /** + * Make plugin methods available + */ + $.fn.dashboard = function (method) { + if (methods[method]) { + return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); + } else if (typeof method === 'object' || !method) { + return methods.init.apply(this, arguments); + } else { + $.error('Method ' + method + ' does not exist on jQuery.dashboard'); + } + } + +})(jQuery); \ No newline at end of file diff --git a/www/analytics/plugins/Dashboard/javascripts/dashboardWidget.js b/www/analytics/plugins/Dashboard/javascripts/dashboardWidget.js new file mode 100755 index 00000000..8269beb9 --- /dev/null +++ b/www/analytics/plugins/Dashboard/javascripts/dashboardWidget.js @@ -0,0 +1,331 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +(function ($) { + + $.widget('piwik.dashboardWidget', { + + /** + * Boolean indicating wether the widget is currently maximised + * @type {Boolean} + */ + isMaximised: false, + /** + * Unique Id of the widget + * @type {String} + */ + uniqueId: null, + /** + * Object holding the widget parameters + * @type {Object} + */ + widgetParameters: {}, + + /** + * Options available for initialization + */ + options: { + uniqueId: null, + isHidden: false, + onChange: null, + widgetParameters: {}, + title: null, + onRemove: null, + onRefresh: null, + onMaximise: null, + onMinimise: null, + autoMaximiseVisualizations: ['tableAllColumns', 'tableGoals'] + }, + + /** + * creates a widget object + */ + _create: function () { + + if (!this.options.uniqueId) { + piwikHelper.error('widgets can\'t be created without an uniqueId'); + return; + } else { + this.uniqueId = this.options.uniqueId; + } + + if (this.options.widgetParameters) { + this.widgetParameters = this.options.widgetParameters; + } + + this._createDashboardWidget(this.uniqueId); + + var self = this; + this.element.on('setParameters.dashboardWidget', function (e, params) { self.setParameters(params); }); + + this.reload(true, true); + }, + + /** + * Cleanup some events and dialog + * Called automatically upon removing the widgets domNode + */ + destroy: function () { + if (this.isMaximised) { + $('[widgetId=' + this.uniqueId + ']').dialog('destroy'); + } + $('*', this.element).off('.dashboardWidget'); // unbind all events + $('.widgetContent', this.element).trigger('widget:destroy'); + require('piwik/UI').UIControl.cleanupUnusedControls(); + return this; + }, + + /** + * Returns the data currently set for the widget + * @return {object} + */ + getWidgetObject: function () { + return { + uniqueId: this.uniqueId, + parameters: this.widgetParameters, + isHidden: this.options.isHidden + }; + }, + + /** + * Show the current widget in an ui.dialog + */ + maximise: function () { + this.isMaximised = true; + + if (this.options.onMaximise) { + this.options.onMaximise(this.element); + } else { + this._maximiseImpl(); + } + + $('.widgetContent', this.element).trigger('widget:maximise'); + return this; + }, + + /** + * Reloads the widgets content with the currently set parameters + */ + reload: function (hideLoading, notJQueryUI, overrideParams) { + if (!notJQueryUI) { + piwikHelper.log('widget.reload() was called by jquery.ui, ignoring', arguments.callee.caller); + return; + } + + var self = this, currentWidget = this.element; + + function onWidgetLoadedReplaceElementWithContent(loadedContent) { + $('.widgetContent', currentWidget).html(loadedContent); + $('.widgetContent', currentWidget).removeClass('loading'); + $('.widgetContent', currentWidget).trigger('widget:create', [self]); + } + + // Reading segment from hash tag (standard case) or from the URL (when embedding dashboard) + var segment = broadcast.getValueFromHash('segment') || broadcast.getValueFromUrl('segment'); + if (segment.length) { + this.widgetParameters.segment = segment; + } + + if (!hideLoading) { + $('.widgetContent', currentWidget).addClass('loading'); + } + + var params = $.extend(this.widgetParameters, overrideParams || {}); + widgetsHelper.loadWidgetAjax(this.uniqueId, params, onWidgetLoadedReplaceElementWithContent); + + return this; + }, + + /** + * Update widget parameters + * + * @param {object} parameters + */ + setParameters: function (parameters) { + if (!this.isMaximised + && this.options.autoMaximiseVisualizations.indexOf(parameters.viewDataTable) !== -1 + ) { + this.maximise(); + } + for (var name in parameters) { + this.widgetParameters[name] = parameters[name]; + } + if (!this.isMaximised) { + this.options.onChange(); + } + + return this; + }, + + /** + * Get widget parameters + * + * @param {object} parameters + */ + getParameters: function () { + return $.extend({}, this.widgetParameters); + }, + + /** + * Creaates the widget markup for the given uniqueId + * + * @param {String} uniqueId + */ + _createDashboardWidget: function (uniqueId) { + + var widgetName = widgetsHelper.getWidgetNameFromUniqueId(uniqueId); + if (!widgetName) { + widgetName = _pk_translate('Dashboard_WidgetNotFound'); + } + + var title = this.options.title === null ? $('').text(widgetName) : this.options.title; + var emptyWidgetContent = require('piwik/UI/Dashboard').WidgetFactory.make(uniqueId, title); + this.element.html(emptyWidgetContent); + + var widgetElement = $('#' + uniqueId, this.element); + var self = this; + widgetElement + .on('mouseenter.dashboardWidget', function () { + if (!self.isMaximised) { + $(this).addClass('widgetHover'); + $('.widgetTop', this).addClass('widgetTopHover'); + } + }) + .on('mouseleave.dashboardWidget', function () { + if (!self.isMaximised) { + $(this).removeClass('widgetHover'); + $('.widgetTop', this).removeClass('widgetTopHover'); + } + }); + + if (this.options.isHidden) { + $('.widgetContent', widgetElement).toggleClass('hidden').closest('.widget').toggleClass('hiddenContent'); + } + + $('.button#close', widgetElement) + .on('click.dashboardWidget', function (ev) { + piwikHelper.modalConfirm('#confirm', {yes: function () { + if (self.options.onRemove) { + self.options.onRemove(self.element); + } else { + self.element.remove(); + self.options.onChange(); + } + }}); + }); + + $('.button#maximise', widgetElement) + .on('click.dashboardWidget', function (ev) { + if (self.options.onMaximise) { + self.options.onMaximise(self.element); + } else { + if ($('.widgetContent', $(this).parents('.widget')).hasClass('hidden')) { + self.showContent(); + } else { + self.maximise(); + } + } + }); + + $('.button#minimise', widgetElement) + .on('click.dashboardWidget', function (ev) { + if (self.options.onMinimise) { + self.options.onMinimise(self.element); + } else { + if (!self.isMaximised) { + self.hideContent(); + } else { + self.element.dialog("close"); + } + } + }); + + $('.button#refresh', widgetElement) + .on('click.dashboardWidget', function (ev) { + if (self.options.onRefresh) { + self.options.onRefresh(self.element); + } else { + self.reload(false, true); + } + }); + }, + + /** + * Hide the widget content. Triggers the onChange event. + */ + hideContent: function () { + $('.widgetContent', this.element.find('.widget').addClass('hiddenContent')).addClass('hidden'); + this.options.isHidden = true; + this.options.onChange(); + }, + + /** + * Show the widget content. Triggers the onChange event. + */ + showContent: function () { + this.isMaximised = false; + this.options.isHidden = false; + this.element.find('.widget').removeClass('hiddenContent').find('.widgetContent').removeClass('hidden'); + this.element.find('.widget').find('div.piwik-graph').trigger('resizeGraph'); + this.options.onChange(); + $('.widgetContent', this.element).trigger('widget:minimise'); + }, + + /** + * Default maximise behavior. Will create a dialog that is 70% of the document's width, + * displaying the widget alone. + */ + _maximiseImpl: function () { + this.detachWidget(); + + var width = Math.floor($('body').width() * 0.7); + + var self = this; + this.element.dialog({ + title: '', + modal: true, + width: width, + position: ['center', 'center'], + resizable: true, + autoOpen: true, + close: function (event, ui) { + self.isMaximised = false; + $('body').off('.dashboardWidget'); + $(this).dialog("destroy"); + $('#' + self.uniqueId + '-placeholder').replaceWith(this); + $(this).removeAttr('style'); + self.options.onChange(); + $(this).find('div.piwik-graph').trigger('resizeGraph'); + $('.widgetContent', self.element).trigger('widget:minimise'); + } + }); + this.element.find('div.piwik-graph').trigger('resizeGraph'); + + var currentWidget = this.element; + $('body').on('click.dashboardWidget', function (ev) { + if (/ui-widget-overlay/.test(ev.target.className)) { + $(currentWidget).dialog("close"); + } + }); + }, + + /** + * Detaches the widget from the DOM and replaces it with a placeholder element. + * The placeholder element will have the save dimensions as the widget and will have + * the widgetPlaceholder CSS class. + * + * @return {jQuery} the detached widget + */ + detachWidget: function () { + this.element.before('
    '); + $('#' + this.uniqueId + '-placeholder').height(this.element.height()); + $('#' + this.uniqueId + '-placeholder').width(this.element.width() - 16); + + return this.element.detach(); + } + }); + +})(jQuery); \ No newline at end of file diff --git a/www/analytics/plugins/Dashboard/javascripts/widgetMenu.js b/www/analytics/plugins/Dashboard/javascripts/widgetMenu.js new file mode 100644 index 00000000..bb24b98d --- /dev/null +++ b/www/analytics/plugins/Dashboard/javascripts/widgetMenu.js @@ -0,0 +1,421 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +function widgetsHelper() { +} + +/** + * Returns the available widgets fetched via AJAX (if not already done) + * + * @return {object} object containing available widgets + */ +widgetsHelper.getAvailableWidgets = function () { + if (!widgetsHelper.availableWidgets) { + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams({ + module: 'Dashboard', + action: 'getAvailableWidgets' + }, 'get'); + ajaxRequest.setCallback( + function (data) { + widgetsHelper.availableWidgets = data; + } + ); + ajaxRequest.send(true); + } + + return widgetsHelper.availableWidgets; +}; + +/** + * Returns the complete widget object by its unique id + * + * @param {string} uniqueId + * @return {object} widget object + */ +widgetsHelper.getWidgetObjectFromUniqueId = function (uniqueId) { + var widgets = widgetsHelper.getAvailableWidgets(); + for (var widgetCategory in widgets) { + var widgetInCategory = widgets[widgetCategory]; + for (var i in widgetInCategory) { + if (widgetInCategory[i]["uniqueId"] == uniqueId) { + return widgetInCategory[i]; + } + } + } + return false; +}; + +/** + * Returns the name of a widget by its unique id + * + * @param {string} uniqueId unique id of the widget + * @return {string} + */ +widgetsHelper.getWidgetNameFromUniqueId = function (uniqueId) { + var widget = this.getWidgetObjectFromUniqueId(uniqueId); + if (widget == false) { + return false; + } + return widget["name"]; +}; + +/** + * Sends and ajax request to query for the widgets html + * + * @param {string} widgetUniqueId unique id of the widget + * @param {object} widgetParameters parameters to be used for loading the widget + * @param {function} onWidgetLoadedCallback callback to be executed after widget is loaded + * @return {object} + */ +widgetsHelper.loadWidgetAjax = function (widgetUniqueId, widgetParameters, onWidgetLoadedCallback) { + var disableLink = broadcast.getValueFromUrl('disableLink'); + if (disableLink.length) { + widgetParameters['disableLink'] = disableLink; + } + + widgetParameters['widget'] = 1; + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(widgetParameters, 'get'); + ajaxRequest.setCallback(onWidgetLoadedCallback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + return ajaxRequest; +}; + +(function ($, require) { + var exports = require('piwik/UI/Dashboard'); + + /** + * Singleton instance that creates widget elements. Normally not needed even + * when embedding/re-using dashboard widgets, but it can be useful when creating + * elements with the same look and feel as dashboard widgets, but different + * behavior (such as the widget preview in the dashboard manager control). + * + * @constructor + */ + var WidgetFactory = function () { + // empty + }; + + /** + * Creates an HTML element for displaying a widget. + * + * @param {string} uniqueId unique id of the widget + * @param {string} widgetName name of the widget + * @return {Element} the empty widget + */ + WidgetFactory.prototype.make = function (uniqueId, widgetName) { + var $result = this.getWidgetTemplate().clone(); + $result.attr('id', uniqueId).find('.widgetName').append(widgetName); + return $result; + }; + + /** + * Returns the base widget template element. The template is stored in the + * element with id == 'widgetTemplate'. + * + * @return {Element} the widget template + */ + WidgetFactory.prototype.getWidgetTemplate = function () { + if (!this.widgetTemplate) { + this.widgetTemplate = $('#widgetTemplate').find('>.widget').detach(); + } + return this.widgetTemplate; + }; + + exports.WidgetFactory = new WidgetFactory(); +})(jQuery, require); + +/** + * widgetPreview jQuery Extension + * + * Converts an dom element to a widget preview + * Widget preview contains an categorylist, widgetlist and a preview + */ +(function ($) { + $.extend({ + widgetPreview: new function () { + + /** + * Default settings for widgetPreview + * @type {object} + */ + var defaultSettings = { + /** + * handler called after a widget preview is loaded in preview element + * @type {function} + */ + onPreviewLoaded: function () {}, + /** + * handler called on click on element in widgetlist or widget header + * @type {function} + */ + onSelect: function () {}, + /** + * callback used to determine if a widget is available or not + * unavailable widgets aren't chooseable in widgetlist + * @type {function} + */ + isWidgetAvailable: function (widgetUniqueId) { return true; }, + /** + * should the lists and preview be reset on widget selection? + * @type {boolean} + */ + resetOnSelect: false, + /** + * css classes for various elements + * @type {string} + */ + baseClass: 'widgetpreview-base', + categorylistClass: 'widgetpreview-categorylist', + widgetlistClass: 'widgetpreview-widgetlist', + widgetpreviewClass: 'widgetpreview-preview', + choosenClass: 'widgetpreview-choosen', + unavailableClass: 'widgetpreview-unavailable' + }; + + /** + * Returns the div to show category list in + * - if element doesn't exist it will be created and added + * - if element already exist it's content will be removed + * + * @return {$} category list element + */ + function createWidgetCategoryList(widgetPreview, availableWidgets) { + var settings = widgetPreview.settings; + + if (!$('.' + settings.categorylistClass, widgetPreview).length) { + $(widgetPreview).append('
      '); + } else { + $('.' + settings.categorylistClass, widgetPreview).empty(); + } + + for (var widgetCategory in availableWidgets) { + + $('.' + settings.categorylistClass, widgetPreview).append('
    • ' + widgetCategory + '
    • '); + } + + return $('.' + settings.categorylistClass, widgetPreview); + } + + /** + * Returns the div to show widget list in + * - if element doesn't exist it will be created and added + * - if element already exist it's content will be removed + * + * @return {$} widget list element + */ + function createWidgetList(widgetPreview) { + var settings = widgetPreview.settings; + + if (!$('.' + settings.widgetlistClass, widgetPreview).length) { + $(widgetPreview).append('
        '); + } else { + $('.' + settings.widgetlistClass + ' li', widgetPreview).off('mouseover'); + $('.' + settings.widgetlistClass + ' li', widgetPreview).off('click'); + $('.' + settings.widgetlistClass, widgetPreview).empty(); + } + + if ($('.' + settings.categorylistClass + ' .' + settings.choosenClass, widgetPreview).length) { + var position = $('.' + settings.categorylistClass + ' .' + settings.choosenClass, widgetPreview).position().top - + $('.' + settings.categorylistClass, widgetPreview).position().top; + + $('.' + settings.widgetlistClass, widgetPreview).css('top', position); + $('.' + settings.widgetlistClass, widgetPreview).css('marginBottom', position); + } + + return $('.' + settings.widgetlistClass, widgetPreview); + } + + /** + * Display the given widgets in a widget list + * + * @param {object} widgets widgets to be displayed + * @return {void} + */ + function showWidgetList(widgets, widgetPreview) { + var settings = widgetPreview.settings; + + var widgetList = createWidgetList(widgetPreview), + widgetPreviewTimer; + + for (var j = 0; j < widgets.length; j++) { + var widgetName = widgets[j]["name"]; + var widgetUniqueId = widgets[j]["uniqueId"]; + // var widgetParameters = widgets[j]["parameters"]; + var widgetClass = ''; + if (!settings.isWidgetAvailable(widgetUniqueId)) { + widgetClass += ' ' + settings.unavailableClass; + } + + widgetList.append('
      • ' + widgetName + '
      • '); + } + + // delay widget preview a few millisconds + $('li', widgetList).on('mouseenter', function () { + var that = this, + widgetUniqueId = $(this).attr('uniqueid'); + clearTimeout(widgetPreview); + widgetPreviewTimer = setTimeout(function () { + $('li', widgetList).removeClass(settings.choosenClass); + $(that).addClass(settings.choosenClass); + + showPreview(widgetUniqueId, widgetPreview); + }, 400); + }); + + // clear timeout after mouse has left + $('li:not(.' + settings.unavailableClass + ')', widgetList).on('mouseleave', function () { + clearTimeout(widgetPreview); + }); + + $('li:not(.' + settings.unavailableClass + ')', widgetList).on('click', function () { + if (!$('.widgetLoading', widgetPreview).length) { + settings.onSelect($(this).attr('uniqueid')); + if (settings.resetOnSelect) { + resetWidgetPreview(widgetPreview); + } + } + return false; + }); + } + + /** + * Returns the div to show widget preview in + * - if element doesn't exist it will be created and added + * - if element already exist it's content will be removed + * + * @return {$} preview element + */ + function createPreviewElement(widgetPreview) { + var settings = widgetPreview.settings; + + if (!$('.' + settings.widgetpreviewClass, widgetPreview).length) { + $(widgetPreview).append('
        '); + } else { + $('.' + settings.widgetpreviewClass + ' .widgetTop', widgetPreview).off('click'); + $('.' + settings.widgetpreviewClass, widgetPreview).empty(); + } + + return $('.' + settings.widgetpreviewClass, widgetPreview); + } + + /** + * Show widget with the given uniqueId in preview + * + * @param {string} widgetUniqueId unique id of widget to display + * @return {void} + */ + function showPreview(widgetUniqueId, widgetPreview) { + // do not reload id widget already displayed + if ($('#' + widgetUniqueId, widgetPreview).length) return; + + var settings = widgetPreview.settings; + + var previewElement = createPreviewElement(widgetPreview); + + var widget = widgetsHelper.getWidgetObjectFromUniqueId(widgetUniqueId); + var widgetParameters = widget['parameters']; + + var emptyWidgetHtml = require('piwik/UI/Dashboard').WidgetFactory.make( + widgetUniqueId, + $('
        ') + .attr('title', _pk_translate("Dashboard_AddPreviewedWidget")) + .text(_pk_translate('Dashboard_WidgetPreview')) + ); + previewElement.html(emptyWidgetHtml); + + var onWidgetLoadedCallback = function (response) { + var widgetElement = $('#' + widgetUniqueId); + $('.widgetContent', widgetElement).html($(response)); + $('.widgetContent', widgetElement).trigger('widget:create'); + settings.onPreviewLoaded(widgetUniqueId, widgetElement); + $('.' + settings.widgetpreviewClass + ' .widgetTop', widgetPreview).on('click', function () { + settings.onSelect(widgetUniqueId); + if (settings.resetOnSelect) { + resetWidgetPreview(widgetPreview); + } + return false; + }); + }; + + // abort previous sent request + if (widgetPreview.widgetAjaxRequest) { + widgetPreview.widgetAjaxRequest.abort(); + } + + widgetPreview.widgetAjaxRequest = widgetsHelper.loadWidgetAjax(widgetUniqueId, widgetParameters, onWidgetLoadedCallback); + } + + /** + * Reset function + * + * @return {void} + */ + function resetWidgetPreview(widgetPreview) { + var settings = widgetPreview.settings; + + $('.' + settings.categorylistClass + ' li', widgetPreview).removeClass(settings.choosenClass); + createWidgetList(widgetPreview); + createPreviewElement(widgetPreview); + } + + /** + * Constructor + * + * @param {object} userSettings Settings to be used + * @return {void} + */ + this.construct = function (userSettings) { + + if (userSettings == 'reset') { + resetWidgetPreview(this); + return; + } + + this.widgetAjaxRequest = null; + + $(this).addClass('widgetpreview-base'); + + this.settings = jQuery.extend({}, defaultSettings, userSettings); + + // set onSelect callback + if (typeof this.settings.onSelect == 'function') { + this.onSelect = this.settings.onSelect; + } + + // set onPreviewLoaded callback + if (typeof this.settings.onPreviewLoaded == 'function') { + this.onPreviewLoaded = this.settings.onPreviewLoaded; + } + + availableWidgets = widgetsHelper.getAvailableWidgets(); + + var categoryList = createWidgetCategoryList(this, availableWidgets); + + var self = this; + $('li', categoryList).on('mouseover', function () { + var category = $(this).text(); + var widgets = availableWidgets[category]; + $('li', categoryList).removeClass(self.settings.choosenClass); + $(this).addClass(self.settings.choosenClass); + showWidgetList(widgets, self); + createPreviewElement(self); // empty preview + }); + }; + } + }); + + /** + * Makes widgetPreview available with $().widgetPreview() + */ + $.fn.extend({ + widgetPreview: $.widgetPreview.construct + }) +})(jQuery); diff --git a/www/analytics/plugins/Dashboard/stylesheets/dashboard.less b/www/analytics/plugins/Dashboard/stylesheets/dashboard.less new file mode 100644 index 00000000..5fe912a5 --- /dev/null +++ b/www/analytics/plugins/Dashboard/stylesheets/dashboard.less @@ -0,0 +1,540 @@ +#dashboard { + margin: 0 -7px; +} + +#root>.top_controls { + margin-left:15px; + margin-right:15px; +} +.top_controls { + position: relative; + height: 32px; + clear: left; +} + +@media all and (max-width: 749px) { + .top_controls { + height: auto; + } + + .top_controls #periodString, + .top_controls .dashboardSettings, + .top_controls #segmentEditorPanel { + position: static; + margin: 0 0 10px; + float: none; + } +} + +#dashboardWidgetsArea { + padding-bottom: 100px; +} + +.col { + float: left; + min-height: 100px; +} + +.col.width-100 { + width: 100%; +} + +.col.width-75 { + width: 75%; +} + +.col.width-67 { + width: 66.67%; +} + +.col.width-50 { + width: 50%; +} + +.col.width-40 { + width: 40%; +} + +.col.width-33 { + width: 33.33%; +} + +.col.width-30 { + width: 30%; +} + +.col.width-25 { + width: 25%; +} + +.hover { + border: 2px dashed #E3E3E3; +} + +.widget { + background: #fff; + border: 1px solid #bbb6ad; + margin: 10px 7px; + border-radius: 4px; + overflow: hidden; + font-size: 14px; + z-index: 1; +} + +.widgetHover { + border: 1px solid #aba494; +} + +.widget .entityContainer { + width: 100%; +} + +.widget .sparkline { + margin-left: 5px; +} + +.widgetContent.hidden { + position: absolute; + top: -5000px; +} + +.widgetContent.loading { + opacity: 0.5; + background: url(plugins/Zeitgeist/images/loading-blue.gif) no-repeat top right; +} + +.widget h2 { + font-size: 1.2em; + margin-left: 10px; + font-weight: bold; +} + +.widget p { + margin-left: 10px; +} + +.widgetTop { + background: #b5b0a7 url(plugins/Zeitgeist/images/dashboard_h_bg.png) repeat-x 0 0; + cursor: move; + font-size: 10pt; + font-weight: bold; + padding-bottom: 4px; +} + +.widgetTopHover { + background: #C4BBAD url(plugins/Zeitgeist/images/dashboard_h_bg_hover.png) repeat-x 0 0; +} + +.widgetName { + font-size: 18px; + padding: 2px 0 0 10px; + font-weight: normal; + color: #fff; + text-shadow: 1px 1px 2px #7e7363; +} + +// Overriding some dataTable css for better dashboard display +.widget .dataTableWrapper { + width: 100% !important; +} + +.widgetTop .button { + cursor: pointer; + float: right; + margin: 6px 6px 0 0; +} + +.ui-confirm { + display: none; + width: 630px; + background: #fff; + color: #444; + cursor: default; + font-size: 12px !important; + font-family: Arial, Verdana, Arial, Helvetica, sans-serif; + border-radius: 4px; + padding: 20px 10px; + border-radius: 4px; + min-height: 0 !important; +} + +.ui-confirm p { + margin-top:10px; + +} +.ui-confirm h2 { + text-align: center; + font-weight: bold; + padding: 0; +} + +.ui-dialog .ui-dialog-buttonpane { + text-align: center; + border: none; +} + +.ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset { + float: none; +} + +.ui-dialog-buttonset input[type=button], .ui-dialog-buttonset button { + background: #B5B0A7 url(plugins/Zeitgeist/images/dashboard_h_bg_hover.png) repeat-x 0 0 !important; + color: #fff !important; + border: 0 !important; + font-size: 12px !important; + padding: 5px 20px !important; + border-radius: 3px; + cursor: pointer; + display: inline-block; + margin: 0 8px 3px 8px !important; +} + +.ui-dialog .ui-button-text { + padding: 0 !important; +} + +.ui-widget-overlay { + opacity: 0.6; + background: none #000; + position: fixed; + z-index: 1000; +} + +.ui-dialog { + z-index: 1001; +} + +.menu { + display: none; +} + +.widgetLoading { + cursor: wait; + padding: 10px; + text-align: center; + font-size: 10pt; +} + +#closeMenuIcon { + float: right; + margin: 3px; + cursor: pointer; +} + +.menuClear { + clear: both; + height: 30px; +} + +.dashboardSettings { + position: absolute; + z-index: 120; + background: #f7f7f7; + border: 1px solid #e4e5e4; + padding: 5px 10px 6px 10px; + border-radius: 4px; + color: #444; + font-size: 14px; + cursor: pointer; + overflow: hidden; +} + +.dashboardSettings:hover { + background: #f1f0eb; + border-color: #a9a399; +} + +.dashboardSettings.visible { + z-index: 1020; /* More than .jqplot-seriespicker-popover (1010) */ +} + +.dashboardSettings > span { + background: url(plugins/Zeitgeist/images/sort_subtable_desc.png) right center no-repeat; + padding-right: 20px; + display: block; +} + +.dashboardSettings ul.submenu { + padding-top: 5px; + display: none; + float: left; +} + +.dashboardSettings.visible ul.submenu { + display: block; + list-style: square outside none; + margin-left: 15px; +} + +.dashboardSettings > ul.submenu > li { + padding: 5px 0; + clear: both; +} + +.dashboardSettings > ul.submenu > li:hover { + color: #e87500; +} + +#changeDashboardLayout h2 { + margin-bottom: 20px; +} + +#columnPreview { + clear: both; + width: 400px; + margin: auto; +} + +#columnPreview > div { + margin: 5px; + float: left; + opacity: 0.4; + cursor: pointer; + filter: Alpha(opacity=40); +} + +#columnPreview > div:hover, #columnPreview > div.choosen { + opacity: 1; + filter: Alpha(opacity=100); +} + +#columnPreview div div { + height: 120px; + float: left; +} + +#columnPreview div div span { + background-color: #ddd; + width: 100%; + height: 100%; + display: block; + border: 2px dotted #555; + margin: 0 1px; +} + +#columnPreview div.choosen div span, #columnPreview div:hover div span { + border-style: solid; +} + +#columnPreview .width-100 { + width: 120px; +} + +#columnPreview .width-75 { + width: 90px; +} + +#columnPreview .width-67 { + width: 80.4px; +} + +#columnPreview .width-50 { + width: 60px; +} + +#columnPreview .width-40 { + width: 48px; +} + +#columnPreview .width-33 { + width: 40px; +} + +#columnPreview .width-30 { + width: 36px; +} + +#columnPreview .width-25 { + width: 30px; +} + +/** + * Layout for widget previews + */ + +.widgetpreview-base { + clear: both; + min-height: 600px; + -height: 600px; +} + +.addWidget, .manageDashboard { + cursor: default; +} + +ul.widgetpreview-widgetlist, +ul.widgetpreview-categorylist { + color: #5d5342; + list-style: none; + font-size: 11px; + line-height: 20px; + float: left; + margin-right: 20px; +} + +ul.widgetpreview-categorylist { + cursor: default; +} + +ul.widgetpreview-categorylist li, +ul.widgetpreview-widgetlist li { + line-height: 20px; + padding: 0 25px 0 5px; + border-radius: 2px; +} + +.widgetpreview-base li.widgetpreview-choosen { + background: #e4e2d7 url(plugins/Zeitgeist/images/arr_r.png) no-repeat right 6px; + color: #255792; + font-weight: bold; +} + +.widgetpreview-categorylist li.widgetpreview-choosen { + color: #000; +} + +.widgetpreview-base li.widgetpreview-unavailable { + color: #D3D3D3; + cursor: default; +} + +ul.widgetpreview-widgetlist { + cursor: pointer; + position: relative; + top: 0; +} + +div.widgetpreview-preview { + float: left; + width: 500px; +} + +.dashboardSettings { + min-height: 0; + height: auto; + margin-right: 10px; +} + +.dashboardSettings .submenu { + font-weight: bold; + color: #255792; +} + +.dashboardSettings .submenu ul { + float: none; + font-weight: normal; + padding-top: 10px; + margin-left: 10px; + color: #5D5342; + list-style: none; + font-size: 11px; + line-height: 20px; + margin-right: 20px; +} + +.dashboardSettings .submenu ul li { + line-height: 20px; + padding: 0 25px 0 5px; + color: #444; +} + +.dashboardSettings .submenu ul li:hover { + color: #e87500; +} + +.dashboardSettings .widgetpreview-widgetlist { + width: 228px; + font-weight: normal; +} + +.dashboardSettings .widgetTop { + cursor: pointer; +} + +.dashboardSettings .widgetpreview-widgetlist, +.dashboardSettings .widgetpreview-preview { + display: none; +} + +.dashboardSettings.visible .widgetpreview-widgetlist, +.dashboardSettings.visible .widgetpreview-preview { + display: block; +} + +.widgetPlaceholder { + border: 1px dashed #bbb6ad; +} + +#newDashboardName, #createDashboardName { + width: 200px; +} + +#newDashboardNameInput, #createDashboardNameInput { + margin: 20px 0 0 100px; + text-align: left; +} + +#createDashboardNameInput input { + margin-bottom: 10px; +} + +.popoverSubMessage { + text-align: center; + padding: 10px 0 5px 0; +} + +#copyDashboardToUserConfirm .inputs { + width: 375px; + margin: 10px auto 0; +} + +#copyDashboardToUserConfirm .inputs select, +#copyDashboardToUserConfirm .inputs input { + width: 150px; + float: left; +} + +#copyDashboardToUserConfirm .inputs label { + width: 200px; + float: left; +} + +@media all and (max-width: 749px) { + #dashboardWidgetsArea { + padding-right: 7px; + } + + .col.width-75, + .col.width-67, + .col.width-50, + .col.width-40, + .col.width-33, + .col.width-30, + .col.width-25 { + width: 100%; + .widget { + margin-right: 0; + } + } + +} + +.widgetTop .button { + display:none; +} + +.widgetTop.widgetTopHover .button { + display:block; +} + +.widget.hiddenContent .widgetTop.widgetTopHover { + .button#minimise,.button#refresh { + display:none; + } +} + +.ui-dialog .widget { + .button#close,.button#maximise { + display:none; + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Dashboard/stylesheets/standalone.css b/www/analytics/plugins/Dashboard/stylesheets/standalone.css new file mode 100644 index 00000000..5a2b0d90 --- /dev/null +++ b/www/analytics/plugins/Dashboard/stylesheets/standalone.css @@ -0,0 +1,72 @@ + +body { + padding-left: 7px; +} + +#dashboard { + margin: 30px -6px 0 -12px; + width: 100%; + padding-top: 8px; +} + +#Dashboard { + z-index: 5; + font-size: 14px; + cursor: pointer; + float: left; + position: relative; +} + +#Dashboard > ul { + list-style: square inside none; + background: #f7f7f7; + border: 1px solid #e4e5e4; + padding: 5px 10px 6px 10px; + border-radius: 4px; + color: #444; + height: 18px; +} + +#Dashboard:hover ul { + background: #f1f0eb; + border-color: #a9a399; +} + +#Dashboard > ul > li { + float: left; + margin-right: 15px; +} + +#Dashboard a { + color: #444; + text-decoration: none; + font-weight: normal; + display: inline-block; +} + +#Dashboard > ul > li:hover, +#Dashboard > ul > li:hover a, +#Dashboard > ul > li.sfHover, +#Dashboard > ul > li.sfHover a { + color: #e87500; +} + +#Dashboard > ul > li.sfHover, +#Dashboard > ul > li.sfHover a { + font-weight: bold; +} + +.top_controls > #Dashboard, +.top_controls > #periodString, +.top_controls > .dashboardSettings { + margin-left: 0; + margin-right: 10px; +} + +.jqplot-seriespicker-popover { + top: 0; +} + +#ajaxLoading { + margin: 40px 0 -30px 0; +} \ No newline at end of file diff --git a/www/analytics/plugins/Dashboard/templates/_dashboardSettings.twig b/www/analytics/plugins/Dashboard/templates/_dashboardSettings.twig new file mode 100644 index 00000000..27209a14 --- /dev/null +++ b/www/analytics/plugins/Dashboard/templates/_dashboardSettings.twig @@ -0,0 +1,22 @@ +{{ 'Dashboard_WidgetsAndDashboard'|translate }} + +
          +
          \ No newline at end of file diff --git a/www/analytics/plugins/Dashboard/templates/_header.twig b/www/analytics/plugins/Dashboard/templates/_header.twig new file mode 100644 index 00000000..055129f4 --- /dev/null +++ b/www/analytics/plugins/Dashboard/templates/_header.twig @@ -0,0 +1,16 @@ +{# This header is for loading the dashboard in stand alone mode #} + + + + + {{ 'Dashboard_Dashboard'|translate }} - {{ 'CoreHome_WebAnalyticsReports'|translate }} + + + + +{% include "_jsGlobalVariables.twig" %} +{% include "_jsCssIncludes.twig" %} + + diff --git a/www/analytics/plugins/Dashboard/templates/_widgetFactoryTemplate.twig b/www/analytics/plugins/Dashboard/templates/_widgetFactoryTemplate.twig new file mode 100644 index 00000000..511fb18c --- /dev/null +++ b/www/analytics/plugins/Dashboard/templates/_widgetFactoryTemplate.twig @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/www/analytics/plugins/Dashboard/templates/embeddedIndex.twig b/www/analytics/plugins/Dashboard/templates/embeddedIndex.twig new file mode 100644 index 00000000..c96e765d --- /dev/null +++ b/www/analytics/plugins/Dashboard/templates/embeddedIndex.twig @@ -0,0 +1,98 @@ + +
          +
          +

          {{ 'Dashboard_DeleteWidgetConfirm'|translate }}

          + + +
          + +
          +

          {{ 'Dashboard_SetAsDefaultWidgetsConfirm'|translate }}

          + {% set resetDashboard %}{{ 'Dashboard_ResetDashboard'|translate }}{% endset %} +
          {{ 'Dashboard_SetAsDefaultWidgetsConfirmHelp'|translate(resetDashboard) }}
          + + +
          + +
          +

          {{ 'Dashboard_ResetDashboardConfirm'|translate }}

          + + +
          + +
          +

          {{ 'Dashboard_DashboardEmptyNotification'|translate }}

          + + +
          + +
          +

          {{ 'Dashboard_SelectDashboardLayout'|translate }}

          + +
          + {% for layout in availableLayouts %} +
          + {% for column in layout %} +
          + {% endfor %} +
          + {% endfor %} +
          + +
          + +
          +

          {{ 'Dashboard_RenameDashboard'|translate }}

          + +
          + +
          + + +
          + + {% if isSuperUser %} +
          +

          {{ 'Dashboard_CopyDashboardToUser'|translate }}

          + +
          + + + + +
          + + +
          + {% endif %} + +
          +

          {{ 'Dashboard_CreateNewDashboard'|translate }}

          + +
          +
          +
          + +
          + + +
          + +
          +

          {{ 'Dashboard_RemoveDashboardConfirm'|translate('')|raw }}

          + +
          {{ 'Dashboard_NotUndo'|translate(resetDashboard) }}
          + + +
          + + {% include "@Dashboard/_widgetFactoryTemplate.twig" %} + +
          +
          \ No newline at end of file diff --git a/www/analytics/plugins/Dashboard/templates/index.twig b/www/analytics/plugins/Dashboard/templates/index.twig new file mode 100644 index 00000000..0a8831fe --- /dev/null +++ b/www/analytics/plugins/Dashboard/templates/index.twig @@ -0,0 +1,20 @@ +{% include "@Dashboard/_header.twig" %} +
          +{% import 'ajaxMacros.twig' as ajax %} +{{ ajax.loadingDiv }} +{% include "@Dashboard/embeddedIndex.twig" %} + + \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/API.php b/www/analytics/plugins/DevicesDetection/API.php new file mode 100644 index 00000000..3426aed7 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/API.php @@ -0,0 +1,153 @@ +getDataTable($name); + $dataTable->filter('Sort', array(Metrics::INDEX_NB_VISITS)); + $dataTable->queueFilter('ReplaceColumnNames'); + $dataTable->queueFilter('ReplaceSummaryRowLabel'); + return $dataTable; + } + + /** + * Gets datatable displaying number of visits by device type (eg. desktop, smartphone, tablet) + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * @return DataTable + */ + public function getType($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getDataTable('DevicesDetection_types', $idSite, $period, $date, $segment); + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'logo', __NAMESPACE__ . '\getDeviceTypeLogo')); + $dataTable->filter('ColumnCallbackReplace', array('label', __NAMESPACE__ . '\getDeviceTypeLabel')); + return $dataTable; + } + + /** + * Gets datatable displaying number of visits by device manufacturer name + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * @return DataTable + */ + public function getBrand($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getDataTable('DevicesDetection_brands', $idSite, $period, $date, $segment); + $dataTable->filter('ColumnCallbackReplace', array('label', __NAMESPACE__ . '\getDeviceBrandLabel')); + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'logo', __NAMESPACE__ . '\getBrandLogo')); + return $dataTable; + } + + /** + * Gets datatable displaying number of visits by device model + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * @return DataTable + */ + public function getModel($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getDataTable('DevicesDetection_models', $idSite, $period, $date, $segment); + $dataTable->filter('ColumnCallbackReplace', array('label', __NAMESPACE__ . '\getModelName')); + return $dataTable; + } + + /** + * Gets datatable displaying number of visits by OS family (eg. Windows, Android, Linux) + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * @return DataTable + */ + public function getOsFamilies($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getDataTable('DevicesDetection_os', $idSite, $period, $date, $segment); + $dataTable->filter('GroupBy', array('label', __NAMESPACE__ . '\getOSFamilyFullNameExtended')); + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'logo', __NAMESPACE__ . '\getOsFamilyLogoExtended')); + return $dataTable; + } + + /** + * Gets datatable displaying number of visits by OS version (eg. Android 4.0, Windows 7) + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * @return DataTable + */ + public function getOsVersions($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getDataTable('DevicesDetection_osVersions', $idSite, $period, $date, $segment); + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'logo', __NAMESPACE__ . '\getOsLogoExtended')); + $dataTable->filter('ColumnCallbackReplace', array('label', __NAMESPACE__ . '\getOsFullNameExtended')); + + return $dataTable; + } + + /** + * Gets datatable displaying number of visits by Browser family (eg. Firefox, InternetExplorer) + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * @return DataTable + */ + public function getBrowserFamilies($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getDataTable('DevicesDetection_browsers', $idSite, $period, $date, $segment); + $dataTable->filter('GroupBy', array('label', __NAMESPACE__ . '\getBrowserFamilyFullNameExtended')); + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'logo', __NAMESPACE__ . '\getBrowserFamilyLogoExtended')); + return $dataTable; + } + + /** + * Gets datatable displaying number of visits by Browser version (eg. Firefox 20, Safari 6.0) + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * @return DataTable + */ + public function getBrowserVersions($idSite, $period, $date, $segment = false) + { + $dataTable = $this->getDataTable('DevicesDetection_browserVersions', $idSite, $period, $date, $segment); + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'logo', __NAMESPACE__ . '\getBrowserLogoExtended')); + $dataTable->filter('ColumnCallbackReplace', array('label', __NAMESPACE__ . '\getBrowserNameExtended')); + return $dataTable; + } +} diff --git a/www/analytics/plugins/DevicesDetection/Archiver.php b/www/analytics/plugins/DevicesDetection/Archiver.php new file mode 100644 index 00000000..dfd42e06 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/Archiver.php @@ -0,0 +1,67 @@ +aggregateByLabel(self::DEVICE_TYPE_FIELD, self::DEVICE_TYPE_RECORD_NAME); + $this->aggregateByLabel(self::DEVICE_BRAND_FIELD, self::DEVICE_BRAND_RECORD_NAME); + $this->aggregateByLabel(self::DEVICE_MODEL_FIELD, self::DEVICE_MODEL_RECORD_NAME); + $this->aggregateByLabel(self::OS_FIELD, self::OS_RECORD_NAME); + $this->aggregateByLabel(self::OS_VERSION_FIELD, self::OS_VERSION_RECORD_NAME); + $this->aggregateByLabel(self::BROWSER_FIELD, self::BROWSER_RECORD_NAME); + $this->aggregateByLabel(self::BROWSER_VERSION_DIMENSION, self::BROWSER_VERSION_RECORD_NAME); + } + + public function aggregateMultipleReports() + { + $dataTablesToSum = array( + self::DEVICE_TYPE_RECORD_NAME, + self::DEVICE_BRAND_RECORD_NAME, + self::DEVICE_MODEL_RECORD_NAME, + self::OS_RECORD_NAME, + self::OS_VERSION_RECORD_NAME, + self::BROWSER_RECORD_NAME, + self::BROWSER_VERSION_RECORD_NAME + ); + foreach ($dataTablesToSum as $dt) { + $this->getProcessor()->aggregateDataTableRecords( + $dt, $this->maximumRows, $this->maximumRows, $columnToSort = "nb_visits"); + } + } + + private function aggregateByLabel($labelSQL, $recordName) + { + $metrics = $this->getLogAggregator()->getMetricsFromVisitByDimension($labelSQL)->asDataTable(); + $report = $metrics->getSerialized($this->maximumRows, null, Metrics::INDEX_NB_VISITS); + $this->getProcessor()->insertBlobRecord($recordName, $report); + } + +} diff --git a/www/analytics/plugins/DevicesDetection/Controller.php b/www/analytics/plugins/DevicesDetection/Controller.php new file mode 100644 index 00000000..be2a2bea --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/Controller.php @@ -0,0 +1,172 @@ +deviceTypes = $view->deviceModels = $view->deviceBrands = $view->osReport = $view->browserReport = "blank"; + $view->deviceTypes = $this->getType(true); + $view->deviceBrands = $this->getBrand(true); + $view->deviceModels = $this->getModel(true); + $view->osReport = $this->getOsFamilies(true); + $view->browserReport = $this->getBrowserFamilies(true); + return $view->render(); + } + + public function getType() + { + return $this->renderReport(__FUNCTION__); + } + + public function getBrand() + { + return $this->renderReport(__FUNCTION__); + } + + public function getModel() + { + return $this->renderReport(__FUNCTION__); + } + + public function getOsFamilies() + { + return $this->renderReport(__FUNCTION__); + } + + public function getOsVersions() + { + return $this->renderReport(__FUNCTION__); + } + + public function getBrowserFamilies() + { + return $this->renderReport(__FUNCTION__); + } + + public function getBrowserVersions() + { + return $this->renderReport(__FUNCTION__); + } + + public function deviceDetection() + { + Piwik::checkUserHasSomeAdminAccess(); + + $view = new View('@DevicesDetection/detection'); + $this->setBasicVariablesView($view); + ControllerAdmin::setBasicVariablesAdminView($view); + + $userAgent = Common::getRequestVar('ua', $_SERVER['HTTP_USER_AGENT'], 'string'); + + $parsedUA = DeviceDetector::getInfoFromUserAgent($userAgent); + + $view->userAgent = $userAgent; + $view->browser_name = $parsedUA['browser']['name']; + $view->browser_short_name = $parsedUA['browser']['short_name']; + $view->browser_version = $parsedUA['browser']['version']; + $view->browser_logo = getBrowserLogoExtended($parsedUA['browser']['short_name']); + $view->browser_family = $parsedUA['browser_family']; + $view->browser_family_logo = getBrowserFamilyLogoExtended($parsedUA['browser_family']); + $view->os_name = $parsedUA['os']['name']; + $view->os_logo = getOsLogoExtended($parsedUA['os']['short_name']); + $view->os_short_name = $parsedUA['os']['short_name']; + $view->os_family = $parsedUA['os_family']; + $view->os_family_logo = getOsFamilyLogoExtended($parsedUA['os_family']); + $view->os_version = $parsedUA['os']['version']; + $view->device_type = getDeviceTypeLabel($parsedUA['device']['type']); + $view->device_type_logo = getDeviceTypeLogo($parsedUA['device']['type']); + $view->device_model = $parsedUA['device']['model']; + $view->device_brand = getDeviceBrandLabel($parsedUA['device']['brand']); + $view->device_brand_logo = getBrandLogo($view->device_brand); + + return $view->render(); + } + + public function showList() + { + Piwik::checkUserHasSomeAdminAccess(); + + $view = new View('@DevicesDetection/list'); + + $type = Common::getRequestVar('type', 'brands', 'string'); + + $list = array(); + + switch ($type) { + case 'brands': + $availableBrands = DeviceDetector::$deviceBrands; + + foreach ($availableBrands AS $short => $name) { + if ($name != 'Unknown') { + $list[$name] = getBrandLogo($name); + } + } + break; + + case 'browsers': + $availableBrowsers = DeviceDetector::$browsers; + + foreach ($availableBrowsers AS $short => $name) { + $list[$name] = getBrowserLogoExtended($short); + } + break; + + case 'browserfamilies': + $availableBrowserFamilies = DeviceDetector::$browserFamilies; + + foreach ($availableBrowserFamilies AS $name => $browsers) { + $list[$name] = getBrowserFamilyLogoExtended($name); + } + break; + + case 'os': + $availableOSs = DeviceDetector::$osShorts; + + foreach ($availableOSs AS $name => $short) { + if ($name != 'Bot') { + $list[$name] = getOsLogoExtended($short); + } + } + break; + + case 'osfamilies': + $osFamilies = DeviceDetector::$osFamilies; + + foreach ($osFamilies AS $name => $oss) { + if ($name != 'Bot') { + $list[$name] = getOsFamilyLogoExtended($name); + } + } + break; + + case 'devicetypes': + $deviceTypes = DeviceDetector::$deviceTypes; + + foreach ($deviceTypes AS $name) { + $list[$name] = getDeviceTypeLogo($name); + } + break; + } + + $view->itemList = $list; + + return $view->render(); + } +} diff --git a/www/analytics/plugins/DevicesDetection/DevicesDetection.php b/www/analytics/plugins/DevicesDetection/DevicesDetection.php new file mode 100644 index 00000000..27d9ff4e --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/DevicesDetection.php @@ -0,0 +1,380 @@ + "[Beta Plugin] " . Piwik::translate("DevicesDetection_PluginDescription"), + 'authors' => array(array('name' => 'Piwik PRO', 'homepage' => 'http://piwik.pro')), + 'version' => '1.14', + 'license' => 'GPL v3+', + 'license_homepage' => 'http://www.gnu.org/licenses/gpl.html' + ); + } + + /** The set of related reports displayed under the 'Operating Systems' header. */ + private $osRelatedReports = null; + private $browserRelatedReports = null; + + public function __construct() + { + parent::__construct(); + $this->osRelatedReports = array( + 'DevicesDetection.getOsFamilies' => Piwik::translate('DevicesDetection_OperatingSystemFamilies'), + 'DevicesDetection.getOsVersions' => Piwik::translate('DevicesDetection_OperatingSystemVersions') + ); + $this->browserRelatedReports = array( + 'DevicesDetection.getBrowserFamilies' => Piwik::translate('UserSettings_BrowserFamilies'), + 'DevicesDetection.getBrowserVersions' => Piwik::translate('DevicesDetection_BrowserVersions') + ); + } + + protected function getRawMetadataDeviceType() + { + $deviceTypeList = implode(", ", DeviceDetector::$deviceTypes); + + $deviceTypeLabelToCode = function ($type) use ($deviceTypeList) { + $index = array_search(strtolower(trim(urldecode($type))), DeviceDetector::$deviceTypes); + if ($index === false) { + throw new Exception("deviceType segment must be one of: $deviceTypeList"); + } + return $index; + }; + + return array( + 'DevicesDetection_DevicesDetection', + 'DevicesDetection_DeviceType', + 'DevicesDetection', + 'getType', + 'DevicesDetection_DeviceType', + + // Segment + 'deviceType', + 'log_visit.config_device_type', + $deviceTypeList, + $deviceTypeLabelToCode + ); + } + + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'Menu.Reporting.addItems' => 'addMenu', + 'Menu.Admin.addItems' => 'addAdminMenu', + 'Tracker.newVisitorInformation' => 'parseMobileVisitData', + 'WidgetsList.addWidgets' => 'addWidgets', + 'API.getReportMetadata' => 'getReportMetadata', + 'API.getSegmentDimensionMetadata' => 'getSegmentsMetadata', + 'ViewDataTable.configure' => 'configureViewDataTable', + ); + } + + public function addAdminMenu() + { + MenuAdmin::getInstance()->add( + 'CoreAdminHome_MenuDiagnostic', 'DevicesDetection_DeviceDetection', + array('module' => 'DevicesDetection', 'action' => 'deviceDetection'), + Piwik::isUserHasSomeAdminAccess(), + $order = 40 + ); + } + + /** + * Defines API reports. + * Also used to define Widgets, and Segment(s) + * + * @return array Category, Report Name, API Module, API action, Translated column name, & optional segment info + */ + protected function getRawMetadataReports() + { + + $report = array( + // device type report (tablet, desktop, mobile...) + $this->getRawMetadataDeviceType(), + + // device brands report + array( + 'DevicesDetection_DevicesDetection', + 'DevicesDetection_DeviceBrand', + 'DevicesDetection', + 'getBrand', + 'DevicesDetection_DeviceBrand', + ), + // device model report + array( + 'DevicesDetection_DevicesDetection', + 'DevicesDetection_DeviceModel', + 'DevicesDetection', + 'getModel', + 'DevicesDetection_DeviceModel', + ), + // device OS family report + array( + 'DevicesDetection_DevicesDetection', + 'DevicesDetection_OperatingSystemFamilies', + 'DevicesDetection', + 'getOsFamilies', + 'DevicesDetection_OperatingSystemFamilies', + ), + // device OS version report + array( + 'DevicesDetection_DevicesDetection', + 'DevicesDetection_OperatingSystemVersions', + 'DevicesDetection', + 'getOsVersions', + 'DevicesDetection_OperatingSystemVersions', + ), + // Browser family report + array( + 'DevicesDetection_DevicesDetection', + 'UserSettings_BrowserFamilies', + 'DevicesDetection', + 'getBrowserFamilies', + 'UserSettings_BrowserFamilies', + ), + // Browser versions report + array( + 'DevicesDetection_DevicesDetection', + 'DevicesDetection_BrowserVersions', + 'DevicesDetection', + 'getBrowserVersions', + 'DevicesDetection_BrowserVersions', + ), + ); + return $report; + } + + public function addWidgets() + { + foreach ($this->getRawMetadataReports() as $report) { + list($category, $name, $controllerName, $controllerAction) = $report; + if ($category == false) + continue; + WidgetsList::add($category, $name, $controllerName, $controllerAction); + } + } + + /** + * Get segments meta data + */ + public function getSegmentsMetadata(&$segments) + { + // Note: only one field segmented so far: deviceType + foreach ($this->getRawMetadataReports() as $report) { + @list($category, $name, $apiModule, $apiAction, $columnName, $segment, $sqlSegment, $acceptedValues, $sqlFilter) = $report; + + if (empty($segment)) continue; + + $segments[] = array( + 'type' => 'dimension', + 'category' => Piwik::translate('General_Visit'), + 'name' => $columnName, + 'segment' => $segment, + 'acceptedValues' => $acceptedValues, + 'sqlSegment' => $sqlSegment, + 'sqlFilter' => isset($sqlFilter) ? $sqlFilter : false + ); + } + } + + public function getReportMetadata(&$reports) + { + $i = 0; + foreach ($this->getRawMetadataReports() as $report) { + list($category, $name, $apiModule, $apiAction, $columnName) = $report; + if ($category == false) + continue; + + $report = array( + 'category' => Piwik::translate($category), + 'name' => Piwik::translate($name), + 'module' => $apiModule, + 'action' => $apiAction, + 'dimension' => Piwik::translate($columnName), + 'order' => $i++ + ); + + $translation = $name . 'Documentation'; + $translated = Piwik::translate($translation, '
          '); + if ($translated != $translation) { + $report['documentation'] = $translated; + } + + $reports[] = $report; + } + } + + public function install() + { +// we catch the exception + try { + $q1 = "ALTER TABLE `" . Common::prefixTable("log_visit") . "` + ADD `config_os_version` VARCHAR( 100 ) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL AFTER `config_os` , + ADD `config_device_type` TINYINT( 100 ) NULL DEFAULT NULL AFTER `config_browser_version` , + ADD `config_device_brand` VARCHAR( 100 ) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL AFTER `config_device_type` , + ADD `config_device_model` VARCHAR( 100 ) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL AFTER `config_device_brand`"; + Db::exec($q1); + + } catch (Exception $e) { + if (!Db::get()->isErrNo($e, '1060')) { + throw $e; + } + } + } + + public function parseMobileVisitData(&$visitorInfo, \Piwik\Tracker\Request $request) + { + $userAgent = $request->getUserAgent(); + + $UAParser = new DeviceDetector($userAgent); + $UAParser->setCache(new CacheFile('tracker', 86400)); + $UAParser->parse(); + $deviceInfo['config_browser_name'] = $UAParser->getBrowser("short_name"); + $deviceInfo['config_browser_version'] = $UAParser->getBrowser("version"); + $deviceInfo['config_os'] = $UAParser->getOs("short_name"); + $deviceInfo['config_os_version'] = $UAParser->getOs("version"); + $deviceInfo['config_device_type'] = $UAParser->getDevice(); + $deviceInfo['config_device_model'] = $UAParser->getModel(); + $deviceInfo['config_device_brand'] = $UAParser->getBrand(); + + $visitorInfo = array_merge($visitorInfo, $deviceInfo); + Common::printDebug("Device Detection:"); + Common::printDebug($deviceInfo); + } + + public function addMenu() + { + MenuMain::getInstance()->add('General_Visitors', 'DevicesDetection_submenu', array('module' => 'DevicesDetection', 'action' => 'index')); + } + + public function configureViewDataTable(ViewDataTable $view) + { + switch ($view->requestConfig->apiMethodToRequestDataTable) { + case 'DevicesDetection.getType': + $this->configureViewForGetType($view); + break; + case 'DevicesDetection.getBrand': + $this->configureViewForGetBrand($view); + break; + case 'DevicesDetection.getModel': + $this->configureViewForGetModel($view); + break; + case 'DevicesDetection.getOsFamilies': + $this->configureViewForGetOsFamilies($view); + break; + case 'DevicesDetection.getOsVersions': + $this->configureViewForGetOsVersions($view); + break; + case 'DevicesDetection.getBrowserFamilies': + $this->configureViewForGetBrowserFamilies($view); + break; + case 'DevicesDetection.getBrowserVersions': + $this->configureViewForGetBrowserVersions($view); + break; + } + } + + private function configureViewForGetType(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate("DevicesDetection_dataTableLabelTypes")); + } + + private function configureViewForGetBrand(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate("DevicesDetection_dataTableLabelBrands")); + } + + private function configureViewForGetModel(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate("DevicesDetection_dataTableLabelModels")); + } + + private function configureViewForGetOsFamilies(ViewDataTable $view) + { + $view->config->title = Piwik::translate('DevicesDetection_OperatingSystemFamilies'); + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate("UserSettings_OperatingSystemFamily")); + $view->config->addRelatedReports($this->getOsRelatedReports()); + } + + private function configureViewForGetOsVersions(ViewDataTable $view) + { + $view->config->title = Piwik::translate('DevicesDetection_OperatingSystemVersions'); + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate("DevicesDetection_dataTableLabelSystemVersion")); + $view->config->addRelatedReports($this->getOsRelatedReports()); + } + + private function configureViewForGetBrowserFamilies(ViewDataTable $view) + { + $view->config->title = Piwik::translate('UserSettings_BrowserFamilies'); + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate("DevicesDetection_dataTableLabelBrowserFamily")); + $view->config->addRelatedReports($this->getBrowserRelatedReports()); + } + + private function configureViewForGetBrowserVersions(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate("UserSettings_ColumnBrowserVersion")); + $view->config->addRelatedReports($this->getBrowserRelatedReports()); + } + + private function getOsRelatedReports() + { + return array( + 'DevicesDetection.getOsFamilies' => Piwik::translate('DevicesDetection_OperatingSystemFamilies'), + 'DevicesDetection.getOsVersions' => Piwik::translate('DevicesDetection_OperatingSystemVersions') + ); + } + + private function getBrowserRelatedReports() + { + return array( + 'DevicesDetection.getBrowserFamilies' => Piwik::translate('UserSettings_BrowserFamilies'), + 'DevicesDetection.getBrowserVersions' => Piwik::translate('DevicesDetection_BrowserVersions') + ); + } +} diff --git a/www/analytics/plugins/DevicesDetection/Updates/1.14.php b/www/analytics/plugins/DevicesDetection/Updates/1.14.php new file mode 100644 index 00000000..45b7efea --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/Updates/1.14.php @@ -0,0 +1,37 @@ + false, + ); + } + + static function isMajorUpdate() + { + return true; + } + + static function update() + { + Updater::updateDatabase(__FILE__, self::getSql()); + } + +} diff --git a/www/analytics/plugins/DevicesDetection/functions.php b/www/analytics/plugins/DevicesDetection/functions.php new file mode 100644 index 00000000..a05e2900 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/functions.php @@ -0,0 +1,220 @@ + $family) { + if (in_array($label, $family)) { + return $name; + } + } + return Piwik::translate('General_Unknown'); +} + +function getBrowserFamilyLogoExtended($label) +{ + if (array_key_exists($label, DeviceDetector::$browserFamilies)) { + return getBrowserLogoExtended(DeviceDetector::$browserFamilies[$label][0]); + } + return getBrowserLogoExtended($label); +} + +function getBrowserNameExtended($label) +{ + $short = substr($label, 0, 2); + $ver = substr($label, 3, 10); + if (array_key_exists($short, DeviceDetector::$browsers)) { + return trim(ucfirst(DeviceDetector::$browsers[$short]) . ' ' . $ver); + } else { + return Piwik::translate('General_Unknown'); + } +} + +/** + * Returns the path to the logo for the given browser + * + * First try to find a logo for the given short code + * If none can be found try to find a logo for the browser family + * Return unkown logo otherwise + * + * @param string $short Shortcode or name of browser + * + * @return string path to image + */ +function getBrowserLogoExtended($short) +{ + $path = 'plugins/UserSettings/images/browsers/%s.gif'; + + // If name is given instead of short code, try to find matching shortcode + if (strlen($short) > 2) { + + if (in_array($short, DeviceDetector::$browsers)) { + $flippedBrowsers = array_flip(DeviceDetector::$browsers); + $short = $flippedBrowsers[$short]; + } else { + $short = substr($short, 0, 2); + } + } + + $family = getBrowserFamilyFullNameExtended($short); + + if (array_key_exists($short, DeviceDetector::$browsers) && file_exists(PIWIK_INCLUDE_PATH.'/'.sprintf($path, $short))) { + return sprintf($path, $short); + } elseif (array_key_exists($family, DeviceDetector::$browserFamilies) && file_exists(PIWIK_INCLUDE_PATH.'/'.sprintf($path, DeviceDetector::$browserFamilies[$family][0]))) { + return sprintf($path, DeviceDetector::$browserFamilies[$family][0]); + } + return sprintf($path, 'UNK'); +} + +function getDeviceBrandLabel($label) +{ + if (array_key_exists($label, DeviceDetector::$deviceBrands)) { + return ucfirst(DeviceDetector::$deviceBrands[$label]); + } else { + return Piwik::translate('General_Unknown'); + } +} + +function getDeviceTypeLabel($label) +{ + $translations = array( + 'desktop' => 'General_Desktop', + 'smartphone' => 'DevicesDetection_Smartphone', + 'tablet' => 'DevicesDetection_Tablet', + 'feature phone' => 'DevicesDetection_FeaturePhone', + 'console' => 'DevicesDetection_Console', + 'tv' => 'DevicesDetection_TV', + 'car browser' => 'DevicesDetection_CarBbrowser', + 'smart display' => 'DevicesDetection_SmartDisplay', + 'camera' => 'DevicesDetection_Camera' + ); + if (isset(DeviceDetector::$deviceTypes[$label]) && isset($translations[DeviceDetector::$deviceTypes[$label]])) { + return Piwik::translate($translations[DeviceDetector::$deviceTypes[$label]]); + } else if (isset($translations[$label])) { + return Piwik::translate($translations[$label]); + } else { + return Piwik::translate('General_Unknown'); + } +} + +function getDeviceTypeLogo($label) +{ + if (is_numeric($label) && isset(DeviceDetector::$deviceTypes[$label])) { + $label = DeviceDetector::$deviceTypes[$label]; + } + + $label = strtolower($label); + + $deviceTypeLogos = Array( + "desktop" => "normal.gif", + "smartphone" => "smartphone.png", + "tablet" => "tablet.png", + "tv" => "tv.png", + "feature phone" => "mobile.gif", + "console" => "console.gif", + "car browser" => "carbrowser.png", + "camera" => "camera.png"); + + if (!array_key_exists($label, $deviceTypeLogos)) { + $label = 'unknown.gif'; + } else { + $label = $deviceTypeLogos[$label]; + } + $path = 'plugins/DevicesDetection/images/screens/' . $label; + return $path; +} + +function getModelName($label) +{ + if (!$label) { + return Piwik::translate('General_Unknown'); + } + return $label; +} + +function getOSFamilyFullNameExtended($label) +{ + $label = DeviceDetector::getOsFamily($label); + if($label !== false) { + return $label; + } + return Piwik::translate('General_Unknown'); +} + +function getOsFamilyLogoExtended($label) +{ + if (array_key_exists($label, DeviceDetector::$osFamilies)) { + return getOsLogoExtended(DeviceDetector::$osFamilies[$label][0]); + } + return getOsLogoExtended($label); +} + +function getOsFullNameExtended($label) +{ + if (!empty($label) && $label != ";") { + $os = substr($label, 0, 3); + $ver = substr($label, 4, 15); + $name = DeviceDetector::getOsNameFromId($os, $ver); + if (!empty($name)) { + return $name; + } + } + return Piwik::translate('General_Unknown'); +} + +/** + * Returns the path to the logo for the given OS + * + * First try to find a logo for the given short code + * If none can be found try to find a logo for the os family + * Return unkown logo otherwise + * + * @param string $short Shortcode or name of OS + * + * @return string path to image + */ +function getOsLogoExtended($short) +{ + $path = 'plugins/UserSettings/images/os/%s.gif'; + + // If name is given instead of short code, try to find matching shortcode + if (strlen($short) > 3) { + + if (array_key_exists($short, DeviceDetector::$osShorts)) { + $short = DeviceDetector::$osShorts[$short]; + } else { + $short = substr($short, 0, 3); + } + } + + $family = getOsFamilyFullNameExtended($short); + + if (in_array($short, DeviceDetector::$osShorts) && file_exists(PIWIK_INCLUDE_PATH.'/'.sprintf($path, $short))) { + return sprintf($path, $short); + } elseif (array_key_exists($family, DeviceDetector::$osFamilies) && file_exists(PIWIK_INCLUDE_PATH.'/'.sprintf($path, DeviceDetector::$osFamilies[$family][0]))) { + return sprintf($path, DeviceDetector::$osFamilies[$family][0]); + } + return sprintf($path, 'UNK'); +} diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Acer.ico b/www/analytics/plugins/DevicesDetection/images/brand/Acer.ico new file mode 100644 index 00000000..33e2e60c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Acer.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Alcatel.ico b/www/analytics/plugins/DevicesDetection/images/brand/Alcatel.ico new file mode 100644 index 00000000..91ccc702 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Alcatel.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Apple.ico b/www/analytics/plugins/DevicesDetection/images/brand/Apple.ico new file mode 100644 index 00000000..3ce2b7d5 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Apple.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Archos.ico b/www/analytics/plugins/DevicesDetection/images/brand/Archos.ico new file mode 100644 index 00000000..a397830f Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Archos.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Asus.ico b/www/analytics/plugins/DevicesDetection/images/brand/Asus.ico new file mode 100644 index 00000000..d5e7e4a4 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Asus.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Audiovox.ico b/www/analytics/plugins/DevicesDetection/images/brand/Audiovox.ico new file mode 100644 index 00000000..d674fc28 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Audiovox.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Avvio.ico b/www/analytics/plugins/DevicesDetection/images/brand/Avvio.ico new file mode 100644 index 00000000..9f28e175 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Avvio.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/BangOlufsen.ico b/www/analytics/plugins/DevicesDetection/images/brand/BangOlufsen.ico new file mode 100644 index 00000000..c3260f44 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/BangOlufsen.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Becker.ico b/www/analytics/plugins/DevicesDetection/images/brand/Becker.ico new file mode 100644 index 00000000..133ff9ac Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Becker.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Beetel.ico b/www/analytics/plugins/DevicesDetection/images/brand/Beetel.ico new file mode 100644 index 00000000..d7602ecb Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Beetel.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/BenQ.ico b/www/analytics/plugins/DevicesDetection/images/brand/BenQ.ico new file mode 100644 index 00000000..10414987 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/BenQ.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Cat.ico b/www/analytics/plugins/DevicesDetection/images/brand/Cat.ico new file mode 100644 index 00000000..a6b262f1 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Cat.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/CnM.ico b/www/analytics/plugins/DevicesDetection/images/brand/CnM.ico new file mode 100644 index 00000000..dae12ed0 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/CnM.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Compal.ico b/www/analytics/plugins/DevicesDetection/images/brand/Compal.ico new file mode 100644 index 00000000..8f89ab98 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Compal.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/CreNova.ico b/www/analytics/plugins/DevicesDetection/images/brand/CreNova.ico new file mode 100644 index 00000000..282b466e Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/CreNova.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Cricket.ico b/www/analytics/plugins/DevicesDetection/images/brand/Cricket.ico new file mode 100644 index 00000000..1e45f3ae Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Cricket.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/DMM.ico b/www/analytics/plugins/DevicesDetection/images/brand/DMM.ico new file mode 100644 index 00000000..07d2fd3e Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/DMM.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Dell.ico b/www/analytics/plugins/DevicesDetection/images/brand/Dell.ico new file mode 100644 index 00000000..0a719c2c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Dell.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Denver.ico b/www/analytics/plugins/DevicesDetection/images/brand/Denver.ico new file mode 100644 index 00000000..0ae2e621 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Denver.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/DoCoMo.ico b/www/analytics/plugins/DevicesDetection/images/brand/DoCoMo.ico new file mode 100644 index 00000000..cf9f3ef0 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/DoCoMo.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Ericsson.ico b/www/analytics/plugins/DevicesDetection/images/brand/Ericsson.ico new file mode 100644 index 00000000..a377acd2 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Ericsson.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Fly.ico b/www/analytics/plugins/DevicesDetection/images/brand/Fly.ico new file mode 100644 index 00000000..a4ae584c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Fly.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Gemini.ico b/www/analytics/plugins/DevicesDetection/images/brand/Gemini.ico new file mode 100644 index 00000000..deeb01e5 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Gemini.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Google.ico b/www/analytics/plugins/DevicesDetection/images/brand/Google.ico new file mode 100644 index 00000000..fe481b51 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Google.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Gradiente.ico b/www/analytics/plugins/DevicesDetection/images/brand/Gradiente.ico new file mode 100644 index 00000000..28d671df Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Gradiente.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Grundig.ico b/www/analytics/plugins/DevicesDetection/images/brand/Grundig.ico new file mode 100644 index 00000000..0a0351a9 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Grundig.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/HP.ico b/www/analytics/plugins/DevicesDetection/images/brand/HP.ico new file mode 100644 index 00000000..5ca41db9 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/HP.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/HTC.ico b/www/analytics/plugins/DevicesDetection/images/brand/HTC.ico new file mode 100644 index 00000000..28c75f0c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/HTC.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Haier.ico b/www/analytics/plugins/DevicesDetection/images/brand/Haier.ico new file mode 100644 index 00000000..03ccce3f Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Haier.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Huawei.ico b/www/analytics/plugins/DevicesDetection/images/brand/Huawei.ico new file mode 100644 index 00000000..441a4022 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Huawei.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Humax.ico b/www/analytics/plugins/DevicesDetection/images/brand/Humax.ico new file mode 100644 index 00000000..cdb27bf4 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Humax.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/INQ.ico b/www/analytics/plugins/DevicesDetection/images/brand/INQ.ico new file mode 100644 index 00000000..3d012cf2 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/INQ.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Ikea.ico b/www/analytics/plugins/DevicesDetection/images/brand/Ikea.ico new file mode 100644 index 00000000..d9c160a0 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Ikea.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Intek.ico b/www/analytics/plugins/DevicesDetection/images/brand/Intek.ico new file mode 100644 index 00000000..b0d505a3 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Intek.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Inverto.ico b/www/analytics/plugins/DevicesDetection/images/brand/Inverto.ico new file mode 100644 index 00000000..88729a4e Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Inverto.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Jolla.ico b/www/analytics/plugins/DevicesDetection/images/brand/Jolla.ico new file mode 100644 index 00000000..92b85357 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Jolla.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/KDDI.ico b/www/analytics/plugins/DevicesDetection/images/brand/KDDI.ico new file mode 100644 index 00000000..0142b8b0 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/KDDI.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Karbonn.ico b/www/analytics/plugins/DevicesDetection/images/brand/Karbonn.ico new file mode 100644 index 00000000..8f200aea Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Karbonn.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Kindle.ico b/www/analytics/plugins/DevicesDetection/images/brand/Kindle.ico new file mode 100644 index 00000000..cbf91588 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Kindle.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Kyocera.ico b/www/analytics/plugins/DevicesDetection/images/brand/Kyocera.ico new file mode 100644 index 00000000..5bc4da8b Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Kyocera.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/LG.ico b/www/analytics/plugins/DevicesDetection/images/brand/LG.ico new file mode 100644 index 00000000..8e7d07b9 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/LG.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/LGUPlus.ico b/www/analytics/plugins/DevicesDetection/images/brand/LGUPlus.ico new file mode 100644 index 00000000..db5f4fe1 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/LGUPlus.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Lanix.ico b/www/analytics/plugins/DevicesDetection/images/brand/Lanix.ico new file mode 100644 index 00000000..790f8ad8 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Lanix.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Lenovo.ico b/www/analytics/plugins/DevicesDetection/images/brand/Lenovo.ico new file mode 100644 index 00000000..42c92f4e Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Lenovo.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Loewe.ico b/www/analytics/plugins/DevicesDetection/images/brand/Loewe.ico new file mode 100644 index 00000000..aabc403d Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Loewe.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Manta_Multimedia.ico b/www/analytics/plugins/DevicesDetection/images/brand/Manta_Multimedia.ico new file mode 100644 index 00000000..05c79869 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Manta_Multimedia.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/MediaTek.ico b/www/analytics/plugins/DevicesDetection/images/brand/MediaTek.ico new file mode 100644 index 00000000..0901a828 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/MediaTek.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Medion.ico b/www/analytics/plugins/DevicesDetection/images/brand/Medion.ico new file mode 100644 index 00000000..7873a808 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Medion.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Metz.ico b/www/analytics/plugins/DevicesDetection/images/brand/Metz.ico new file mode 100644 index 00000000..58017817 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Metz.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/MicroMax.ico b/www/analytics/plugins/DevicesDetection/images/brand/MicroMax.ico new file mode 100644 index 00000000..1b3beda0 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/MicroMax.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Microsoft.ico b/www/analytics/plugins/DevicesDetection/images/brand/Microsoft.ico new file mode 100644 index 00000000..f1417973 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Microsoft.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Mio.ico b/www/analytics/plugins/DevicesDetection/images/brand/Mio.ico new file mode 100644 index 00000000..a7abb662 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Mio.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Mitsubishi.ico b/www/analytics/plugins/DevicesDetection/images/brand/Mitsubishi.ico new file mode 100644 index 00000000..248a92fc Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Mitsubishi.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Motorola.ico b/www/analytics/plugins/DevicesDetection/images/brand/Motorola.ico new file mode 100644 index 00000000..7a4daa95 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Motorola.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/MyPhone.ico b/www/analytics/plugins/DevicesDetection/images/brand/MyPhone.ico new file mode 100644 index 00000000..37172706 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/MyPhone.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/NEC.ico b/www/analytics/plugins/DevicesDetection/images/brand/NEC.ico new file mode 100644 index 00000000..f2c60d1c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/NEC.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/NGM.ico b/www/analytics/plugins/DevicesDetection/images/brand/NGM.ico new file mode 100644 index 00000000..533c88ba Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/NGM.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Nexian.ico b/www/analytics/plugins/DevicesDetection/images/brand/Nexian.ico new file mode 100644 index 00000000..fba9dd7d Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Nexian.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Nintendo.ico b/www/analytics/plugins/DevicesDetection/images/brand/Nintendo.ico new file mode 100644 index 00000000..20c6f4b5 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Nintendo.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Nokia.ico b/www/analytics/plugins/DevicesDetection/images/brand/Nokia.ico new file mode 100644 index 00000000..5af67b35 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Nokia.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/O2.ico b/www/analytics/plugins/DevicesDetection/images/brand/O2.ico new file mode 100644 index 00000000..468e9f5e Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/O2.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/OPPO.ico b/www/analytics/plugins/DevicesDetection/images/brand/OPPO.ico new file mode 100644 index 00000000..d947bb8c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/OPPO.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Onda.ico b/www/analytics/plugins/DevicesDetection/images/brand/Onda.ico new file mode 100644 index 00000000..386ea8ed Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Onda.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Orange.ico b/www/analytics/plugins/DevicesDetection/images/brand/Orange.ico new file mode 100644 index 00000000..d3303f58 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Orange.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/PEAQ.ico b/www/analytics/plugins/DevicesDetection/images/brand/PEAQ.ico new file mode 100644 index 00000000..0626e7a9 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/PEAQ.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Panasonic.ico b/www/analytics/plugins/DevicesDetection/images/brand/Panasonic.ico new file mode 100644 index 00000000..6480c23a Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Panasonic.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Pantech.ico b/www/analytics/plugins/DevicesDetection/images/brand/Pantech.ico new file mode 100644 index 00000000..f3ff1432 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Pantech.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Philips.ico b/www/analytics/plugins/DevicesDetection/images/brand/Philips.ico new file mode 100644 index 00000000..6f952a10 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Philips.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Polaroid.ico b/www/analytics/plugins/DevicesDetection/images/brand/Polaroid.ico new file mode 100644 index 00000000..3c1dc59a Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Polaroid.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/PolyPad.ico b/www/analytics/plugins/DevicesDetection/images/brand/PolyPad.ico new file mode 100644 index 00000000..d6ef0058 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/PolyPad.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/RIM.ico b/www/analytics/plugins/DevicesDetection/images/brand/RIM.ico new file mode 100644 index 00000000..32636388 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/RIM.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Sagem.ico b/www/analytics/plugins/DevicesDetection/images/brand/Sagem.ico new file mode 100644 index 00000000..1f2f11d8 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Sagem.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Samsung.ico b/www/analytics/plugins/DevicesDetection/images/brand/Samsung.ico new file mode 100644 index 00000000..ddef8460 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Samsung.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Sanyo.ico b/www/analytics/plugins/DevicesDetection/images/brand/Sanyo.ico new file mode 100644 index 00000000..5bc4da8b Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Sanyo.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Sega.ico b/www/analytics/plugins/DevicesDetection/images/brand/Sega.ico new file mode 100644 index 00000000..58e505f1 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Sega.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Selevision.ico b/www/analytics/plugins/DevicesDetection/images/brand/Selevision.ico new file mode 100644 index 00000000..7f549976 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Selevision.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Sharp.ico b/www/analytics/plugins/DevicesDetection/images/brand/Sharp.ico new file mode 100644 index 00000000..5d7b8523 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Sharp.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Siemens.ico b/www/analytics/plugins/DevicesDetection/images/brand/Siemens.ico new file mode 100644 index 00000000..f11c4d6a Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Siemens.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Smart.ico b/www/analytics/plugins/DevicesDetection/images/brand/Smart.ico new file mode 100644 index 00000000..9e3d779c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Smart.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Softbank.ico b/www/analytics/plugins/DevicesDetection/images/brand/Softbank.ico new file mode 100644 index 00000000..d0b8dc8d Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Softbank.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Sony.ico b/www/analytics/plugins/DevicesDetection/images/brand/Sony.ico new file mode 100644 index 00000000..8d9dbf37 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Sony.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Sony_Ericsson.ico b/www/analytics/plugins/DevicesDetection/images/brand/Sony_Ericsson.ico new file mode 100644 index 00000000..a3cb9d29 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Sony_Ericsson.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Spice.ico b/www/analytics/plugins/DevicesDetection/images/brand/Spice.ico new file mode 100644 index 00000000..e9d7e060 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Spice.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/T-Mobile.ico b/www/analytics/plugins/DevicesDetection/images/brand/T-Mobile.ico new file mode 100644 index 00000000..8908033d Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/T-Mobile.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/TCL.ico b/www/analytics/plugins/DevicesDetection/images/brand/TCL.ico new file mode 100644 index 00000000..a66cd3e9 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/TCL.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/TechniSat.ico b/www/analytics/plugins/DevicesDetection/images/brand/TechniSat.ico new file mode 100644 index 00000000..f29d4e26 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/TechniSat.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/TechnoTrend.ico b/www/analytics/plugins/DevicesDetection/images/brand/TechnoTrend.ico new file mode 100644 index 00000000..9ef28850 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/TechnoTrend.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Telefunken.ico b/www/analytics/plugins/DevicesDetection/images/brand/Telefunken.ico new file mode 100644 index 00000000..9656092a Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Telefunken.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Telit.ico b/www/analytics/plugins/DevicesDetection/images/brand/Telit.ico new file mode 100644 index 00000000..f06186c5 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Telit.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Thomson.ico b/www/analytics/plugins/DevicesDetection/images/brand/Thomson.ico new file mode 100644 index 00000000..89c51339 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Thomson.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/TiPhone.ico b/www/analytics/plugins/DevicesDetection/images/brand/TiPhone.ico new file mode 100644 index 00000000..da1531ac Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/TiPhone.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Toshiba.ico b/www/analytics/plugins/DevicesDetection/images/brand/Toshiba.ico new file mode 100644 index 00000000..3ea1260d Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Toshiba.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Vertu.ico b/www/analytics/plugins/DevicesDetection/images/brand/Vertu.ico new file mode 100644 index 00000000..caa2ec1a Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Vertu.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Vestel.ico b/www/analytics/plugins/DevicesDetection/images/brand/Vestel.ico new file mode 100644 index 00000000..74466e4d Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Vestel.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Videocon.ico b/www/analytics/plugins/DevicesDetection/images/brand/Videocon.ico new file mode 100644 index 00000000..aee145d5 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Videocon.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Videoweb.ico b/www/analytics/plugins/DevicesDetection/images/brand/Videoweb.ico new file mode 100644 index 00000000..8892f58f Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Videoweb.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/ViewSonic.ico b/www/analytics/plugins/DevicesDetection/images/brand/ViewSonic.ico new file mode 100644 index 00000000..0a57879b Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/ViewSonic.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Voxtel.ico b/www/analytics/plugins/DevicesDetection/images/brand/Voxtel.ico new file mode 100644 index 00000000..b2b4b9af Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Voxtel.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Xiaomi.ico b/www/analytics/plugins/DevicesDetection/images/brand/Xiaomi.ico new file mode 100644 index 00000000..662a1a84 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Xiaomi.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Yuandao.ico b/www/analytics/plugins/DevicesDetection/images/brand/Yuandao.ico new file mode 100644 index 00000000..50b25f57 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Yuandao.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/ZTE.ico b/www/analytics/plugins/DevicesDetection/images/brand/ZTE.ico new file mode 100644 index 00000000..b1e74f2a Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/ZTE.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/Zonda.ico b/www/analytics/plugins/DevicesDetection/images/brand/Zonda.ico new file mode 100644 index 00000000..6002ce66 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/Zonda.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/eTouch.ico b/www/analytics/plugins/DevicesDetection/images/brand/eTouch.ico new file mode 100644 index 00000000..30e09812 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/eTouch.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/i-mobile.ico b/www/analytics/plugins/DevicesDetection/images/brand/i-mobile.ico new file mode 100644 index 00000000..2b020668 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/i-mobile.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/brand/unknown.ico b/www/analytics/plugins/DevicesDetection/images/brand/unknown.ico new file mode 100644 index 00000000..2c75a533 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/brand/unknown.ico differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/camera.png b/www/analytics/plugins/DevicesDetection/images/screens/camera.png new file mode 100644 index 00000000..bd9deed8 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/camera.png differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/carbrowser.png b/www/analytics/plugins/DevicesDetection/images/screens/carbrowser.png new file mode 100644 index 00000000..4200115c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/carbrowser.png differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/computer.png b/www/analytics/plugins/DevicesDetection/images/screens/computer.png new file mode 100644 index 00000000..9763689e Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/computer.png differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/console.gif b/www/analytics/plugins/DevicesDetection/images/screens/console.gif new file mode 100644 index 00000000..7957a910 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/console.gif differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/dual.gif b/www/analytics/plugins/DevicesDetection/images/screens/dual.gif new file mode 100644 index 00000000..a8cb8b29 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/dual.gif differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/mobile.gif b/www/analytics/plugins/DevicesDetection/images/screens/mobile.gif new file mode 100644 index 00000000..81464293 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/mobile.gif differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/normal.gif b/www/analytics/plugins/DevicesDetection/images/screens/normal.gif new file mode 100644 index 00000000..afe97e9d Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/normal.gif differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/smartphone.png b/www/analytics/plugins/DevicesDetection/images/screens/smartphone.png new file mode 100644 index 00000000..c5bfb31c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/smartphone.png differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/tablet.png b/www/analytics/plugins/DevicesDetection/images/screens/tablet.png new file mode 100644 index 00000000..e6ac30bd Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/tablet.png differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/tv.png b/www/analytics/plugins/DevicesDetection/images/screens/tv.png new file mode 100644 index 00000000..fb6db07c Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/tv.png differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/unknown.gif b/www/analytics/plugins/DevicesDetection/images/screens/unknown.gif new file mode 100644 index 00000000..2c440834 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/unknown.gif differ diff --git a/www/analytics/plugins/DevicesDetection/images/screens/wide.gif b/www/analytics/plugins/DevicesDetection/images/screens/wide.gif new file mode 100644 index 00000000..1b09fc52 Binary files /dev/null and b/www/analytics/plugins/DevicesDetection/images/screens/wide.gif differ diff --git a/www/analytics/plugins/DevicesDetection/lang/bg.json b/www/analytics/plugins/DevicesDetection/lang/bg.json new file mode 100644 index 00000000..cbd37620 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/bg.json @@ -0,0 +1,27 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Версии на браузъра", + "Camera": "Камера", + "CarBrowser": "Браузър, който се използва в кола", + "Console": "Конзола", + "dataTableLabelBrands": "Марка", + "dataTableLabelBrowserFamily": "Вид браузър", + "dataTableLabelModels": "Модел", + "dataTableLabelSystemVersion": "Версия на операционната система", + "dataTableLabelTypes": "Тип", + "Device": "Устройство", + "DeviceBrand": "Марка на устройството", + "DeviceDetection": "Определяне на устройство", + "DeviceModel": "Модел на устройството", + "DevicesDetection": "Устройства, които използва посетителят", + "DeviceType": "Вид устройство", + "OperatingSystemFamilies": "Вид операционна система", + "OperatingSystemVersions": "Версия на операционната система", + "PluginDescription": "Тази добавка предоставя допълнителна информация относно мобилните устройства: марка (производител), модел (версия на устройството), подобрен механизъм за установяване на типа устройство (телевизор, конзола, смартфон, компютър и др.) и т.н. Тази добавка добавя нов отчет „Посетители > Устройства“.", + "SmartDisplay": "„Умен“ дисплей", + "Smartphone": "Смартфон", + "submenu": "Устройства", + "Tablet": "Таблет", + "TV": "ТВ" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/cs.json b/www/analytics/plugins/DevicesDetection/lang/cs.json new file mode 100644 index 00000000..d4fd5865 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/cs.json @@ -0,0 +1,11 @@ +{ + "DevicesDetection": { + "dataTableLabelBrands": "Značka", + "dataTableLabelModels": "Model", + "dataTableLabelTypes": "Typ", + "DeviceBrand": "Značka zařízení", + "DeviceModel": "Model zařízení", + "DevicesDetection": "Zařízení návštěvníků", + "submenu": "Zařízení" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/da.json b/www/analytics/plugins/DevicesDetection/lang/da.json new file mode 100644 index 00000000..43e6e71d --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/da.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Browser-versioner", + "Camera": "Kamera", + "CarBrowser": "Bil browser", + "Console": "Konsol", + "dataTableLabelBrands": "Mærke", + "dataTableLabelBrowserFamily": "Browser familie", + "dataTableLabelModels": "Model", + "dataTableLabelSystemVersion": "Operativsystem-version", + "dataTableLabelTypes": "Type", + "Device": "Enhed", + "DeviceBrand": "Enhedsmærke", + "DeviceDetection": "Detektering af enheder", + "DeviceModel": "Enhedsmodel", + "DevicesDetection": "Besøgendes enheder", + "DeviceType": "Enhedstypen", + "FeaturePhone": "Telefonfunktioner", + "OperatingSystemFamilies": "Operativsystem familier", + "OperatingSystemVersions": "Operativsystem-versioner", + "PluginDescription": "Udvidelsen indeholderudvidet information om mobile enheder, såsom mærke (producent), model (enhed version) og bedre enhedstype detektion (tv, konsoller, smartphones, desktop, osv). Programudvidelsen tilføjer en ny rapport i 'Besøgende > Enheder'.", + "SmartDisplay": "Smart skærm", + "Smartphone": "Smartphone", + "submenu": "Enheder", + "Tablet": "Tablet", + "TV": "TV", + "UserAgent": "Brugeragent" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/de.json b/www/analytics/plugins/DevicesDetection/lang/de.json new file mode 100644 index 00000000..431728a5 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/de.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Browser Versionen", + "Camera": "Digitalcamera", + "CarBrowser": "PKW-Browser", + "Console": "Konsole", + "dataTableLabelBrands": "Marke", + "dataTableLabelBrowserFamily": "Browser Familie", + "dataTableLabelModels": "Modell", + "dataTableLabelSystemVersion": "Betriebssystem Version", + "dataTableLabelTypes": "Typ", + "Device": "Gerät", + "DeviceBrand": "Gerätemarke", + "DeviceDetection": "Geräteerkennung", + "DeviceModel": "Gerätemodell", + "DevicesDetection": "Geräte der Besucher", + "DeviceType": "Gerätetyp", + "FeaturePhone": "Feature-Phone", + "OperatingSystemFamilies": "Betriebssystem Familie", + "OperatingSystemVersions": "Betriebssystem Familien", + "PluginDescription": "Dieses Plugin stellt erweiterte Informationen über mobile Geräte, wie Marke (Hersteller), Modell (Geräteversion), bessere Gerätetyperkennung (TV, Konsolen, Smartphones, Desktop, etc) und mehr bereit. Dieses Plugin fügt einen neuen Bericht unter 'Besucher > Geräte' hinzu.", + "SmartDisplay": "Smart Display", + "Smartphone": "Smartphone", + "submenu": "Geräte", + "Tablet": "Tablet", + "TV": "TV", + "UserAgent": "User-Agent" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/el.json b/www/analytics/plugins/DevicesDetection/lang/el.json new file mode 100644 index 00000000..cd8b47e3 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/el.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Εκδόσεις προγραμμάτων περιήγησης", + "Camera": "Κάμερα", + "CarBrowser": "Πρόγραμμα περιήγησης αυτοκινήτου", + "Console": "Κονσόλα", + "dataTableLabelBrands": "Μάρκα", + "dataTableLabelBrowserFamily": "Οικογένεια προγράμματος περιήγησης", + "dataTableLabelModels": "Μοντέλο", + "dataTableLabelSystemVersion": "Έκδοση λειτουργικού συστήματος", + "dataTableLabelTypes": "Τύπος", + "Device": "Συσκευή", + "DeviceBrand": "Μάρκα συσκευής", + "DeviceDetection": "Εντοπισμός συσκευής", + "DeviceModel": "Μοντέλο συσκευής", + "DevicesDetection": "Συσκευές επισκεπτών", + "DeviceType": "Τύπος συσκευής", + "FeaturePhone": "Τηλέφωνο", + "OperatingSystemFamilies": "Οικογένειες Λειτουργικών Συστημάτων", + "OperatingSystemVersions": "Εκδόσεις Λειτουργικών Συστημάτων", + "PluginDescription": "Το πρόσθετο παρέχει εκτεταμένες πληροφορίες σχετικά με φορητές συσκευές, όπως Μάρκα (κατασκευαστής), Μοντέλο (έκδοση συσκευής), καλύτερο εντοπισμό τύπου συσκευής (τηλεόραση, κονσόλες, έξυπνα τηλέφωνα, επιτραπέζιοι υπολογιστές, κτλ.) και πολλά άλλα. Το πρόσθετο προσθέτει μια νέα αναφορά στους 'Επισκέπτες > Συσκευές'.", + "SmartDisplay": "Έξυπνη οθόνη", + "Smartphone": "Έξυπνο κινητό", + "submenu": "Συσκευές", + "Tablet": "Ταμπλέτα", + "TV": "Τηλεόραση", + "UserAgent": "Πρόγραμμα πελάτη χρήστη" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/en.json b/www/analytics/plugins/DevicesDetection/lang/en.json new file mode 100644 index 00000000..c10124ef --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/en.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "PluginDescription": "This plugin provides extended information about mobile devices, such as Brand (manufacturer), Model (device version), better Device type detection (tv, consoles, smart phones, desktop, etc) and more. This plugin adds a new report in 'Visitors > Devices'.", + "submenu": "Devices", + "DevicesDetection": "Visitor Devices", + "dataTableLabelBrands": "Brand", + "dataTableLabelTypes": "Type", + "dataTableLabelModels": "Model", + "dataTableLabelSystemVersion": "Operating System version", + "dataTableLabelBrowserFamily": "Browser family", + "OperatingSystemVersions": "Operating System versions", + "OperatingSystemFamilies": "Operating System families", + "BrowserVersions": "Browser versions", + "DeviceType": "Device type", + "DeviceBrand": "Device brand", + "DeviceModel": "Device model", + "DeviceDetection": "Device detection", + "Device": "Device", + "UserAgent": "User-Agent", + "Smartphone": "Smartphone", + "CarBrowser": "Car browser", + "Camera": "Camera", + "Tablet": "Tablet", + "SmartDisplay": "Smart display", + "FeaturePhone": "Feature phone", + "TV": "Tv", + "Console": "Console" + } +} diff --git a/www/analytics/plugins/DevicesDetection/lang/es.json b/www/analytics/plugins/DevicesDetection/lang/es.json new file mode 100644 index 00000000..7f3bb251 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/es.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Versiones de navegadores", + "Camera": "Cámara", + "CarBrowser": "Navegador para coche", + "Console": "Consola", + "dataTableLabelBrands": "Marca", + "dataTableLabelBrowserFamily": "Familia de navegadores", + "dataTableLabelModels": "Modelo", + "dataTableLabelSystemVersion": "Versión del sistema operativo", + "dataTableLabelTypes": "Tipo", + "Device": "Dispositivo", + "DeviceBrand": "Marca del dispositivo", + "DeviceDetection": "Detección de dispositivos", + "DeviceModel": "Modelo del dispositivo", + "DevicesDetection": "Dispositivos de visitantes", + "DeviceType": "Tipo de dispositivo", + "FeaturePhone": "Teléfono principal", + "OperatingSystemFamilies": "Familia de sistemas operativos", + "OperatingSystemVersions": "Versiones de sistema operativo", + "PluginDescription": "Este plugin ofrece amplia información sobre dispositivos móviles, como Marca (fabricación), Modelo (versión de dispositivo), una mejor detección de Dispositivo (televisión, consola, smartphone, escritorio, etc.) y más. Este plugin agrega un nuevo informe en la sección ´Visitantes > Dispositivos´.", + "SmartDisplay": "Pantalla inteligente", + "Smartphone": "Smartphone", + "submenu": "Dispositivos", + "Tablet": "Tablet", + "TV": "Televisión", + "UserAgent": "Agente de usuario" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/et.json b/www/analytics/plugins/DevicesDetection/lang/et.json new file mode 100644 index 00000000..ebf1ec97 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/et.json @@ -0,0 +1,28 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Brauseri versioonid", + "CarBrowser": "Sõiduki sirvik", + "Console": "Konsool", + "dataTableLabelBrands": "Bränd", + "dataTableLabelBrowserFamily": "Sirvija tüüp", + "dataTableLabelModels": "Mudel", + "dataTableLabelSystemVersion": "Operatsioonisüsteemi versioon", + "dataTableLabelTypes": "Tüüp", + "Device": "Seade", + "DeviceBrand": "Seadme bränd", + "DeviceDetection": "Seadme tuvastus", + "DeviceModel": "Seadme mudel", + "DevicesDetection": "Külastajate seadmed", + "DeviceType": "Seadme tüüp", + "FeaturePhone": "Tark telefon", + "OperatingSystemFamilies": "Operatsioonisüsteemide tüübid", + "OperatingSystemVersions": "Operatsioonisüsteemide versioonid", + "PluginDescription": "Antud plugin annab mobiilsete seadmete kohta detailsemat infot, nagu bränd (tootja), mudel (seadme versioon), täpsema seadmete tuvastuse (tv, konsool, nutitelefon, tahvelarvuti jne). See plugin lisab uue raporti 'Külastajad > Seadmed' alla.", + "SmartDisplay": "Nutikas kuvar", + "Smartphone": "Nutitelefon", + "submenu": "Seadmed", + "Tablet": "Tahvelarvuti", + "TV": "Televiisor", + "UserAgent": "Veebisirvija tüüp" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/fa.json b/www/analytics/plugins/DevicesDetection/lang/fa.json new file mode 100644 index 00000000..abcc556d --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/fa.json @@ -0,0 +1,28 @@ +{ + "DevicesDetection": { + "BrowserVersions": "نسخه مرورگر", + "Camera": "دوربین", + "CarBrowser": "مرورگر خودرو", + "Console": "کنسول", + "dataTableLabelBrands": "مارک", + "dataTableLabelBrowserFamily": "خانواده مرورگر", + "dataTableLabelModels": "مدل", + "dataTableLabelSystemVersion": "نسخه سیستم عامل", + "dataTableLabelTypes": "نوع", + "Device": "وسیله", + "DeviceBrand": "مارک وسیله", + "DeviceDetection": "تشخیص وسیله", + "DeviceModel": "مدل وسیله", + "DevicesDetection": "وسایل بازدیده کنندگان", + "DeviceType": "نوع وسیله", + "FeaturePhone": "گوشی", + "OperatingSystemFamilies": "خانواده سیستم عامل", + "OperatingSystemVersions": "نسخه سیستم عامل", + "SmartDisplay": "صفحه نمایش هوشمند", + "Smartphone": "تلفن هوشمند", + "submenu": "وسایل", + "Tablet": "تبلت", + "TV": "تلویزیون", + "UserAgent": "کاربر-نمایندگی" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/fi.json b/www/analytics/plugins/DevicesDetection/lang/fi.json new file mode 100644 index 00000000..7112623c --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/fi.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Selainversiot", + "Camera": "Kamera", + "CarBrowser": "Car browser", + "Console": "Konsoli", + "dataTableLabelBrands": "Merkki", + "dataTableLabelBrowserFamily": "Selainperhe", + "dataTableLabelModels": "Malli", + "dataTableLabelSystemVersion": "Käyttöjärjestelmäversio", + "dataTableLabelTypes": "Tyyppi", + "Device": "Laite", + "DeviceBrand": "Laitteen merkki", + "DeviceDetection": "Laitteen jäljitys", + "DeviceModel": "Laitteen malli", + "DevicesDetection": "Kävijän laitteet", + "DeviceType": "Laitteen tyyppi", + "FeaturePhone": "Puhelintoiminto", + "OperatingSystemFamilies": "Käyttöjärjestelmäperheet", + "OperatingSystemVersions": "Käyttöjärjestelmäversiot", + "PluginDescription": "Tämä liitännäinen tarjoaa laajennettua tietoa mobiililaitteista, kuten merkki (valmistaja), malli (laitteen malli), paremman laitetyypin tunnistuksen (tv, konsolit, älypuhelimet, tietokoneet jne) sekä muuta. Liitännäinen lisää uuden raportin 'Kävijät > Laitteet'.", + "SmartDisplay": "Smart näyttö", + "Smartphone": "Älypuhelin", + "submenu": "Laitteet", + "Tablet": "Tabletti", + "TV": "TV", + "UserAgent": "Käyttäjä-Agentti" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/fr.json b/www/analytics/plugins/DevicesDetection/lang/fr.json new file mode 100644 index 00000000..b2b10a5b --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/fr.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Versions du Navigateur", + "Camera": "Camera", + "CarBrowser": "Navigateur de voiture", + "Console": "Console", + "dataTableLabelBrands": "Marque", + "dataTableLabelBrowserFamily": "Famille de navigateurs", + "dataTableLabelModels": "Modèle", + "dataTableLabelSystemVersion": "Version du système d'exploitation", + "dataTableLabelTypes": "Type", + "Device": "Périphèrique", + "DeviceBrand": "Marque du périphérique", + "DeviceDetection": "Détection du périphérique", + "DeviceModel": "Modèle du périphérique", + "DevicesDetection": "Périphériques du visiteur", + "DeviceType": "Type du périphérique", + "FeaturePhone": "Fonctionnalité téléphone", + "OperatingSystemFamilies": "Familles de système d'exploitation", + "OperatingSystemVersions": "Versions de système d'exploitation", + "PluginDescription": "Ce composant additionnel fournit des informations détaillées à propos des périphériques mobiles, telles que la marque (constructeur), le modèle (version du périphérique), une meilleure détection du type de périphérique (tv, consoles, smart phones, pc, etc) et autres. Ce composant additionnel ajoute un nouveau rapport dans Visiteur > Périphériques.", + "SmartDisplay": "Affichage intelligent", + "Smartphone": "Téléphone intelligent (smartphone)", + "submenu": "Périphériques", + "Tablet": "Tabelette", + "TV": "TV", + "UserAgent": "Agent Utilisateur (User-Agent)" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/it.json b/www/analytics/plugins/DevicesDetection/lang/it.json new file mode 100644 index 00000000..8ee79e1a --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/it.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Versioni browser", + "Camera": "Fotocamera", + "CarBrowser": "Browser in auto", + "Console": "Console", + "dataTableLabelBrands": "Marca", + "dataTableLabelBrowserFamily": "Famiglia browser", + "dataTableLabelModels": "Modello", + "dataTableLabelSystemVersion": "Versione Sistema Operativo", + "dataTableLabelTypes": "Tipo", + "Device": "Dispositivo", + "DeviceBrand": "Marca dispositivo", + "DeviceDetection": "Rilevamento dispositivo", + "DeviceModel": "Modello dispositivo", + "DevicesDetection": "Dispositivi visitatore", + "DeviceType": "Tipo dispositivo", + "FeaturePhone": "Feature phone", + "OperatingSystemFamilies": "Famiglie Sistema Operativo", + "OperatingSystemVersions": "Versioni Sistema Operativo", + "PluginDescription": "Questo plugin fornisce informazioni estese sui dispositivi mobili, come Marca (costruttore), Modello (versione dispositivo), una migliore individuazione del dispositivo (tv, console, smartphone, desktop, ecc) e altro. Questo plugin aggiunge un nuovo report in 'Visitatori > Dispositivi'.", + "SmartDisplay": "Smart display", + "Smartphone": "Smartphone", + "submenu": "Dispositivi", + "Tablet": "Tablet", + "TV": "Apparecchio TV", + "UserAgent": "User-Agent" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/ja.json b/www/analytics/plugins/DevicesDetection/lang/ja.json new file mode 100644 index 00000000..a63d2704 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/ja.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "ブラウザのバージョン", + "Camera": "カメラ", + "CarBrowser": "車載ブラウザ", + "Console": "コンソール", + "dataTableLabelBrands": "ブランド", + "dataTableLabelBrowserFamily": "ブラウザファミリー", + "dataTableLabelModels": "モデル", + "dataTableLabelSystemVersion": "オペレーティングシステムのバージョン", + "dataTableLabelTypes": "タイプ", + "Device": "デバイス", + "DeviceBrand": "デバイスのブランド", + "DeviceDetection": "デバイス検出", + "DeviceModel": "デバイスのモデル", + "DevicesDetection": "ビジターのデバイス", + "DeviceType": "デバイスタイプ", + "FeaturePhone": "携帯電話", + "OperatingSystemFamilies": "オペレーティングシステムファミリー", + "OperatingSystemVersions": "オペレーティングシステムのバージョン", + "PluginDescription": "このプラグインは、訪問者の使用する接続機器情報を表示します。モバイル端末のブランド(メーカー)やモデル(デバイスのバージョン)や、また多くの機種(テレビ、コンソール、スマートフォン、デスクトップPCなど)も検出します。このプラグインはメニューの「ビジター > デバイス」に新しいレポートが追加されます。", + "SmartDisplay": "スマートディスプレイ", + "Smartphone": "スマートフォン", + "submenu": "デバイス", + "Tablet": "タブレット", + "TV": "TV", + "UserAgent": "ユーザーエージェント" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/nb.json b/www/analytics/plugins/DevicesDetection/lang/nb.json new file mode 100644 index 00000000..02f72193 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/nb.json @@ -0,0 +1,13 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Nettleser-versjoner", + "Camera": "Kamera", + "dataTableLabelBrowserFamily": "Nettleserfamilie", + "dataTableLabelModels": "Modell", + "Device": "Enhet", + "Smartphone": "Smarttelefon", + "submenu": "Enheter", + "Tablet": "Nettbrett", + "TV": "TV" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/nl.json b/www/analytics/plugins/DevicesDetection/lang/nl.json new file mode 100644 index 00000000..9c03c02e --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/nl.json @@ -0,0 +1,27 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Browser versies", + "Camera": "Fototoestel", + "CarBrowser": "Auto browser", + "Console": "Console", + "dataTableLabelBrands": "Merk", + "dataTableLabelBrowserFamily": "Browser familie", + "dataTableLabelModels": "Model", + "dataTableLabelSystemVersion": "Besturingssysteem versie", + "dataTableLabelTypes": "Type", + "Device": "Apparaat", + "DeviceBrand": "Apparaat merk", + "DeviceDetection": "Apparaat detectie", + "DeviceModel": "Apparaat model", + "DevicesDetection": "Bezoeker Apparaten", + "DeviceType": "Apparaat type", + "OperatingSystemFamilies": "Besturingssysteem families", + "OperatingSystemVersions": "Besturingssysteem versies", + "PluginDescription": "Deze plugin geeft uitgebreide informatie over mobile apparaten, zoals Merk (fabrikant), Model (apparaat versie), betere Apparaat type detectie (tv, consoles, smart phones, desktop, enz) en meer. Deze plugin voegt een nieuw rapport toe in 'Bezoekers > Devices'.", + "SmartDisplay": "Slim scherm", + "Smartphone": "Smartphone", + "submenu": "Apparaten", + "Tablet": "Tablet", + "TV": "Tv" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/pt-br.json b/www/analytics/plugins/DevicesDetection/lang/pt-br.json new file mode 100644 index 00000000..2785007e --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/pt-br.json @@ -0,0 +1,24 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Versões de navegadores", + "Camera": "Câmera", + "Console": "Console", + "dataTableLabelBrands": "Marca", + "dataTableLabelBrowserFamily": "Família de navegadores", + "dataTableLabelModels": "Modelo", + "dataTableLabelSystemVersion": "Versão do Sistema Operacional", + "dataTableLabelTypes": "Tipo", + "Device": "Dispositivo", + "DeviceBrand": "Marca do dispositivo", + "DeviceDetection": "Detecção de dispositivo", + "DeviceModel": "Modelo do dispositivo", + "DeviceType": "Tipo de dispositivo", + "OperatingSystemFamilies": "Famílias de Sistemas Operacionais", + "OperatingSystemVersions": "Versões dos Sistemas Operacionais", + "Smartphone": "Smartphone", + "submenu": "Dispositivos", + "Tablet": "Tablet", + "TV": "Tv", + "UserAgent": "User-Agent" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/ro.json b/www/analytics/plugins/DevicesDetection/lang/ro.json new file mode 100644 index 00000000..2f5e8906 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/ro.json @@ -0,0 +1,25 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Versiunile browserului", + "CarBrowser": "Browser de masina", + "Console": "Consola", + "dataTableLabelBrands": "Brand", + "dataTableLabelBrowserFamily": "Familia browserului", + "dataTableLabelModels": "Model", + "dataTableLabelSystemVersion": "Versiunea Sistemului de Operare", + "dataTableLabelTypes": "Tip", + "Device": "Dispozitiv", + "DeviceBrand": "Brandul dispozitivului", + "DeviceDetection": "Detectarea dispozitivului", + "DeviceModel": "Modelul dispozitivului", + "DevicesDetection": "Dispozitivele Utilizatorilor", + "DeviceType": "Tipul dispozitivului", + "OperatingSystemFamilies": "Familiile Sistemului de Operare", + "OperatingSystemVersions": "Versiunile Sistemului de Operare", + "Smartphone": "Smartphone", + "submenu": "Dispozitive", + "Tablet": "Tableta", + "TV": "Tv", + "UserAgent": "User-Agent" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/ru.json b/www/analytics/plugins/DevicesDetection/lang/ru.json new file mode 100644 index 00000000..5f4c993f --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/ru.json @@ -0,0 +1,18 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Версии браузеров", + "dataTableLabelBrands": "Фирма-производитель", + "dataTableLabelBrowserFamily": "Семейство браузеров", + "dataTableLabelModels": "Модель", + "dataTableLabelSystemVersion": "Версия операционной системы", + "dataTableLabelTypes": "Тип", + "DeviceBrand": "Фирма-производитель устройства", + "DeviceModel": "Модель устройства", + "DevicesDetection": "Устройства посетителей", + "DeviceType": "Тип устройства", + "OperatingSystemFamilies": "Семейства операционных систем", + "OperatingSystemVersions": "Версии операционных систем", + "PluginDescription": "Этот плагин предоставляет расширенную информацию о мобильных устройствах, таких как фирма-производитель, модель устройства, улучшенное обнаружение типов устройств (телевизор, консоль, смартфон, стационарный компьютер и т.д.) и другое. Этот плагин добавляет новый отчет в 'Посетители > Устройства'.", + "submenu": "Устройства" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/sr.json b/www/analytics/plugins/DevicesDetection/lang/sr.json new file mode 100644 index 00000000..0a329f53 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/sr.json @@ -0,0 +1,16 @@ +{ + "DevicesDetection": { + "Camera": "Kamera", + "Console": "Konzola", + "dataTableLabelBrowserFamily": "Porodica brauzera", + "dataTableLabelSystemVersion": "Verzija operativnog sistema", + "Device": "Uređaj", + "DeviceDetection": "Prepoznavanje uređaja", + "DevicesDetection": "Uređaji posetilaca", + "FeaturePhone": "Telefon", + "OperatingSystemVersions": "Verzije operativnog sistema", + "SmartDisplay": "Pametan displej", + "submenu": "Uređaji", + "TV": "TV" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/lang/sv.json b/www/analytics/plugins/DevicesDetection/lang/sv.json new file mode 100644 index 00000000..84fbb16d --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/lang/sv.json @@ -0,0 +1,29 @@ +{ + "DevicesDetection": { + "BrowserVersions": "Version av webbläsare", + "Camera": "Kamera", + "CarBrowser": "Car browser", + "Console": "Konsol", + "dataTableLabelBrands": "Märke", + "dataTableLabelBrowserFamily": "Webbläsare familj", + "dataTableLabelModels": "Modell", + "dataTableLabelSystemVersion": "Hanterar Systemets version", + "dataTableLabelTypes": "Sort", + "Device": "Enhet", + "DeviceBrand": "Typ av utrustning", + "DeviceDetection": "Utrustning upptäcks", + "DeviceModel": "Utrustning model", + "DevicesDetection": "Besöksutrustning", + "DeviceType": "Utrustningstyp", + "FeaturePhone": "Telefonmodell", + "OperatingSystemFamilies": "Hanterar System familjer", + "OperatingSystemVersions": "Nuvarande System version", + "PluginDescription": "Det här pluginet innehåller förlängd information om mobilverktyg, så som märke (tillvärkare), modell (modellens version), tv, konsol, smart phones, laptops och annat. Det här pluginet lägger till nya rapporter i 'Visitors > Verktyg'.", + "SmartDisplay": "Smart skärm", + "Smartphone": "Smartphone", + "submenu": "Utrustning", + "Tablet": "Bord", + "TV": "TV", + "UserAgent": "Användar-Agent" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/DevicesDetection/templates/detection.twig b/www/analytics/plugins/DevicesDetection/templates/detection.twig new file mode 100644 index 00000000..ccc99c5f --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/templates/detection.twig @@ -0,0 +1,97 @@ +{% extends 'admin.twig' %} + +{% block content %} + + + + +

          {{ 'DevicesDetection_DeviceDetection'|translate }}

          + +

          {{ 'DevicesDetection_UserAgent'|translate }}

          +
          + + +
          + +

          {{ 'UserSettings_ColumnOperatingSystem'|translate }}

          + + + + + + + + + + + + + +
          {{ 'General_Name'|translate }} ({{ 'Mobile_ShowAll'|translate }}){{ os_name }}
          {{ 'CorePluginsAdmin_Version'|translate }}{{ os_version }}
          {{ 'UserSettings_OperatingSystemFamily'|translate }} ({{ 'Mobile_ShowAll'|translate }}){{ os_family }}
          + +

          {{ 'UserSettings_ColumnBrowser'|translate }}

          + + + + + + + + + + + + + +
          {{ 'General_Name'|translate }} ({{ 'Mobile_ShowAll'|translate }}){{ browser_name }}
          {{ 'CorePluginsAdmin_Version'|translate }}{{ browser_version }}
          {{ 'UserSettings_ColumnBrowserFamily'|translate }} ({{ 'Mobile_ShowAll'|translate }}){{ browser_family }}
          + +

          {{ 'DevicesDetection_Device'|translate }}

          + + + + + + + + + + + + + +
          {{ 'DevicesDetection_dataTableLabelTypes'|translate }} ({{ 'Mobile_ShowAll'|translate }}){{ device_type }}
          {{ 'DevicesDetection_dataTableLabelBrands'|translate }} ({{ 'Mobile_ShowAll'|translate }}){{ device_brand }}
          {{ 'DevicesDetection_dataTableLabelModels'|translate }}{{ device_model }}
          + + + +{% endblock %} diff --git a/www/analytics/plugins/DevicesDetection/templates/index.twig b/www/analytics/plugins/DevicesDetection/templates/index.twig new file mode 100644 index 00000000..3391ed5f --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/templates/index.twig @@ -0,0 +1,15 @@ +
          +

          {{ "DevicesDetection_DeviceType"|translate }}

          + {{ deviceTypes | raw}} +

          {{ "DevicesDetection_DeviceBrand"|translate }}

          + {{ deviceBrands | raw }} +

          {{ "DevicesDetection_DeviceModel"|translate }}

          + {{ deviceModels | raw }} +
          + +
          +

          {{ "DevicesDetection_OperatingSystemFamilies"|translate }}

          + {{ osReport | raw}} +

          {{ "UserSettings_BrowserFamilies"|translate }}

          + {{ browserReport | raw }} +
          diff --git a/www/analytics/plugins/DevicesDetection/templates/list.twig b/www/analytics/plugins/DevicesDetection/templates/list.twig new file mode 100644 index 00000000..1fa57d84 --- /dev/null +++ b/www/analytics/plugins/DevicesDetection/templates/list.twig @@ -0,0 +1,7 @@ + + {% for name,image in itemList %} + + + + {% endfor %} +
          {{ name }}
          diff --git a/www/analytics/plugins/Events/API.php b/www/analytics/plugins/Events/API.php new file mode 100644 index 00000000..0be60375 --- /dev/null +++ b/www/analytics/plugins/Events/API.php @@ -0,0 +1,204 @@ + 'eventAction', + 'getAction' => 'eventName', + 'getName' => 'eventAction', + ); + + protected $mappingApiToRecord = array( + 'getCategory' => + array( + 'eventAction' => Archiver::EVENTS_CATEGORY_ACTION_RECORD_NAME, + 'eventName' => Archiver::EVENTS_CATEGORY_NAME_RECORD_NAME, + ), + 'getAction' => + array( + 'eventName' => Archiver::EVENTS_ACTION_NAME_RECORD_NAME, + 'eventCategory' => Archiver::EVENTS_ACTION_CATEGORY_RECORD_NAME, + ), + 'getName' => + array( + 'eventAction' => Archiver::EVENTS_NAME_ACTION_RECORD_NAME, + 'eventCategory' => Archiver::EVENTS_NAME_CATEGORY_RECORD_NAME, + ), + 'getActionFromCategoryId' => Archiver::EVENTS_CATEGORY_ACTION_RECORD_NAME, + 'getNameFromCategoryId' => Archiver::EVENTS_CATEGORY_NAME_RECORD_NAME, + 'getCategoryFromActionId' => Archiver::EVENTS_ACTION_CATEGORY_RECORD_NAME, + 'getNameFromActionId' => Archiver::EVENTS_ACTION_NAME_RECORD_NAME, + 'getActionFromNameId' => Archiver::EVENTS_NAME_ACTION_RECORD_NAME, + 'getCategoryFromNameId' => Archiver::EVENTS_NAME_CATEGORY_RECORD_NAME, + ); + + /** + * @ignore + */ + public function getActionToLoadSubtables($apiMethod, $secondaryDimension = false) + { + $recordName = $this->getRecordNameForAction($apiMethod, $secondaryDimension); + $apiMethod = array_search( $recordName, $this->mappingApiToRecord ); + return $apiMethod; + } + + /** + * @ignore + */ + public function getDefaultSecondaryDimension($apiMethod) + { + if(isset($this->defaultMappingApiToSecondaryDimension[$apiMethod])) { + return $this->defaultMappingApiToSecondaryDimension[$apiMethod]; + } + return false; + } + + + protected function getRecordNameForAction($apiMethod, $secondaryDimension = false) + { + if (empty($secondaryDimension)) { + $secondaryDimension = $this->getDefaultSecondaryDimension($apiMethod); + } + $record = $this->mappingApiToRecord[$apiMethod]; + if(!is_array($record)) { + return $record; + } + // when secondaryDimension is incorrectly set + if(empty($record[$secondaryDimension])) { + return key($record); + } + return $record[$secondaryDimension]; + } + + /** + * @ignore + * @param $apiMethod + * @return array + */ + public function getSecondaryDimensions($apiMethod) + { + $records = $this->mappingApiToRecord[$apiMethod]; + if(!is_array($records)) { + return false; + } + return array_keys($records); + } + + protected function checkSecondaryDimension($apiMethod, $secondaryDimension) + { + if (empty($secondaryDimension)) { + return; + } + + $isSecondaryDimensionValid = + isset($this->mappingApiToRecord[$apiMethod]) + && isset($this->mappingApiToRecord[$apiMethod][$secondaryDimension]); + + if (!$isSecondaryDimensionValid) { + throw new \Exception( + "Secondary dimension '$secondaryDimension' is not valid for the API $apiMethod. ". + "Use one of: " . implode(", ", $this->getSecondaryDimensions($apiMethod)) + ); + } + } + + protected function getDataTable($name, $idSite, $period, $date, $segment, $expanded = false, $idSubtable = null, $secondaryDimension = false) + { + Piwik::checkUserHasViewAccess($idSite); + $this->checkSecondaryDimension($name, $secondaryDimension); + $recordName = $this->getRecordNameForAction($name, $secondaryDimension); + $dataTable = Archive::getDataTableFromArchive($recordName, $idSite, $period, $date, $segment, $expanded, $idSubtable); + $this->filterDataTable($dataTable); + return $dataTable; + } + + public function getCategory($idSite, $period, $date, $segment = false, $expanded = false, $secondaryDimension = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded, $idSubtable = false, $secondaryDimension); + } + + public function getAction($idSite, $period, $date, $segment = false, $expanded = false, $secondaryDimension = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded, $idSubtable = false, $secondaryDimension); + } + + public function getName($idSite, $period, $date, $segment = false, $expanded = false, $secondaryDimension = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded, $idSubtable = false, $secondaryDimension); + } + + public function getActionFromCategoryId($idSite, $period, $date, $idSubtable, $segment = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded = false, $idSubtable); + } + + public function getNameFromCategoryId($idSite, $period, $date, $idSubtable, $segment = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded = false, $idSubtable); + } + + public function getCategoryFromActionId($idSite, $period, $date, $idSubtable, $segment = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded = false, $idSubtable); + } + + public function getNameFromActionId($idSite, $period, $date, $idSubtable, $segment = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded = false, $idSubtable); + } + + public function getActionFromNameId($idSite, $period, $date, $idSubtable, $segment = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded = false, $idSubtable); + } + + public function getCategoryFromNameId($idSite, $period, $date, $idSubtable, $segment = false) + { + return $this->getDataTable(__FUNCTION__, $idSite, $period, $date, $segment, $expanded = false, $idSubtable); + } + + /** + * @param DataTable $dataTable + */ + protected function filterDataTable($dataTable) + { + $dataTable->filter('Sort', array(Metrics::INDEX_NB_VISITS)); + $dataTable->queueFilter('ReplaceColumnNames'); + $dataTable->queueFilter('ReplaceSummaryRowLabel'); + $dataTable->filter(function (DataTable $table) { + $row = $table->getRowFromLabel(Archiver::EVENT_NAME_NOT_SET); + if ($row) { + $row->setColumn('label', Piwik::translate('General_NotDefined', Piwik::translate('Events_EventName'))); + } + }); + + // add processed metric avg_event_value + $dataTable->queueFilter('ColumnCallbackAddColumnQuotient', + array('avg_event_value', + 'sum_event_value', + 'nb_events_with_value', + $precision = 2, + $shouldSkipRows = true) + ); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/Archiver.php b/www/analytics/plugins/Events/Archiver.php new file mode 100644 index 00000000..1289424b --- /dev/null +++ b/www/analytics/plugins/Events/Archiver.php @@ -0,0 +1,260 @@ + Actions X + - Top Category > Names X + - Top Actions > Categories X + - Top Actions > Names X + - Top Names > Actions X + - Top Names > Categories X + + UI + - Overview at the top (graph + Sparklines) + - Below show the left menu, defaults to Top Event Category + + Not MVP: + - On hover on any row: Show % of total events + - Add min value metric, max value metric in tooltip + - List event scope Custom Variables Names > Custom variables values > Event Names > Event Actions + - List event scope Custom Variables Value > Event Category > Event Names > Event Actions + + NOTES: + - For a given Name, Category is often constant + + */ +class Archiver extends \Piwik\Plugin\Archiver +{ + const EVENTS_CATEGORY_ACTION_RECORD_NAME = 'Events_category_action'; + const EVENTS_CATEGORY_NAME_RECORD_NAME = 'Events_category_name'; + const EVENTS_ACTION_CATEGORY_RECORD_NAME = 'Events_action_category'; + const EVENTS_ACTION_NAME_RECORD_NAME = 'Events_action_name'; + const EVENTS_NAME_ACTION_RECORD_NAME = 'Events_name_action'; + const EVENTS_NAME_CATEGORY_RECORD_NAME = 'Events_name_category'; + const EVENT_NAME_NOT_SET = 'Piwik_EventNameNotSet'; + + /** + * @var DataArray[] + */ + protected $arrays = array(); + + function __construct($processor) + { + parent::__construct($processor); + $this->columnToSortByBeforeTruncation = Metrics::INDEX_NB_VISITS; + $this->maximumRowsInDataTable = Config::getInstance()->General['datatable_archiving_maximum_rows_events']; + $this->maximumRowsInSubDataTable = Config::getInstance()->General['datatable_archiving_maximum_rows_subtable_events']; + } + + protected function getRecordToDimensions() + { + return array( + self::EVENTS_CATEGORY_ACTION_RECORD_NAME => array("eventCategory", "eventAction"), + self::EVENTS_CATEGORY_NAME_RECORD_NAME => array("eventCategory", "eventName"), + self::EVENTS_ACTION_NAME_RECORD_NAME => array("eventAction", "eventName"), + self::EVENTS_ACTION_CATEGORY_RECORD_NAME => array("eventAction", "eventCategory"), + self::EVENTS_NAME_ACTION_RECORD_NAME => array("eventName", "eventAction"), + self::EVENTS_NAME_CATEGORY_RECORD_NAME => array("eventName", "eventCategory"), + ); + } + + public function aggregateMultipleReports() + { + $dataTableToSum = $this->getRecordNames(); + $this->getProcessor()->aggregateDataTableRecords($dataTableToSum, $this->maximumRowsInDataTable, $this->maximumRowsInSubDataTable, $this->columnToSortByBeforeTruncation); + } + + protected function getRecordNames() + { + $mapping = $this->getRecordToDimensions(); + return array_keys($mapping); + } + + public function aggregateDayReport() + { + $this->aggregateDayEvents(); + $this->insertDayReports(); + } + + protected function aggregateDayEvents() + { + $select = " + log_action_event_category.name as eventCategory, + log_action_event_action.name as eventAction, + log_action_event_name.name as eventName, + + count(distinct log_link_visit_action.idvisit) as `" . Metrics::INDEX_NB_VISITS . "`, + count(distinct log_link_visit_action.idvisitor) as `" . Metrics::INDEX_NB_UNIQ_VISITORS . "`, + count(*) as `" . Metrics::INDEX_EVENT_NB_HITS . "`, + + sum( + case when " . Action::DB_COLUMN_CUSTOM_FLOAT . " is null + then 0 + else " . Action::DB_COLUMN_CUSTOM_FLOAT . " + end + ) as `" . Metrics::INDEX_EVENT_SUM_EVENT_VALUE . "`, + sum( case when " . Action::DB_COLUMN_CUSTOM_FLOAT . " is null then 0 else 1 end ) + as `" . Metrics::INDEX_EVENT_NB_HITS_WITH_VALUE . "`, + min(" . Action::DB_COLUMN_CUSTOM_FLOAT . ") as `" . Metrics::INDEX_EVENT_MIN_EVENT_VALUE . "`, + max(" . Action::DB_COLUMN_CUSTOM_FLOAT . ") as `" . Metrics::INDEX_EVENT_MAX_EVENT_VALUE . "` + "; + + $from = array( + "log_link_visit_action", + array( + "table" => "log_action", + "tableAlias" => "log_action_event_category", + "joinOn" => "log_link_visit_action.idaction_event_category = log_action_event_category.idaction" + ), + array( + "table" => "log_action", + "tableAlias" => "log_action_event_action", + "joinOn" => "log_link_visit_action.idaction_event_action = log_action_event_action.idaction" + ), + array( + "table" => "log_action", + "tableAlias" => "log_action_event_name", + "joinOn" => "log_link_visit_action.idaction_name = log_action_event_name.idaction" + ) + ); + + $where = "log_link_visit_action.server_time >= ? + AND log_link_visit_action.server_time <= ? + AND log_link_visit_action.idsite = ? + AND log_link_visit_action.idaction_event_category IS NOT NULL"; + + $groupBy = "log_action_event_category.idaction, + log_action_event_action.idaction, + log_action_event_name.idaction"; + + $orderBy = "`" . Metrics::INDEX_NB_VISITS . "` DESC"; + + $rankingQueryLimit = ArchivingHelper::getRankingQueryLimit(); + $rankingQuery = null; + if ($rankingQueryLimit > 0) { + $rankingQuery = new RankingQuery($rankingQueryLimit); + $rankingQuery->setOthersLabel(DataTable::LABEL_SUMMARY_ROW); + $rankingQuery->addLabelColumn(array('eventCategory', 'eventAction', 'eventName')); + $rankingQuery->addColumn(array(Metrics::INDEX_NB_UNIQ_VISITORS)); + $rankingQuery->addColumn(array(Metrics::INDEX_EVENT_NB_HITS, Metrics::INDEX_NB_VISITS, Metrics::INDEX_EVENT_NB_HITS_WITH_VALUE), 'sum'); + $rankingQuery->addColumn(Metrics::INDEX_EVENT_SUM_EVENT_VALUE, 'sum'); + $rankingQuery->addColumn(Metrics::INDEX_EVENT_MIN_EVENT_VALUE, 'min'); + $rankingQuery->addColumn(Metrics::INDEX_EVENT_MAX_EVENT_VALUE, 'max'); + } + + $this->archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, $rankingQuery); + } + + + protected function archiveDayQueryProcess($select, $from, $where, $orderBy, $groupBy, RankingQuery $rankingQuery) + { + // get query with segmentation + $query = $this->getLogAggregator()->generateQuery($select, $from, $where, $groupBy, $orderBy); + + // apply ranking query + if ($rankingQuery) { + $query['sql'] = $rankingQuery->generateQuery($query['sql']); + } + + // get result + $resultSet = $this->getLogAggregator()->getDb()->query($query['sql'], $query['bind']); + + if ($resultSet === false) { + return; + } + + while ($row = $resultSet->fetch()) { + $this->aggregateEventRow($row); + } + } + + + /** + * Records the daily datatables + */ + protected function insertDayReports() + { + foreach ($this->arrays as $recordName => $dataArray) { + $dataTable = $dataArray->asDataTable(); + $blob = $dataTable->getSerialized( + $this->maximumRowsInDataTable, + $this->maximumRowsInSubDataTable, + $this->columnToSortByBeforeTruncation); + $this->getProcessor()->insertBlobRecord($recordName, $blob); + } + } + + /** + * @param string $name + * @return DataArray + */ + protected function getDataArray($name) + { + if(empty($this->arrays[$name])) { + $this->arrays[$name] = new DataArray(); + } + return $this->arrays[$name]; + } + + protected function aggregateEventRow($row) + { + foreach ($this->getRecordToDimensions() as $record => $dimensions) { + $dataArray = $this->getDataArray($record); + + $mainDimension = $dimensions[0]; + $mainLabel = $row[$mainDimension]; + + // Event name is optional + if ($mainDimension == 'eventName' + && empty($mainLabel)) { + $mainLabel = self::EVENT_NAME_NOT_SET; + } + $dataArray->sumMetricsEvents($mainLabel, $row); + + $subDimension = $dimensions[1]; + $subLabel = $row[$subDimension]; + if (empty($subLabel)) { + continue; + } + $dataArray->sumMetricsEventsPivot($mainLabel, $subLabel, $row); + } + } + +} diff --git a/www/analytics/plugins/Events/Controller.php b/www/analytics/plugins/Events/Controller.php new file mode 100644 index 00000000..87112ccc --- /dev/null +++ b/www/analytics/plugins/Events/Controller.php @@ -0,0 +1,115 @@ +leftMenuReports = $this->getLeftMenuReports(); + return $view->render(); + } + + private function getLeftMenuReports() + { + $reports = new View\ReportsByDimension('Events'); + foreach(Events::getLabelTranslations() as $apiAction => $translations) { + // 'getCategory' is the API method, but we are loading 'indexCategory' which displays

          + $count = 1; + $controllerAction = str_replace("get", "index", $apiAction, $count); + $params = array( + 'secondaryDimension' => API::getInstance()->getDefaultSecondaryDimension($apiAction) + ); + $reports->addReport('Events_TopEvents', $translations[0], 'Events.' . $controllerAction, $params); + } + return $reports->render(); + } + + public function indexCategory() + { + return $this->indexEvent(__FUNCTION__); + } + + public function indexAction() + { + return $this->indexEvent(__FUNCTION__); + } + + public function indexName() + { + return $this->indexEvent(__FUNCTION__); + } + + public function getCategory() + { + return $this->renderReport(__FUNCTION__); + } + + public function getAction() + { + return $this->renderReport(__FUNCTION__); + } + + public function getName() + { + return $this->renderReport(__FUNCTION__); + } + + public function getActionFromCategoryId() + { + return $this->renderReport(__FUNCTION__); + } + + public function getNameFromCategoryId() + { + return $this->renderReport(__FUNCTION__); + } + + public function getCategoryFromActionId() + { + return $this->renderReport(__FUNCTION__); + } + + public function getNameFromActionId() + { + return $this->renderReport(__FUNCTION__); + } + + public function getActionFromNameId() + { + return $this->renderReport(__FUNCTION__); + } + + public function getCategoryFromNameId() + { + return $this->renderReport(__FUNCTION__); + } + + protected function indexEvent($controllerMethod) + { + $count = 1; + $apiMethod = str_replace('index', 'get', $controllerMethod, $count); + $events = new Events; + $title = $events->getReportTitleTranslation($apiMethod); + return View::singleReport( + $title, + $this->$apiMethod() + ); + } +} diff --git a/www/analytics/plugins/Events/Events.php b/www/analytics/plugins/Events/Events.php new file mode 100644 index 00000000..86f5c4d3 --- /dev/null +++ b/www/analytics/plugins/Events/Events.php @@ -0,0 +1,306 @@ + 'getSegmentsMetadata', + 'Metrics.getDefaultMetricTranslations' => 'addMetricTranslations', + 'API.getReportMetadata' => 'getReportMetadata', + 'Menu.Reporting.addItems' => 'addMenus', + 'WidgetsList.addWidgets' => 'addWidgets', + 'ViewDataTable.configure' => 'configureViewDataTable', + + ); + } + public function addWidgets() + { + foreach(self::getLabelTranslations() as $apiMethod => $labels) { + $params = array( + 'secondaryDimension' => API::getInstance()->getDefaultSecondaryDimension($apiMethod) + ); + WidgetsList::add('Events_Events', $labels[0], 'Events', $apiMethod, $params); + } + } + + public function addMenus() + { + MenuMain::getInstance()->add('General_Actions', 'Events_Events', array('module' => 'Events', 'action' => 'index'), true, 30); + } + + public function addMetricTranslations(&$translations) + { + $translations = array_merge($translations, $this->getMetricTranslations()); + } + + public function getMetricDocumentation() + { + $documentation = array( + 'nb_events' => 'Events_TotalEventsDocumentation', + 'sum_event_value' => 'Events_TotalValueDocumentation', + 'min_event_value' => 'Events_MinValueDocumentation', + 'max_event_value' => 'Events_MaxValueDocumentation', + 'avg_event_value' => 'Events_AvgValueDocumentation', + 'nb_events_with_value' => 'Events_EventsWithValueDocumentation', + ); + $documentation = array_map(array('\\Piwik\\Piwik', 'translate'), $documentation); + return $documentation; + } + + public function getMetricTranslations() + { + $metrics = array( + 'nb_events' => 'Events_TotalEvents', + 'sum_event_value' => 'Events_TotalValue', + 'min_event_value' => 'Events_MinValue', + 'max_event_value' => 'Events_MaxValue', + 'avg_event_value' => 'Events_AvgValue', + 'nb_events_with_value' => 'Events_EventsWithValue', + ); + $metrics = array_map(array('\\Piwik\\Piwik', 'translate'), $metrics); + return $metrics; + } + + public $metadataDimensions = array( + 'eventCategory' => array('Events_EventCategory', 'log_link_visit_action.idaction_event_category'), + 'eventAction' => array('Events_EventAction', 'log_link_visit_action.idaction_event_action'), + 'eventName' => array('Events_EventName', 'log_link_visit_action.idaction_name'), + ); + + public function getDimensionLabel($dimension) + { + return Piwik::translate($this->metadataDimensions[$dimension][0]); + } + /** + * @return array + */ + static public function getLabelTranslations() + { + return array( + 'getCategory' => array('Events_EventCategories', 'Events_EventCategory'), + 'getAction' => array('Events_EventActions', 'Events_EventAction'), + 'getName' => array('Events_EventNames', 'Events_EventName'), + ); + } + + + public function getSegmentsMetadata(&$segments) + { + $sqlFilter = '\\Piwik\\Tracker\\TableLogAction::getIdActionFromSegment'; + + foreach($this->metadataDimensions as $dimension => $metadata) { + $segments[] = array( + 'type' => 'dimension', + 'category' => 'Events_Events', + 'name' => $metadata[0], + 'segment' => $dimension, + 'sqlSegment' => $metadata[1], + 'sqlFilter' => $sqlFilter, + ); + } + $segments[] = array( + 'type' => 'metric', + 'category' => Piwik::translate('General_Visit'), + 'name' => 'Events_TotalEvents', + 'segment' => 'events', + 'sqlSegment' => 'log_visit.visit_total_events', + 'acceptedValues' => 'To select all visits who triggered an Event, use: &segment=events>0', + ); +// $segments[] = array( +// 'type' => 'metric', +// 'category' => 'Events_Events', +// 'name' => 'Events_EventValue', +// 'segment' => 'eventValue', +// 'sqlSegment' => 'log_link_visit_action.custom_float', +// 'sqlFilter' => '\\Piwik\\Plugins\\Events\\Events::getSegmentEventValue' +// ); + } +// +// public static function getSegmentEventValue($valueToMatch, $sqlField, $matchType, $segmentName) +// { +// $andActionisNotEvent = \Piwik\Plugins\Actions\Archiver::getWhereClauseActionIsNotEvent(); +// $andActionisEvent = str_replace("IS NULL", "IS NOT NULL", $andActionisNotEvent); +// +// return array( +// 'extraWhere' => $andActionisEvent, +// 'bind' => $valueToMatch +// ); +// } + + public function getReportMetadata(&$reports) + { + $metrics = $this->getMetricTranslations(); + $documentation = $this->getMetricDocumentation(); + $labelTranslations = $this->getLabelTranslations(); + + $order = 0; + foreach($labelTranslations as $action => $translations) { + $secondaryDimension = $this->getSecondaryDimensionFromRequest(); + $actionToLoadSubtables = API::getInstance()->getActionToLoadSubtables($action, $secondaryDimension); + $reports[] = array( + 'category' => Piwik::translate('Events_Events'), + 'name' => Piwik::translate($translations[0]), + 'module' => 'Events', + 'action' => $action, + 'dimension' => Piwik::translate($translations[1]), + 'metrics' => $metrics, + 'metricsDocumentation' => $documentation, + 'processedMetrics' => false, + 'actionToLoadSubTables' => $actionToLoadSubtables, + 'order' => $order++ + ); + + } + } + + + /** + * Given getCategory, returns "Event Categories" + * + * @param $apiMethod + * @return string + */ + public function getReportTitleTranslation($apiMethod) + { + return $this->getTranslation($apiMethod, $index = 0); + } + + /** + * Given getCategory, returns "Event Category" + * + * @param $apiMethod + * @return string + */ + public function getColumnTranslation($apiMethod) + { + return $this->getTranslation($apiMethod, $index = 1); + } + + protected function getTranslation($apiMethod, $index) + { + $labels = $this->getLabelTranslations(); + foreach ($labels as $action => $translations) { + // Events.getActionFromCategoryId returns translation for Events.getAction + if (strpos($apiMethod, $action) === 0) { + $columnLabel = $translations[$index]; + return Piwik::translate($columnLabel); + } + } + throw new \Exception("Translation not found for report $apiMethod"); + } + + public function configureViewDataTable(ViewDataTable $view) + { + if($view->requestConfig->getApiModuleToRequest() != 'Events') { + return; + } + + // eg. 'Events.getCategory' + $apiMethod = $view->requestConfig->getApiMethodToRequest(); + + $secondaryDimension = $this->getSecondaryDimensionFromRequest(); + $view->config->subtable_controller_action = API::getInstance()->getActionToLoadSubtables($apiMethod, $secondaryDimension); + $view->config->columns_to_display = array('label', 'nb_events', 'sum_event_value'); + $view->config->show_flatten_table = true; + $view->config->show_table_all_columns = false; + $view->requestConfig->filter_sort_column = 'nb_events'; + + $labelTranslation = $this->getColumnTranslation($apiMethod); + $view->config->addTranslation('label', $labelTranslation); + $view->config->addTranslations($this->getMetricTranslations()); + $this->addRelatedReports($view, $secondaryDimension); + $this->addTooltipEventValue($view); + } + + protected function addRelatedReports($view, $secondaryDimension) + { + if(empty($secondaryDimension)) { + // eg. Row Evolution + return; + } + $view->config->show_related_reports = true; + + $apiMethod = $view->requestConfig->getApiMethodToRequest(); + $secondaryDimensions = API::getInstance()->getSecondaryDimensions($apiMethod); + + if(empty($secondaryDimensions)) { + return; + } + + $secondaryDimensionTranslation = $this->getDimensionLabel($secondaryDimension); + $view->config->related_reports_title = + Piwik::translate('Events_SecondaryDimension', $secondaryDimensionTranslation) + . "
          " + . Piwik::translate('Events_SwitchToSecondaryDimension', ''); + + foreach($secondaryDimensions as $dimension) { + if($dimension == $secondaryDimension) { + // don't show as related report the currently selected dimension + continue; + } + + $dimensionTranslation = $this->getDimensionLabel($dimension); + $view->config->addRelatedReport( + $view->requestConfig->apiMethodToRequestDataTable, + $dimensionTranslation, + array('secondaryDimension' => $dimension) + ); + } + + } + + protected function addTooltipEventValue($view) + { + // Creates the tooltip message for Event Value column + $tooltipCallback = function ($hits, $min, $max, $avg) { + if (!$hits) { + return false; + } + $msgEventMinMax = Piwik::translate("Events_EventValueTooltip", array($hits, "
          ", $min, $max)); + $msgEventAvg = Piwik::translate("Events_AvgEventValue", $avg); + return $msgEventMinMax . "
          " . $msgEventAvg; + }; + + // Add tooltip metadata column to the DataTable + $view->config->filters[] = array('ColumnCallbackAddMetadata', + array( + array( + 'nb_events', + 'min_event_value', + 'max_event_value', + 'avg_event_value' + ), + 'sum_event_value_tooltip', + $tooltipCallback + ) + ); + } + + /** + * @return mixed + */ + protected function getSecondaryDimensionFromRequest() + { + return Common::getRequestVar('secondaryDimension', false, 'string'); + } +} diff --git a/www/analytics/plugins/Events/lang/bg.json b/www/analytics/plugins/Events/lang/bg.json new file mode 100644 index 00000000..3569ed41 --- /dev/null +++ b/www/analytics/plugins/Events/lang/bg.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "Събитие", + "EventAction": "Действие на събитието", + "EventCategory": "Категория на събитие", + "EventName": "Име на събитие", + "Events": "Събития", + "EventValue": "Стойност за събитие" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/da.json b/www/analytics/plugins/Events/lang/da.json new file mode 100644 index 00000000..a1f8b54c --- /dev/null +++ b/www/analytics/plugins/Events/lang/da.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "Hændelse", + "EventAction": "Hændelsesaktion", + "EventCategory": "Hændelseskategori", + "EventName": "Hændelsesnavn", + "Events": "Hændelser", + "EventValue": "Hændelsesværdi" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/de.json b/www/analytics/plugins/Events/lang/de.json new file mode 100644 index 00000000..c041c6a9 --- /dev/null +++ b/www/analytics/plugins/Events/lang/de.json @@ -0,0 +1,21 @@ +{ + "Events": { + "Event": "Ereignis", + "EventAction": "Ereignisaktion", + "EventActions": "Ereignisaktionen", + "EventCategories": "Ereigniskategorien", + "EventCategory": "Ereigniskategorie", + "EventName": "Ereignisname", + "EventNames": "Ereignisnamen", + "Events": "Ereignisse", + "EventValue": "Ereigniswert", + "MaxValue": "Maximaler Wert", + "MaxValueDocumentation": "Maximaler Wert für dieses Ereignis", + "MinValue": "Minimaler Wert", + "MinValueDocumentation": "Minimaler Wert für dieses Ereignis", + "TotalEvents": "Gesamtzahl an Ereignissen", + "TotalEventsDocumentation": "Gesamtanzahl der Ereignisse", + "TotalValue": "Gesamtwert", + "TotalValueDocumentation": "Gesamtanzahl der Ereignisse (Summe der Ereigniswerte)" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/el.json b/www/analytics/plugins/Events/lang/el.json new file mode 100644 index 00000000..c8226799 --- /dev/null +++ b/www/analytics/plugins/Events/lang/el.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "Γεγονός", + "EventAction": "Ενέργεια Συμβάντος", + "EventCategory": "Κατηγορία Συμβάντος", + "EventName": "Όνομα Συμβάντος", + "Events": "Συμβάντα", + "EventValue": "Τιμή Συμβάντος" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/en.json b/www/analytics/plugins/Events/lang/en.json new file mode 100644 index 00000000..10958efc --- /dev/null +++ b/www/analytics/plugins/Events/lang/en.json @@ -0,0 +1,31 @@ +{ + "Events": { + "Events": "Events", + "Event": "Event", + "EventCategory": "Event Category", + "EventCategories": "Event Categories", + "EventAction": "Event Action", + "EventActions": "Event Actions", + "EventName": "Event Name", + "EventNames": "Event Names", + "EventValue": "Event Value", + "TotalEvents": "Total events", + "TotalValue": "Total value", + "MinValue": "Minimum value", + "MaxValue": "Maximum value", + "AvgValue": "Average value", + "EventsWithValue": "Events with a value", + "TotalEventsDocumentation": "Total number of events", + "TotalValueDocumentation": "The sum of event values", + "MinValueDocumentation": "The minimum value for this event", + "MaxValueDocumentation": "The maximum value for this event", + "AvgValueDocumentation": "The average of all values for this event", + "EventsWithValueDocumentation": "Number of events where an Event value was set", + "EventValueTooltip": "Total Event value is the sum of %s events values %s between minimum of %s and maximum of %s.", + "AvgEventValue": "Average Event value is: %s", + "TopEvents": "Top Events", + "SecondaryDimension": "Secondary dimension is %s.", + "SwitchToSecondaryDimension": "Switch to %s", + "ViewEvents": "View Events" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/es.json b/www/analytics/plugins/Events/lang/es.json new file mode 100644 index 00000000..cbe258b6 --- /dev/null +++ b/www/analytics/plugins/Events/lang/es.json @@ -0,0 +1,21 @@ +{ + "Events": { + "Event": "Evento", + "EventAction": "Acción de evento", + "EventActions": "Acciones de evento", + "EventCategories": "Categorias del evento", + "EventCategory": "Categoría del evento", + "EventName": "Nombre del evento", + "EventNames": "Nombres de los eventos", + "Events": "Eventos", + "EventValue": "Valor del evento", + "MaxValue": "Valor máximo", + "MaxValueDocumentation": "El valor máximo de evento", + "MinValue": "Valor minimo", + "MinValueDocumentation": "El valor minimo de este evento", + "TotalEvents": "El total de eventos", + "TotalEventsDocumentation": "El numero total de eventos", + "TotalValue": "Valor total", + "TotalValueDocumentation": "Valor total de eventos (la suma del valor de los eventos)" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/et.json b/www/analytics/plugins/Events/lang/et.json new file mode 100644 index 00000000..ad62ea46 --- /dev/null +++ b/www/analytics/plugins/Events/lang/et.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "Sündmus", + "EventAction": "Sündmuse tegevus", + "EventCategory": "Sündmuse kategooria", + "EventName": "Sündmuse nimi", + "Events": "Sündmused", + "EventValue": "Sündmuse väärtus" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/fa.json b/www/analytics/plugins/Events/lang/fa.json new file mode 100644 index 00000000..a71de29a --- /dev/null +++ b/www/analytics/plugins/Events/lang/fa.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "رویداد", + "EventAction": "اقدامات رویداد", + "EventCategory": "دسته رویداد", + "EventName": "نام رویداد", + "Events": "رویداد ها", + "EventValue": "ارزش رویداد" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/fi.json b/www/analytics/plugins/Events/lang/fi.json new file mode 100644 index 00000000..1a553fe9 --- /dev/null +++ b/www/analytics/plugins/Events/lang/fi.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "Tapahtuma", + "EventAction": "Tapahtuman toiminto", + "EventCategory": "Tapahtuman kategoria", + "EventName": "Tapahtuman nimi", + "Events": "Tapahtumat", + "EventValue": "Tapahtuman arvo" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/fr.json b/www/analytics/plugins/Events/lang/fr.json new file mode 100644 index 00000000..98ad87ed --- /dev/null +++ b/www/analytics/plugins/Events/lang/fr.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "Evènement", + "EventAction": "Action de l'évènement", + "EventCategory": "Catégorie d'évènement", + "EventName": "Nom d'évènement", + "Events": "Evènements", + "EventValue": "Valeur d'évènement" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/it.json b/www/analytics/plugins/Events/lang/it.json new file mode 100644 index 00000000..a1594a45 --- /dev/null +++ b/www/analytics/plugins/Events/lang/it.json @@ -0,0 +1,21 @@ +{ + "Events": { + "Event": "Evento", + "EventAction": "Azione Evento", + "EventActions": "Azioni Evento", + "EventCategories": "Categorie Evento", + "EventCategory": "Categoria Evento", + "EventName": "Nome Evento", + "EventNames": "Nomi Evento", + "Events": "Eventi", + "EventValue": "Valore Evento", + "MaxValue": "Valore massimo", + "MaxValueDocumentation": "Valore massimo per questo evento", + "MinValue": "Valore minimo", + "MinValueDocumentation": "Valore minimo per questo evento", + "TotalEvents": "Totale eventi", + "TotalEventsDocumentation": "Numero totale degli eventi", + "TotalValue": "Valore totale", + "TotalValueDocumentation": "Valore totale degli eventi (somma dei valori degli eventi)" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/ja.json b/www/analytics/plugins/Events/lang/ja.json new file mode 100644 index 00000000..e1ff33da --- /dev/null +++ b/www/analytics/plugins/Events/lang/ja.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "イベント", + "EventAction": "イベントのアクション", + "EventCategory": "イベントのカテゴリー", + "EventName": "イベントの名称", + "Events": "イベント", + "EventValue": "イベントの価値" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/nl.json b/www/analytics/plugins/Events/lang/nl.json new file mode 100644 index 00000000..e7396662 --- /dev/null +++ b/www/analytics/plugins/Events/lang/nl.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "Gebeurtenis", + "EventAction": "Gebeurtenis Actie", + "EventCategory": "Gebeurtenis Categorie", + "EventName": "Gebeurtenis Naam", + "Events": "Gebeurtenissen", + "EventValue": "Gebeurtenis Waarde" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/pt-br.json b/www/analytics/plugins/Events/lang/pt-br.json new file mode 100644 index 00000000..9bc1180f --- /dev/null +++ b/www/analytics/plugins/Events/lang/pt-br.json @@ -0,0 +1,9 @@ +{ + "Events": { + "Event": "Evento", + "EventCategory": "Categoria do Evento", + "EventName": "Nome do Evento", + "Events": "Eventos", + "EventValue": "Valor do Evento" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/ro.json b/www/analytics/plugins/Events/lang/ro.json new file mode 100644 index 00000000..0816a5ac --- /dev/null +++ b/www/analytics/plugins/Events/lang/ro.json @@ -0,0 +1,21 @@ +{ + "Events": { + "Event": "Eveniment", + "EventAction": "Actiunea Evenimentului", + "EventActions": "Actiunile Evenimentului", + "EventCategories": "Categoriile Evenimentului", + "EventCategory": "Categoria Evenimentului", + "EventName": "Numele Evenimentului", + "EventNames": "Numele Evenimentelor", + "Events": "Evenimente", + "EventValue": "Valoarea Evenimentului", + "MaxValue": "Valoarea maxima", + "MaxValueDocumentation": "Valoarea maxima pentru acest eveniment", + "MinValue": "Valoarea minima", + "MinValueDocumentation": "Valoarea minima pentru acest eveniment", + "TotalEvents": "Totalul evenimentelor", + "TotalEventsDocumentation": "Numarul total al evenimentelor", + "TotalValue": "Valoarea totala", + "TotalValueDocumentation": "Valoarea totala a evenimentelor (suma valorilor evenimentelor)" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/sr.json b/www/analytics/plugins/Events/lang/sr.json new file mode 100644 index 00000000..2cde5bc9 --- /dev/null +++ b/www/analytics/plugins/Events/lang/sr.json @@ -0,0 +1,13 @@ +{ + "Events": { + "Event": "Događaj", + "EventActions": "Akcije događaja", + "EventCategory": "Kategorije događaja", + "EventNames": "Nazivi događaja", + "EventValue": "Vrednost događaja", + "MaxValueDocumentation": "Maksimalna vrednost za ovaj događaj", + "MinValueDocumentation": "Minimalna vrednost za ovaj događaj", + "TotalEventsDocumentation": "Ukupan broj događaja", + "TotalValueDocumentation": "Ukupna vrednost događaja (suma vrednosti događaja)" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/sv.json b/www/analytics/plugins/Events/lang/sv.json new file mode 100644 index 00000000..7513cda9 --- /dev/null +++ b/www/analytics/plugins/Events/lang/sv.json @@ -0,0 +1,10 @@ +{ + "Events": { + "Event": "Händelse", + "EventAction": "Händelse Åtgärd", + "EventCategory": "Händelsekategori", + "EventName": "Händelse Namn", + "Events": "Händelser", + "EventValue": "Händelse Värde" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/ta.json b/www/analytics/plugins/Events/lang/ta.json new file mode 100644 index 00000000..1227447c --- /dev/null +++ b/www/analytics/plugins/Events/lang/ta.json @@ -0,0 +1,9 @@ +{ + "Events": { + "EventAction": "நிகழ்ச்சி செயல்", + "EventCategory": "நிகழ்ச்சி வகை", + "EventName": "நிகழ்ச்சி பெயர்", + "Events": "நிகழ்சிகள்", + "EventValue": "நிகழ்ச்சி பெறுமதி" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/vi.json b/www/analytics/plugins/Events/lang/vi.json new file mode 100644 index 00000000..6bf5ed24 --- /dev/null +++ b/www/analytics/plugins/Events/lang/vi.json @@ -0,0 +1,9 @@ +{ + "Events": { + "EventAction": "Hành động sự kiện", + "EventCategory": "Danh mục sự kiện", + "EventName": "Tên sự kiện", + "Events": "Các sự kiện", + "EventValue": "Giá trị sự kiện" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/lang/zh-cn.json b/www/analytics/plugins/Events/lang/zh-cn.json new file mode 100644 index 00000000..40e17624 --- /dev/null +++ b/www/analytics/plugins/Events/lang/zh-cn.json @@ -0,0 +1,9 @@ +{ + "Events": { + "EventAction": "事件行为", + "EventCategory": "事件分类", + "EventName": "事件名字", + "Events": "事件", + "EventValue": "事件值" + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/plugin.json b/www/analytics/plugins/Events/plugin.json new file mode 100644 index 00000000..908441b7 --- /dev/null +++ b/www/analytics/plugins/Events/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "Events", + "version": "1.0", + "description": "Track Custom Events and get reports on your visitors activity.", + "theme": false +} \ No newline at end of file diff --git a/www/analytics/plugins/Events/templates/index.twig b/www/analytics/plugins/Events/templates/index.twig new file mode 100644 index 00000000..a12afb65 --- /dev/null +++ b/www/analytics/plugins/Events/templates/index.twig @@ -0,0 +1,2 @@ +{{ leftMenuReports|raw }} + diff --git a/www/analytics/plugins/ExampleAPI/API.php b/www/analytics/plugins/ExampleAPI/API.php new file mode 100644 index 00000000..63823835 --- /dev/null +++ b/www/analytics/plugins/ExampleAPI/API.php @@ -0,0 +1,159 @@ +source code in in the file plugins/ExampleAPI/API.php for more documentation. + * @method static \Piwik\Plugins\ExampleAPI\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + /** + * Get Piwik version + * @return string + */ + public function getPiwikVersion() + { + Piwik::checkUserHasSomeViewAccess(); + return Version::VERSION; + } + + /** + * Get Answer to Life + * @return integer + */ + public function getAnswerToLife() + { + return 42; + } + + /** + * Returns a custom object. + * API format conversion will fail for this custom object. + * If used internally, the data structure can be returned untouched by using + * the API parameter 'format=original' + * + * @return MagicObject Will return a standard Piwik error when called from the Web APIs + */ + public function getObject() + { + return new MagicObject(); + } + + /** + * Sums two floats and returns the result. + * The paramaters are set automatically from the GET request + * when the API function is called. You can also use default values + * as shown in this example. + * + * @param float|int $a + * @param float|int $b + * @return float + */ + public function getSum($a = 0, $b = 0) + { + return (float)($a + $b); + } + + /** + * Returns null value + * + * @return null + */ + public function getNull() + { + return null; + } + + /** + * Get array of descriptive text + * When called from the Web API, you see that simple arrays like this one + * are automatically converted in the various formats (xml, csv, etc.) + * + * @return array + */ + public function getDescriptionArray() + { + return array('piwik', 'open source', 'web analytics', 'free', 'Strong message: Свободный Тибет'); + } + + /** + * Returns a custom data table. + * This data table will be converted to all available formats + * when requested in the API request. + * + * @return DataTable + */ + public function getCompetitionDatatable() + { + $dataTable = new DataTable(); + + $row1 = new Row(); + $row1->setColumns(array('name' => 'piwik', 'license' => 'GPL')); + + // Rows Metadata is useful to store non stats data for example (logos, urls, etc.) + // When printed out, they are simply merged with columns + $row1->setMetadata('logo', 'logo.png'); + $dataTable->addRow($row1); + + $dataTable->addRowFromSimpleArray(array('name' => 'google analytics', 'license' => 'commercial')); + + return $dataTable; + } + + /** + * Get more information on the Answer to Life... + * + * @return string + */ + public function getMoreInformationAnswerToLife() + { + return "Check http://en.wikipedia.org/wiki/The_Answer_to_Life,_the_Universe,_and_Everything"; + } + + /** + * Returns a Multidimensional Array + * Only supported in JSON + * + * @return array + */ + public function getMultiArray() + { + $return = array( + 'Limitation' => array( + "Multi dimensional arrays is only supported by format=JSON", + "Known limitation" + ), + 'Second Dimension' => array(true, false, 1, 0, 152, 'test', array(42 => 'end')), + ); + return $return; + } +} + +/** + * Magic Object + * + */ +class MagicObject +{ + function Incredible() + { + return 'Incroyable'; + } + + protected $wonderful = 'magnifique'; + public $great = 'formidable'; +} diff --git a/www/analytics/plugins/ExampleAPI/ExampleAPI.php b/www/analytics/plugins/ExampleAPI/ExampleAPI.php new file mode 100644 index 00000000..3d38b3a3 --- /dev/null +++ b/www/analytics/plugins/ExampleAPI/ExampleAPI.php @@ -0,0 +1,18 @@ +setName('examplecommand:helloworld'); + $this->setDescription('ExampleCommand'); + $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Your name:'); + } + + /** + * Execute command like: ./console examplecommand:helloworld --name="The Piwik Team" + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $name = $input->getOption('name'); + + $message = sprintf('HelloWorld: %s', $name); + + $output->writeln($message); + } +} diff --git a/www/analytics/plugins/ExampleCommand/plugin.json b/www/analytics/plugins/ExampleCommand/plugin.json new file mode 100644 index 00000000..24cc1735 --- /dev/null +++ b/www/analytics/plugins/ExampleCommand/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "ExampleCommand", + "version": "0.1.0", + "description": "Example for console commands", + "theme": false +} \ No newline at end of file diff --git a/www/analytics/plugins/ExamplePlugin/.gitignore b/www/analytics/plugins/ExamplePlugin/.gitignore new file mode 100644 index 00000000..b141f450 --- /dev/null +++ b/www/analytics/plugins/ExamplePlugin/.gitignore @@ -0,0 +1 @@ +tests/processed/*xml \ No newline at end of file diff --git a/www/analytics/plugins/ExamplePlugin/.travis.yml b/www/analytics/plugins/ExamplePlugin/.travis.yml new file mode 100644 index 00000000..7dd87060 --- /dev/null +++ b/www/analytics/plugins/ExamplePlugin/.travis.yml @@ -0,0 +1,42 @@ +language: php + +php: +- 5.3 + +env: + matrix: + - TEST_SUITE=CoreTests MYSQL_ADAPTER=PDO_MYSQL + - TEST_SUITE=PluginTests MYSQL_ADAPTER=PDO_MYSQL + +script: ./travis.sh + +install: + - TEST_PIWIK_VERSION=$(wget builds.piwik.org/LATEST -q -O -) + - TEST_PIWIK_VERSION=`echo $TEST_PIWIK_VERSION | tr -d ' ' | tr -d '\n'` + - mkdir ExamplePlugin + - cp -R !(ExamplePlugin) ExamplePlugin + - cp -R .git/ ExamplePlugin/ + - git clone https://github.com/piwik/piwik.git piwik + - cd piwik + - git checkout "$TEST_PIWIK_VERSION" + - git submodule init + - git submodule update || true + - composer self-update + - composer install + - rm -rf plugins/ExamplePlugin + - cd ../ + - mv ExamplePlugin piwik/plugins + +before_script: + - cd piwik + - uname -a + - date + - mysql -e 'create database piwik_tests;' + - ./tests/travis/prepare.sh + - ./tests/travis/setup_webserver.sh + - wget https://raw.github.com/piwik/piwik-tests-plugins/master/activateplugin.php + - php activateplugin.php ExamplePlugin + - cd tests/PHPUnit + +after_script: + - cat /var/log/nginx/error.log diff --git a/www/analytics/plugins/ExamplePlugin/API.php b/www/analytics/plugins/ExamplePlugin/API.php new file mode 100644 index 00000000..d0a5d505 --- /dev/null +++ b/www/analytics/plugins/ExamplePlugin/API.php @@ -0,0 +1,36 @@ +setBasicVariablesView($view); + $view->answerToLife = '42'; + + return $view->render(); + } +} diff --git a/www/analytics/plugins/ExamplePlugin/ExamplePlugin.php b/www/analytics/plugins/ExamplePlugin/ExamplePlugin.php new file mode 100644 index 00000000..103a3002 --- /dev/null +++ b/www/analytics/plugins/ExamplePlugin/ExamplePlugin.php @@ -0,0 +1,29 @@ + 'getJsFiles', + ); + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = 'plugins/ExamplePlugin/javascripts/plugin.js'; + } +} diff --git a/www/analytics/plugins/ExamplePlugin/README.md b/www/analytics/plugins/ExamplePlugin/README.md new file mode 100644 index 00000000..bd61a4a2 --- /dev/null +++ b/www/analytics/plugins/ExamplePlugin/README.md @@ -0,0 +1,18 @@ +# Piwik ExamplePlugin Plugin + +## Description + +Add your plugin description here. + +## FAQ + +__My question?__ +My answer + +## Changelog + +Here goes the changelog text. + +## Support + +Please direct any feedback to ... \ No newline at end of file diff --git a/www/analytics/plugins/ExamplePlugin/javascripts/plugin.js b/www/analytics/plugins/ExamplePlugin/javascripts/plugin.js new file mode 100644 index 00000000..e5bf3542 --- /dev/null +++ b/www/analytics/plugins/ExamplePlugin/javascripts/plugin.js @@ -0,0 +1,20 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready(function () { + /** + * Please note that this JavaScript file will be loaded only if you + * enable the following setting in your config: + * + * [Debug] + * disable_merged_assets = 1 + */ + + if('undefined' != (typeof console)) { /* IE8 has no console */ + console.log('Plugin file loaded'); + } +}); diff --git a/www/analytics/plugins/ExamplePlugin/plugin.json b/www/analytics/plugins/ExamplePlugin/plugin.json new file mode 100644 index 00000000..b6328199 --- /dev/null +++ b/www/analytics/plugins/ExamplePlugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "ExamplePlugin", + "version": "0.1.0", + "description": "ExampleDescription", + "theme": false, + "authors": [ + { + "name": "Piwik", + "email": "", + "homepage": "" + } + ] +} \ No newline at end of file diff --git a/www/analytics/plugins/ExamplePlugin/screenshots/.gitkeep b/www/analytics/plugins/ExamplePlugin/screenshots/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/www/analytics/plugins/ExamplePlugin/templates/index.twig b/www/analytics/plugins/ExamplePlugin/templates/index.twig new file mode 100644 index 00000000..d8c940d0 --- /dev/null +++ b/www/analytics/plugins/ExamplePlugin/templates/index.twig @@ -0,0 +1,4 @@ +Hello world! +
          + +The answer to life is {{ answerToLife }} \ No newline at end of file diff --git a/www/analytics/plugins/ExampleRssWidget/Controller.php b/www/analytics/plugins/ExampleRssWidget/Controller.php new file mode 100644 index 00000000..635ab8ec --- /dev/null +++ b/www/analytics/plugins/ExampleRssWidget/Controller.php @@ -0,0 +1,53 @@ +showDescription(true); + return $rss->get(); + } catch (Exception $e) { + return $this->error($e); + } + } + + public function rssChangelog() + { + try { + $rss = new RssRenderer('http://feeds.feedburner.com/PiwikReleases'); + $rss->setCountPosts(1); + $rss->showDescription(true); + $rss->showContent(false); + return $rss->get(); + } catch (Exception $e) { + return $this->error($e); + } + } + + /** + * @param \Exception $e + */ + protected function error($e) + { + return '
          ' + . Piwik::translate('General_ErrorRequest') + . ' - ' . $e->getMessage() . '
          '; + } +} diff --git a/www/analytics/plugins/ExampleRssWidget/ExampleRssWidget.php b/www/analytics/plugins/ExampleRssWidget/ExampleRssWidget.php new file mode 100644 index 00000000..0e501cf2 --- /dev/null +++ b/www/analytics/plugins/ExampleRssWidget/ExampleRssWidget.php @@ -0,0 +1,39 @@ + 'getStylesheetFiles', + 'WidgetsList.addWidgets' => 'addWidgets' + ); + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/ExampleRssWidget/stylesheets/rss.less"; + } + + public function addWidgets() + { + WidgetsList::add('Example Widgets', 'Piwik.org Blog', 'ExampleRssWidget', 'rssPiwik'); + WidgetsList::add('Example Widgets', 'Piwik Changelog', 'ExampleRssWidget', 'rssChangelog'); + } +} diff --git a/www/analytics/plugins/ExampleRssWidget/RssRenderer.php b/www/analytics/plugins/ExampleRssWidget/RssRenderer.php new file mode 100644 index 00000000..508c4058 --- /dev/null +++ b/www/analytics/plugins/ExampleRssWidget/RssRenderer.php @@ -0,0 +1,84 @@ +url = $url; + } + + public function showDescription($bool) + { + $this->showDescription = $bool; + } + + public function showContent($bool) + { + $this->showContent = $bool; + } + + public function setCountPosts($count) + { + $this->count = $count; + } + + public function get() + { + try { + $content = Http::fetchRemoteFile($this->url); + $rss = simplexml_load_string($content); + } catch (\Exception $e) { + echo "Error while importing feed: {$e->getMessage()}\n"; + exit; + } + + $output = '
            '; + $i = 0; + + $items = array(); + if(!empty($rss->channel->item)) { + $items = $rss->channel->item; + } + foreach ($items as $post) { + $title = $post->title; + $date = @strftime("%B %e, %Y", strtotime($post->pubDate)); + $link = $post->link; + + $output .= '
          • ' . $title . '' . + '' . $date . ''; + if ($this->showDescription) { + $output .= '
            ' . $post->description . '
            '; + } + + if ($this->showContent) { + $output .= '
            ' . $post->content . '
            '; + } + $output .= '
          • '; + + if (++$i == $this->count) { + break; + } + } + + $output .= '
          '; + return $output; + } +} diff --git a/www/analytics/plugins/ExampleRssWidget/plugin.json b/www/analytics/plugins/ExampleRssWidget/plugin.json new file mode 100644 index 00000000..cbb36f38 --- /dev/null +++ b/www/analytics/plugins/ExampleRssWidget/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "ExampleRssWidget", + "description": "Example Plugin: This plugin showcases how to create a new widget that displays a user submitted RSS feed.", + "version": "1.0", + "keywords": ["example", "feed", "widget"], + "homepage": "http://piwik.org", + "license": "GPL v3+", + "authors": [ + { + "name": "Piwik", + "email": "hello@piwik.org", + "homepage": "http://piwik.org" + } + ] +} \ No newline at end of file diff --git a/www/analytics/plugins/ExampleRssWidget/stylesheets/rss.less b/www/analytics/plugins/ExampleRssWidget/stylesheets/rss.less new file mode 100644 index 00000000..5676571a --- /dev/null +++ b/www/analytics/plugins/ExampleRssWidget/stylesheets/rss.less @@ -0,0 +1,33 @@ +.rss ul { + list-style: none outside none; + padding: 0; +} + +.rss li { + line-height: 140%; + margin: 0.5em 0 1em; +} + +.rss-title, .rss-date { + float: left; + font-size: 14px; + line-height: 140%; +} + +.rss-title { + color: #2583AD; + margin: 0 0.5em 0.2em 0; + font-weight: bold; +} + +.rss-date { + color: #999999; + margin: 0; +} + +.rss-content, .rss-description { + clear: both; + line-height: 1.5em; + font-size: 11px; + color: #333333; +} diff --git a/www/analytics/plugins/ExampleSettingsPlugin/Settings.php b/www/analytics/plugins/ExampleSettingsPlugin/Settings.php new file mode 100644 index 00000000..2eb78b46 --- /dev/null +++ b/www/analytics/plugins/ExampleSettingsPlugin/Settings.php @@ -0,0 +1,157 @@ +autoRefresh->getValue(); + * $settings->metric->getValue(); + * + */ +class Settings extends \Piwik\Plugin\Settings +{ + /** @var UserSetting */ + public $autoRefresh; + + /** @var UserSetting */ + public $refreshInterval; + + /** @var UserSetting */ + public $color; + + /** @var SystemSetting */ + public $metric; + + /** @var SystemSetting */ + public $browsers; + + /** @var SystemSetting */ + public $description; + + /** @var SystemSetting */ + public $password; + + protected function init() + { + $this->setIntroduction('Here you can specify the settings for this plugin.'); + + // User setting --> checkbox converted to bool + $this->createAutoRefreshSetting(); + + // User setting --> textbox converted to int defining a validator and filter + $this->createRefreshIntervalSetting(); + + // User setting --> readio + $this->createColorSetting(); + + // System setting --> allows selection of a single value + $this->createMetricSetting(); + + // System setting --> allows selection of multiple values + $this->createBrowsersSetting(); + + // System setting --> textarea + $this->createDescriptionSetting(); + + // System setting --> textarea + $this->createPasswordSetting(); + } + + private function createAutoRefreshSetting() + { + $this->autoRefresh = new UserSetting('autoRefresh', 'Auto refresh'); + $this->autoRefresh->type = static::TYPE_BOOL; + $this->autoRefresh->uiControlType = static::CONTROL_CHECKBOX; + $this->autoRefresh->description = 'If enabled, the value will be automatically refreshed depending on the specified interval'; + $this->autoRefresh->defaultValue = false; + + $this->addSetting($this->autoRefresh); + } + + private function createRefreshIntervalSetting() + { + $this->refreshInterval = new UserSetting('refreshInterval', 'Refresh Interval'); + $this->refreshInterval->type = static::TYPE_INT; + $this->refreshInterval->uiControlType = static::CONTROL_TEXT; + $this->refreshInterval->uiControlAttributes = array('size' => 3); + $this->refreshInterval->description = 'Defines how often the value should be updated'; + $this->refreshInterval->inlineHelp = 'Enter a number which is >= 15'; + $this->refreshInterval->defaultValue = '30'; + $this->refreshInterval->validate = function ($value, $setting) { + if ($value < 15) { + throw new \Exception('Value is invalid'); + } + }; + + $this->addSetting($this->refreshInterval); + } + + private function createColorSetting() + { + $this->color = new UserSetting('color', 'Color'); + $this->color->uiControlType = static::CONTROL_RADIO; + $this->color->description = 'Pick your favourite color'; + $this->color->availableValues = array('red' => 'Red', 'blue' => 'Blue', 'green' => 'Green'); + + $this->addSetting($this->color); + } + + private function createMetricSetting() + { + $this->metric = new SystemSetting('metric', 'Metric to display'); + $this->metric->type = static::TYPE_STRING; + $this->metric->uiControlType = static::CONTROL_SINGLE_SELECT; + $this->metric->availableValues = array('nb_visits' => 'Visits', 'nb_actions' => 'Actions', 'visitors' => 'Visitors'); + $this->metric->introduction = 'Only Super Users can change the following settings:'; + $this->metric->description = 'Choose the metric that should be displayed in the browser tab'; + $this->metric->defaultValue = 'nb_visits'; + + $this->addSetting($this->metric); + } + + private function createBrowsersSetting() + { + $this->browsers = new SystemSetting('browsers', 'Supported Browsers'); + $this->browsers->type = static::TYPE_ARRAY; + $this->browsers->uiControlType = static::CONTROL_MULTI_SELECT; + $this->browsers->availableValues = array('firefox' => 'Firefox', 'chromium' => 'Chromium', 'safari' => 'safari'); + $this->browsers->description = 'The value will be only displayed in the following browsers'; + $this->browsers->defaultValue = array('firefox', 'chromium', 'safari'); + + $this->addSetting($this->browsers); + } + + private function createDescriptionSetting() + { + $this->description = new SystemSetting('description', 'Description for value'); + $this->description->uiControlType = static::CONTROL_TEXTAREA; + $this->description->description = 'This description will be displayed next to the value'; + $this->description->defaultValue = "This is the value: \nAnother line"; + + $this->addSetting($this->description); + } + + private function createPasswordSetting() + { + $this->password = new SystemSetting('password', 'API password'); + $this->password->uiControlType = static::CONTROL_PASSWORD; + $this->password->description = 'Password for the 3rd API where we fetch the value'; + $this->password->transform = function ($value) { + return sha1($value . 'salt'); + }; + + $this->addSetting($this->password); + } +} diff --git a/www/analytics/plugins/ExampleSettingsPlugin/plugin.json b/www/analytics/plugins/ExampleSettingsPlugin/plugin.json new file mode 100644 index 00000000..ca450fd2 --- /dev/null +++ b/www/analytics/plugins/ExampleSettingsPlugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "ExampleSettingsPlugin", + "version": "0.1.0", + "description": "Plugin to demonstrate how to define and how to access plugin settings", + "theme": false +} \ No newline at end of file diff --git a/www/analytics/plugins/ExampleTheme/README.md b/www/analytics/plugins/ExampleTheme/README.md new file mode 100644 index 00000000..a0ad81fd --- /dev/null +++ b/www/analytics/plugins/ExampleTheme/README.md @@ -0,0 +1,21 @@ +# Piwik ExampleTheme Theme + +## Description + +Add your theme description here. + +## FAQ + +__My question?__ +My answer + +## Changelog + +Here goes the changelog text. + +## Support + +Please direct any feedback to ... + + + diff --git a/www/analytics/plugins/ExampleTheme/plugin.json b/www/analytics/plugins/ExampleTheme/plugin.json new file mode 100644 index 00000000..b43f1a61 --- /dev/null +++ b/www/analytics/plugins/ExampleTheme/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "ExampleTheme", + "description": "ExampleDescription", + "version": "0.1.0", + "theme": true, + "stylesheet": "stylesheets/theme.less" +} \ No newline at end of file diff --git a/www/analytics/plugins/ExampleTheme/stylesheets/theme.less b/www/analytics/plugins/ExampleTheme/stylesheets/theme.less new file mode 100644 index 00000000..e69de29b diff --git a/www/analytics/plugins/ExampleUI/API.php b/www/analytics/plugins/ExampleUI/API.php new file mode 100644 index 00000000..9d46d772 --- /dev/null +++ b/www/analytics/plugins/ExampleUI/API.php @@ -0,0 +1,102 @@ +setDefaultEndDate($date); + + foreach ($period->getSubperiods() as $subPeriod) { + if (self::$disableRandomness) { + $server1 = 50; + $server2 = 40; + } else { + $server1 = mt_rand(50, 90); + $server2 = mt_rand(40, 110); + } + + $value = array('server1' => $server1, 'server2' => $server2); + + $temperatures[$subPeriod->getLocalizedShortString()] = $value; + } + + return DataTable::makeFromIndexedArray($temperatures); + } + + public function getTemperatures() + { + $xAxis = array( + '0h', '1h', '2h', '3h', '4h', '5h', '6h', '7h', '8h', '9h', '10h', '11h', + '12h', '13h', '14h', '15h', '16h', '17h', '18h', '19h', '20h', '21h', '22h', '23h', + ); + + $temperatureValues = array_slice(range(50, 90), 0, count($xAxis)); + if (!self::$disableRandomness) { + shuffle($temperatureValues); + } + + $temperatures = array(); + foreach ($xAxis as $i => $xAxisLabel) { + $temperatures[$xAxisLabel] = $temperatureValues[$i]; + } + + return DataTable::makeFromIndexedArray($temperatures); + } + + public function getPlanetRatios() + { + $planetRatios = array( + 'Mercury' => 0.382, + 'Venus' => 0.949, + 'Earth' => 1.00, + 'Mars' => 0.532, + 'Jupiter' => 11.209, + 'Saturn' => 9.449, + 'Uranus' => 4.007, + 'Neptune' => 3.883, + ); + + return DataTable::makeFromIndexedArray($planetRatios); + } + + public function getPlanetRatiosWithLogos() + { + $planetsDataTable = $this->getPlanetRatios(); + + foreach ($planetsDataTable->getRows() as $row) { + $logo = sprintf('plugins/ExampleUI/images/icons-planet/%s.png', strtolower($row->getColumn('label'))); + $url = sprintf('http://en.wikipedia.org/wiki/%s', $row->getColumn('label')); + + $row->addMetadata('logo', $logo); + $row->addMetadata('url', $url); + } + + return $planetsDataTable; + } +} diff --git a/www/analytics/plugins/ExampleUI/Controller.php b/www/analytics/plugins/ExampleUI/Controller.php new file mode 100644 index 00000000..d4dd15c3 --- /dev/null +++ b/www/analytics/plugins/ExampleUI/Controller.php @@ -0,0 +1,196 @@ +pluginName . '.' . __FUNCTION__; + $apiAction = 'ExampleUI.getTemperatures'; + + $view = ViewDataTableFactory::build('table', $apiAction, $controllerAction); + + $view->config->translations['value'] = 'Temperature in °C'; + $view->config->translations['label'] = 'Hour of day'; + $view->requestConfig->filter_sort_column = 'label'; + $view->requestConfig->filter_sort_order = 'asc'; + $view->requestConfig->filter_limit = 24; + $view->config->columns_to_display = array('label', 'value'); + $view->config->y_axis_unit = '°C'; // useful if the user requests the bar graph + $view->config->show_exclude_low_population = false; + $view->config->show_table_all_columns = false; + $view->config->disable_row_evolution = true; + $view->config->max_graph_elements = 24; + $view->config->metrics_documentation = array('value' => 'Documentation for temperature metric'); + + return $view->render(); + } + + public function evolutionGraph() + { + $view = new View('@ExampleUI/evolutiongraph'); + + $this->setPeriodVariablesView($view); + $view->evolutionGraph = $this->getEvolutionGraph(array('server1', 'server2')); + + return $view->render(); + } + + public function notifications() + { + $notification = new Notification('Lorem ipsum dolor sit amet, consectetur adipiscing elit.'); + Notification\Manager::notify('ExampleUI_InfoSimple', $notification); + + $notification = new Notification('Neque porro quisquam est qui dolorem ipsum quia dolor sit amet.'); + $notification->title = 'Warning:'; + $notification->context = Notification::CONTEXT_WARNING; + $notification->flags = null; + Notification\Manager::notify('ExampleUI_warningWithClose', $notification); + + $notification = new Notification('Phasellus tincidunt arcu at justo faucibus, et lacinia est accumsan. '); + $notification->title = 'Well done'; + $notification->context = Notification::CONTEXT_SUCCESS; + $notification->type = Notification::TYPE_TOAST; + Notification\Manager::notify('ExampleUI_successToast', $notification); + + $notification = new Notification('Phasellus tincidunt arcu at justo faucibus, et lacinia est accumsan. '); + $notification->raw = true; + $notification->context = Notification::CONTEXT_ERROR; + Notification\Manager::notify('ExampleUI_error', $notification); + + $view = new View('@ExampleUI/notifications'); + $this->setGeneralVariablesView($view); + return $view->render(); + } + + public function getEvolutionGraph(array $columns = array()) + { + if (empty($columns)) { + $columns = Common::getRequestVar('columns'); + $columns = Piwik::getArrayFromApiParameter($columns); + } + + $view = $this->getLastUnitGraphAcrossPlugins($this->pluginName, __FUNCTION__, $columns, + $selectableColumns = array('server1', 'server2'), 'My documentation', 'ExampleUI.getTemperaturesEvolution'); + $view->requestConfig->filter_sort_column = 'label'; + + return $this->renderView($view); + } + + public function barGraph() + { + $view = ViewDataTableFactory::build( + 'graphVerticalBar', 'ExampleUI.getTemperatures', $controllerAction = 'ExampleUI.barGraph'); + + $view->config->y_axis_unit = '°C'; + $view->config->show_footer = false; + $view->config->translations['value'] = "Temperature"; + $view->config->selectable_columns = array("value"); + $view->config->max_graph_elements = 24; + + return $view->render(); + } + + public function pieGraph() + { + $view = ViewDataTableFactory::build( + 'graphPie', 'ExampleUI.getPlanetRatios', $controllerAction = 'ExampleUI.pieGraph'); + + $view->config->columns_to_display = array('value'); + $view->config->translations['value'] = "times the diameter of Earth"; + $view->config->show_footer_icons = false; + $view->config->selectable_columns = array("value"); + $view->config->max_graph_elements = 10; + + return $view->render(); + } + + public function tagClouds() + { + $output = "

          Simple tag cloud

          "; + $output .= $this->echoSimpleTagClouds(); + + $output .= "

          Advanced tag cloud: with logos and links

          +
            +
          • The logo size is proportional to the value returned by the API
          • +
          • The logo is linked to a specific URL
          • +


          "; + $output .= $this->echoAdvancedTagClouds(); + + return $output; + } + + public function echoSimpleTagClouds() + { + $view = ViewDataTableFactory::build( + 'cloud', 'ExampleUI.getPlanetRatios', $controllerAction = 'ExampleUI.echoSimpleTagClouds'); + + $view->config->columns_to_display = array('label', 'value'); + $view->config->translations['value'] = "times the diameter of Earth"; + $view->config->show_footer = false; + + return $view->render(); + } + + public function echoAdvancedTagClouds() + { + $view = ViewDataTableFactory::build( + 'cloud', 'ExampleUI.getPlanetRatiosWithLogos', $controllerAction = 'ExampleUI.echoAdvancedTagClouds'); + + $view->config->display_logo_instead_of_label = true; + $view->config->columns_to_display = array('label', 'value'); + $view->config->translations['value'] = "times the diameter of Earth"; + + return $view->render(); + } + + public function sparklines() + { + $view = new View('@ExampleUI/sparklines'); + $view->urlSparkline1 = $this->getUrlSparkline('generateSparkline', array('server' => 'server1', 'rand' => mt_rand())); + $view->urlSparkline2 = $this->getUrlSparkline('generateSparkline', array('server' => 'server2', 'rand' => mt_rand())); + + return $view->render(); + } + + public function generateSparkline() + { + $view = ViewDataTableFactory::build( + 'sparkline', 'ExampleUI.getTemperaturesEvolution', $controllerAction = 'ExampleUI.generateSparkline'); + + $serverRequested = Common::getRequestVar('server', false); + if (false !== $serverRequested) { + $view->config->columns_to_display = array($serverRequested); + } + + return $view->render(); + } + + public function treemap() + { + $view = ViewDataTableFactory::build( + 'infoviz-treemap', 'ExampleUI.getTemperatures', $controllerAction = 'ExampleUI.treemap'); + + $view->config->translations['value'] = "Temperature"; + $view->config->columns_to_display = array("label", "value"); + $view->config->selectable_columns = array("value"); + $view->config->show_evolution_values = 0; + + return $view->render(); + } +} diff --git a/www/analytics/plugins/ExampleUI/ExampleUI.php b/www/analytics/plugins/ExampleUI/ExampleUI.php new file mode 100644 index 00000000..a9b3a831 --- /dev/null +++ b/www/analytics/plugins/ExampleUI/ExampleUI.php @@ -0,0 +1,55 @@ + 'addReportingMenuItems', + 'Menu.Top.addItems' => 'addTopMenuItems', + ); + } + + function addReportingMenuItems() + { + MenuMain::getInstance()->add('UI Framework', '', array('module' => 'ExampleUI', 'action' => 'dataTables'), true, 30); + + $this->addSubMenu('Data tables', 'dataTables', 1); + $this->addSubMenu('Bar graph', 'barGraph', 2); + $this->addSubMenu('Pie graph', 'pieGraph', 3); + $this->addSubMenu('Tag clouds', 'tagClouds', 4); + $this->addSubMenu('Sparklines', 'sparklines', 5); + $this->addSubMenu('Evolution Graph', 'evolutionGraph', 6); + + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('TreemapVisualization')) { + $this->addSubMenu('Treemap', 'treemap', 7); + } + } + + function addTopMenuItems() + { + $urlParams = array('module' => 'ExampleUI', 'action' => 'notifications'); + MenuTop::getInstance()->addEntry('UI Notifications', $urlParams, $displayedForCurrentUser = true, $order = 3); + } + + private function addSubMenu($subMenu, $action, $order) + { + MenuMain::getInstance()->add('UI Framework', $subMenu, array('module' => 'ExampleUI', 'action' => $action), true, $order); + } +} diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/LICENSE b/www/analytics/plugins/ExampleUI/images/icons-planet/LICENSE new file mode 100644 index 00000000..cbaa1f23 --- /dev/null +++ b/www/analytics/plugins/ExampleUI/images/icons-planet/LICENSE @@ -0,0 +1,3 @@ +Author : Dan Wiersema +License: Free for non-commercial use. +http://www.iconspedia.com/icon/neptune-4672.html \ No newline at end of file diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/earth.png b/www/analytics/plugins/ExampleUI/images/icons-planet/earth.png new file mode 100644 index 00000000..448f8a63 Binary files /dev/null and b/www/analytics/plugins/ExampleUI/images/icons-planet/earth.png differ diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/jupiter.png b/www/analytics/plugins/ExampleUI/images/icons-planet/jupiter.png new file mode 100644 index 00000000..a3377465 Binary files /dev/null and b/www/analytics/plugins/ExampleUI/images/icons-planet/jupiter.png differ diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/mars.png b/www/analytics/plugins/ExampleUI/images/icons-planet/mars.png new file mode 100644 index 00000000..1335faab Binary files /dev/null and b/www/analytics/plugins/ExampleUI/images/icons-planet/mars.png differ diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/mercury.png b/www/analytics/plugins/ExampleUI/images/icons-planet/mercury.png new file mode 100644 index 00000000..90f72e69 Binary files /dev/null and b/www/analytics/plugins/ExampleUI/images/icons-planet/mercury.png differ diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/neptune.png b/www/analytics/plugins/ExampleUI/images/icons-planet/neptune.png new file mode 100644 index 00000000..c2acf71b Binary files /dev/null and b/www/analytics/plugins/ExampleUI/images/icons-planet/neptune.png differ diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/saturn.png b/www/analytics/plugins/ExampleUI/images/icons-planet/saturn.png new file mode 100644 index 00000000..5cbde1b1 Binary files /dev/null and b/www/analytics/plugins/ExampleUI/images/icons-planet/saturn.png differ diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/uranus.png b/www/analytics/plugins/ExampleUI/images/icons-planet/uranus.png new file mode 100644 index 00000000..3657d3d8 Binary files /dev/null and b/www/analytics/plugins/ExampleUI/images/icons-planet/uranus.png differ diff --git a/www/analytics/plugins/ExampleUI/images/icons-planet/venus.png b/www/analytics/plugins/ExampleUI/images/icons-planet/venus.png new file mode 100644 index 00000000..41af2ae0 Binary files /dev/null and b/www/analytics/plugins/ExampleUI/images/icons-planet/venus.png differ diff --git a/www/analytics/plugins/ExampleUI/plugin.json b/www/analytics/plugins/ExampleUI/plugin.json new file mode 100644 index 00000000..6080c51a --- /dev/null +++ b/www/analytics/plugins/ExampleUI/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "ExampleUI", + "description": "Example Plugin: This plugin showcases the Piwik User Interface framework, how to easily display custom data tables, graphs, and more.", + "version": "1.0.1", + "keywords": ["example", "framework", "platform", "ui", "visualization"], + "homepage": "http://piwik.org", + "license": "GPL v3+", + "authors": [ + { + "name": "Piwik", + "email": "hello@piwik.org", + "homepage": "http://piwik.org" + } + ] +} \ No newline at end of file diff --git a/www/analytics/plugins/ExampleUI/templates/evolutiongraph.twig b/www/analytics/plugins/ExampleUI/templates/evolutiongraph.twig new file mode 100644 index 00000000..1aa2b105 --- /dev/null +++ b/www/analytics/plugins/ExampleUI/templates/evolutiongraph.twig @@ -0,0 +1,3 @@ +

          Evolution of server temperatures over the last few days

          + +{{ evolutionGraph|raw }} \ No newline at end of file diff --git a/www/analytics/plugins/ExampleUI/templates/notifications.twig b/www/analytics/plugins/ExampleUI/templates/notifications.twig new file mode 100644 index 00000000..2212ead3 --- /dev/null +++ b/www/analytics/plugins/ExampleUI/templates/notifications.twig @@ -0,0 +1,9 @@ +{% extends 'dashboard.twig' %} + +{% block content %} +

          Inline notification example:

          + +
          + {{ 'This is an example for an inline notification. Have you noticed the success message disappeared after a few seconds?'|notification({'placeAt': '#exampleUI_notifications', 'title': 'Info: ', 'noclear': true, 'context': 'info'}) }} +
          +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/ExampleUI/templates/sparklines.twig b/www/analytics/plugins/ExampleUI/templates/sparklines.twig new file mode 100644 index 00000000..a7860566 --- /dev/null +++ b/www/analytics/plugins/ExampleUI/templates/sparklines.twig @@ -0,0 +1,9 @@ +
          + {{ sparkline(urlSparkline1) }} + Evolution of temperature for server piwik.org +
          +
          + {{ sparkline(urlSparkline2) }} + Evolution of temperature for server dev.piwik.org +
          + diff --git a/www/analytics/plugins/ExampleVisualization/ExampleVisualization.php b/www/analytics/plugins/ExampleVisualization/ExampleVisualization.php new file mode 100644 index 00000000..dea47070 --- /dev/null +++ b/www/analytics/plugins/ExampleVisualization/ExampleVisualization.php @@ -0,0 +1,29 @@ + 'getAvailableVisualizations' + ); + } + + public function getAvailableVisualizations(&$visualizations) + { + $visualizations[] = __NAMESPACE__ . '\\SimpleTable'; + } +} diff --git a/www/analytics/plugins/ExampleVisualization/README.md b/www/analytics/plugins/ExampleVisualization/README.md new file mode 100644 index 00000000..79c04726 --- /dev/null +++ b/www/analytics/plugins/ExampleVisualization/README.md @@ -0,0 +1,18 @@ +# Piwik SimpleTableVisualizationExample Plugin + +## Description + +Example for generating a simple visualization. + +## FAQ + +__My question?__ +My answer + +## Changelog + +Here goes the changelog text. + +## Support + +Please direct any feedback to ... \ No newline at end of file diff --git a/www/analytics/plugins/ExampleVisualization/SimpleTable.php b/www/analytics/plugins/ExampleVisualization/SimpleTable.php new file mode 100644 index 00000000..81c58cfe --- /dev/null +++ b/www/analytics/plugins/ExampleVisualization/SimpleTable.php @@ -0,0 +1,71 @@ +requestConfig->filter_sort_order = 'desc'; + } + + public function beforeGenericFiltersAreAppliedToLoadedDataTable() + { + // this hook is executed before generic filters like "filter_limit" and "filter_offset" are applied + // Usage: + // $this->dateTable->filter($nameOrClosure); + } + + public function afterGenericFiltersAreAppliedToLoadedDataTable() + { + // this hook is executed after generic filters like "filter_limit" and "filter_offset" are applied + // Usage: + // $this->dateTable->filter($nameOrClosure, $parameters); + } + + public function afterAllFiltersAreApplied() + { + // this hook is executed after the data table is loaded and after all filteres are applied. + // format your data here that you want to pass to the view + + $this->assignTemplateVar('vizTitle', 'MyAwesomeTitle'); + } + + public function beforeRender() + { + // Configure how your visualization should look like, for instance you can disable search + // By defining the config properties shortly before rendering you make sure the config properties have a certain + // value because they could be changed by a report or by request parameters ($_GET / $_POST) before. + // $this->config->show_search = false + } + + public static function canDisplayViewDataTable(ViewDataTable $view) + { + // You usually do not need to implement this method. Here you can define whether your visualization can display + // a specific data table or not. For instance you may only display your visualization in case a single data + // table is requested. Example: + // return $view->isRequestingSingleDataTable(); + + return parent::canDisplayViewDataTable($view); + } +} diff --git a/www/analytics/plugins/ExampleVisualization/images/table.png b/www/analytics/plugins/ExampleVisualization/images/table.png new file mode 100644 index 00000000..ab081477 Binary files /dev/null and b/www/analytics/plugins/ExampleVisualization/images/table.png differ diff --git a/www/analytics/plugins/ExampleVisualization/plugin.json b/www/analytics/plugins/ExampleVisualization/plugin.json new file mode 100644 index 00000000..73a31c9b --- /dev/null +++ b/www/analytics/plugins/ExampleVisualization/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "ExampleVisualization", + "version": "0.1.0", + "description": "Example for generating a simple visualization", + "theme": false, + "license": "GPL v3+", + "keywords": ["SimpleTable"], + "homepage": "http://piwik.org", + "authors": [ + { + "name": "The Piwik Team", + "email": "hello@piwik.org", + "homepage": "http://piwik.org" + } + ] +} \ No newline at end of file diff --git a/www/analytics/plugins/ExampleVisualization/templates/simpleTable.twig b/www/analytics/plugins/ExampleVisualization/templates/simpleTable.twig new file mode 100644 index 00000000..6b074871 --- /dev/null +++ b/www/analytics/plugins/ExampleVisualization/templates/simpleTable.twig @@ -0,0 +1,26 @@ +
          +

          {{ vizTitle }}

          + + + + + {% for name in properties.columns_to_display %} + {% if name in properties.translations|keys %} + + {% else %} + + {% endif %} + {% endfor %} + + + + {% for tableRow in dataTable.getRows %} + + {% for column in properties.columns_to_display %} + + {% endfor %} + + {% endfor %} + +
          {{ properties.translations[name]|translate }}{{ name }}
          {{ tableRow.getColumn(column)|default('-')|truncate(50)|raw }}
          +
          \ No newline at end of file diff --git a/www/analytics/plugins/Feedback/API.php b/www/analytics/plugins/Feedback/API.php new file mode 100644 index 00000000..73b3dbaf --- /dev/null +++ b/www/analytics/plugins/Feedback/API.php @@ -0,0 +1,108 @@ +getEnglishTranslationForFeatureName($featureName); + + $likeText = 'Yes'; + if (empty($like)) { + $likeText = 'No'; + } + + $body = sprintf("Feature: %s\nLike: %s\n", $featureName, $likeText, $message); + if (!empty($message)) { + $body .= sprintf("Feedback:\n%s\n", $message); + } else { + $body .= "No feedback\n"; + } + + $this->sendMail($featureName, $body); + } + + private function sendMail($subject, $body) + { + $feedbackEmailAddress = Config::getInstance()->General['feedback_email_address']; + + $subject = '[ Feedback Feature - Piwik ] ' . $subject; + $body = Common::unsanitizeInputValue($body) . "\n" + . 'Piwik ' . Version::VERSION . "\n" + . 'IP: ' . IP::getIpFromHeader() . "\n" + . 'URL: ' . Url::getReferrer() . "\n"; + + $mail = new Mail(); + $mail->setFrom(Piwik::getCurrentUserEmail()); + $mail->addTo($feedbackEmailAddress, 'Piwik Team'); + $mail->setSubject($subject); + $mail->setBodyText($body); + @$mail->send(); + } + + private function findTranslationKeyForFeatureName($featureName) + { + if (empty($GLOBALS['Piwik_translations'])) { + return; + } + + foreach ($GLOBALS['Piwik_translations'] as $key => $translations) { + $possibleKey = array_search($featureName, $translations); + if (!empty($possibleKey)) { + return $key . '_' . $possibleKey; + } + } + } + + private function getEnglishTranslationForFeatureName($featureName) + { + $loadedLanguage = Translate::getLanguageLoaded(); + + if ($loadedLanguage == 'en') { + return $featureName; + } + + $translationKeyForFeature = $this->findTranslationKeyForFeatureName($featureName); + + if (!empty($translationKeyForFeature)) { + Translate::reloadLanguage('en'); + + $featureName = Piwik::translate($translationKeyForFeature); + Translate::reloadLanguage($loadedLanguage); + return $featureName; + } + + return $featureName; + } +} diff --git a/www/analytics/plugins/Feedback/Controller.php b/www/analytics/plugins/Feedback/Controller.php new file mode 100644 index 00000000..24aeb1ee --- /dev/null +++ b/www/analytics/plugins/Feedback/Controller.php @@ -0,0 +1,34 @@ +setGeneralVariablesView($view); + $view->piwikVersion = Version::VERSION; + return $view->render(); + } +} diff --git a/www/analytics/plugins/Feedback/Feedback.php b/www/analytics/plugins/Feedback/Feedback.php new file mode 100644 index 00000000..2d68c001 --- /dev/null +++ b/www/analytics/plugins/Feedback/Feedback.php @@ -0,0 +1,70 @@ + 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'Menu.Top.addItems' => 'addTopMenu', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys' + ); + } + + public function addTopMenu() + { + MenuTop::addEntry( + 'General_Help', + array('module' => 'Feedback', 'action' => 'index', 'segment' => false), + true, + $order = 20, + $isHTML = false, + $tooltip = Piwik::translate('Feedback_TopLinkTooltip') + ); + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/Feedback/stylesheets/feedback.less"; + + $stylesheets[] = "plugins/Feedback/angularjs/ratefeature/ratefeature.less"; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/Feedback/angularjs/ratefeature/ratefeature-model.js"; + $jsFiles[] = "plugins/Feedback/angularjs/ratefeature/ratefeature-controller.js"; + $jsFiles[] = "plugins/Feedback/angularjs/ratefeature/ratefeature-directive.js"; + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = 'Feedback_ThankYou'; + $translationKeys[] = 'Feedback_RateFeatureTitle'; + $translationKeys[] = 'Feedback_RateFeatureThankYouTitle'; + $translationKeys[] = 'Feedback_RateFeatureLeaveMessageLike'; + $translationKeys[] = 'Feedback_RateFeatureLeaveMessageDislike'; + $translationKeys[] = 'Feedback_SendFeedback'; + $translationKeys[] = 'Feedback_RateFeatureSendFeedbackInformation'; + $translationKeys[] = 'General_Ok'; + } +} diff --git a/www/analytics/plugins/Feedback/angularjs/ratefeature/icon_license b/www/analytics/plugins/Feedback/angularjs/ratefeature/icon_license new file mode 100644 index 00000000..78a63191 --- /dev/null +++ b/www/analytics/plugins/Feedback/angularjs/ratefeature/icon_license @@ -0,0 +1,7 @@ +thumbs-up.png +https://www.iconfinder.com/icons/83403/thumbs_up_icon#size=32 +Creative Commons (Attribution-Share Alike 3.0 Unported) + +thumbs-down.png +https://www.iconfinder.com/icons/83402/down_thumbs_icon#size=32 +Creative Commons (Attribution-Share Alike 3.0 Unported) \ No newline at end of file diff --git a/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-controller.js b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-controller.js new file mode 100644 index 00000000..7a654749 --- /dev/null +++ b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-controller.js @@ -0,0 +1,23 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp').controller('RateFeatureController', function($scope, rateFeatureModel, $filter){ + + $scope.dislikeFeature = function () { + $scope.like = false; + }; + + $scope.likeFeature = function () { + $scope.like = true; + }; + + $scope.sendFeedback = function (message) { + rateFeatureModel.sendFeedbackForFeature($scope.title, $scope.like, message); + $scope.ratingDone = true; + // alert($filter('translate')('Feedback_ThankYou')); + }; +}); diff --git a/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-directive.js b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-directive.js new file mode 100644 index 00000000..d045a4cf --- /dev/null +++ b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-directive.js @@ -0,0 +1,22 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Usage: + *
          + */ +angular.module('piwikApp').directive('piwikRateFeature', function($document, piwik, $filter){ + + return { + restrict: 'A', + scope: { + title: '@' + }, + templateUrl: 'plugins/Feedback/angularjs/ratefeature/ratefeature.html?cb=' + piwik.cacheBuster, + controller: 'RateFeatureController' + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-model.js b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-model.js new file mode 100644 index 00000000..c724cbc8 --- /dev/null +++ b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature-model.js @@ -0,0 +1,22 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp').factory('rateFeatureModel', function (piwikApi) { + + var model = {}; + + model.sendFeedbackForFeature = function (featureName, like, message) { + return piwikApi.fetch({ + method: 'Feedback.sendFeedbackForFeature', + featureName: featureName, + like: like ? '1' : '0', + message: message + '' + }); + }; + + return model; +}); diff --git a/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature.html b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature.html new file mode 100644 index 00000000..66b2add7 --- /dev/null +++ b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature.html @@ -0,0 +1,38 @@ +
          + +
          + + + + +
          + +
          +

          {{ 'Feedback_RateFeatureThankYouTitle'|translate:title }}

          +

          {{ 'Feedback_RateFeatureLeaveMessageLike'|translate }}

          +

          {{ 'Feedback_RateFeatureLeaveMessageDislike'|translate }}

          +
          + +
          + +
          + + +
          + +
          +

          {{ 'Feedback_ThankYou'|translate:title }}

          + + +
          + +
          \ No newline at end of file diff --git a/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature.less b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature.less new file mode 100644 index 00000000..83abd897 --- /dev/null +++ b/www/analytics/plugins/Feedback/angularjs/ratefeature/ratefeature.less @@ -0,0 +1,30 @@ +.ratefeatureDialog { + text-align: center; + + textarea { + margin-top: 5px; + width: 100%; + height: 80px; + } +} + +.ratefeature { + + font-size: 1px; + + .iconContainer { + display: inline-block; + } + + .dislike-icon, + .like-icon { + opacity: 0.2; + width: 22px; + height: 22px; + cursor: pointer; + + &:hover { + opacity: 0.9; + } + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Feedback/angularjs/ratefeature/thumbs-down.png b/www/analytics/plugins/Feedback/angularjs/ratefeature/thumbs-down.png new file mode 100644 index 00000000..c9ce2048 Binary files /dev/null and b/www/analytics/plugins/Feedback/angularjs/ratefeature/thumbs-down.png differ diff --git a/www/analytics/plugins/Feedback/angularjs/ratefeature/thumbs-up.png b/www/analytics/plugins/Feedback/angularjs/ratefeature/thumbs-up.png new file mode 100644 index 00000000..a94421ba Binary files /dev/null and b/www/analytics/plugins/Feedback/angularjs/ratefeature/thumbs-up.png differ diff --git a/www/analytics/plugins/Feedback/images/facebook.png b/www/analytics/plugins/Feedback/images/facebook.png new file mode 100644 index 00000000..2d558fcf Binary files /dev/null and b/www/analytics/plugins/Feedback/images/facebook.png differ diff --git a/www/analytics/plugins/Feedback/images/github.png b/www/analytics/plugins/Feedback/images/github.png new file mode 100644 index 00000000..df3ac566 Binary files /dev/null and b/www/analytics/plugins/Feedback/images/github.png differ diff --git a/www/analytics/plugins/Feedback/images/linkedin.png b/www/analytics/plugins/Feedback/images/linkedin.png new file mode 100644 index 00000000..fd66065e Binary files /dev/null and b/www/analytics/plugins/Feedback/images/linkedin.png differ diff --git a/www/analytics/plugins/Feedback/images/newsletter.png b/www/analytics/plugins/Feedback/images/newsletter.png new file mode 100644 index 00000000..0be3f622 Binary files /dev/null and b/www/analytics/plugins/Feedback/images/newsletter.png differ diff --git a/www/analytics/plugins/Feedback/images/twitter.png b/www/analytics/plugins/Feedback/images/twitter.png new file mode 100644 index 00000000..95381409 Binary files /dev/null and b/www/analytics/plugins/Feedback/images/twitter.png differ diff --git a/www/analytics/plugins/Feedback/stylesheets/feedback.less b/www/analytics/plugins/Feedback/stylesheets/feedback.less new file mode 100644 index 00000000..8ac50e8c --- /dev/null +++ b/www/analytics/plugins/Feedback/stylesheets/feedback.less @@ -0,0 +1,115 @@ +#feedback-faq { + color: #5e5e5c; + width: 675px; + font-size: 14px; + + strong { + color: #5e5e5c; + } + + ul { + list-style: none; + padding: 0 0 0 20px; + font-size: 12px; + line-height: 18px; + } + + p { + padding-bottom: 0px; + line-height: 1.7em; + } + + a { + color: #5176a0; + text-decoration: none; + } + + .piwik-donate-call { + border: 0px; + padding-left: 0px; + padding-top: 0px; + } + + .donate-form-instructions { + margin: 0 1.25em 0 0em; + color: #5E5E5C; + } + + .piwik-donate-slider { + margin: 1em 0 1em 0em; + } + + #piwik-worth { + margin: 0 1em 0 0em; + font-size: 1em; + font-style: normal; + } + + .footer { + text-align: center; + + a { + color: black; + &:hover { + text-decoration: underline; + } + } + + hr { + background-color: #CCC; + height: 1px; + border: 0px; + } + } + + .claim { + font-size: 13px; + line-height: 16px; + color: #808080; + margin-bottom: 50px; + margin-top: 15px; + } + + .menu { + li { + margin: 0 10px 10px 0px + } + + li:not(:first-child) { + &:before { + color: #333; + content: '· '; + } + } + + a { + display: inline-block; + padding-right: 10px; + padding-left: 18px; + } + } + + .social { + margin-top: 22px; + + li { + margin: 0 20px 10px; + } + } + + .menu, + .social { + margin: 15px 0 10px; + display: block; + + .icon { + width: 15px; + height: 15px; + margin-bottom: 2px; + } + + li { + display: inline-block; + } + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Feedback/templates/index.twig b/www/analytics/plugins/Feedback/templates/index.twig new file mode 100644 index 00000000..dba9db49 --- /dev/null +++ b/www/analytics/plugins/Feedback/templates/index.twig @@ -0,0 +1,106 @@ +{% extends 'dashboard.twig' %} + +{% set test_piwikUrl='http://demo.piwik.org/' %} +{% set isPiwikDemo %}{{ piwikUrl == 'http://demo.piwik.org/' or piwikUrl == 'https://demo.piwik.org/'}}{% endset %} + +{% block content %} + +
          +

          {{ 'General_AboutPiwikX'|translate(piwikVersion) }}

          + +
          +

          {{ 'General_PiwikIsACollaborativeProjectYouCanContributeAndDonate'|translate( + "", + "", + "", + "", + "", + "", + "", + "" + )|raw }} +

          +
          +
          + +

          {{ 'Do you need help?'|translate }}

          + +
          +

          • {{ 'Feedback_ViewUserGuides'|translate("","")|raw }}.

          +

          • {{ 'Feedback_ViewAnswersToFAQ'|translate("","")|raw }}.

          +

          • {{ 'Feedback_VisitTheForums'|translate("","")|raw }}.

          +
          +
          + +

          {{ 'Feedback_DoYouHaveBugReportOrFeatureRequest'|translate }}

          + +
          +

          {{ 'Feedback_HowToCreateIssue'|translate( + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + )|raw }}

          +
          +
          + +

          {{ 'Feedback_SpecialRequest'|translate }}

          + +
          +

          {{ 'Feedback_GetInTouch'|translate }} + {{ 'Feedback_ContactThePiwikTeam'|translate }} +

          +
          +
          + + + {% include "@CoreHome/_donate.twig" with { 'msg':""} %} +
          + + +
          +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Goals/API.php b/www/analytics/plugins/Goals/API.php new file mode 100644 index 00000000..63d5d5b1 --- /dev/null +++ b/www/analytics/plugins/Goals/API.php @@ -0,0 +1,567 @@ +tracking Ecommerce orders and products on your site, the functions "getItemsSku", "getItemsName" and "getItemsCategory" + * will return the list of products purchased on your site, either grouped by Product SKU, Product Name or Product Category. For each name, SKU or category, the following + * metrics are returned: Total revenue, Total quantity, average price, average quantity, number of orders (or abandoned carts) containing this product, number of visits on the Product page, + * Conversion rate. + * + * By default, these functions return the 'Products purchased'. These functions also accept an optional parameter &abandonedCarts=1. + * If the parameter is set, it will instead return the metrics for products that were left in an abandoned cart therefore not purchased. + * + * The API also lets you request overall Goal metrics via the method "get": Conversions, Visits with at least one conversion, Conversion rate and Revenue. + * If you wish to request specific metrics about Ecommerce goals, you can set the parameter &idGoal=ecommerceAbandonedCart to get metrics about abandoned carts (including Lost revenue, and number of items left in the cart) + * or &idGoal=ecommerceOrder to get metrics about Ecommerce orders (number of orders, visits with an order, subtotal, tax, shipping, discount, revenue, items ordered) + * + * See also the documentation about Tracking Goals in Piwik. + * + * @method static \Piwik\Plugins\Goals\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + const AVG_PRICE_VIEWED = 'avg_price_viewed'; + + /** + * Returns all Goals for a given website, or list of websites + * + * @param string|array $idSite Array or Comma separated list of website IDs to request the goals for + * @return array Array of Goal attributes + */ + public function getGoals($idSite) + { + //TODO calls to this function could be cached as static + // would help UI at least, since some UI requests would call this 2-3 times.. + $idSite = Site::getIdSitesFromIdSitesString($idSite); + if (empty($idSite)) { + return array(); + } + Piwik::checkUserHasViewAccess($idSite); + $goals = Db::fetchAll("SELECT * + FROM " . Common::prefixTable('goal') . " + WHERE idsite IN (" . implode(", ", $idSite) . ") + AND deleted = 0"); + $cleanedGoals = array(); + foreach ($goals as &$goal) { + if ($goal['match_attribute'] == 'manually') { + unset($goal['pattern']); + unset($goal['pattern_type']); + unset($goal['case_sensitive']); + } + $cleanedGoals[$goal['idgoal']] = $goal; + } + return $cleanedGoals; + } + + /** + * Creates a Goal for a given website. + * + * @param int $idSite + * @param string $name + * @param string $matchAttribute 'url', 'title', 'file', 'external_website' or 'manually' + * @param string $pattern eg. purchase-confirmation.htm + * @param string $patternType 'regex', 'contains', 'exact' + * @param bool $caseSensitive + * @param bool|float $revenue If set, default revenue to assign to conversions + * @param bool $allowMultipleConversionsPerVisit By default, multiple conversions in the same visit will only record the first conversion. + * If set to true, multiple conversions will all be recorded within a visit (useful for Ecommerce goals) + * @return int ID of the new goal + */ + public function addGoal($idSite, $name, $matchAttribute, $pattern, $patternType, $caseSensitive = false, $revenue = false, $allowMultipleConversionsPerVisit = false) + { + Piwik::checkUserHasAdminAccess($idSite); + $this->checkPatternIsValid($patternType, $pattern); + $name = $this->checkName($name); + $pattern = $this->checkPattern($pattern); + + // save in db + $db = Db::get(); + $idGoal = $db->fetchOne("SELECT max(idgoal) + 1 + FROM " . Common::prefixTable('goal') . " + WHERE idsite = ?", $idSite); + if ($idGoal == false) { + $idGoal = 1; + } + $db->insert(Common::prefixTable('goal'), + array( + 'idsite' => $idSite, + 'idgoal' => $idGoal, + 'name' => $name, + 'match_attribute' => $matchAttribute, + 'pattern' => $pattern, + 'pattern_type' => $patternType, + 'case_sensitive' => (int)$caseSensitive, + 'allow_multiple' => (int)$allowMultipleConversionsPerVisit, + 'revenue' => (float)$revenue, + 'deleted' => 0, + )); + Cache::regenerateCacheWebsiteAttributes($idSite); + return $idGoal; + } + + /** + * Updates a Goal description. + * Will not update or re-process the conversions already recorded + * + * @see addGoal() for parameters description + * @param int $idSite + * @param int $idGoal + * @param $name + * @param $matchAttribute + * @param string $pattern + * @param string $patternType + * @param bool $caseSensitive + * @param bool|float $revenue + * @param bool $allowMultipleConversionsPerVisit + * @return void + */ + public function updateGoal($idSite, $idGoal, $name, $matchAttribute, $pattern, $patternType, $caseSensitive = false, $revenue = false, $allowMultipleConversionsPerVisit = false) + { + Piwik::checkUserHasAdminAccess($idSite); + $name = $this->checkName($name); + $pattern = $this->checkPattern($pattern); + $this->checkPatternIsValid($patternType, $pattern); + Db::get()->update(Common::prefixTable('goal'), + array( + 'name' => $name, + 'match_attribute' => $matchAttribute, + 'pattern' => $pattern, + 'pattern_type' => $patternType, + 'case_sensitive' => (int)$caseSensitive, + 'allow_multiple' => (int)$allowMultipleConversionsPerVisit, + 'revenue' => (float)$revenue, + ), + "idsite = '$idSite' AND idgoal = '$idGoal'" + ); + Cache::regenerateCacheWebsiteAttributes($idSite); + } + + private function checkPatternIsValid($patternType, $pattern) + { + if ($patternType == 'exact' + && substr($pattern, 0, 4) != 'http' + ) { + throw new Exception(Piwik::translate('Goals_ExceptionInvalidMatchingString', array("http:// or https://", "http://www.yourwebsite.com/newsletter/subscribed.html"))); + } + } + + private function checkName($name) + { + return urldecode($name); + } + + private function checkPattern($pattern) + { + return urldecode($pattern); + } + + /** + * Soft deletes a given Goal. + * Stats data in the archives will still be recorded, but not displayed. + * + * @param int $idSite + * @param int $idGoal + * @return void + */ + public function deleteGoal($idSite, $idGoal) + { + Piwik::checkUserHasAdminAccess($idSite); + Db::query("UPDATE " . Common::prefixTable('goal') . " + SET deleted = 1 + WHERE idsite = ? + AND idgoal = ?", + array($idSite, $idGoal)); + Db::deleteAllRows(Common::prefixTable("log_conversion"), "WHERE idgoal = ? AND idsite = ?", "idvisit", 100000, array($idGoal, $idSite)); + Cache::regenerateCacheWebsiteAttributes($idSite); + } + + /** + * Returns a datatable of Items SKU/name or categories and their metrics + * If $abandonedCarts set to 1, will return items abandoned in carts. If set to 0, will return items ordered + */ + protected function getItems($recordName, $idSite, $period, $date, $abandonedCarts, $segment) + { + Piwik::checkUserHasViewAccess($idSite); + $recordNameFinal = $recordName; + if ($abandonedCarts) { + $recordNameFinal = Archiver::getItemRecordNameAbandonedCart($recordName); + } + $archive = Archive::build($idSite, $period, $date, $segment); + $dataTable = $archive->getDataTable($recordNameFinal); + + $dataTable->filter('Sort', array(Metrics::INDEX_ECOMMERCE_ITEM_REVENUE)); + + $this->enrichItemsTableWithViewMetrics($dataTable, $recordName, $idSite, $period, $date, $segment); + + // First rename the avg_price_viewed column + $renameColumn = array(self::AVG_PRICE_VIEWED => 'avg_price'); + $dataTable->queueFilter('ReplaceColumnNames', array($renameColumn)); + + $dataTable->queueFilter('ReplaceColumnNames'); + $dataTable->queueFilter('ReplaceSummaryRowLabel'); + + $ordersColumn = 'orders'; + if ($abandonedCarts) { + $ordersColumn = 'abandoned_carts'; + $dataTable->renameColumn(Metrics::INDEX_ECOMMERCE_ORDERS, $ordersColumn); + } + + // Average price = sum product revenue / quantity + $dataTable->queueFilter('ColumnCallbackAddColumnQuotient', array('avg_price', 'price', $ordersColumn, GoalManager::REVENUE_PRECISION)); + + // Average quantity = sum product quantity / abandoned carts + $dataTable->queueFilter('ColumnCallbackAddColumnQuotient', + array('avg_quantity', 'quantity', $ordersColumn, $precision = 1)); + $dataTable->queueFilter('ColumnDelete', array('price')); + + // Product conversion rate = orders / visits + $dataTable->queueFilter('ColumnCallbackAddColumnPercentage', array('conversion_rate', $ordersColumn, 'nb_visits', GoalManager::REVENUE_PRECISION)); + + return $dataTable; + } + + protected function renameNotDefinedRow($dataTable, $notDefinedStringPretty) + { + if ($dataTable instanceof DataTable\Map) { + foreach ($dataTable->getDataTables() as $table) { + $this->renameNotDefinedRow($table, $notDefinedStringPretty); + } + return; + } + $rowNotDefined = $dataTable->getRowFromLabel(\Piwik\Plugins\CustomVariables\Archiver::LABEL_CUSTOM_VALUE_NOT_DEFINED); + if ($rowNotDefined) { + $rowNotDefined->setColumn('label', $notDefinedStringPretty); + } + } + + protected function enrichItemsDataTableWithItemsViewMetrics($dataTable, $idSite, $period, $date, $segment, $idSubtable) + { + $ecommerceViews = \Piwik\Plugins\CustomVariables\API::getInstance()->getCustomVariablesValuesFromNameId($idSite, $period, $date, $idSubtable, $segment, $_leavePriceViewedColumn = true); + + // For Product names and SKU reports, and for Category report + // Use the Price (tracked on page views) + // ONLY when the price sold in conversions is not found (ie. product viewed but not sold) + foreach ($ecommerceViews->getRows() as $rowView) { + // If there is not already a 'sum price' for this product + $rowFound = $dataTable->getRowFromLabel($rowView->getColumn('label')); + $price = $rowFound + ? $rowFound->getColumn(Metrics::INDEX_ECOMMERCE_ITEM_PRICE) + : false; + if (empty($price)) { + // If a price was tracked on the product page + if ($rowView->getColumn(Metrics::INDEX_ECOMMERCE_ITEM_PRICE_VIEWED)) { + $rowView->renameColumn(Metrics::INDEX_ECOMMERCE_ITEM_PRICE_VIEWED, self::AVG_PRICE_VIEWED); + } + } + $rowView->deleteColumn(Metrics::INDEX_ECOMMERCE_ITEM_PRICE_VIEWED); + } + + $dataTable->addDataTable($ecommerceViews); + } + + public function getItemsSku($idSite, $period, $date, $abandonedCarts = false, $segment = false) + { + return $this->getItems('Goals_ItemsSku', $idSite, $period, $date, $abandonedCarts, $segment); + } + + public function getItemsName($idSite, $period, $date, $abandonedCarts = false, $segment = false) + { + return $this->getItems('Goals_ItemsName', $idSite, $period, $date, $abandonedCarts, $segment); + } + + public function getItemsCategory($idSite, $period, $date, $abandonedCarts = false, $segment = false) + { + return $this->getItems('Goals_ItemsCategory', $idSite, $period, $date, $abandonedCarts, $segment); + } + + /** + * Helper function that checks for special string goal IDs and converts them to + * their integer equivalents. + * + * Checks for the following values: + * Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER + * Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART + * + * @param string|int $idGoal The goal id as an integer or a special string. + * @return int The numeric goal id. + */ + protected static function convertSpecialGoalIds($idGoal) + { + if ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER) { + return GoalManager::IDGOAL_ORDER; + } else if ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART) { + return GoalManager::IDGOAL_CART; + } else { + return $idGoal; + } + } + + /** + * Returns Goals data + * + * @param int $idSite + * @param string $period + * @param string $date + * @param bool $segment + * @param bool|int $idGoal + * @param array $columns Array of metrics to fetch: nb_conversions, conversion_rate, revenue + * @return DataTable + */ + public function get($idSite, $period, $date, $segment = false, $idGoal = false, $columns = array()) + { + Piwik::checkUserHasViewAccess($idSite); + $archive = Archive::build($idSite, $period, $date, $segment); + $columns = Piwik::getArrayFromApiParameter($columns); + + // Mapping string idGoal to internal ID + $idGoal = self::convertSpecialGoalIds($idGoal); + + if (empty($columns)) { + $columns = Goals::getGoalColumns($idGoal); + if ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER) { + $columns[] = 'avg_order_revenue'; + } + } + if (in_array('avg_order_revenue', $columns) + && $idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER + ) { + $columns[] = 'nb_conversions'; + $columns[] = 'revenue'; + $columns = array_values(array_unique($columns)); + } + $columnsToSelect = array(); + foreach ($columns as &$columnName) { + $columnsToSelect[] = Archiver::getRecordName($columnName, $idGoal); + } + $dataTable = $archive->getDataTableFromNumeric($columnsToSelect); + + // Rewrite column names as we expect them + foreach ($columnsToSelect as $id => $oldName) { + $dataTable->renameColumn($oldName, $columns[$id]); + } + if ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER) { + if ($dataTable instanceof DataTable\Map) { + foreach ($dataTable->getDataTables() as $row) { + $this->enrichTable($row); + } + } else { + $this->enrichTable($dataTable); + } + } + return $dataTable; + } + + protected function enrichTable($table) + { + $row = $table->getFirstRow(); + if (!$row) { + return; + } + // AVG order per visit + if (false !== $table->getColumn('avg_order_revenue')) { + $conversions = $row->getColumn('nb_conversions'); + if ($conversions) { + $row->setColumn('avg_order_revenue', round($row->getColumn('revenue') / $conversions, 2)); + } + } + } + + protected function getNumeric($idSite, $period, $date, $segment, $toFetch) + { + Piwik::checkUserHasViewAccess($idSite); + $archive = Archive::build($idSite, $period, $date, $segment); + $dataTable = $archive->getDataTableFromNumeric($toFetch); + return $dataTable; + } + + /** + * @ignore + */ + public function getConversions($idSite, $period, $date, $segment = false, $idGoal = false) + { + return $this->getNumeric($idSite, $period, $date, $segment, Archiver::getRecordName('nb_conversions', $idGoal)); + } + + /** + * @ignore + */ + public function getNbVisitsConverted($idSite, $period, $date, $segment = false, $idGoal = false) + { + return $this->getNumeric($idSite, $period, $date, $segment, Archiver::getRecordName('nb_visits_converted', $idGoal)); + } + + /** + * @ignore + */ + public function getConversionRate($idSite, $period, $date, $segment = false, $idGoal = false) + { + return $this->getNumeric($idSite, $period, $date, $segment, Archiver::getRecordName('conversion_rate', $idGoal)); + } + + /** + * @ignore + */ + public function getRevenue($idSite, $period, $date, $segment = false, $idGoal = false) + { + return $this->getNumeric($idSite, $period, $date, $segment, Archiver::getRecordName('revenue', $idGoal)); + } + + /** + * Utility method that retrieve an archived DataTable for a specific site, date range, + * segment and goal. If not goal is specified, this method will retrieve and sum the + * data for every goal. + * + * @param string $recordName The archive entry name. + * @param int|string $idSite The site(s) to select data for. + * @param string $period The period type. + * @param string $date The date type. + * @param string $segment The segment. + * @param int|bool $idGoal The id of the goal to get data for. If this is set to false, + * data for every goal that belongs to $idSite is returned. + * @return false|DataTable + */ + protected function getGoalSpecificDataTable($recordName, $idSite, $period, $date, $segment, $idGoal) + { + Piwik::checkUserHasViewAccess($idSite); + + $archive = Archive::build($idSite, $period, $date, $segment); + + // check for the special goal ids + $realGoalId = $idGoal != true ? false : self::convertSpecialGoalIds($idGoal); + + // get the data table + $dataTable = $archive->getDataTable(Archiver::getRecordName($recordName, $realGoalId), $idSubtable = null); + $dataTable->queueFilter('ReplaceColumnNames'); + + return $dataTable; + } + + /** + * Gets a DataTable that maps ranges of days to the number of conversions that occurred + * within those ranges, for the specified site, date range, segment and goal. + * + * @param int $idSite The site to select data from. + * @param string $period The period type. + * @param string $date The date type. + * @param string|bool $segment The segment. + * @param int|bool $idGoal The id of the goal to get data for. If this is set to false, + * data for every goal that belongs to $idSite is returned. + * @return false|DataTable + */ + public function getDaysToConversion($idSite, $period, $date, $segment = false, $idGoal = false) + { + $dataTable = $this->getGoalSpecificDataTable( + Archiver::DAYS_UNTIL_CONV_RECORD_NAME, $idSite, $period, $date, $segment, $idGoal); + + $dataTable->queueFilter('Sort', array('label', 'asc', true)); + $dataTable->queueFilter( + 'BeautifyRangeLabels', array(Piwik::translate('General_OneDay'), Piwik::translate('General_NDays'))); + + return $dataTable; + } + + /** + * Gets a DataTable that maps ranges of visit counts to the number of conversions that + * occurred on those visits for the specified site, date range, segment and goal. + * + * @param int $idSite The site to select data from. + * @param string $period The period type. + * @param string $date The date type. + * @param string|bool $segment The segment. + * @param int|bool $idGoal The id of the goal to get data for. If this is set to false, + * data for every goal that belongs to $idSite is returned. + * @return bool|DataTable + */ + public function getVisitsUntilConversion($idSite, $period, $date, $segment = false, $idGoal = false) + { + $dataTable = $this->getGoalSpecificDataTable( + Archiver::VISITS_UNTIL_RECORD_NAME, $idSite, $period, $date, $segment, $idGoal); + + $dataTable->queueFilter('Sort', array('label', 'asc', true)); + $dataTable->queueFilter( + 'BeautifyRangeLabels', array(Piwik::translate('General_OneVisit'), Piwik::translate('General_NVisits'))); + + return $dataTable; + } + + /** + * Enhances the dataTable with Items attributes found in the Custom Variables report. + * + * @param $dataTable + * @param $recordName + * @param $idSite + * @param $period + * @param $date + * @param $segment + */ + protected function enrichItemsTableWithViewMetrics($dataTable, $recordName, $idSite, $period, $date, $segment) + { + // Enrich the datatable with Product/Categories views, and conversion rates + $customVariables = \Piwik\Plugins\CustomVariables\API::getInstance()->getCustomVariables($idSite, $period, $date, $segment, $expanded = false, + $_leavePiwikCoreVariables = true); + $mapping = array( + 'Goals_ItemsSku' => '_pks', + 'Goals_ItemsName' => '_pkn', + 'Goals_ItemsCategory' => '_pkc', + ); + $reportToNotDefinedString = array( + 'Goals_ItemsSku' => Piwik::translate('General_NotDefined', Piwik::translate('Goals_ProductSKU')), // Note: this should never happen + 'Goals_ItemsName' => Piwik::translate('General_NotDefined', Piwik::translate('Goals_ProductName')), + 'Goals_ItemsCategory' => Piwik::translate('General_NotDefined', Piwik::translate('Goals_ProductCategory')) + ); + $notDefinedStringPretty = $reportToNotDefinedString[$recordName]; + $customVarNameToLookFor = $mapping[$recordName]; + + // Handle case where date=last30&period=day + if ($customVariables instanceof DataTable\Map) { + $customVariableDatatables = $customVariables->getDataTables(); + $dataTables = $dataTable->getDataTables(); + foreach ($customVariableDatatables as $key => $customVariableTableForDate) { + $dataTableForDate = isset($dataTables[$key]) ? $dataTables[$key] : new DataTable(); + + // we do not enter the IF + // if case idSite=1,3 AND period=day&date=datefrom,dateto, + if ($customVariableTableForDate instanceof DataTable + && $customVariableTableForDate->getMetadata(Archive\DataTableFactory::TABLE_METADATA_PERIOD_INDEX) + ) { + $dateRewrite = $customVariableTableForDate->getMetadata(Archive\DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getDateStart()->toString(); + $row = $customVariableTableForDate->getRowFromLabel($customVarNameToLookFor); + if ($row) { + $idSubtable = $row->getIdSubDataTable(); + $this->enrichItemsDataTableWithItemsViewMetrics($dataTableForDate, $idSite, $period, $dateRewrite, $segment, $idSubtable); + } + $dataTable->addTable($dataTableForDate, $key); + } + $this->renameNotDefinedRow($dataTableForDate, $notDefinedStringPretty); + } + } elseif ($customVariables instanceof DataTable) { + $row = $customVariables->getRowFromLabel($customVarNameToLookFor); + if ($row) { + $idSubtable = $row->getIdSubDataTable(); + $this->enrichItemsDataTableWithItemsViewMetrics($dataTable, $idSite, $period, $date, $segment, $idSubtable); + } + $this->renameNotDefinedRow($dataTable, $notDefinedStringPretty); + } + } +} diff --git a/www/analytics/plugins/Goals/Archiver.php b/www/analytics/plugins/Goals/Archiver.php new file mode 100644 index 00000000..4dfa18fa --- /dev/null +++ b/www/analytics/plugins/Goals/Archiver.php @@ -0,0 +1,418 @@ + self::ITEMS_SKU_RECORD_NAME, + self::NAME_FIELD => self::ITEMS_NAME_RECORD_NAME, + self::CATEGORY_FIELD => self::ITEMS_CATEGORY_RECORD_NAME + ); + + /** + * Array containing one DataArray for each Ecommerce items dimension (name/sku/category abandoned carts and orders) + * @var array + */ + protected $itemReports = array(); + + public function aggregateDayReport() + { + $this->aggregateGeneralGoalMetrics(); + $this->aggregateEcommerceItems(); + } + + protected function aggregateGeneralGoalMetrics() + { + $prefixes = array( + self::VISITS_UNTIL_RECORD_NAME => 'vcv', + self::DAYS_UNTIL_CONV_RECORD_NAME => 'vdsf', + ); + + $selects = array(); + $selects = array_merge($selects, LogAggregator::getSelectsFromRangedColumn( + self::VISITS_COUNT_FIELD, self::$visitCountRanges, self::LOG_CONVERSION_TABLE, $prefixes[self::VISITS_UNTIL_RECORD_NAME] + )); + $selects = array_merge($selects, LogAggregator::getSelectsFromRangedColumn( + self::DAYS_SINCE_FIRST_VISIT_FIELD, self::$daysToConvRanges, self::LOG_CONVERSION_TABLE, $prefixes[self::DAYS_UNTIL_CONV_RECORD_NAME] + )); + + $query = $this->getLogAggregator()->queryConversionsByDimension(array(), false, $selects); + if ($query === false) { + return; + } + + $totalConversions = $totalRevenue = 0; + $goals = new DataArray(); + $visitsToConversions = $daysToConversions = array(); + + $conversionMetrics = $this->getLogAggregator()->getConversionsMetricFields(); + while ($row = $query->fetch()) { + $idGoal = $row['idgoal']; + unset($row['idgoal']); + unset($row['label']); + + $values = array(); + foreach ($conversionMetrics as $field => $statement) { + $values[$field] = $row[$field]; + } + $goals->sumMetrics($idGoal, $values); + + if (empty($visitsToConversions[$idGoal])) { + $visitsToConversions[$idGoal] = new DataTable(); + } + $array = LogAggregator::makeArrayOneColumn($row, Metrics::INDEX_NB_CONVERSIONS, $prefixes[self::VISITS_UNTIL_RECORD_NAME]); + $visitsToConversions[$idGoal]->addDataTable(DataTable::makeFromIndexedArray($array)); + + if (empty($daysToConversions[$idGoal])) { + $daysToConversions[$idGoal] = new DataTable(); + } + $array = LogAggregator::makeArrayOneColumn($row, Metrics::INDEX_NB_CONVERSIONS, $prefixes[self::DAYS_UNTIL_CONV_RECORD_NAME]); + $daysToConversions[$idGoal]->addDataTable(DataTable::makeFromIndexedArray($array)); + + // We don't want to sum Abandoned cart metrics in the overall revenue/conversions/converted visits + // since it is a "negative conversion" + if ($idGoal != GoalManager::IDGOAL_CART) { + $totalConversions += $row[Metrics::INDEX_GOAL_NB_CONVERSIONS]; + $totalRevenue += $row[Metrics::INDEX_GOAL_REVENUE]; + } + } + + // Stats by goal, for all visitors + $numericRecords = $this->getConversionsNumericMetrics($goals); + $this->getProcessor()->insertNumericRecords($numericRecords); + + $this->insertReports(self::VISITS_UNTIL_RECORD_NAME, $visitsToConversions); + $this->insertReports(self::DAYS_UNTIL_CONV_RECORD_NAME, $daysToConversions); + + // Stats for all goals + $nbConvertedVisits = $this->getProcessor()->getNumberOfVisitsConverted(); + $metrics = array( + self::getRecordName('conversion_rate') => $this->getConversionRate($nbConvertedVisits), + self::getRecordName('nb_conversions') => $totalConversions, + self::getRecordName('nb_visits_converted') => $nbConvertedVisits, + self::getRecordName('revenue') => $totalRevenue, + ); + $this->getProcessor()->insertNumericRecords($metrics); + } + + protected function getConversionsNumericMetrics(DataArray $goals) + { + $numericRecords = array(); + $goals = $goals->getDataArray(); + foreach ($goals as $idGoal => $array) { + foreach ($array as $metricId => $value) { + $metricName = Metrics::$mappingFromIdToNameGoal[$metricId]; + $recordName = self::getRecordName($metricName, $idGoal); + $numericRecords[$recordName] = $value; + } + if (!empty($array[Metrics::INDEX_GOAL_NB_VISITS_CONVERTED])) { + $conversion_rate = $this->getConversionRate($array[Metrics::INDEX_GOAL_NB_VISITS_CONVERTED]); + $recordName = self::getRecordName('conversion_rate', $idGoal); + $numericRecords[$recordName] = $conversion_rate; + } + } + return $numericRecords; + } + + /** + * @param string $recordName 'nb_conversions' + * @param int|bool $idGoal idGoal to return the metrics for, or false to return overall + * @return string Archive record name + */ + static public function getRecordName($recordName, $idGoal = false) + { + $idGoalStr = ''; + if ($idGoal !== false) { + $idGoalStr = $idGoal . "_"; + } + return 'Goal_' . $idGoalStr . $recordName; + } + + protected function getConversionRate($count) + { + $visits = $this->getProcessor()->getNumberOfVisits(); + return round(100 * $count / $visits, GoalManager::REVENUE_PRECISION); + } + + protected function insertReports($recordName, $visitsToConversions) + { + foreach ($visitsToConversions as $idGoal => $table) { + $record = self::getRecordName($recordName, $idGoal); + $this->getProcessor()->insertBlobRecord($record, $table->getSerialized()); + } + $overviewTable = $this->getOverviewFromGoalTables($visitsToConversions); + $this->getProcessor()->insertBlobRecord(self::getRecordName($recordName), $overviewTable->getSerialized()); + } + + protected function getOverviewFromGoalTables($tableByGoal) + { + $overview = new DataTable(); + foreach ($tableByGoal as $idGoal => $table) { + if ($this->isStandardGoal($idGoal)) { + $overview->addDataTable($table); + } + } + return $overview; + } + + protected function isStandardGoal($idGoal) + { + return !in_array($idGoal, $this->getEcommerceIdGoals()); + } + + protected function aggregateEcommerceItems() + { + $this->initItemReports(); + foreach ($this->getItemsDimensions() as $dimension) { + $query = $this->getLogAggregator()->queryEcommerceItems($dimension); + if ($query == false) { + continue; + } + $this->aggregateFromEcommerceItems($query, $dimension); + } + $this->insertItemReports(); + return true; + } + + protected function initItemReports() + { + foreach ($this->getEcommerceIdGoals() as $ecommerceType) { + foreach ($this->dimensionRecord as $dimension => $record) { + $this->itemReports[$dimension][$ecommerceType] = new DataArray(); + } + } + } + + protected function insertItemReports() + { + /** @var DataArray $array */ + foreach ($this->itemReports as $dimension => $itemAggregatesByType) { + foreach ($itemAggregatesByType as $ecommerceType => $itemAggregate) { + $recordName = $this->dimensionRecord[$dimension]; + if ($ecommerceType == GoalManager::IDGOAL_CART) { + $recordName = self::getItemRecordNameAbandonedCart($recordName); + } + $table = $itemAggregate->asDataTable(); + $this->getProcessor()->insertBlobRecord($recordName, $table->getSerialized()); + } + } + } + + protected function getItemsDimensions() + { + $dimensions = array_keys($this->dimensionRecord); + foreach ($this->getItemExtraCategories() as $category) { + $dimensions[] = $category; + } + return $dimensions; + } + + protected function getItemExtraCategories() + { + return array(self::CATEGORY2_FIELD, self::CATEGORY3_FIELD, self::CATEGORY4_FIELD, self::CATEGORY5_FIELD); + } + + protected function isItemExtraCategory($field) + { + return in_array($field, $this->getItemExtraCategories()); + } + + protected function aggregateFromEcommerceItems($query, $dimension) + { + while ($row = $query->fetch()) { + $ecommerceType = $row['ecommerceType']; + + $label = $this->cleanupRowGetLabel($row, $dimension); + if ($label === false) { + continue; + } + + // Aggregate extra categories in the Item categories array + if ($this->isItemExtraCategory($dimension)) { + $array = $this->itemReports[self::CATEGORY_FIELD][$ecommerceType]; + } else { + $array = $this->itemReports[$dimension][$ecommerceType]; + } + + $this->roundColumnValues($row); + $array->sumMetrics($label, $row); + } + } + + protected function cleanupRowGetLabel(&$row, $currentField) + { + $label = $row['label']; + if (empty($label)) { + // An empty additional category -> skip this iteration + if ($this->isItemExtraCategory($currentField)) { + return false; + } + $label = "Value not defined"; + // Product Name/Category not defined" + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('CustomVariables')) { + $label = \Piwik\Plugins\CustomVariables\Archiver::LABEL_CUSTOM_VALUE_NOT_DEFINED; + } + } + + if ($row['ecommerceType'] == GoalManager::IDGOAL_CART) { + // abandoned carts are the numner of visits with an abandoned cart + $row[Metrics::INDEX_ECOMMERCE_ORDERS] = $row[Metrics::INDEX_NB_VISITS]; + } + + unset($row[Metrics::INDEX_NB_VISITS]); + unset($row['label']); + unset($row['labelIdAction']); + unset($row['ecommerceType']); + + return $label; + } + + protected function roundColumnValues(&$row) + { + $columnsToRound = array( + Metrics::INDEX_ECOMMERCE_ITEM_REVENUE, + Metrics::INDEX_ECOMMERCE_ITEM_QUANTITY, + Metrics::INDEX_ECOMMERCE_ITEM_PRICE, + Metrics::INDEX_ECOMMERCE_ITEM_PRICE_VIEWED, + ); + foreach ($columnsToRound as $column) { + if (isset($row[$column]) + && $row[$column] == round($row[$column]) + ) { + $row[$column] = round($row[$column]); + } + } + } + + protected function getEcommerceIdGoals() + { + return array(GoalManager::IDGOAL_CART, GoalManager::IDGOAL_ORDER); + } + + static public function getItemRecordNameAbandonedCart($recordName) + { + return $recordName . '_Cart'; + } + + /** + * @internal param $this->getProcessor() + */ + public function aggregateMultipleReports() + { + /* + * Archive Ecommerce Items + */ + $dataTableToSum = $this->dimensionRecord; + foreach ($this->dimensionRecord as $recordName) { + $dataTableToSum[] = self::getItemRecordNameAbandonedCart($recordName); + } + $this->getProcessor()->aggregateDataTableRecords($dataTableToSum); + + /* + * Archive General Goal metrics + */ + $goalIdsToSum = GoalManager::getGoalIds($this->getProcessor()->getParams()->getSite()->getId()); + + //Ecommerce + $goalIdsToSum[] = GoalManager::IDGOAL_ORDER; + $goalIdsToSum[] = GoalManager::IDGOAL_CART; //bug here if idgoal=1 + // Overall goal metrics + $goalIdsToSum[] = false; + + $fieldsToSum = array(); + foreach ($goalIdsToSum as $goalId) { + $metricsToSum = Goals::getGoalColumns($goalId); + unset($metricsToSum[array_search('conversion_rate', $metricsToSum)]); + foreach ($metricsToSum as $metricName) { + $fieldsToSum[] = self::getRecordName($metricName, $goalId); + } + } + $records = $this->getProcessor()->aggregateNumericMetrics($fieldsToSum); + + // also recording conversion_rate for each goal + foreach ($goalIdsToSum as $goalId) { + $nb_conversions = $records[self::getRecordName('nb_visits_converted', $goalId)]; + $conversion_rate = $this->getConversionRate($nb_conversions); + $this->getProcessor()->insertNumericRecord(self::getRecordName('conversion_rate', $goalId), $conversion_rate); + + // sum up the visits to conversion data table & the days to conversion data table + $this->getProcessor()->aggregateDataTableRecords(array( + self::getRecordName(self::VISITS_UNTIL_RECORD_NAME, $goalId), + self::getRecordName(self::DAYS_UNTIL_CONV_RECORD_NAME, $goalId))); + } + + // sum up goal overview reports + $this->getProcessor()->aggregateDataTableRecords(array( + self::getRecordName(self::VISITS_UNTIL_RECORD_NAME), + self::getRecordName(self::DAYS_UNTIL_CONV_RECORD_NAME))); + } +} diff --git a/www/analytics/plugins/Goals/Controller.php b/www/analytics/plugins/Goals/Controller.php new file mode 100644 index 00000000..2d641359 --- /dev/null +++ b/www/analytics/plugins/Goals/Controller.php @@ -0,0 +1,494 @@ + 'General_AverageOrderValue', + 'nb_conversions' => 'Goals_ColumnConversions', + 'conversion_rate' => 'General_ColumnConversionRate', + 'revenue' => 'General_TotalRevenue', + 'items' => 'General_PurchasedProducts', + ); + + private function formatConversionRate($conversionRate) + { + if ($conversionRate instanceof DataTable) { + if ($conversionRate->getRowsCount() == 0) { + $conversionRate = 0; + } else { + $columns = $conversionRate->getFirstRow()->getColumns(); + $conversionRate = (float)reset($columns); + } + } + return sprintf('%.' . self::CONVERSION_RATE_PRECISION . 'f%%', $conversionRate); + } + + public function __construct() + { + parent::__construct(); + $this->idSite = Common::getRequestVar('idSite', null, 'int'); + $this->goals = API::getInstance()->getGoals($this->idSite); + foreach ($this->goals as &$goal) { + $goal['name'] = Common::sanitizeInputValue($goal['name']); + if (isset($goal['pattern'])) { + $goal['pattern'] = Common::sanitizeInputValue($goal['pattern']); + } + } + } + + public function widgetGoalReport() + { + $view = $this->getGoalReportView($idGoal = Common::getRequestVar('idGoal', null, 'string')); + $view->displayFullReport = false; + return $view->render(); + } + + public function goalReport() + { + $view = $this->getGoalReportView($idGoal = Common::getRequestVar('idGoal', null, 'string')); + $view->displayFullReport = true; + return $view->render(); + } + + public function ecommerceReport() + { + if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated('CustomVariables')) { + throw new Exception("Ecommerce Tracking requires that the plugin Custom Variables is enabled. Please enable the plugin CustomVariables (or ask your admin)."); + } + + $view = $this->getGoalReportView($idGoal = Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER); + $view->displayFullReport = true; + return $view->render(); + } + + public function getEcommerceLog($fetch = false) + { + $saveGET = $_GET; + $filterEcommerce = Common::getRequestVar('filterEcommerce', self::ECOMMERCE_LOG_SHOW_ORDERS, 'int'); + if($filterEcommerce == self::ECOMMERCE_LOG_SHOW_ORDERS) { + $segment = urlencode('visitEcommerceStatus==ordered,visitEcommerceStatus==orderedThenAbandonedCart'); + } else { + $segment = urlencode('visitEcommerceStatus==abandonedCart,visitEcommerceStatus==orderedThenAbandonedCart'); + } + $_GET['segment'] = $segment; + $_GET['filterEcommerce'] = $filterEcommerce; + $_GET['widget'] = 1; + $output = FrontController::getInstance()->dispatch('Live', 'getVisitorLog', array($fetch)); + $_GET = $saveGET; + return $output; + } + + protected function getGoalReportView($idGoal = false) + { + $view = new View('@Goals/getGoalReportView'); + if ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER) { + $goalDefinition['name'] = Piwik::translate('Goals_Ecommerce'); + $goalDefinition['allow_multiple'] = true; + $ecommerce = $view->ecommerce = true; + } else { + if (!isset($this->goals[$idGoal])) { + Piwik::redirectToModule('Goals', 'index', array('idGoal' => null)); + } + $goalDefinition = $this->goals[$idGoal]; + } + $this->setGeneralVariablesView($view); + $goal = $this->getMetricsForGoal($idGoal); + foreach ($goal as $name => $value) { + $view->$name = $value; + } + if ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER) { + $goal = $this->getMetricsForGoal(Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART); + foreach ($goal as $name => $value) { + $name = 'cart_' . $name; + $view->$name = $value; + } + } + $view->idGoal = $idGoal; + $view->goalName = $goalDefinition['name']; + $view->goalAllowMultipleConversionsPerVisit = $goalDefinition['allow_multiple']; + $view->graphEvolution = $this->getEvolutionGraph(array('nb_conversions'), $idGoal); + $view->nameGraphEvolution = 'Goals.getEvolutionGraph' . $idGoal; + $view->topDimensions = $this->getTopDimensions($idGoal); + + // conversion rate for new and returning visitors + $segment = urldecode(\Piwik\Plugins\VisitFrequency\API::RETURNING_VISITOR_SEGMENT); + $conversionRateReturning = API::getInstance()->getConversionRate($this->idSite, Common::getRequestVar('period'), Common::getRequestVar('date'), $segment, $idGoal); + $view->conversion_rate_returning = $this->formatConversionRate($conversionRateReturning); + $segment = 'visitorType==new'; + $conversionRateNew = API::getInstance()->getConversionRate($this->idSite, Common::getRequestVar('period'), Common::getRequestVar('date'), $segment, $idGoal); + $view->conversion_rate_new = $this->formatConversionRate($conversionRateNew); + $view->goalReportsByDimension = $this->getGoalReportsByDimensionTable( + $view->nb_conversions, isset($ecommerce), !empty($view->cart_nb_conversions)); + return $view; + } + + public function index() + { + $view = $this->getOverviewView(); + + // unsanitize goal names and other text data (not done in API so as not to break + // any other code/cause security issues) + $goals = $this->goals; + foreach ($goals as &$goal) { + $goal['name'] = Common::unsanitizeInputValue($goal['name']); + if (isset($goal['pattern'])) { + $goal['pattern'] = Common::unsanitizeInputValue($goal['pattern']); + } + } + $view->goalsJSON = Common::json_encode($goals); + + $view->userCanEditGoals = Piwik::isUserHasAdminAccess($this->idSite); + $view->ecommerceEnabled = $this->site->isEcommerceEnabled(); + $view->displayFullReport = true; + return $view->render(); + } + + public function widgetGoalsOverview() + { + $view = $this->getOverviewView(); + $view->displayFullReport = false; + return $view->render(); + } + + protected function getOverviewView() + { + $view = new View('@Goals/getOverviewView'); + $this->setGeneralVariablesView($view); + + $view->graphEvolution = $this->getEvolutionGraph(array('nb_conversions')); + $view->nameGraphEvolution = 'GoalsgetEvolutionGraph'; + + // sparkline for the historical data of the above values + $view->urlSparklineConversions = $this->getUrlSparkline('getEvolutionGraph', array('columns' => array('nb_conversions'), 'idGoal' => '')); + $view->urlSparklineConversionRate = $this->getUrlSparkline('getEvolutionGraph', array('columns' => array('conversion_rate'), 'idGoal' => '')); + $view->urlSparklineRevenue = $this->getUrlSparkline('getEvolutionGraph', array('columns' => array('revenue'), 'idGoal' => '')); + + // Pass empty idGoal will return Goal overview + $request = new Request("method=Goals.get&format=original&idGoal="); + $datatable = $request->process(); + $dataRow = $datatable->getFirstRow(); + $view->nb_conversions = $dataRow->getColumn('nb_conversions'); + $view->nb_visits_converted = $dataRow->getColumn('nb_visits_converted'); + $view->conversion_rate = $this->formatConversionRate($dataRow->getColumn('conversion_rate')); + $view->revenue = $dataRow->getColumn('revenue'); + + $goalMetrics = array(); + foreach ($this->goals as $idGoal => $goal) { + $goalMetrics[$idGoal] = $this->getMetricsForGoal($idGoal); + $goalMetrics[$idGoal]['name'] = $goal['name']; + $goalMetrics[$idGoal]['goalAllowMultipleConversionsPerVisit'] = $goal['allow_multiple']; + } + + $view->goalMetrics = $goalMetrics; + $view->goals = $this->goals; + $view->goalReportsByDimension = $this->getGoalReportsByDimensionTable( + $view->nb_conversions, $ecommerce = false, !empty($view->cart_nb_conversions)); + return $view; + } + + public function getLastNbConversionsGraph() + { + $view = $this->getLastUnitGraph($this->pluginName, __FUNCTION__, 'Goals.getConversions'); + return $this->renderView($view); + } + + public function getLastConversionRateGraph() + { + $view = $this->getLastUnitGraph($this->pluginName, __FUNCTION__, 'Goals.getConversionRate'); + return $this->renderView($view); + } + + public function getLastRevenueGraph() + { + $view = $this->getLastUnitGraph($this->pluginName, __FUNCTION__, 'Goals.getRevenue'); + return $this->renderView($view); + } + + public function addNewGoal() + { + $view = new View('@Goals/addNewGoal'); + $this->setGeneralVariablesView($view); + $view->userCanEditGoals = Piwik::isUserHasAdminAccess($this->idSite); + $view->onlyShowAddNewGoal = true; + return $view->render(); + } + + public function getEvolutionGraph(array $columns = array(), $idGoal = false) + { + if (empty($columns)) { + $columns = Common::getRequestVar('columns'); + $columns = Piwik::getArrayFromApiParameter($columns); + } + + $columns = !is_array($columns) ? array($columns) : $columns; + + if (empty($idGoal)) { + $idGoal = Common::getRequestVar('idGoal', false, 'string'); + } + $view = $this->getLastUnitGraph($this->pluginName, __FUNCTION__, 'Goals.get'); + $view->requestConfig->request_parameters_to_modify['idGoal'] = $idGoal; + + $nameToLabel = $this->goalColumnNameToLabel; + if ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER) { + $nameToLabel['nb_conversions'] = 'General_EcommerceOrders'; + } elseif ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART) { + $nameToLabel['nb_conversions'] = Piwik::translate('General_VisitsWith', Piwik::translate('Goals_AbandonedCart')); + $nameToLabel['conversion_rate'] = $nameToLabel['nb_conversions']; + $nameToLabel['revenue'] = Piwik::translate('Goals_LeftInCart', Piwik::translate('General_ColumnRevenue')); + $nameToLabel['items'] = Piwik::translate('Goals_LeftInCart', Piwik::translate('Goals_Products')); + } + + $selectableColumns = array('nb_conversions', 'conversion_rate', 'revenue'); + if ($this->site->isEcommerceEnabled()) { + $selectableColumns[] = 'items'; + $selectableColumns[] = 'avg_order_revenue'; + } + + foreach (array_merge($columns, $selectableColumns) as $columnName) { + $columnTranslation = ''; + // find the right translation for this column, eg. find 'revenue' if column is Goal_1_revenue + foreach ($nameToLabel as $metric => $metricTranslation) { + if (strpos($columnName, $metric) !== false) { + $columnTranslation = Piwik::translate($metricTranslation); + break; + } + } + + if (!empty($idGoal) && isset($this->goals[$idGoal])) { + $goalName = $this->goals[$idGoal]['name']; + $columnTranslation = "$columnTranslation (" . Piwik::translate('Goals_GoalX', "$goalName") . ")"; + } + $view->config->translations[$columnName] = $columnTranslation; + } + $view->config->columns_to_display = $columns; + $view->config->selectable_columns = $selectableColumns; + + $langString = $idGoal ? 'Goals_SingleGoalOverviewDocumentation' : 'Goals_GoalsOverviewDocumentation'; + $view->config->documentation = Piwik::translate($langString, '
          '); + + return $this->renderView($view); + } + + protected function getTopDimensions($idGoal) + { + $columnNbConversions = 'goal_' . $idGoal . '_nb_conversions'; + $columnConversionRate = 'goal_' . $idGoal . '_conversion_rate'; + + $topDimensionsToLoad = array(); + + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('UserCountry')) { + $topDimensionsToLoad += array( + 'country' => 'UserCountry.getCountry', + ); + } + + $keywordNotDefinedString = ''; + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('Referrers')) { + $keywordNotDefinedString = APIReferrers::getKeywordNotDefinedString(); + $topDimensionsToLoad += array( + 'keyword' => 'Referrers.getKeywords', + 'website' => 'Referrers.getWebsites', + ); + } + $topDimensions = array(); + foreach ($topDimensionsToLoad as $dimensionName => $apiMethod) { + $request = new Request("method=$apiMethod + &format=original + &filter_update_columns_when_show_all_goals=1 + &idGoal=" . AddColumnsProcessedMetricsGoal::GOALS_FULL_TABLE . " + &filter_sort_order=desc + &filter_sort_column=$columnNbConversions" . + // select a couple more in case some are not valid (ie. conversions==0 or they are "Keyword not defined") + "&filter_limit=" . (self::COUNT_TOP_ROWS_TO_DISPLAY + 2)); + $datatable = $request->process(); + $topDimension = array(); + $count = 0; + foreach ($datatable->getRows() as $row) { + $conversions = $row->getColumn($columnNbConversions); + if ($conversions > 0 + && $count < self::COUNT_TOP_ROWS_TO_DISPLAY + + // Don't put the "Keyword not defined" in the best segment since it's irritating + && !($dimensionName == 'keyword' + && $row->getColumn('label') == $keywordNotDefinedString) + ) { + $topDimension[] = array( + 'name' => $row->getColumn('label'), + 'nb_conversions' => $conversions, + 'conversion_rate' => $this->formatConversionRate($row->getColumn($columnConversionRate)), + 'metadata' => $row->getMetadata(), + ); + $count++; + } + } + $topDimensions[$dimensionName] = $topDimension; + } + return $topDimensions; + } + + protected function getMetricsForGoal($idGoal) + { + $request = new Request("method=Goals.get&format=original&idGoal=$idGoal"); + $datatable = $request->process(); + $dataRow = $datatable->getFirstRow(); + $nbConversions = $dataRow->getColumn('nb_conversions'); + $nbVisitsConverted = $dataRow->getColumn('nb_visits_converted'); + // Backward compatibilty before 1.3, this value was not processed + if (empty($nbVisitsConverted)) { + $nbVisitsConverted = $nbConversions; + } + $revenue = $dataRow->getColumn('revenue'); + $return = array( + 'id' => $idGoal, + 'nb_conversions' => (int)$nbConversions, + 'nb_visits_converted' => (int)$nbVisitsConverted, + 'conversion_rate' => $this->formatConversionRate($dataRow->getColumn('conversion_rate')), + 'revenue' => $revenue ? $revenue : 0, + 'urlSparklineConversions' => $this->getUrlSparkline('getEvolutionGraph', array('columns' => array('nb_conversions'), 'idGoal' => $idGoal)), + 'urlSparklineConversionRate' => $this->getUrlSparkline('getEvolutionGraph', array('columns' => array('conversion_rate'), 'idGoal' => $idGoal)), + 'urlSparklineRevenue' => $this->getUrlSparkline('getEvolutionGraph', array('columns' => array('revenue'), 'idGoal' => $idGoal)), + ); + if ($idGoal == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER) { + $items = $dataRow->getColumn('items'); + $aov = $dataRow->getColumn('avg_order_revenue'); + $return = array_merge($return, array( + 'revenue_subtotal' => $dataRow->getColumn('revenue_subtotal'), + 'revenue_tax' => $dataRow->getColumn('revenue_tax'), + 'revenue_shipping' => $dataRow->getColumn('revenue_shipping'), + 'revenue_discount' => $dataRow->getColumn('revenue_discount'), + + 'items' => $items ? $items : 0, + 'avg_order_revenue' => $aov ? $aov : 0, + 'urlSparklinePurchasedProducts' => $this->getUrlSparkline('getEvolutionGraph', array('columns' => array('items'), 'idGoal' => $idGoal)), + 'urlSparklineAverageOrderValue' => $this->getUrlSparkline('getEvolutionGraph', array('columns' => array('avg_order_revenue'), 'idGoal' => $idGoal)), + )); + } + return $return; + } + + /** + * Utility function that returns HTML that displays Goal information for reports. This + * is the HTML that is at the bottom of every goals page. + * + * @param int $conversions The number of conversions for this goal (or all goals + * in case of the overview). + * @param bool $ecommerce Whether to show ecommerce reports or not. + * @param bool $cartNbConversions Whether there are cart conversions or not for this + * goal. + * @return string + */ + private function getGoalReportsByDimensionTable($conversions, $ecommerce = false, $cartNbConversions = false) + { + $preloadAbandonedCart = $cartNbConversions !== false && $conversions == 0; + + $goalReportsByDimension = new ReportsByDimension('Goals'); + + // add ecommerce reports + $ecommerceCustomParams = array(); + if ($ecommerce) { + if ($preloadAbandonedCart) { + $ecommerceCustomParams['viewDataTable'] = 'ecommerceAbandonedCart'; + $ecommerceCustomParams['filterEcommerce'] = self::ECOMMERCE_LOG_SHOW_ABANDONED_CARTS; + } + + $goalReportsByDimension->addReport( + 'Goals_EcommerceReports', 'Goals_ProductSKU', 'Goals.getItemsSku', $ecommerceCustomParams); + $goalReportsByDimension->addReport( + 'Goals_EcommerceReports', 'Goals_ProductName', 'Goals.getItemsName', $ecommerceCustomParams); + $goalReportsByDimension->addReport( + 'Goals_EcommerceReports', 'Goals_ProductCategory', 'Goals.getItemsCategory', $ecommerceCustomParams); + + $goalReportsByDimension->addReport( + 'Goals_EcommerceReports', 'Goals_EcommerceLog', 'Goals.getEcommerceLog', $ecommerceCustomParams); + } + + if ($conversions > 0) { + // for non-Goals reports, we show the goals table + $customParams = $ecommerceCustomParams + array('documentationForGoalsPage' => '1'); + + if (Common::getRequestVar('idGoal', '') === '') // if no idGoal, use 0 for overview + { + $customParams['idGoal'] = '0'; // NOTE: Must be string! Otherwise Piwik_View_HtmlTable_Goals fails. + } + + $allReports = Goals::getReportsWithGoalMetrics(); + foreach ($allReports as $category => $reports) { + $categoryText = Piwik::translate('Goals_ViewGoalsBy', $category); + foreach ($reports as $report) { + if(empty($report['viewDataTable'])) { + $report['viewDataTable'] = 'tableGoals'; + } + $customParams['viewDataTable'] = $report['viewDataTable']; + + $goalReportsByDimension->addReport( + $categoryText, $report['name'], $report['module'] . '.' . $report['action'], $customParams); + } + } + } + + return $goalReportsByDimension->render(); + } + + // + // Report rendering actions + // + + public function getItemsSku() + { + return $this->renderReport(__FUNCTION__); + } + + public function getItemsName() + { + return $this->renderReport(__FUNCTION__); + } + + public function getItemsCategory() + { + return $this->renderReport(__FUNCTION__); + } + + public function getVisitsUntilConversion() + { + return $this->renderReport(__FUNCTION__); + } + + public function getDaysToConversion() + { + return $this->renderReport(__FUNCTION__); + } +} diff --git a/www/analytics/plugins/Goals/Goals.php b/www/analytics/plugins/Goals/Goals.php new file mode 100644 index 00000000..e86738b1 --- /dev/null +++ b/www/analytics/plugins/Goals/Goals.php @@ -0,0 +1,680 @@ +', '')); + $info = parent::getInformation(); + $info['description'] .= ' ' . $suffix; + return $info; + } + + protected $ecommerceReports = array( + array('Goals_ProductSKU', 'Goals', 'getItemsSku'), + array('Goals_ProductName', 'Goals', 'getItemsName'), + array('Goals_ProductCategory', 'Goals', 'getItemsCategory') + ); + + static public function getReportsWithGoalMetrics() + { + $dimensions = self::getAllReportsWithGoalMetrics(); + + $dimensionsByGroup = array(); + foreach ($dimensions as $dimension) { + $group = $dimension['category']; + unset($dimension['category']); + $dimensionsByGroup[$group][] = $dimension; + } + + uksort($dimensionsByGroup, array('self', 'sortGoalDimensionsByModule')); + return $dimensionsByGroup; + } + + public static function sortGoalDimensionsByModule($a, $b) + { + $order = array( + Piwik::translate('Referrers_Referrers'), + Piwik::translate('General_Visit'), + Piwik::translate('VisitTime_ColumnServerTime'), + ); + $orderA = array_search($a, $order); + $orderB = array_search($b, $order); + return $orderA > $orderB; + } + + static public function getGoalColumns($idGoal) + { + $columns = array( + 'nb_conversions', + 'nb_visits_converted', + 'conversion_rate', + 'revenue', + ); + if ($idGoal === false) { + return $columns; + } + // Orders + if ($idGoal === GoalManager::IDGOAL_ORDER) { + $columns = array_merge($columns, array( + 'revenue_subtotal', + 'revenue_tax', + 'revenue_shipping', + 'revenue_discount', + )); + } + // Abandoned carts & orders + if ($idGoal <= GoalManager::IDGOAL_ORDER) { + $columns[] = 'items'; + } + return $columns; + } + + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + $hooks = array( + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'Tracker.Cache.getSiteAttributes' => 'fetchGoalsFromDb', + 'API.getReportMetadata.end' => 'getReportMetadata', + 'API.getSegmentDimensionMetadata' => 'getSegmentsMetadata', + 'WidgetsList.addWidgets' => 'addWidgets', + 'Menu.Reporting.addItems' => 'addMenus', + 'SitesManager.deleteSite.end' => 'deleteSiteGoals', + 'Goals.getReportsWithGoalMetrics' => 'getActualReportsWithGoalMetrics', + 'ViewDataTable.configure' => 'configureViewDataTable', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + 'ViewDataTable.addViewDataTable' => 'getAvailableDataTableVisualizations' + ); + return $hooks; + } + + public function getAvailableDataTableVisualizations(&$visualizations) + { + $visualizations[] = 'Piwik\\Plugins\\Goals\\Visualizations\\Goals'; + } + + /** + * Delete goals recorded for this site + */ + function deleteSiteGoals($idSite) + { + Db::query("DELETE FROM " . Common::prefixTable('goal') . " WHERE idsite = ? ", array($idSite)); + } + + /** + * Returns the Metadata for the Goals plugin API. + * The API returns general Goal metrics: conv, conv rate and revenue globally + * and for each goal. + * + * Also, this will update metadata of all other reports that have Goal segmentation + */ + public function getReportMetadata(&$reports, $info) + { + $idSites = $info['idSites']; + + // Processed in AddColumnsProcessedMetricsGoal + // These metrics will also be available for some reports, for each goal + // Example: Conversion rate for Goal 2 for the keyword 'piwik' + $goalProcessedMetrics = array( + 'revenue_per_visit' => Piwik::translate('General_ColumnValuePerVisit'), + ); + + $goalMetrics = array( + 'nb_conversions' => Piwik::translate('Goals_ColumnConversions'), + 'nb_visits_converted' => Piwik::translate('General_ColumnVisitsWithConversions'), + 'conversion_rate' => Piwik::translate('General_ColumnConversionRate'), + 'revenue' => Piwik::translate('General_ColumnRevenue') + ); + + $conversionReportMetrics = array( + 'nb_conversions' => Piwik::translate('Goals_ColumnConversions') + ); + + // General Goal metrics: conversions, conv rate, revenue + $goalsCategory = Piwik::translate('Goals_Goals'); + $reports[] = array( + 'category' => $goalsCategory, + 'name' => Piwik::translate('Goals_Goals'), + 'module' => 'Goals', + 'action' => 'get', + 'metrics' => $goalMetrics, + 'processedMetrics' => array(), + 'order' => 1 + ); + + // If only one website is selected, we add the Goal metrics + if (count($idSites) == 1) { + $idSite = reset($idSites); + $goals = API::getInstance()->getGoals($idSite); + + // Add overall visits to conversion report + $reports[] = array( + 'category' => $goalsCategory, + 'name' => Piwik::translate('Goals_VisitsUntilConv'), + 'module' => 'Goals', + 'action' => 'getVisitsUntilConversion', + 'dimension' => Piwik::translate('Goals_VisitsUntilConv'), + 'constantRowsCount' => true, + 'parameters' => array(), + 'metrics' => $conversionReportMetrics, + 'order' => 5 + ); + + // Add overall days to conversion report + $reports[] = array( + 'category' => $goalsCategory, + 'name' => Piwik::translate('Goals_DaysToConv'), + 'module' => 'Goals', + 'action' => 'getDaysToConversion', + 'dimension' => Piwik::translate('Goals_DaysToConv'), + 'constantRowsCount' => true, + 'parameters' => array(), + 'metrics' => $conversionReportMetrics, + 'order' => 10 + ); + + foreach ($goals as $goal) { + // Add the general Goal metrics: ie. total Goal conversions, + // Goal conv rate or Goal total revenue. + // This API call requires a custom parameter + $goal['name'] = Common::sanitizeInputValue($goal['name']); + $reports[] = array( + 'category' => $goalsCategory, + 'name' => Piwik::translate('Goals_GoalX', $goal['name']), + 'module' => 'Goals', + 'action' => 'get', + 'parameters' => array('idGoal' => $goal['idgoal']), + 'metrics' => $goalMetrics, + 'processedMetrics' => false, + 'order' => 50 + $goal['idgoal'] * 3 + ); + + // Add visits to conversion report + $reports[] = array( + 'category' => $goalsCategory, + 'name' => $goal['name'] . ' - ' . Piwik::translate('Goals_VisitsUntilConv'), + 'module' => 'Goals', + 'action' => 'getVisitsUntilConversion', + 'dimension' => Piwik::translate('Goals_VisitsUntilConv'), + 'constantRowsCount' => true, + 'parameters' => array('idGoal' => $goal['idgoal']), + 'metrics' => $conversionReportMetrics, + 'order' => 51 + $goal['idgoal'] * 3 + ); + + // Add days to conversion report + $reports[] = array( + 'category' => $goalsCategory, + 'name' => $goal['name'] . ' - ' . Piwik::translate('Goals_DaysToConv'), + 'module' => 'Goals', + 'action' => 'getDaysToConversion', + 'dimension' => Piwik::translate('Goals_DaysToConv'), + 'constantRowsCount' => true, + 'parameters' => array('idGoal' => $goal['idgoal']), + 'metrics' => $conversionReportMetrics, + 'order' => 52 + $goal['idgoal'] * 3 + ); + } + + $site = new Site($idSite); + if ($site->isEcommerceEnabled()) { + $category = Piwik::translate('Goals_Ecommerce'); + $ecommerceMetrics = array_merge($goalMetrics, array( + 'revenue_subtotal' => Piwik::translate('General_Subtotal'), + 'revenue_tax' => Piwik::translate('General_Tax'), + 'revenue_shipping' => Piwik::translate('General_Shipping'), + 'revenue_discount' => Piwik::translate('General_Discount'), + 'items' => Piwik::translate('General_PurchasedProducts'), + 'avg_order_revenue' => Piwik::translate('General_AverageOrderValue') + )); + $ecommerceMetrics['nb_conversions'] = Piwik::translate('General_EcommerceOrders'); + + // General Ecommerce metrics + $reports[] = array( + 'category' => $category, + 'name' => Piwik::translate('General_EcommerceOrders'), + 'module' => 'Goals', + 'action' => 'get', + 'parameters' => array('idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER), + 'metrics' => $ecommerceMetrics, + 'processedMetrics' => false, + 'order' => 10 + ); + $reports[] = array( + 'category' => $category, + 'name' => Piwik::translate('General_EcommerceOrders') . ' - ' . Piwik::translate('Goals_VisitsUntilConv'), + 'module' => 'Goals', + 'action' => 'getVisitsUntilConversion', + 'dimension' => Piwik::translate('Goals_VisitsUntilConv'), + 'constantRowsCount' => true, + 'metrics' => $conversionReportMetrics, + 'parameters' => array('idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER), + 'order' => 11 + ); + $reports[] = array( + 'category' => $category, + 'name' => Piwik::translate('General_EcommerceOrders') . ' - ' . Piwik::translate('Goals_DaysToConv'), + 'module' => 'Goals', + 'action' => 'getDaysToConversion', + 'dimension' => Piwik::translate('Goals_DaysToConv'), + 'constantRowsCount' => true, + 'metrics' => $conversionReportMetrics, + 'parameters' => array('idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER), + 'order' => 12 + ); + + // Abandoned cart general metrics + $abandonedCartMetrics = $goalMetrics; + $abandonedCartMetrics['nb_conversions'] = Piwik::translate('General_AbandonedCarts'); + $abandonedCartMetrics['revenue'] = Piwik::translate('Goals_LeftInCart', Piwik::translate('General_ColumnRevenue')); + $abandonedCartMetrics['items'] = Piwik::translate('Goals_LeftInCart', Piwik::translate('Goals_Products')); + unset($abandonedCartMetrics['nb_visits_converted']); + + // Abandoned Cart metrics + $reports[] = array( + 'category' => $category, + 'name' => Piwik::translate('General_AbandonedCarts'), + 'module' => 'Goals', + 'action' => 'get', + 'parameters' => array('idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART), + 'metrics' => $abandonedCartMetrics, + 'processedMetrics' => false, + 'order' => 15 + ); + + $reports[] = array( + 'category' => $category, + 'name' => Piwik::translate('General_AbandonedCarts') . ' - ' . Piwik::translate('Goals_VisitsUntilConv'), + 'module' => 'Goals', + 'action' => 'getVisitsUntilConversion', + 'dimension' => Piwik::translate('Goals_VisitsUntilConv'), + 'constantRowsCount' => true, + 'metrics' => $conversionReportMetrics, + 'parameters' => array('idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART), + 'order' => 20 + ); + $reports[] = array( + 'category' => $category, + 'name' => Piwik::translate('General_AbandonedCarts') . ' - ' . Piwik::translate('Goals_DaysToConv'), + 'module' => 'Goals', + 'action' => 'getDaysToConversion', + 'dimension' => Piwik::translate('Goals_DaysToConv'), + 'constantRowsCount' => true, + 'metrics' => $conversionReportMetrics, + 'parameters' => array('idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART), + 'order' => 25 + ); + + // Product reports metadata + $productColumns = self::getProductReportColumns(); + foreach ($this->ecommerceReports as $i => $ecommerceReport) { + $reports[] = array( + 'category' => $category, + 'name' => Piwik::translate($ecommerceReport[0]), + 'module' => 'Goals', + 'action' => $ecommerceReport[2], + 'dimension' => Piwik::translate($ecommerceReport[0]), + 'metrics' => $productColumns, + 'processedMetrics' => false, + 'order' => 30 + $i + ); + } + } + } + + unset($goalMetrics['nb_visits_converted']); + + $reportsWithGoals = self::getAllReportsWithGoalMetrics(); + + foreach ($reportsWithGoals as $reportWithGoals) { + // Select this report from the API metadata array + // and add the Goal metrics to it + foreach ($reports as &$apiReportToUpdate) { + if ($apiReportToUpdate['module'] == $reportWithGoals['module'] + && $apiReportToUpdate['action'] == $reportWithGoals['action'] + ) { + $apiReportToUpdate['metricsGoal'] = $goalMetrics; + $apiReportToUpdate['processedMetricsGoal'] = $goalProcessedMetrics; + break; + } + } + } + } + + static private function getAllReportsWithGoalMetrics() + { + $reportsWithGoals = array(); + + /** + * Triggered when gathering all reports that contain Goal metrics. The list of reports + * will be displayed on the left column of the bottom of every _Goals_ page. + * + * If plugins define reports that contain goal metrics (such as **conversions** or **revenue**), + * they can use this event to make sure their reports can be viewed on Goals pages. + * + * **Example** + * + * public function getReportsWithGoalMetrics(&$reports) + * { + * $reports[] = array( + * 'category' => Piwik::translate('MyPlugin_myReportCategory'), + * 'name' => Piwik::translate('MyPlugin_myReportDimension'), + * 'module' => 'MyPlugin', + * 'action' => 'getMyReport' + * ); + * } + * + * @param array &$reportsWithGoals The list of arrays describing reports that have Goal metrics. + * Each element of this array must be an array with the following + * properties: + * + * - **category**: The report category. This should be a translated string. + * - **name**: The report's translated name. + * - **module**: The plugin the report is in, eg, `'UserCountry'`. + * - **action**: The API method of the report, eg, `'getCountry'`. + */ + Piwik::postEvent('Goals.getReportsWithGoalMetrics', array(&$reportsWithGoals)); + + return $reportsWithGoals; + } + + static public function getProductReportColumns() + { + return array( + 'revenue' => Piwik::translate('General_ProductRevenue'), + 'quantity' => Piwik::translate('General_Quantity'), + 'orders' => Piwik::translate('General_UniquePurchases'), + 'avg_price' => Piwik::translate('General_AveragePrice'), + 'avg_quantity' => Piwik::translate('General_AverageQuantity'), + 'nb_visits' => Piwik::translate('General_ColumnNbVisits'), + 'conversion_rate' => Piwik::translate('General_ProductConversionRate'), + ); + } + + /** + * This function executes when the 'Goals.getReportsWithGoalMetrics' event fires. It + * adds the 'visits to conversion' report metadata to the list of goal reports so + * this report will be displayed. + */ + public function getActualReportsWithGoalMetrics(&$dimensions) + { + $reportWithGoalMetrics = array( + array('category' => Piwik::translate('General_Visit'), + 'name' => Piwik::translate('Goals_VisitsUntilConv'), + 'module' => 'Goals', + 'action' => 'getVisitsUntilConversion', + 'viewDataTable' => 'table', + ), + array('category' => Piwik::translate('General_Visit'), + 'name' => Piwik::translate('Goals_DaysToConv'), + 'module' => 'Goals', + 'action' => 'getDaysToConversion', + 'viewDataTable' => 'table', + ) + ); + $dimensions = array_merge($dimensions, $reportWithGoalMetrics); + } + + public function getSegmentsMetadata(&$segments) + { + $segments[] = array( + 'type' => 'dimension', + 'category' => Piwik::translate('General_Visit'), + 'name' => 'General_VisitConvertedGoalId', + 'segment' => 'visitConvertedGoalId', + 'sqlSegment' => 'log_conversion.idgoal', + 'acceptedValues' => '1, 2, 3, etc.', + ); + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/Goals/javascripts/goalsForm.js"; + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/Goals/stylesheets/goals.css"; + } + + public function fetchGoalsFromDb(&$array, $idSite) + { + // add the 'goal' entry in the website array + $array['goals'] = API::getInstance()->getGoals($idSite); + } + + public function addWidgets() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + + // Ecommerce widgets + $site = new Site($idSite); + if ($site->isEcommerceEnabled()) { + WidgetsList::add('Goals_Ecommerce', 'Goals_EcommerceOverview', 'Goals', 'widgetGoalReport', array('idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER)); + WidgetsList::add('Goals_Ecommerce', 'Goals_EcommerceLog', 'Goals', 'getEcommerceLog'); + foreach ($this->ecommerceReports as $widget) { + WidgetsList::add('Goals_Ecommerce', $widget[0], $widget[1], $widget[2]); + } + } + + // Goals widgets + WidgetsList::add('Goals_Goals', 'Goals_GoalsOverview', 'Goals', 'widgetGoalsOverview'); + $goals = API::getInstance()->getGoals($idSite); + if (count($goals) > 0) { + foreach ($goals as $goal) { + WidgetsList::add('Goals_Goals', Common::sanitizeInputValue($goal['name']), 'Goals', 'widgetGoalReport', array('idGoal' => $goal['idgoal'])); + } + } + } + + function addMenus() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + $goals = API::getInstance()->getGoals($idSite); + $mainGoalMenu = $this->getGoalCategoryName($idSite); + $site = new Site($idSite); + if (count($goals) == 0) { + MenuMain::getInstance()->add($mainGoalMenu, '', array( + 'module' => 'Goals', + 'action' => ($site->isEcommerceEnabled() ? 'ecommerceReport' : 'addNewGoal'), + 'idGoal' => ($site->isEcommerceEnabled() ? Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER : null)), + true, + 25); + if ($site->isEcommerceEnabled()) { + MenuMain::getInstance()->add($mainGoalMenu, 'Goals_Ecommerce', array('module' => 'Goals', 'action' => 'ecommerceReport', 'idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER), true, 1); + } + MenuMain::getInstance()->add($mainGoalMenu, 'Goals_AddNewGoal', array('module' => 'Goals', 'action' => 'addNewGoal')); + } else { + MenuMain::getInstance()->add($mainGoalMenu, '', array( + 'module' => 'Goals', + 'action' => ($site->isEcommerceEnabled() ? 'ecommerceReport' : 'index'), + 'idGoal' => ($site->isEcommerceEnabled() ? Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER : null)), + true, + 25); + + if ($site->isEcommerceEnabled()) { + MenuMain::getInstance()->add($mainGoalMenu, 'Goals_Ecommerce', array('module' => 'Goals', 'action' => 'ecommerceReport', 'idGoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER), true, 1); + } + MenuMain::getInstance()->add($mainGoalMenu, 'Goals_GoalsOverview', array('module' => 'Goals', 'action' => 'index'), true, 2); + foreach ($goals as $goal) { + MenuMain::getInstance()->add($mainGoalMenu, str_replace('%', '%%', Translate::clean($goal['name'])), array('module' => 'Goals', 'action' => 'goalReport', 'idGoal' => $goal['idgoal'])); + } + } + } + + protected function getGoalCategoryName($idSite) + { + $site = new Site($idSite); + return $site->isEcommerceEnabled() ? 'Goals_EcommerceAndGoalsMenu' : 'Goals_Goals'; + } + + public function configureViewDataTable(ViewDataTable $view) + { + switch ($view->requestConfig->apiMethodToRequestDataTable) { + case 'Goals.getItemsSku': + $this->configureViewForGetItemsSku($view); + break; + case 'Goals.getItemsName': + $this->configureViewForGetItemsName($view); + break; + case 'Goals.getItemsCategory': + $this->configureViewForGetItemsCategory($view); + break; + case 'Goals.getVisitsUntilConversion': + $this->configureViewForGetVisitsUntilConversion($view); + break; + case 'Goals.getDaysToConversion': + $this->configureViewForGetDaysToConversion($view); + break; + } + } + + private function configureViewForGetItemsSku(ViewDataTable $view) + { + return $this->configureViewForItemsReport($view, Piwik::translate('Goals_ProductSKU')); + } + + private function configureViewForGetItemsName(ViewDataTable $view) + { + return $this->configureViewForItemsReport($view, Piwik::translate('Goals_ProductName')); + } + + private function configureViewForGetItemsCategory(ViewDataTable $view) + { + return $this->configureViewForItemsReport($view, Piwik::translate('Goals_ProductCategory')); + } + + private function configureViewForGetVisitsUntilConversion(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->show_table_all_columns = false; + $view->config->columns_to_display = array('label', 'nb_conversions'); + $view->config->show_offset_information = false; + $view->config->show_pagination_control = false; + $view->config->show_all_views_icons = false; + + $view->requestConfig->filter_sort_column = 'label'; + $view->requestConfig->filter_sort_order = 'asc'; + $view->requestConfig->filter_limit = count(Archiver::$visitCountRanges); + + $view->config->addTranslations(array( + 'label' => Piwik::translate('Goals_VisitsUntilConv'), + 'nb_conversions' => Piwik::translate('Goals_ColumnConversions'), + )); + } + + private function configureViewForGetDaysToConversion(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->show_table_all_columns = false; + $view->config->show_all_views_icons = false; + $view->config->show_offset_information = false; + $view->config->show_pagination_control = false; + $view->config->columns_to_display = array('label', 'nb_conversions'); + + $view->requestConfig->filter_sort_column = 'label'; + $view->requestConfig->filter_sort_order = 'asc'; + $view->requestConfig->filter_limit = count(Archiver::$daysToConvRanges); + + $view->config->addTranslations(array( + 'label' => Piwik::translate('Goals_DaysToConv'), + 'nb_conversions' => Piwik::translate('Goals_ColumnConversions'), + )); + } + + private function configureViewForItemsReport(ViewDataTable $view, $label) + { + $idSite = Common::getRequestVar('idSite'); + + $moneyColumns = array('revenue', 'avg_price'); + $prettifyMoneyColumns = array( + 'ColumnCallbackReplace', array($moneyColumns, '\Piwik\MetricsFormatter::getPrettyMoney', array($idSite))); + + $view->config->show_ecommerce = true; + $view->config->show_table = false; + $view->config->show_all_views_icons = false; + $view->config->show_exclude_low_population = false; + $view->config->show_table_all_columns = false; + $view->config->addTranslation('label', $label); + $view->config->filters[] = $prettifyMoneyColumns; + + $view->requestConfig->filter_limit = 10; + $view->requestConfig->filter_sort_column = 'revenue'; + $view->requestConfig->filter_sort_order = 'desc'; + + // set columns/translations which differ based on viewDataTable TODO: shouldn't have to do this check... amount of reports should be dynamic, but metadata should be static + $columns = Goals::getProductReportColumns(); + + $abandonedCart = Common::getRequestVar('viewDataTable', 'ecommerceOrder', 'string') == 'ecommerceAbandonedCart'; + if ($abandonedCart) { + $columns['abandoned_carts'] = Piwik::translate('General_AbandonedCarts'); + $columns['revenue'] = Piwik::translate('Goals_LeftInCart', Piwik::translate('General_ProductRevenue')); + $columns['quantity'] = Piwik::translate('Goals_LeftInCart', Piwik::translate('General_Quantity')); + $columns['avg_quantity'] = Piwik::translate('Goals_LeftInCart', Piwik::translate('General_AverageQuantity')); + unset($columns['orders']); + unset($columns['conversion_rate']); + + $view->requestConfig->request_parameters_to_modify['abandonedCarts'] = '1'; + } + + $translations = array_merge(array('label' => $label), $columns); + + $view->config->addTranslations($translations); + $view->config->columns_to_display = array_keys($translations); + + // set metrics documentation in normal ecommerce report + if (!$abandonedCart) { + $view->config->metrics_documentation = array( + 'revenue' => Piwik::translate('Goals_ColumnRevenueDocumentation', + Piwik::translate('Goals_DocumentationRevenueGeneratedByProductSales')), + 'quantity' => Piwik::translate('Goals_ColumnQuantityDocumentation', $label), + 'orders' => Piwik::translate('Goals_ColumnOrdersDocumentation', $label), + 'avg_price' => Piwik::translate('Goals_ColumnAveragePriceDocumentation', $label), + 'avg_quantity' => Piwik::translate('Goals_ColumnAverageQuantityDocumentation', $label), + 'nb_visits' => Piwik::translate('Goals_ColumnVisitsProductDocumentation', $label), + 'conversion_rate' => Piwik::translate('Goals_ColumnConversionRateProductDocumentation', $label), + ); + } + + $view->config->custom_parameters['viewDataTable'] = + $abandonedCart ? Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART : Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER; + } + + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = 'Goals_AddGoal'; + $translationKeys[] = 'Goals_UpdateGoal'; + $translationKeys[] = 'Goals_DeleteGoalConfirm'; + } +} diff --git a/www/analytics/plugins/Goals/Visualizations/Goals.php b/www/analytics/plugins/Goals/Visualizations/Goals.php new file mode 100644 index 00000000..bee7c892 --- /dev/null +++ b/www/analytics/plugins/Goals/Visualizations/Goals.php @@ -0,0 +1,258 @@ +config->disable_subtable_when_show_goals) { + $this->config->subtable_controller_action = null; + } + + $this->setShowGoalsColumnsProperties(); + } + + public function beforeRender() + { + $this->config->show_goals = true; + $this->config->show_goals_columns = true; + $this->config->datatable_css_class = 'dataTableVizGoals'; + $this->config->show_exclude_low_population = true; + + $this->config->translations += array( + 'nb_conversions' => Piwik::translate('Goals_ColumnConversions'), + 'conversion_rate' => Piwik::translate('General_ColumnConversionRate'), + 'revenue' => Piwik::translate('General_ColumnRevenue'), + 'revenue_per_visit' => Piwik::translate('General_ColumnValuePerVisit'), + ); + + $this->config->metrics_documentation['nb_visits'] = Piwik::translate('Goals_ColumnVisits'); + + if (1 == Common::getRequestVar('documentationForGoalsPage', 0, 'int')) { + // TODO: should not use query parameter + $this->config->documentation = Piwik::translate('Goals_ConversionByTypeReportDocumentation', + array('
          ', '
          ', '', '')); + } + + parent::beforeRender(); + } + + private function setShowGoalsColumnsProperties() + { + // set view properties based on goal requested + $idSite = Common::getRequestVar('idSite', null, 'int'); + $idGoal = Common::getRequestVar('idGoal', AddColumnsProcessedMetricsGoal::GOALS_OVERVIEW, 'string'); + + if (Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER == $idGoal) { + $this->setPropertiesForEcommerceView(); + } else if (AddColumnsProcessedMetricsGoal::GOALS_FULL_TABLE == $idGoal) { + $this->setPropertiesForGoals($idSite, 'all'); + } else if (AddColumnsProcessedMetricsGoal::GOALS_OVERVIEW == $idGoal) { + $this->setPropertiesForGoalsOverview($idSite); + } else { + $this->setPropertiesForGoals($idSite, array($idGoal)); + } + + // add goals columns + $this->config->filters[] = array('AddColumnsProcessedMetricsGoal', array($ignore = true, $idGoal), $priority = true); + + // prettify columns + $setRatePercent = function ($rate, $thang = false) { + return $rate == 0 ? "0%" : $rate; + }; + + foreach ($this->config->columns_to_display as $columnName) { + if (false !== strpos($columnName, 'conversion_rate')) { + $this->config->filters[] = array('ColumnCallbackReplace', array($columnName, $setRatePercent)); + } + } + + $formatPercent = function ($value) use ($idSite) { + return MetricsFormatter::getPrettyMoney(sprintf("%.1f", $value), $idSite); + }; + + foreach ($this->config->columns_to_display as $columnName) { + if ($this->isRevenueColumn($columnName)) { + $this->config->filters[] = array('ColumnCallbackReplace', array($columnName, $formatPercent)); + } + } + + // this ensures that the value is set to zero for all rows where the value was not set (no conversion) + $identityFunction = function ($value) { + return $value; + }; + + foreach ($this->config->columns_to_display as $columnName) { + if (!$this->isRevenueColumn($columnName)) { + $this->config->filters[] = array('ColumnCallbackReplace', array($columnName, $identityFunction)); + } + } + } + + private function setPropertiesForEcommerceView() + { + $this->requestConfig->filter_sort_column = 'goal_ecommerceOrder_revenue'; + $this->requestConfig->filter_sort_order = 'desc'; + + $this->config->columns_to_display = array( + 'label', 'nb_visits', 'goal_ecommerceOrder_nb_conversions', 'goal_ecommerceOrder_revenue', + 'goal_ecommerceOrder_conversion_rate', 'goal_ecommerceOrder_avg_order_revenue', 'goal_ecommerceOrder_items', + 'goal_ecommerceOrder_revenue_per_visit' + ); + + $this->config->translations += array( + 'goal_ecommerceOrder_conversion_rate' => Piwik::translate('Goals_ConversionRate', Piwik::translate('Goals_EcommerceOrder')), + 'goal_ecommerceOrder_nb_conversions' => Piwik::translate('General_EcommerceOrders'), + 'goal_ecommerceOrder_revenue' => Piwik::translate('General_TotalRevenue'), + 'goal_ecommerceOrder_revenue_per_visit' => Piwik::translate('General_ColumnValuePerVisit'), + 'goal_ecommerceOrder_avg_order_revenue' => Piwik::translate('General_AverageOrderValue'), + 'goal_ecommerceOrder_items' => Piwik::translate('General_PurchasedProducts') + ); + + $goalName = Piwik::translate('General_EcommerceOrders'); + $this->config->metrics_documentation += array( + 'goal_ecommerceOrder_conversion_rate' => Piwik::translate('Goals_ColumnConversionRateDocumentation', $goalName), + 'goal_ecommerceOrder_nb_conversions' => Piwik::translate('Goals_ColumnConversionsDocumentation', $goalName), + 'goal_ecommerceOrder_revenue' => Piwik::translate('Goals_ColumnRevenueDocumentation', $goalName), + 'goal_ecommerceOrder_revenue_per_visit' => Piwik::translate('Goals_ColumnAverageOrderRevenueDocumentation', $goalName), + 'goal_ecommerceOrder_avg_order_revenue' => Piwik::translate('Goals_ColumnAverageOrderRevenueDocumentation', $goalName), + 'goal_ecommerceOrder_items' => Piwik::translate('Goals_ColumnPurchasedProductsDocumentation', $goalName), + 'revenue_per_visit' => Piwik::translate('Goals_ColumnRevenuePerVisitDocumentation', $goalName) + ); + } + + private function setPropertiesForGoalsOverview($idSite) + { + $allGoals = $this->getGoals($idSite); + + // set view properties + $this->config->columns_to_display = array('label', 'nb_visits'); + + foreach ($allGoals as $goal) { + $column = "goal_{$goal['idgoal']}_conversion_rate"; + $documentation = Piwik::translate('Goals_ColumnConversionRateDocumentation', $goal['quoted_name'] ? : $goal['name']); + + $this->config->columns_to_display[] = $column; + $this->config->translations[$column] = Piwik::translate('Goals_ConversionRate', $goal['name']); + $this->config->metrics_documentation[$column] = $documentation; + } + + $this->config->columns_to_display[] = 'revenue_per_visit'; + $this->config->metrics_documentation['revenue_per_visit'] = + Piwik::translate('Goals_ColumnRevenuePerVisitDocumentation', Piwik::translate('Goals_EcommerceAndGoalsMenu')); + } + + private function setPropertiesForGoals($idSite, $idGoals) + { + $allGoals = $this->getGoals($idSite); + + if ('all' == $idGoals) { + $idGoals = array_keys($allGoals); + } else { + // only sort by a goal's conversions if not showing all goals (for FULL_REPORT) + $this->requestConfig->filter_sort_column = 'goal_' . reset($idGoals) . '_nb_conversions'; + $this->requestConfig->filter_sort_order = 'desc'; + } + + $this->config->columns_to_display = array('label', 'nb_visits'); + + $goalColumnTemplates = array( + 'goal_%s_nb_conversions', + 'goal_%s_conversion_rate', + 'goal_%s_revenue', + 'goal_%s_revenue_per_visit', + ); + + // set columns to display (columns of same type but different goals will be next to each other, + // ie, goal_0_nb_conversions, goal_1_nb_conversions, etc.) + foreach ($goalColumnTemplates as $idx => $columnTemplate) { + foreach ($idGoals as $idGoal) { + $this->config->columns_to_display[] = sprintf($columnTemplate, $idGoal); + } + } + + // set translations & metric docs for goal specific metrics + foreach ($idGoals as $idGoal) { + $goalName = $allGoals[$idGoal]['name']; + $quotedGoalName = $allGoals[$idGoal]['quoted_name'] ? : $goalName; + + $this->config->translations += array( + 'goal_' . $idGoal . '_nb_conversions' => Piwik::translate('Goals_Conversions', $goalName), + 'goal_' . $idGoal . '_conversion_rate' => Piwik::translate('Goals_ConversionRate', $goalName), + 'goal_' . $idGoal . '_revenue' => + Piwik::translate('%s ' . Piwik::translate('General_ColumnRevenue'), $goalName), + 'goal_' . $idGoal . '_revenue_per_visit' => + Piwik::translate('%s ' . Piwik::translate('General_ColumnValuePerVisit'), $goalName), + ); + + $this->config->metrics_documentation += array( + 'goal_' . $idGoal . '_nb_conversions' => Piwik::translate('Goals_ColumnConversionsDocumentation', $quotedGoalName), + 'goal_' . $idGoal . '_conversion_rate' => Piwik::translate('Goals_ColumnConversionRateDocumentation', $quotedGoalName), + 'goal_' . $idGoal . '_revenue' => Piwik::translate('Goals_ColumnRevenueDocumentation', $quotedGoalName), + 'goal_' . $idGoal . '_revenue_per_visit' => + Piwik::translate('Goals_ColumnRevenuePerVisitDocumentation', Piwik::translate('Goals_EcommerceAndGoalsMenu')), + ); + } + + $this->config->columns_to_display[] = 'revenue_per_visit'; + } + + private function getGoals($idSite) + { + // get all goals to display info for + $allGoals = array(); + + // add the ecommerce goal if ecommerce is enabled for the site + if (Site::isEcommerceEnabledFor($idSite)) { + $ecommerceGoal = array( + 'idgoal' => Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER, + 'name' => Piwik::translate('Goals_EcommerceOrder'), + 'quoted_name' => false + ); + $allGoals[$ecommerceGoal['idgoal']] = $ecommerceGoal; + } + + // add the site's goals (and escape all goal names) + $siteGoals = APIGoals::getInstance()->getGoals($idSite); + + foreach ($siteGoals as &$goal) { + $goal['name'] = Common::sanitizeInputValue($goal['name']); + + $goal['quoted_name'] = '"' . $goal['name'] . '"'; + $allGoals[$goal['idgoal']] = $goal; + } + + return $allGoals; + } + + private function isRevenueColumn($name) + { + return strpos($name, '_revenue') !== false || $name == 'revenue_per_visit'; + } +} diff --git a/www/analytics/plugins/Goals/javascripts/goalsForm.js b/www/analytics/plugins/Goals/javascripts/goalsForm.js new file mode 100644 index 00000000..a3bc6431 --- /dev/null +++ b/www/analytics/plugins/Goals/javascripts/goalsForm.js @@ -0,0 +1,173 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +function showAddNewGoal() { + hideForms(); + $(".entityAddContainer").show(); + showCancel(); + piwikHelper.lazyScrollTo(".entityContainer", 400); + return false; +} + +function showEditGoals() { + hideForms(); + $("#entityEditContainer").show(); + showCancel(); + piwikHelper.lazyScrollTo(".entityContainer", 400); + return false; +} + +function hideForms() { + $(".entityAddContainer").hide(); + $("#entityEditContainer").hide(); +} + +function showCancel() { + $(".entityCancel").show(); + $('.entityCancelLink').click(function () { + hideForms(); + $(".entityCancel").hide(); + }); +} + +// init the goal form with existing goal value, if any +function initGoalForm(goalMethodAPI, submitText, goalName, matchAttribute, pattern, patternType, caseSensitive, revenue, allowMultiple, goalId) { + $('#goal_name').val(goalName); + if (matchAttribute == 'manually') { + $('select[name=trigger_type] option[value=manually]').prop('selected', true); + $('input[name=match_attribute]').prop('disabled', true); + $('#match_attribute_section').hide(); + $('#manual_trigger_section').show(); + matchAttribute = 'url'; + } else { + $('select[name=trigger_type] option[value=visitors]').prop('selected', true); + } + $('input[name=match_attribute][value=' + matchAttribute + ']').prop('checked', true); + $('input[name=allow_multiple][value=' + allowMultiple + ']').prop('checked', true); + $('#match_attribute_name').html(mappingMatchTypeName[matchAttribute]); + $('#examples_pattern').html(mappingMatchTypeExamples[matchAttribute]); + $('select[name=pattern_type] option[value=' + patternType + ']').prop('selected', true); + $('input[name=pattern]').val(pattern); + $('#case_sensitive').prop('checked', caseSensitive); + $('input[name=revenue]').val(revenue); + $('input[name=methodGoalAPI]').val(goalMethodAPI); + $('#goal_submit').val(submitText); + if (goalId != undefined) { + $('input[name=goalIdUpdate]').val(goalId); + } +} + + +function bindGoalForm() { + $('select[name=trigger_type]').click(function () { + var triggerTypeId = $(this).val(); + if (triggerTypeId == "manually") { + $('input[name=match_attribute]').prop('disabled', true); + $('#match_attribute_section').hide(); + $('#manual_trigger_section').show(); + } else { + $('input[name=match_attribute]').removeProp('disabled'); + $('#match_attribute_section').show(); + $('#manual_trigger_section').hide(); + } + }); + + $('input[name=match_attribute]').click(function () { + var matchTypeId = $(this).val(); + $('#match_attribute_name').html(mappingMatchTypeName[matchTypeId]); + $('#examples_pattern').html(mappingMatchTypeExamples[matchTypeId]); + }); + + $('#goal_submit').click(function () { + // prepare ajax query to API to add goal + ajaxAddGoal(); + return false; + }); + + $('a[name=linkAddNewGoal]').click(function () { + initAndShowAddGoalForm(); + piwikHelper.lazyScrollTo('#goal_name'); + }); +} + +function ajaxDeleteGoal(idGoal) { + piwikHelper.lazyScrollTo(".entityContainer", 400); + + var parameters = {}; + parameters.format = 'json'; + parameters.idGoal = idGoal; + parameters.module = 'API'; + parameters.method = 'Goals.deleteGoal'; + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(parameters, 'get'); + ajaxRequest.setLoadingElement('#goalAjaxLoading'); + ajaxRequest.setCallback(function () { location.reload(); }); + ajaxRequest.send(true); +} + +function ajaxAddGoal() { + piwikHelper.lazyScrollTo(".entityContainer", 400); + + var parameters = {}; + parameters.name = encodeURIComponent($('#goal_name').val()); + + if ($('[name=trigger_type]').val() == 'manually') { + parameters.matchAttribute = 'manually'; + parameters.patternType = 'regex'; + parameters.pattern = '.*'; + parameters.caseSensitive = 0; + } else { + parameters.matchAttribute = $('input[name=match_attribute]:checked').val(); + parameters.patternType = $('[name=pattern_type]').val(); + parameters.pattern = encodeURIComponent($('input[name=pattern]').val()); + parameters.caseSensitive = $('#case_sensitive').prop('checked') == true ? 1 : 0; + } + parameters.revenue = $('input[name=revenue]').val(); + parameters.allowMultipleConversionsPerVisit = $('input[name=allow_multiple]:checked').val() == true ? 1 : 0; + + parameters.idGoal = $('input[name=goalIdUpdate]').val(); + parameters.format = 'json'; + parameters.module = 'API'; + parameters.method = $('input[name=methodGoalAPI]').val(); + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(parameters, 'get'); + ajaxRequest.setLoadingElement('#goalAjaxLoading'); + ajaxRequest.setCallback(function () { location.reload(); }); + ajaxRequest.send(true); +} + +function bindListGoalEdit() { + $('a[name=linkEditGoal]').click(function () { + var goalId = $(this).attr('id'); + var goal = piwik.goals[goalId]; + initGoalForm("Goals.updateGoal", _pk_translate('Goals_UpdateGoal'), goal.name, goal.match_attribute, goal.pattern, goal.pattern_type, (goal.case_sensitive != '0'), goal.revenue, goal.allow_multiple, goalId); + showAddNewGoal(); + return false; + }); + + $('a[name=linkDeleteGoal]').click(function () { + var goalId = $(this).attr('id'); + var goal = piwik.goals[goalId]; + + $('#confirm').find('h2').text(sprintf(_pk_translate('Goals_DeleteGoalConfirm'), '"' + goal.name + '"')); + piwikHelper.modalConfirm('#confirm', {yes: function () { + ajaxDeleteGoal(goalId); + }}); + return false; + }); + + $('a[name=linkEditGoals]').click(function () { + return showEditGoals(); + }); +} + +function initAndShowAddGoalForm() { + initGoalForm('Goals.addGoal', _pk_translate('Goals_AddGoal'), '', 'url', '', 'contains', /*caseSensitive = */false, /*allowMultiple = */'0', '0'); + return showAddNewGoal(); +} diff --git a/www/analytics/plugins/Goals/stylesheets/goals.css b/www/analytics/plugins/Goals/stylesheets/goals.css new file mode 100644 index 00000000..52602215 --- /dev/null +++ b/www/analytics/plugins/Goals/stylesheets/goals.css @@ -0,0 +1,27 @@ +.goalTopElement { + border-bottom: 1px dotted; +} + +.goalEntry { + margin: 0 0 20px 0; + padding: 0 0 10px 0; + border-bottom: 1px solid #7e7363; + width: 614px; +} + +/* dimension selector */ +#titleGoalsByDimension { + padding-top: 30px; +} + +ul.ulGoalTopElements { + list-style-type: circle; + margin-left: 30px; +} + +.ulGoalTopElements a { + text-decoration: none; + color: #0033CC; + border-bottom: 1px dotted #0033CC; + line-height: 2em; +} diff --git a/www/analytics/plugins/Goals/templates/_addEditGoal.twig b/www/analytics/plugins/Goals/templates/_addEditGoal.twig new file mode 100644 index 00000000..c8feb87b --- /dev/null +++ b/www/analytics/plugins/Goals/templates/_addEditGoal.twig @@ -0,0 +1,81 @@ +{% if onlyShowAddNewGoal is defined %} +

          {{ 'Goals_AddNewGoal'|translate }}

          +

          {{ 'Goals_NewGoalIntro'|translate }}

          +

          {{ 'Goals_NewGoalDescription'|translate }} + {{ 'Goals_NewWhatDoYouWantUsersToDo'|translate }} + {{ 'Goals_NewGoalYouWillBeAbleTo'|translate }}

          +

          {{ 'Goals_LearnMoreAboutGoalTrackingDocumentation'|translate("","")|raw }} +

          +{% else %} +
          +

          {{ 'Goals_GoalsManagement'|translate }}

          +
          + +
          +
          +{% endif %} + +{% import 'ajaxMacros.twig' as ajax %} +{{ ajax.errorDiv() }} +{{ ajax.loadingDiv('goalAjaxLoading') }} + +
          + {% if onlyShowAddNewGoal is not defined %} + {% include "@Goals/_listGoalEdit.twig" %} + {% endif %} + {% include "@Goals/_formAddGoal.twig" %} + {% if onlyShowAddNewGoal is not defined %} + + {% endif %} + +
          +

          + diff --git a/www/analytics/plugins/Goals/templates/_formAddGoal.twig b/www/analytics/plugins/Goals/templates/_formAddGoal.twig new file mode 100644 index 00000000..e3c94eb8 --- /dev/null +++ b/www/analytics/plugins/Goals/templates/_formAddGoal.twig @@ -0,0 +1,97 @@ + diff --git a/www/analytics/plugins/Goals/templates/_listGoalEdit.twig b/www/analytics/plugins/Goals/templates/_listGoalEdit.twig new file mode 100644 index 00000000..0d9444dc --- /dev/null +++ b/www/analytics/plugins/Goals/templates/_listGoalEdit.twig @@ -0,0 +1,64 @@ + + +
          +

          + + +
          + + diff --git a/www/analytics/plugins/Goals/templates/_listTopDimension.twig b/www/analytics/plugins/Goals/templates/_listTopDimension.twig new file mode 100644 index 00000000..8de4594f --- /dev/null +++ b/www/analytics/plugins/Goals/templates/_listTopDimension.twig @@ -0,0 +1,16 @@ +{% for element in topDimension %} + {% set goal_nb_conversion=element.nb_conversions %} + {% set goal_conversion_rate=element.conversion_rate %} + "~goal_nb_conversion~"
          ")|raw }}, + {{ 'Goals_ConversionRate'|translate(""~goal_conversion_rate~"")|raw }}'> + {{ element.name }} + + + {% import 'macros.twig' as piwik %} + {{ piwik.logoHtml(element.metadata, element.name) }} + {% if loop.index == loop.length-1 %} + and + {% elseif loop.index < loop.length-1 %} + , + {% endif %} +{% endfor %} diff --git a/www/analytics/plugins/Goals/templates/_titleAndEvolutionGraph.twig b/www/analytics/plugins/Goals/templates/_titleAndEvolutionGraph.twig new file mode 100644 index 00000000..d3a1d304 --- /dev/null +++ b/www/analytics/plugins/Goals/templates/_titleAndEvolutionGraph.twig @@ -0,0 +1,81 @@ + + +{% if displayFullReport %} +

          {% if goalName is defined %}{{ 'Goals_GoalX'|translate(goalName)|raw }}{% else %}{{ 'Goals_GoalsOverview'|translate }}{% endif %}

          +{% endif %} +{{ graphEvolution|raw }} + +
          +
          {{ sparkline(urlSparklineConversions) }} + {% if ecommerce is defined %} + {{ nb_conversions }} + {{ 'General_EcommerceOrders'|translate }} + + {% else %} + {{ 'Goals_Conversions'|translate(""~nb_conversions~"")|raw }} + {% endif %} + {% if goalAllowMultipleConversionsPerVisit is defined and goalAllowMultipleConversionsPerVisit %} + ({{ 'General_NVisits'|translate(""~nb_visits_converted~"")|raw }}) + {% endif %} +
          + {% if revenue != 0 or ecommerce is defined %} +
          + {{ sparkline(urlSparklineRevenue) }} + {% set revenue=revenue|money(idSite) %} + {% if ecommerce is defined %} + {{ revenue|raw }} {{ 'General_TotalRevenue'|translate }} + {% else %} + {{ 'Goals_OverallRevenue'|translate(""~revenue~"")|raw }} + {% endif %} +
          + {% endif %} + {% if ecommerce is defined %} +
          {{ sparkline(urlSparklineAverageOrderValue) }} + {{ avg_order_revenue|money(idSite)|raw }} + {{ 'General_AverageOrderValue'|translate }} +
          + {% endif %} + +
          +
          +
          {{ sparkline(urlSparklineConversionRate) }} + {% if ecommerce is defined %} + {% set ecommerceOrdersText %}{{ 'General_EcommerceOrders'|translate }}{% endset %} + {{ 'Goals_ConversionRate'|translate(""~conversion_rate~" "~ecommerceOrdersText)|raw }} + {% else %} + {{ 'Goals_OverallConversionRate'|translate(""~conversion_rate~"")|raw }} + {% endif %} +
          + {% if ecommerce is defined %} +
          {{ sparkline(urlSparklinePurchasedProducts) }} + {{ items }} {{ 'General_PurchasedProducts'|translate }}
          + {% endif %} +
          +{% if ecommerce is defined %} +
          +
          + {{ 'General_AbandonedCarts'|translate }} +
          + +
          + {{ sparkline(cart_urlSparklineConversions) }} + {% set ecommerceAbandonedCartsText %}{{ 'Goals_AbandonedCart'|translate }}{% endset %} + {{ cart_nb_conversions }} {{ 'General_VisitsWith'|translate(ecommerceAbandonedCartsText) }} +
          + +
          + {{ sparkline(cart_urlSparklineRevenue) }} + {% set revenue %}{{ cart_revenue|money(idSite)|raw }}{% endset %} + {% set revenueText %}{{ 'General_ColumnRevenue'|translate }}{% endset %} + {{ revenue }} {{ 'Goals_LeftInCart'|translate(revenueText) }} +
          + +
          + {{ sparkline(cart_urlSparklineConversionRate) }} + {{ cart_conversion_rate }} + {{ 'General_VisitsWith'|translate(ecommerceAbandonedCartsText) }} +
          +
          +{% endif %} +{% include "_sparklineFooter.twig" %} + diff --git a/www/analytics/plugins/Goals/templates/addNewGoal.twig b/www/analytics/plugins/Goals/templates/addNewGoal.twig new file mode 100644 index 00000000..e4cbaea7 --- /dev/null +++ b/www/analytics/plugins/Goals/templates/addNewGoal.twig @@ -0,0 +1,11 @@ +{% if userCanEditGoals %} + {% include "@Goals/_addEditGoal.twig" %} +{% else %} +

          {{ 'Goals_CreateNewGOal'|translate }}

          +

          + {{ 'Goals_NoGoalsNeedAccess'|translate|raw }} +

          +

          + {{ 'Goals_LearnMoreAboutGoalTrackingDocumentation'|translate("","")|raw }} +

          +{% endif %} diff --git a/www/analytics/plugins/Goals/templates/getGoalReportView.twig b/www/analytics/plugins/Goals/templates/getGoalReportView.twig new file mode 100644 index 00000000..55f7f074 --- /dev/null +++ b/www/analytics/plugins/Goals/templates/getGoalReportView.twig @@ -0,0 +1,66 @@ + +{% include "@Goals/_titleAndEvolutionGraph.twig" | raw %} + +
          +{% if nb_conversions > 0 %} +

          {{ 'Goals_ConversionsOverview'|translate }}

          +
            + {% if ecommerce is not defined %} + {% if topDimensions.country is defined %} +
          • {{ 'Goals_BestCountries'|translate }} {% include '@Goals/_listTopDimension.twig' with {'topDimension':topDimensions.country} %}
          • + {% endif %} + {% if topDimensions.keyword is defined and topDimensions.keyword|length > 0 %} +
          • {{ 'Goals_BestKeywords'|translate }} {% include '@Goals/_listTopDimension.twig' with {'topDimension':topDimensions.keyword} %}
          • + {% endif %} + {% if topDimensions.website is defined and topDimensions.website|length > 0 %} +
          • {{ 'Goals_BestReferrers'|translate }} {% include '@Goals/_listTopDimension.twig' with {'topDimension':topDimensions.website} %}
          • + {% endif %} +
          • + {{ 'Goals_ReturningVisitorsConversionRateIs'|translate(""~conversion_rate_returning~"")|raw }} + , {{ 'Goals_NewVisitorsConversionRateIs'|translate(""~conversion_rate_new~"")|raw }} +
          • + {% else %} +
          • + {{ 'General_ColumnRevenue'|translate }}: {{ revenue|money(idSite)|raw -}} + {% if revenue_subtotal is not empty %}, + {{ 'General_Subtotal'|translate }}: {{ revenue_subtotal|money(idSite)|raw -}} + {% endif %} + {%- if revenue_tax is not empty -%}, + {{ 'General_Tax'|translate }}: {{ revenue_tax|money(idSite)|raw -}} + {% endif %} + {%- if revenue_shipping is not empty -%}, + {{ 'General_Shipping'|translate }}: {{ revenue_shipping|money(idSite)|raw -}} + {% endif %} + {%- if revenue_discount is not empty -%}, + {{ 'General_Discount'|translate }}: {{ revenue_discount|money(idSite)|raw -}} + {% endif %} +
          • + {% endif %} +
          +{% endif %} + + + +{% if displayFullReport %} + {% if nb_conversions > 0 or cart_nb_conversions is defined %} +

          + {% if idGoal is defined %} + {{ 'Goals_GoalConversionsBy'|translate(goalName)|raw }} + {% else %} + {{ 'Goals_ConversionsOverviewBy'|translate }} + {% endif %} +

          + {{ goalReportsByDimension|raw }} + {% endif %} +{% endif %} diff --git a/www/analytics/plugins/Goals/templates/getOverviewView.twig b/www/analytics/plugins/Goals/templates/getOverviewView.twig new file mode 100644 index 00000000..9217aa69 --- /dev/null +++ b/www/analytics/plugins/Goals/templates/getOverviewView.twig @@ -0,0 +1,50 @@ + + +{% include "@Goals/_titleAndEvolutionGraph.twig" %} +{% set sum_nb_conversions=nb_conversions %} + +{% for goal in goalMetrics %} + {% set nb_conversions=goal.nb_conversions %} + {% set nb_visits_converted=goal.nb_visits_converted %} + {% set conversion_rate=goal.conversion_rate %} + {% set name=goal.name %} +
          +

          + + {{ 'Goals_GoalX'|translate("'"~name~"'")|raw }} + +

          + +
          +
          {{ sparkline(goal.urlSparklineConversions) }} + {{ 'Goals_Conversions'|translate(""~nb_conversions~"")|raw }} + {% if goal.goalAllowMultipleConversionsPerVisit %} + ({{ 'General_NVisits'|translate(""~nb_visits_converted~"") | raw }}) + {% endif %} +
          +
          +
          +
          {{ sparkline(goal.urlSparklineConversionRate) }} + {{ 'Goals_ConversionRate'|translate(""~conversion_rate~"")|raw }} +
          +
          +
          +
          +{% endfor %} + +{% if displayFullReport %} + {% if sum_nb_conversions != 0 %} +

          + {% if idGoal is defined %} + {{ 'Goals_GoalConversionsBy'|translate(goalName)|raw }} + {% else %} + {{ 'Goals_ConversionsOverviewBy'|translate }} + {% endif %} +

          + {{ goalReportsByDimension|raw }} + {% endif %} + + {% if userCanEditGoals %} + {% include "@Goals/_addEditGoal.twig" %} + {% endif %} +{% endif %} diff --git a/www/analytics/plugins/ImageGraph/API.php b/www/analytics/plugins/ImageGraph/API.php new file mode 100644 index 00000000..532c9ef5 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/API.php @@ -0,0 +1,548 @@ + + * - $graphType defines the type of graph plotted, accepted values are: 'evolution', 'verticalBar', 'pie' and '3dPie'
          + * - $colors accepts a comma delimited list of colors that will overwrite the default Piwik colors
          + * - you can also customize the width, height, font size, metric being plotted (in case the data contains multiple columns/metrics). + * + * See also How to embed static Image Graphs? for more information. + * + * @method static \Piwik\Plugins\ImageGraph\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + const FILENAME_KEY = 'filename'; + const TRUNCATE_KEY = 'truncate'; + const WIDTH_KEY = 'width'; + const HEIGHT_KEY = 'height'; + const MAX_WIDTH = 2048; + const MAX_HEIGHT = 2048; + + static private $DEFAULT_PARAMETERS = array( + StaticGraph::GRAPH_TYPE_BASIC_LINE => array( + self::FILENAME_KEY => 'BasicLine', + self::TRUNCATE_KEY => 6, + self::WIDTH_KEY => 1044, + self::HEIGHT_KEY => 290, + ), + StaticGraph::GRAPH_TYPE_VERTICAL_BAR => array( + self::FILENAME_KEY => 'BasicBar', + self::TRUNCATE_KEY => 6, + self::WIDTH_KEY => 1044, + self::HEIGHT_KEY => 290, + ), + StaticGraph::GRAPH_TYPE_HORIZONTAL_BAR => array( + self::FILENAME_KEY => 'HorizontalBar', + self::TRUNCATE_KEY => null, // horizontal bar graphs are dynamically truncated + self::WIDTH_KEY => 800, + self::HEIGHT_KEY => 290, + ), + StaticGraph::GRAPH_TYPE_3D_PIE => array( + self::FILENAME_KEY => '3DPie', + self::TRUNCATE_KEY => 5, + self::WIDTH_KEY => 1044, + self::HEIGHT_KEY => 290, + ), + StaticGraph::GRAPH_TYPE_BASIC_PIE => array( + self::FILENAME_KEY => 'BasicPie', + self::TRUNCATE_KEY => 5, + self::WIDTH_KEY => 1044, + self::HEIGHT_KEY => 290, + ), + ); + + static private $DEFAULT_GRAPH_TYPE_OVERRIDE = array( + 'UserSettings_getPlugin' => array( + false // override if !$isMultiplePeriod + => StaticGraph::GRAPH_TYPE_HORIZONTAL_BAR, + ), + 'Referrers_getReferrerType' => array( + false // override if !$isMultiplePeriod + => StaticGraph::GRAPH_TYPE_HORIZONTAL_BAR, + ), + ); + + const GRAPH_OUTPUT_INLINE = 0; + const GRAPH_OUTPUT_FILE = 1; + const GRAPH_OUTPUT_PHP = 2; + + const DEFAULT_ORDINATE_METRIC = 'nb_visits'; + const FONT_DIR = '/plugins/ImageGraph/fonts/'; + const DEFAULT_FONT = 'tahoma.ttf'; + const UNICODE_FONT = 'unifont.ttf'; + const DEFAULT_FONT_SIZE = 9; + const DEFAULT_LEGEND_FONT_SIZE_OFFSET = 2; + const DEFAULT_TEXT_COLOR = '222222'; + const DEFAULT_BACKGROUND_COLOR = 'FFFFFF'; + const DEFAULT_GRID_COLOR = 'CCCCCC'; + + // number of row evolutions to plot when no labels are specified, can be overridden using &filter_limit + const DEFAULT_NB_ROW_EVOLUTIONS = 5; + const MAX_NB_ROW_LABELS = 10; + + public function get( + $idSite, + $period, + $date, + $apiModule, + $apiAction, + $graphType = false, + $outputType = API::GRAPH_OUTPUT_INLINE, + $columns = false, + $labels = false, + $showLegend = true, + $width = false, + $height = false, + $fontSize = API::DEFAULT_FONT_SIZE, + $legendFontSize = false, + $aliasedGraph = true, + $idGoal = false, + $colors = false, + $textColor = API::DEFAULT_TEXT_COLOR, + $backgroundColor = API::DEFAULT_BACKGROUND_COLOR, + $gridColor = API::DEFAULT_GRID_COLOR, + $idSubtable = false, + $legendAppendMetric = true, + $segment = false + ) + { + Piwik::checkUserHasViewAccess($idSite); + + // Health check - should we also test for GD2 only? + if (!SettingsServer::isGdExtensionEnabled()) { + throw new Exception('Error: To create graphs in Piwik, please enable GD php extension (with Freetype support) in php.ini, + and restart your web server.'); + } + + $useUnicodeFont = array( + 'am', 'ar', 'el', 'fa', 'fi', 'he', 'ja', 'ka', 'ko', 'te', 'th', 'zh-cn', 'zh-tw', + ); + $languageLoaded = Translate::getLanguageLoaded(); + $font = self::getFontPath(self::DEFAULT_FONT); + if (in_array($languageLoaded, $useUnicodeFont)) { + $unicodeFontPath = self::getFontPath(self::UNICODE_FONT); + $font = file_exists($unicodeFontPath) ? $unicodeFontPath : $font; + } + + // save original GET to reset after processing. Important for API-in-API-call + $savedGET = $_GET; + + try { + $apiParameters = array(); + if (!empty($idGoal)) { + $apiParameters = array('idGoal' => $idGoal); + } + // Fetch the metadata for given api-action + $metadata = APIMetadata::getInstance()->getMetadata( + $idSite, $apiModule, $apiAction, $apiParameters, $languageLoaded, $period, $date, + $hideMetricsDoc = false, $showSubtableReports = true); + if (!$metadata) { + throw new Exception('Invalid API Module and/or API Action'); + } + + $metadata = $metadata[0]; + $reportHasDimension = !empty($metadata['dimension']); + $constantRowsCount = !empty($metadata['constantRowsCount']); + + $isMultiplePeriod = Period::isMultiplePeriod($date, $period); + if (!$reportHasDimension && !$isMultiplePeriod) { + throw new Exception('The graph cannot be drawn for this combination of \'date\' and \'period\' parameters.'); + } + + if (empty($legendFontSize)) { + $legendFontSize = (int)$fontSize + self::DEFAULT_LEGEND_FONT_SIZE_OFFSET; + } + + if (empty($graphType)) { + if ($isMultiplePeriod) { + $graphType = StaticGraph::GRAPH_TYPE_BASIC_LINE; + } else { + if ($constantRowsCount) { + $graphType = StaticGraph::GRAPH_TYPE_VERTICAL_BAR; + } else { + $graphType = StaticGraph::GRAPH_TYPE_HORIZONTAL_BAR; + } + } + + $reportUniqueId = $metadata['uniqueId']; + if (isset(self::$DEFAULT_GRAPH_TYPE_OVERRIDE[$reportUniqueId][$isMultiplePeriod])) { + $graphType = self::$DEFAULT_GRAPH_TYPE_OVERRIDE[$reportUniqueId][$isMultiplePeriod]; + } + } else { + $availableGraphTypes = StaticGraph::getAvailableStaticGraphTypes(); + if (!in_array($graphType, $availableGraphTypes)) { + throw new Exception( + Piwik::translate( + 'General_ExceptionInvalidStaticGraphType', + array($graphType, implode(', ', $availableGraphTypes)) + ) + ); + } + } + + $width = (int)$width; + $height = (int)$height; + if (empty($width)) { + $width = self::$DEFAULT_PARAMETERS[$graphType][self::WIDTH_KEY]; + } + if (empty($height)) { + $height = self::$DEFAULT_PARAMETERS[$graphType][self::HEIGHT_KEY]; + } + + // Cap width and height to a safe amount + $width = min($width, self::MAX_WIDTH); + $height = min($height, self::MAX_HEIGHT); + + $reportColumns = array_merge( + !empty($metadata['metrics']) ? $metadata['metrics'] : array(), + !empty($metadata['processedMetrics']) ? $metadata['processedMetrics'] : array(), + !empty($metadata['metricsGoal']) ? $metadata['metricsGoal'] : array(), + !empty($metadata['processedMetricsGoal']) ? $metadata['processedMetricsGoal'] : array() + ); + + $ordinateColumns = array(); + if (empty($columns)) { + $ordinateColumns[] = + empty($reportColumns[self::DEFAULT_ORDINATE_METRIC]) ? key($metadata['metrics']) : self::DEFAULT_ORDINATE_METRIC; + } else { + $ordinateColumns = explode(',', $columns); + foreach ($ordinateColumns as $column) { + if (empty($reportColumns[$column])) { + throw new Exception( + Piwik::translate( + 'ImageGraph_ColumnOrdinateMissing', + array($column, implode(',', array_keys($reportColumns))) + ) + ); + } + } + } + + $ordinateLabels = array(); + foreach ($ordinateColumns as $column) { + $ordinateLabels[$column] = $reportColumns[$column]; + } + + // sort and truncate filters + $defaultFilterTruncate = self::$DEFAULT_PARAMETERS[$graphType][self::TRUNCATE_KEY]; + switch ($graphType) { + case StaticGraph::GRAPH_TYPE_3D_PIE: + case StaticGraph::GRAPH_TYPE_BASIC_PIE: + + if (count($ordinateColumns) > 1) { + // pChart doesn't support multiple series on pie charts + throw new Exception("Pie charts do not currently support multiple series"); + } + + $_GET['filter_sort_column'] = reset($ordinateColumns); + $this->setFilterTruncate($defaultFilterTruncate); + break; + + case StaticGraph::GRAPH_TYPE_VERTICAL_BAR: + case StaticGraph::GRAPH_TYPE_BASIC_LINE: + + if (!$isMultiplePeriod && !$constantRowsCount) { + $this->setFilterTruncate($defaultFilterTruncate); + } + break; + } + + $ordinateLogos = array(); + + // row evolutions + if ($isMultiplePeriod && $reportHasDimension) { + $plottedMetric = reset($ordinateColumns); + + // when no labels are specified, getRowEvolution returns the top N=filter_limit row evolutions + // rows are sorted using filter_sort_column (see DataTableGenericFilter for more info) + if (!$labels) { + $savedFilterSortColumnValue = Common::getRequestVar('filter_sort_column', ''); + $_GET['filter_sort_column'] = $plottedMetric; + + $savedFilterLimitValue = Common::getRequestVar('filter_limit', -1, 'int'); + if ($savedFilterLimitValue == -1 || $savedFilterLimitValue > self::MAX_NB_ROW_LABELS) { + $_GET['filter_limit'] = self::DEFAULT_NB_ROW_EVOLUTIONS; + } + } + + $processedReport = APIMetadata::getInstance()->getRowEvolution( + $idSite, + $period, + $date, + $apiModule, + $apiAction, + $labels, + $segment, + $plottedMetric, + $languageLoaded, + $idGoal, + $legendAppendMetric, + $labelUseAbsoluteUrl = false + ); + + //@review this test will need to be updated after evaluating the @review comment in API/API.php + if (!$processedReport) { + throw new Exception(Piwik::translate('General_NoDataForGraph')); + } + + // restoring generic filter parameters + if (!$labels) { + $_GET['filter_sort_column'] = $savedFilterSortColumnValue; + if ($savedFilterLimitValue != -1) { + $_GET['filter_limit'] = $savedFilterLimitValue; + } + } + + // retrieve metric names & labels + $metrics = $processedReport['metadata']['metrics']; + $ordinateLabels = array(); + + // getRowEvolution returned more than one label + if (!array_key_exists($plottedMetric, $metrics)) { + $ordinateColumns = array(); + $i = 0; + foreach ($metrics as $metric => $info) { + $ordinateColumn = $plottedMetric . '_' . $i++; + $ordinateColumns[] = $metric; + $ordinateLabels[$ordinateColumn] = $info['name']; + + if (isset($info['logo'])) { + $ordinateLogo = $info['logo']; + + // @review pChart does not support gifs in graph legends, would it be possible to convert all plugin pictures (cookie.gif, flash.gif, ..) to png files? + if (!strstr($ordinateLogo, '.gif')) { + $absoluteLogoPath = self::getAbsoluteLogoPath($ordinateLogo); + if (file_exists($absoluteLogoPath)) { + $ordinateLogos[$ordinateColumn] = $absoluteLogoPath; + } + } + } + } + } else { + $ordinateLabels[$plottedMetric] = $processedReport['label'] . ' (' . $metrics[$plottedMetric]['name'] . ')'; + } + } else { + $processedReport = APIMetadata::getInstance()->getProcessedReport( + $idSite, + $period, + $date, + $apiModule, + $apiAction, + $segment, + $apiParameters = false, + $idGoal, + $languageLoaded, + $showTimer = true, + $hideMetricsDoc = false, + $idSubtable, + $showRawMetrics = false + ); + } + // prepare abscissa and ordinate series + $abscissaSeries = array(); + $abscissaLogos = array(); + $ordinateSeries = array(); + /** @var \Piwik\DataTable\Simple|\Piwik\DataTable\Map $reportData */ + $reportData = $processedReport['reportData']; + $hasData = false; + $hasNonZeroValue = false; + + if (!$isMultiplePeriod) { + $reportMetadata = $processedReport['reportMetadata']->getRows(); + + $i = 0; + // $reportData instanceof DataTable + foreach ($reportData->getRows() as $row) // Row[] + { + // $row instanceof Row + $rowData = $row->getColumns(); // Associative Array + $abscissaSeries[] = Common::unsanitizeInputValue($rowData['label']); + + foreach ($ordinateColumns as $column) { + $parsedOrdinateValue = $this->parseOrdinateValue($rowData[$column]); + $hasData = true; + + if ($parsedOrdinateValue != 0) { + $hasNonZeroValue = true; + } + $ordinateSeries[$column][] = $parsedOrdinateValue; + } + + if (isset($reportMetadata[$i])) { + $rowMetadata = $reportMetadata[$i]->getColumns(); + if (isset($rowMetadata['logo'])) { + $absoluteLogoPath = self::getAbsoluteLogoPath($rowMetadata['logo']); + if (file_exists($absoluteLogoPath)) { + $abscissaLogos[$i] = $absoluteLogoPath; + } + } + } + $i++; + } + } else // if the report has no dimension we have multiple reports each with only one row within the reportData + { + // $periodsData instanceof Simple[] + $periodsData = array_values($reportData->getDataTables()); + $periodsCount = count($periodsData); + + for ($i = 0; $i < $periodsCount; $i++) { + // $periodsData[$i] instanceof Simple + // $rows instanceof Row[] + if (empty($periodsData[$i])) { + continue; + } + $rows = $periodsData[$i]->getRows(); + + if (array_key_exists(0, $rows)) { + $rowData = $rows[0]->getColumns(); // associative Array + + foreach ($ordinateColumns as $column) { + $ordinateValue = $rowData[$column]; + $parsedOrdinateValue = $this->parseOrdinateValue($ordinateValue); + + $hasData = true; + + if (!empty($parsedOrdinateValue)) { + $hasNonZeroValue = true; + } + + $ordinateSeries[$column][] = $parsedOrdinateValue; + } + } else { + foreach ($ordinateColumns as $column) { + $ordinateSeries[$column][] = 0; + } + } + + $rowId = $periodsData[$i]->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX)->getLocalizedShortString(); + $abscissaSeries[] = Common::unsanitizeInputValue($rowId); + } + } + + if (!$hasData || !$hasNonZeroValue) { + throw new Exception(Piwik::translate('General_NoDataForGraph')); + } + + //Setup the graph + $graph = StaticGraph::factory($graphType); + $graph->setWidth($width); + $graph->setHeight($height); + $graph->setFont($font); + $graph->setFontSize($fontSize); + $graph->setLegendFontSize($legendFontSize); + $graph->setOrdinateLabels($ordinateLabels); + $graph->setShowLegend($showLegend); + $graph->setAliasedGraph($aliasedGraph); + $graph->setAbscissaSeries($abscissaSeries); + $graph->setAbscissaLogos($abscissaLogos); + $graph->setOrdinateSeries($ordinateSeries); + $graph->setOrdinateLogos($ordinateLogos); + $graph->setColors(!empty($colors) ? explode(',', $colors) : array()); + $graph->setTextColor($textColor); + $graph->setBackgroundColor($backgroundColor); + $graph->setGridColor($gridColor); + + // when requested period is day, x-axis unit is time and all date labels can not be displayed + // within requested width, force labels to be skipped every 6 days to delimit weeks + if ($period == 'day' && $isMultiplePeriod) { + $graph->setForceSkippedLabels(6); + } + + // render graph + $graph->renderGraph(); + } catch (\Exception $e) { + + $graph = new \Piwik\Plugins\ImageGraph\StaticGraph\Exception(); + $graph->setWidth($width); + $graph->setHeight($height); + $graph->setFont($font); + $graph->setFontSize($fontSize); + $graph->setBackgroundColor($backgroundColor); + $graph->setTextColor($textColor); + $graph->setException($e); + $graph->renderGraph(); + } + + // restoring get parameters + $_GET = $savedGET; + + switch ($outputType) { + case self::GRAPH_OUTPUT_FILE: + if ($idGoal != '') { + $idGoal = '_' . $idGoal; + } + $fileName = self::$DEFAULT_PARAMETERS[$graphType][self::FILENAME_KEY] . '_' . $apiModule . '_' . $apiAction . $idGoal . ' ' . str_replace(',', '-', $date) . ' ' . $idSite . '.png'; + $fileName = str_replace(array(' ', '/'), '_', $fileName); + + if (!Filesystem::isValidFilename($fileName)) { + throw new Exception('Error: Image graph filename ' . $fileName . ' is not valid.'); + } + + return $graph->sendToDisk($fileName); + + case self::GRAPH_OUTPUT_PHP: + return $graph->getRenderedImage(); + + case self::GRAPH_OUTPUT_INLINE: + default: + $graph->sendToBrowser(); + exit; + } + } + + private function setFilterTruncate($default) + { + $_GET['filter_truncate'] = Common::getRequestVar('filter_truncate', $default, 'int'); + } + + private static function parseOrdinateValue($ordinateValue) + { + $ordinateValue = @str_replace(',', '.', $ordinateValue); + + // convert hh:mm:ss formatted time values to number of seconds + if (preg_match('/([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2})/', $ordinateValue, $matches)) { + $hour = $matches[1]; + $min = $matches[2]; + $sec = $matches[3]; + + $ordinateValue = ($hour * 3600) + ($min * 60) + $sec; + } + + // OK, only numbers from here please (strip out currency sign) + $ordinateValue = preg_replace('/[^0-9.]/', '', $ordinateValue); + return $ordinateValue; + } + + private static function getFontPath($font) + { + return PIWIK_INCLUDE_PATH . self::FONT_DIR . $font; + } + + protected static function getAbsoluteLogoPath($relativeLogoPath) + { + return PIWIK_INCLUDE_PATH . '/' . $relativeLogoPath; + } +} diff --git a/www/analytics/plugins/ImageGraph/Controller.php b/www/analytics/plugins/ImageGraph/Controller.php new file mode 100644 index 00000000..dacfc8bf --- /dev/null +++ b/www/analytics/plugins/ImageGraph/Controller.php @@ -0,0 +1,76 @@ +getReportMetadata($idSite, $period, $date); + $plot = array(); + foreach ($reports as $report) { + if (!empty($report['imageGraphUrl'])) { + $plot[] = array( + // Title + $report['category'] . ' › ' . $report['name'], + //URL + SettingsPiwik::getPiwikUrl() . $report['imageGraphUrl'] + ); + } + } + $view = new View('@ImageGraph/index'); + $view->titleAndUrls = $plot; + return $view->render(); + } + + // Draw graphs for all sizes (DEBUG) + public function testAllSizes() + { + Piwik::checkUserHasSuperUserAccess(); + + $view = new View('@ImageGraph/testAllSizes'); + $this->setGeneralVariablesView($view); + + $period = Common::getRequestVar('period', 'day', 'string'); + $date = Common::getRequestVar('date', 'today', 'string'); + + $_GET['token_auth'] = Piwik::getCurrentUserTokenAuth(); + $availableReports = APIPlugins::getInstance()->getReportMetadata($this->idSite, $period, $date); + $view->availableReports = $availableReports; + $view->graphTypes = array( + '', // default graph type +// 'evolution', +// 'verticalBar', +// 'horizontalBar', +// 'pie', +// '3dPie', + ); + $view->graphSizes = array( + array(null, null), // default graph size + array(460, 150), // standard phone + array(300, 150), // standard phone 2 + array(240, 150), // smallest mobile display + array(800, 150), // landscape mode + array(600, 300, $fontSize = 18, 300, 150), // iphone requires bigger font, then it will be scaled down by ios + ); + return $view->render(); + } +} diff --git a/www/analytics/plugins/ImageGraph/ImageGraph.php b/www/analytics/plugins/ImageGraph/ImageGraph.php new file mode 100644 index 00000000..c10a1b22 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/ImageGraph.php @@ -0,0 +1,161 @@ + 'ImageGraph', 'action' => 'index')) . '">All images'; + $info = parent::getInformation(); + $info['description'] .= ' ' . $suffix; + return $info; + } + + static private $CONSTANT_ROW_COUNT_REPORT_EXCEPTIONS = array( + 'Referrers_getReferrerType', + ); + + // row evolution support not yet implemented for these APIs + static private $REPORTS_DISABLED_EVOLUTION_GRAPH = array( + 'Referrers_getAll', + ); + + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + $hooks = array( + 'API.getReportMetadata.end' => array('function' => 'getReportMetadata', + 'after' => true), + ); + return $hooks; + } + + // Number of periods to plot on an evolution graph + const GRAPH_EVOLUTION_LAST_PERIODS = 30; + + /** + * @param array $reports + * @param array $info + * @return mixed + */ + public function getReportMetadata(&$reports, $info) + { + $idSites = $info['idSites']; + + // If only one website is selected, we add the Graph URL + if (count($idSites) != 1) { + return; + } + $idSite = reset($idSites); + + // in case API.getReportMetadata was not called with date/period we use sane defaults + if (empty($info['period'])) { + $info['period'] = 'day'; + } + if (empty($info['date'])) { + $info['date'] = 'today'; + } + + // need two sets of period & date, one for single period graphs, one for multiple periods graphs + if (Period::isMultiplePeriod($info['date'], $info['period'])) { + $periodForMultiplePeriodGraph = $info['period']; + $dateForMultiplePeriodGraph = $info['date']; + + $periodForSinglePeriodGraph = 'range'; + $dateForSinglePeriodGraph = $info['date']; + } else { + $periodForSinglePeriodGraph = $info['period']; + $dateForSinglePeriodGraph = $info['date']; + + $piwikSite = new Site($idSite); + if ($periodForSinglePeriodGraph == 'range') { + $periodForMultiplePeriodGraph = Config::getInstance()->General['graphs_default_period_to_plot_when_period_range']; + $dateForMultiplePeriodGraph = $dateForSinglePeriodGraph; + } else { + $periodForMultiplePeriodGraph = $periodForSinglePeriodGraph; + $dateForMultiplePeriodGraph = Range::getRelativeToEndDate( + $periodForSinglePeriodGraph, + 'last' . self::GRAPH_EVOLUTION_LAST_PERIODS, + $dateForSinglePeriodGraph, + $piwikSite + ); + } + } + + $token_auth = Common::getRequestVar('token_auth', false); + + $urlPrefix = "index.php?"; + foreach ($reports as &$report) { + $reportModule = $report['module']; + $reportAction = $report['action']; + $reportUniqueId = $reportModule . '_' . $reportAction; + + $parameters = array(); + $parameters['module'] = 'API'; + $parameters['method'] = 'ImageGraph.get'; + $parameters['idSite'] = $idSite; + $parameters['apiModule'] = $reportModule; + $parameters['apiAction'] = $reportAction; + if (!empty($token_auth)) { + $parameters['token_auth'] = $token_auth; + } + + // Forward custom Report parameters to the graph URL + if (!empty($report['parameters'])) { + $parameters = array_merge($parameters, $report['parameters']); + } + if (empty($report['dimension'])) { + $parameters['period'] = $periodForMultiplePeriodGraph; + $parameters['date'] = $dateForMultiplePeriodGraph; + } else { + $parameters['period'] = $periodForSinglePeriodGraph; + $parameters['date'] = $dateForSinglePeriodGraph; + } + + // add the idSubtable if it exists + $idSubtable = Common::getRequestVar('idSubtable', false); + if ($idSubtable !== false) { + $parameters['idSubtable'] = $idSubtable; + } + + if (!empty($_GET['_restrictSitesToLogin']) && TaskScheduler::isTaskBeingExecuted()) { + $parameters['_restrictSitesToLogin'] = $_GET['_restrictSitesToLogin']; + } + + $report['imageGraphUrl'] = $urlPrefix . Url::getQueryStringFromParameters($parameters); + + // thanks to API.getRowEvolution, reports with dimensions can now be plotted using an evolution graph + // however, most reports with a fixed set of dimension values are excluded + // this is done so Piwik Mobile and Scheduled Reports do not display them + $reportWithDimensionsSupportsEvolution = empty($report['constantRowsCount']) || in_array($reportUniqueId, self::$CONSTANT_ROW_COUNT_REPORT_EXCEPTIONS); + + $reportSupportsEvolution = !in_array($reportUniqueId, self::$REPORTS_DISABLED_EVOLUTION_GRAPH); + + if ($reportSupportsEvolution + && $reportWithDimensionsSupportsEvolution + ) { + $parameters['period'] = $periodForMultiplePeriodGraph; + $parameters['date'] = $dateForMultiplePeriodGraph; + $report['imageGraphEvolutionUrl'] = $urlPrefix . Url::getQueryStringFromParameters($parameters); + } + } + } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph.php b/www/analytics/plugins/ImageGraph/StaticGraph.php new file mode 100644 index 00000000..46fc9bde --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph.php @@ -0,0 +1,355 @@ + 'Evolution', + self::GRAPH_TYPE_VERTICAL_BAR => 'VerticalBar', + self::GRAPH_TYPE_HORIZONTAL_BAR => 'HorizontalBar', + self::GRAPH_TYPE_BASIC_PIE => 'Pie', + self::GRAPH_TYPE_3D_PIE => 'Pie3D', + ); + + const ABSCISSA_SERIE_NAME = 'ABSCISSA'; + + private $aliasedGraph; + + /** + * @var pImage + */ + protected $pImage; + /** + * @var pData + */ + protected $pData; + protected $ordinateLabels; + protected $showLegend; + protected $abscissaSeries; + protected $abscissaLogos; + protected $ordinateSeries; + protected $ordinateLogos; + protected $colors; + protected $font; + protected $fontSize; + protected $textColor; + protected $backgroundColor; + protected $gridColor; + protected $legendFontSize; + protected $width; + protected $height; + protected $forceSkippedLabels = false; + + abstract protected function getDefaultColors(); + + abstract public function renderGraph(); + + /** + * Return the StaticGraph according to the static graph type $graphType + * + * @throws Exception If the static graph type is unknown + * @param string $graphType + * @return \Piwik\Plugins\ImageGraph\StaticGraph + */ + public static function factory($graphType) + { + if (isset(self::$availableStaticGraphTypes[$graphType])) { + + $className = self::$availableStaticGraphTypes[$graphType]; + $className = __NAMESPACE__ . "\\StaticGraph\\" . $className; + Loader::loadClass($className); + return new $className; + } else { + throw new Exception( + Piwik::translate( + 'General_ExceptionInvalidStaticGraphType', + array($graphType, implode(', ', self::getAvailableStaticGraphTypes())) + ) + ); + } + } + + public static function getAvailableStaticGraphTypes() + { + return array_keys(self::$availableStaticGraphTypes); + } + + /** + * Save rendering to disk + * + * @param string $filename without path + * @return string path of file + */ + public function sendToDisk($filename) + { + $filePath = self::getOutputPath($filename); + $this->pImage->Render($filePath); + return $filePath; + } + + /** + * @return resource rendered static graph + */ + public function getRenderedImage() + { + return $this->pImage->Picture; + } + + /** + * Output rendering to browser + */ + public function sendToBrowser() + { + $this->pImage->stroke(); + } + + public function setWidth($width) + { + $this->width = $width; + } + + public function setHeight($height) + { + $this->height = $height; + } + + public function setFontSize($fontSize) + { + if (!is_numeric($fontSize)) { + $fontSize = API::DEFAULT_FONT_SIZE; + } + $this->fontSize = $fontSize; + } + + public function setLegendFontSize($legendFontSize) + { + $this->legendFontSize = $legendFontSize; + } + + public function setFont($font) + { + $this->font = $font; + } + + public function setTextColor($textColor) + { + $this->textColor = self::hex2rgb($textColor); + } + + public function setBackgroundColor($backgroundColor) + { + $this->backgroundColor = self::hex2rgb($backgroundColor); + } + + public function setGridColor($gridColor) + { + $this->gridColor = self::hex2rgb($gridColor); + } + + public function setOrdinateSeries($ordinateSeries) + { + $this->ordinateSeries = $ordinateSeries; + } + + public function setOrdinateLogos($ordinateLogos) + { + $this->ordinateLogos = $ordinateLogos; + } + + public function setAbscissaLogos($abscissaLogos) + { + $this->abscissaLogos = $abscissaLogos; + } + + public function setAbscissaSeries($abscissaSeries) + { + $this->abscissaSeries = $abscissaSeries; + } + + public function setShowLegend($showLegend) + { + $this->showLegend = $showLegend; + } + + public function setForceSkippedLabels($forceSkippedLabels) + { + $this->forceSkippedLabels = $forceSkippedLabels; + } + + public function setOrdinateLabels($ordinateLabels) + { + $this->ordinateLabels = $ordinateLabels; + } + + public function setAliasedGraph($aliasedGraph) + { + $this->aliasedGraph = $aliasedGraph; + } + + public function setColors($colors) + { + $i = 0; + foreach ($this->getDefaultColors() as $colorKey => $defaultColor) { + if (isset($colors[$i]) && $this->hex2rgb($colors[$i])) { + $hexColor = $colors[$i]; + } else { + $hexColor = $defaultColor; + } + + $this->colors[$colorKey] = $this->hex2rgb($hexColor); + $i++; + } + } + + /** + * Return $filename with temp directory and delete file + * + * @static + * @param $filename + * @return string path of file in temp directory + */ + protected static function getOutputPath($filename) + { + $outputFilename = PIWIK_USER_PATH . '/tmp/assets/' . $filename; + $outputFilename = SettingsPiwik::rewriteTmpPathWithHostname($outputFilename); + + @chmod($outputFilename, 0600); + @unlink($outputFilename); + return $outputFilename; + } + + protected function initpData() + { + $this->pData = new pData(); + + foreach ($this->ordinateSeries as $column => $data) { + $this->pData->addPoints($data, $column); + $this->pData->setSerieDescription($column, $this->ordinateLabels[$column]); + if (isset($this->ordinateLogos[$column])) { + $ordinateLogo = $this->ordinateLogos[$column]; + $this->pData->setSeriePicture($column, $ordinateLogo); + } + } + + $this->pData->addPoints($this->abscissaSeries, self::ABSCISSA_SERIE_NAME); + $this->pData->setAbscissa(self::ABSCISSA_SERIE_NAME); + } + + protected function initpImage() + { + $this->pImage = new pImage($this->width, $this->height, $this->pData); + $this->pImage->Antialias = $this->aliasedGraph; + + $this->pImage->setFontProperties( + array_merge( + array( + 'FontName' => $this->font, + 'FontSize' => $this->fontSize, + ), + $this->textColor + ) + ); + } + + protected function getTextWidthHeight($text, $fontSize = false) + { + if (!$fontSize) { + $fontSize = $this->fontSize; + } + + if (!$this->pImage) { + $this->initpImage(); + } + + // could not find a way to get pixel perfect width & height info using imageftbbox + $textInfo = $this->pImage->drawText( + 0, 0, $text, + array( + 'Alpha' => 0, + 'FontSize' => $fontSize, + 'FontName' => $this->font + ) + ); + + return array($textInfo[1]['X'] + 1, $textInfo[0]['Y'] - $textInfo[2]['Y']); + } + + protected function getMaximumTextWidthHeight($values) + { + if (array_values($values) === $values) { + $values = array('' => $values); + } + + $maxWidth = 0; + $maxHeight = 0; + foreach ($values as $column => $data) { + foreach ($data as $value) { + list($valueWidth, $valueHeight) = $this->getTextWidthHeight($value); + + if ($valueWidth > $maxWidth) { + $maxWidth = $valueWidth; + } + + if ($valueHeight > $maxHeight) { + $maxHeight = $valueHeight; + } + } + } + + return array($maxWidth, $maxHeight); + } + + protected function drawBackground() + { + $this->pImage->drawFilledRectangle( + 0, + 0, + $this->width, + $this->height, + array_merge(array('Alpha' => 100), $this->backgroundColor) + ); + } + + private static function hex2rgb($hexColor) + { + if (preg_match('/([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/', $hexColor, $matches)) { + return array( + 'R' => hexdec($matches[1]), + 'G' => hexdec($matches[2]), + 'B' => hexdec($matches[3]) + ); + } else { + return false; + } + } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph/Evolution.php b/www/analytics/plugins/ImageGraph/StaticGraph/Evolution.php new file mode 100644 index 00000000..32bc5bc9 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph/Evolution.php @@ -0,0 +1,30 @@ +initGridChart( + $displayVerticalGridLines = true, + $bulletType = LEGEND_FAMILY_LINE, + $horizontalGraph = false, + $showTicks = true, + $verticalLegend = true + ); + + $this->pImage->drawLineChart(); + } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph/Exception.php b/www/analytics/plugins/ImageGraph/StaticGraph/Exception.php new file mode 100644 index 00000000..6ba920af --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph/Exception.php @@ -0,0 +1,78 @@ +exception = $exception; + } + + protected function getDefaultColors() + { + return array(); + } + + public function setWidth($width) + { + if (empty($width)) { + $width = 450; + } + parent::setWidth($width); + } + + public function setHeight($height) + { + if (empty($height)) { + $height = 300; + } + parent::setHeight($height); + } + + public function renderGraph() + { + $this->pData = new pData(); + + $message = $this->exception->getMessage(); + list($textWidth, $textHeight) = $this->getTextWidthHeight($message); + + if ($this->width == null) { + $this->width = $textWidth + self::MESSAGE_RIGHT_MARGIN; + } + + if ($this->height == null) { + $this->height = $textHeight; + } + + $this->initpImage(); + + $this->drawBackground(); + + $this->pImage->drawText( + 0, + $textHeight, + $message, + $this->textColor + ); + } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph/GridGraph.php b/www/analytics/plugins/ImageGraph/StaticGraph/GridGraph.php new file mode 100644 index 00000000..02efb8d3 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph/GridGraph.php @@ -0,0 +1,485 @@ + '5170AE', + self::GRAPHIC_COLOR_KEY . '2' => 'F29007', + self::GRAPHIC_COLOR_KEY . '3' => 'CC3399', + self::GRAPHIC_COLOR_KEY . '4' => '9933CC', + self::GRAPHIC_COLOR_KEY . '5' => '80A033', + self::GRAPHIC_COLOR_KEY . '6' => '246AD2' + ); + } + + protected function initGridChart( + $displayVerticalGridLines, + $bulletType, + $horizontalGraph, + $showTicks, + $verticalLegend + ) + { + $this->initpData(); + + $colorIndex = 1; + foreach ($this->ordinateSeries as $column => $data) { + $this->pData->setSerieWeight($column, self::DEFAULT_SERIE_WEIGHT); + $graphicColor = $this->colors[self::GRAPHIC_COLOR_KEY . $colorIndex++]; + $this->pData->setPalette($column, $graphicColor); + } + + $this->initpImage(); + + // graph area coordinates + $topLeftXValue = $this->getGridLeftMargin($horizontalGraph, $withLabel = true); + $topLeftYValue = $this->getGridTopMargin($horizontalGraph, $verticalLegend); + $bottomRightXValue = $this->width - $this->getGridRightMargin($horizontalGraph); + $bottomRightYValue = $this->getGraphBottom($horizontalGraph); + + $this->drawBackground(); + + $this->pImage->setGraphArea( + $topLeftXValue, + $topLeftYValue, + $bottomRightXValue, + $bottomRightYValue + ); + + // determine how many labels need to be skipped + $skippedLabels = 0; + if (!$horizontalGraph) { + list($abscissaMaxWidth, $abscissaMaxHeight) = $this->getMaximumTextWidthHeight($this->abscissaSeries); + $graphWidth = $bottomRightXValue - $topLeftXValue; + $maxNumOfLabels = floor($graphWidth / ($abscissaMaxWidth + self::LABEL_SPACE_VERTICAL_GRAPH)); + + $abscissaSeriesCount = count($this->abscissaSeries); + if ($maxNumOfLabels < $abscissaSeriesCount) { + for ($candidateSkippedLabels = 1; $candidateSkippedLabels < $abscissaSeriesCount; $candidateSkippedLabels++) { + $numberOfSegments = $abscissaSeriesCount / ($candidateSkippedLabels + 1); + $numberOfCompleteSegments = floor($numberOfSegments); + + $numberOfLabels = $numberOfCompleteSegments; + if ($numberOfSegments > $numberOfCompleteSegments) { + $numberOfLabels++; + } + + if ($numberOfLabels <= $maxNumOfLabels) { + $skippedLabels = $candidateSkippedLabels; + break; + } + } + } + + if ($this->forceSkippedLabels + && $skippedLabels + && $skippedLabels < $this->forceSkippedLabels + && $abscissaSeriesCount > $this->forceSkippedLabels + 1 + ) { + $skippedLabels = $this->forceSkippedLabels; + } + } + + $ordinateAxisLength = + $horizontalGraph ? $bottomRightXValue - $topLeftXValue : $this->getGraphHeight($horizontalGraph, $verticalLegend); + + $maxOrdinateValue = 0; + foreach ($this->ordinateSeries as $column => $data) { + $currentMax = $this->pData->getMax($column); + + if ($currentMax > $maxOrdinateValue) { + $maxOrdinateValue = $currentMax; + } + } + + // rounding top scale value to the next multiple of 10 + if ($maxOrdinateValue > 10) { + $modTen = $maxOrdinateValue % 10; + if ($modTen) $maxOrdinateValue += 10 - $modTen; + } + + $gridColor = $this->gridColor; + $this->pImage->drawScale( + array( + 'Mode' => SCALE_MODE_MANUAL, + 'GridTicks' => 0, + 'LabelSkip' => $skippedLabels, + 'DrawXLines' => $displayVerticalGridLines, + 'Factors' => array(ceil($maxOrdinateValue / 2)), + 'MinDivHeight' => $ordinateAxisLength / 2, + 'AxisAlpha' => 0, + 'SkippedAxisAlpha' => 0, + 'TickAlpha' => $showTicks ? self::DEFAULT_TICK_ALPHA : 0, + 'InnerTickWidth' => self::INNER_TICK_WIDTH, + 'OuterTickWidth' => self::OUTER_TICK_WIDTH, + 'GridR' => $gridColor['R'], + 'GridG' => $gridColor['G'], + 'GridB' => $gridColor['B'], + 'GridAlpha' => 100, + 'ManualScale' => array( + 0 => array( + 'Min' => 0, + 'Max' => $maxOrdinateValue + ) + ), + 'Pos' => $horizontalGraph ? SCALE_POS_TOPBOTTOM : SCALE_POS_LEFTRIGHT, + ) + ); + + if ($this->showLegend) { + switch ($bulletType) { + case LEGEND_FAMILY_LINE: + $bulletWidth = self::LEGEND_LINE_BULLET_WIDTH; + + // measured using a picture editing software + $iconOffsetAboveLabelSymmetryAxis = -2; + break; + + case LEGEND_FAMILY_BOX: + $bulletWidth = self::LEGEND_BOX_BULLET_WIDTH; + + // measured using a picture editing software + $iconOffsetAboveLabelSymmetryAxis = 3; + break; + } + + // pChart requires two coordinates to draw the legend $legendTopLeftXValue & $legendTopLeftYValue + // $legendTopLeftXValue = legend's left padding + $legendTopLeftXValue = $topLeftXValue + ($verticalLegend ? self::VERTICAL_LEGEND_LEFT_MARGIN : self::HORIZONTAL_LEGEND_LEFT_MARGIN); + + // $legendTopLeftYValue = y coordinate of the top edge of the legend's icons + // Caution : + // - pChart will silently add some value (see $paddingAddedByPChart) to $legendTopLeftYValue depending on multiple criterias + // - pChart will not take into account the size of the text. Setting $legendTopLeftYValue = 0 will crop the legend's labels + // The following section of code determines the value of $legendTopLeftYValue while taking into account the following paremeters : + // - whether legend items have icons + // - whether icons are bigger than the legend's labels + // - how much colored shadow padding is required + list($maxLogoWidth, $maxLogoHeight) = self::getMaxLogoSize(array_values($this->ordinateLogos)); + if ($maxLogoHeight >= $this->legendFontSize) { + $heightOfTextAboveBulletTop = 0; + $paddingCreatedByLogo = $maxLogoHeight - $this->legendFontSize; + $effectiveShadowPadding = $paddingCreatedByLogo < self::LEGEND_VERTICAL_SHADOW_PADDING * 2 ? self::LEGEND_VERTICAL_SHADOW_PADDING - ($paddingCreatedByLogo / 2) : 0; + } else { + if ($maxLogoHeight) { + // measured using a picture editing software + $iconOffsetAboveLabelSymmetryAxis = 5; + } + $heightOfTextAboveBulletTop = $this->legendFontSize / 2 - $iconOffsetAboveLabelSymmetryAxis; + $effectiveShadowPadding = self::LEGEND_VERTICAL_SHADOW_PADDING; + } + + $effectiveLegendItemVerticalInterstice = $this->legendFontSize + self::LEGEND_ITEM_VERTICAL_INTERSTICE_OFFSET; + $effectiveLegendItemHorizontalInterstice = self::LEGEND_ITEM_HORIZONTAL_INTERSTICE + self::LEGEND_HORIZONTAL_SHADOW_PADDING; + + $legendTopMargin = $verticalLegend ? self::VERTICAL_LEGEND_TOP_MARGIN : self::HORIZONTAL_LEGEND_TOP_MARGIN; + $requiredPaddingAboveItemBullet = $legendTopMargin + $heightOfTextAboveBulletTop + $effectiveShadowPadding; + + $paddingAddedByPChart = 0; + if ($verticalLegend) { + if ($maxLogoHeight) { + // see line 1691 of pDraw.class.php + if ($maxLogoHeight < $effectiveLegendItemVerticalInterstice) { + $paddingAddedByPChart = ($effectiveLegendItemVerticalInterstice / 2) - ($maxLogoHeight / 2); + } + } else { + // see line 1711 of pDraw.class.php ($Y+$IconAreaHeight/2) + $paddingAddedByPChart = $effectiveLegendItemVerticalInterstice / 2; + } + } + + $legendTopLeftYValue = $paddingAddedByPChart < $requiredPaddingAboveItemBullet ? $requiredPaddingAboveItemBullet - $paddingAddedByPChart : 0; + + // add colored background to each legend item + if (count($this->ordinateLabels) > 1) { + $currentPosition = $verticalLegend ? $legendTopMargin : $legendTopLeftXValue; + $colorIndex = 1; + foreach ($this->ordinateLabels as $metricCode => &$label) { + $color = $this->colors[self::GRAPHIC_COLOR_KEY . $colorIndex++]; + + $paddedBulletWidth = $bulletWidth; + if (isset($this->ordinateLogos[$metricCode])) { + $paddedBulletWidth = $maxLogoWidth; + } + $paddedBulletWidth += self::LEGEND_BULLET_RIGHT_PADDING; + + // truncate labels if required + if ($verticalLegend) { + $label = $this->truncateLabel($label, ($this->width * self::VERTICAL_LEGEND_MAX_WIDTH_PCT) - $legendTopLeftXValue - $paddedBulletWidth, $this->legendFontSize); + $this->pData->setSerieDescription($metricCode, $label); + } + + $rectangleTopLeftXValue = ($verticalLegend ? $legendTopLeftXValue : $currentPosition) + $paddedBulletWidth - self::LEGEND_HORIZONTAL_SHADOW_PADDING; + $rectangleTopLeftYValue = $verticalLegend ? $currentPosition : $legendTopMargin; + + list($labelWidth, $labelHeight) = $this->getTextWidthHeight($label, $this->legendFontSize); + $legendItemWidth = $paddedBulletWidth + $labelWidth + $effectiveLegendItemHorizontalInterstice; + $rectangleBottomRightXValue = $rectangleTopLeftXValue + $labelWidth + (self::LEGEND_HORIZONTAL_SHADOW_PADDING * 2); + + $legendItemHeight = max($maxLogoHeight, $this->legendFontSize) + ($effectiveShadowPadding * 2); + $rectangleBottomRightYValue = $rectangleTopLeftYValue + $legendItemHeight; + + $this->pImage->drawFilledRectangle( + $rectangleTopLeftXValue, + $rectangleTopLeftYValue, + $rectangleBottomRightXValue, + $rectangleBottomRightYValue, + array( + 'Alpha' => self::LEGEND_SHADOW_OPACITY, + 'R' => $color['R'], + 'G' => $color['G'], + 'B' => $color['B'], + ) + ); + + if ($verticalLegend) { + $currentPositionIncrement = max($maxLogoHeight, $effectiveLegendItemVerticalInterstice, $this->legendFontSize) + self::PCHART_HARD_CODED_VERTICAL_LEGEND_INTERSTICE; + } else { + $currentPositionIncrement = $legendItemWidth; + } + + $currentPosition += $currentPositionIncrement; + } + } + + // draw legend + $legendColor = $this->textColor; + $this->pImage->drawLegend( + $legendTopLeftXValue, + $legendTopLeftYValue, + array( + 'Style' => LEGEND_NOBORDER, + 'FontSize' => $this->legendFontSize, + 'BoxWidth' => $bulletWidth, + 'XSpacing' => $effectiveLegendItemHorizontalInterstice, // not effective when vertical + 'Mode' => $verticalLegend ? LEGEND_VERTICAL : LEGEND_HORIZONTAL, + 'BoxHeight' => $verticalLegend ? $effectiveLegendItemVerticalInterstice : null, + 'Family' => $bulletType, + 'FontR' => $legendColor['R'], + 'FontG' => $legendColor['G'], + 'FontB' => $legendColor['B'], + ) + ); + } + } + + protected static function getMaxLogoSize($logoPaths) + { + $maxLogoWidth = 0; + $maxLogoHeight = 0; + foreach ($logoPaths as $logoPath) { + list($logoWidth, $logoHeight) = self::getLogoSize($logoPath); + + if ($logoWidth > $maxLogoWidth) { + $maxLogoWidth = $logoWidth; + } + if ($logoHeight > $maxLogoHeight) { + $maxLogoHeight = $logoHeight; + } + } + + return array($maxLogoWidth, $maxLogoHeight); + } + + protected static function getLogoSize($logoPath) + { + $pathInfo = getimagesize($logoPath); + return array($pathInfo[0], $pathInfo[1]); + } + + protected function getGridLeftMargin($horizontalGraph, $withLabel) + { + $gridLeftMargin = self::LEFT_GRID_MARGIN + self::OUTER_TICK_WIDTH; + + if ($withLabel) { + list($maxTextWidth, $maxTextHeight) = $this->getMaximumTextWidthHeight($horizontalGraph ? $this->abscissaSeries : $this->ordinateSeries); + $gridLeftMargin += $maxTextWidth; + } + + return $gridLeftMargin; + } + + protected function getGridTopMargin($horizontalGraph, $verticalLegend) + { + list($ordinateMaxWidth, $ordinateMaxHeight) = $this->getMaximumTextWidthHeight($this->ordinateSeries); + + if ($horizontalGraph) { + $topMargin = $ordinateMaxHeight + self::TOP_GRID_MARGIN_HORIZONTAL_GRAPH + self::OUTER_TICK_WIDTH; + } else { + $topMargin = $ordinateMaxHeight / 2; + } + + if ($this->showLegend && !$verticalLegend) { + $topMargin += $this->getHorizontalLegendHeight(); + } + + return $topMargin; + } + + private function getHorizontalLegendHeight() + { + list($maxMetricLegendWidth, $maxMetricLegendHeight) = + $this->getMaximumTextWidthHeight(array_values($this->ordinateLabels), $this->legendFontSize); + + return $maxMetricLegendHeight + self::HORIZONTAL_LEGEND_BOTTOM_MARGIN + self::HORIZONTAL_LEGEND_TOP_MARGIN; + } + + protected function getGraphHeight($horizontalGraph, $verticalLegend) + { + return $this->getGraphBottom($horizontalGraph) - $this->getGridTopMargin($horizontalGraph, $verticalLegend); + } + + private function getGridBottomMargin($horizontalGraph) + { + $gridBottomMargin = self::BOTTOM_GRID_MARGIN; + if (!$horizontalGraph) { + list($abscissaMaxWidth, $abscissaMaxHeight) = $this->getMaximumTextWidthHeight($this->abscissaSeries); + $gridBottomMargin += $abscissaMaxHeight; + } + return $gridBottomMargin; + } + + protected function getGridRightMargin($horizontalGraph) + { + if ($horizontalGraph) { + // in horizontal graphs, metric values are displayed on the far right of the bar + list($ordinateMaxWidth, $ordinateMaxHeight) = $this->getMaximumTextWidthHeight($this->ordinateSeries); + return self::RIGHT_GRID_MARGIN_HORIZONTAL_GRAPH + $ordinateMaxWidth; + } else { + return 0; + } + } + + protected function getGraphBottom($horizontalGraph) + { + return $this->height - $this->getGridBottomMargin($horizontalGraph); + } + + protected function truncateLabel($label, $labelWidthLimit, $fontSize = false) + { + list($truncationTextWidth, $truncationTextHeight) = $this->getTextWidthHeight(self::TRUNCATION_TEXT, $fontSize); + list($labelWidth, $labelHeight) = $this->getTextWidthHeight($label, $fontSize); + + if ($labelWidth > $labelWidthLimit) { + $averageCharWidth = $labelWidth / strlen($label); + $charsToKeep = floor(($labelWidthLimit - $truncationTextWidth) / $averageCharWidth); + $label = substr($label, 0, $charsToKeep) . self::TRUNCATION_TEXT; + } + return $label; + } + // display min & max values + // can not currently be used because pChart's label design is not flexible enough + // e.g: it is not possible to remove the box border & the square icon + // it would require modifying pChart code base which we try to avoid + // see http://dev.piwik.org/trac/ticket/3396 +// protected function displayMinMaxValues() +// { +// if($displayMinMax) +// { +// // when plotting multiple metrics, display min & max on both series +// // to fix: in vertical bars, labels are hidden when multiple metrics are plotted, hence the restriction on count($this->ordinateSeries) == 1 +// if($this->multipleMetrics && count($this->ordinateSeries) == 1) +// { +// $colorIndex = 1; +// foreach($this->ordinateSeries as $column => $data) +// { +// $color = $this->colors[self::GRAPHIC_COLOR_KEY . $colorIndex++]; +// +// $this->pImage->writeLabel( +// $column, +// self::locateMinMaxValue($data), +// $Format = array( +// 'NoTitle' => true, +// 'DrawPoint' => false, +// 'DrawSerieColor' => true, +// 'TitleMode' => LABEL_TITLE_NOBACKGROUND, +// 'GradientStartR' => $color['R'], +// 'GradientStartG' => $color['G'], +// 'GradientStartB' => $color['B'], +// 'GradientEndR' => 255, +// 'GradientEndG' => 255, +// 'GradientEndB' => 255, +// 'BoxWidth' => 0, +// 'VerticalMargin' => 9, +// 'HorizontalMargin' => 7, +// ) +// ); +// } +// } +// else +// { +// // display only one min & max label +// } +// } +// } + +// protected static function locateMinMaxValue($data) +// { +// $firstValue = $data[0]; +// $minValue = $firstValue; +// $minValueIndex = 0; +// $maxValue = $firstValue; +// $maxValueIndex = 0; +// foreach($data as $index => $value) +// { +// if($value > $maxValue) +// { +// $maxValue = $value; +// $maxValueIndex = $index; +// } +// +// if($value < $minValue) +// { +// $minValue = $value; +// $minValueIndex = $index; +// } +// } +// +// return array($minValueIndex, $maxValueIndex); +// } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph/HorizontalBar.php b/www/analytics/plugins/ImageGraph/StaticGraph/HorizontalBar.php new file mode 100644 index 00000000..b3b30637 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph/HorizontalBar.php @@ -0,0 +1,186 @@ +abscissaLogos); + + foreach ($this->abscissaLogos as $logoPath) { + list($logoWidth, $logoHeight) = self::getLogoSize($logoPath); + $logoPathToHeight[$logoPath] = $logoHeight; + } + + // truncate report + $graphHeight = $this->getGraphBottom($horizontalGraph = true) - $this->getGridTopMargin($horizontalGraph = true, $verticalLegend); + + list($abscissaMaxWidth, $abscissaMaxHeight) = $this->getMaximumTextWidthHeight($this->abscissaSeries); + list($ordinateMaxWidth, $ordinateMaxHeight) = $this->getMaximumTextWidthHeight($this->ordinateSeries); + + $numberOfSeries = count($this->ordinateSeries); + $ordinateMaxHeight = $ordinateMaxHeight * $numberOfSeries; + + $textMaxHeight = $abscissaMaxHeight > $ordinateMaxHeight ? $abscissaMaxHeight : $ordinateMaxHeight; + + $minLineWidth = ($textMaxHeight > $maxLogoHeight ? $textMaxHeight : $maxLogoHeight) + (self::MIN_SPACE_BETWEEN_HORIZONTAL_VALUES * $numberOfSeries); + $maxNumOfValues = floor($graphHeight / $minLineWidth); + $abscissaSeriesCount = count($this->abscissaSeries); + + if ($maxNumOfValues < $abscissaSeriesCount - 1) { + $sumOfOthers = array(); + $truncatedOrdinateSeries = array(); + $truncatedAbscissaLogos = array(); + $truncatedAbscissaSeries = array(); + foreach ($this->ordinateSeries as $column => $data) { + $truncatedOrdinateSeries[$column] = array(); + $sumOfOthers[$column] = 0; + } + + $i = 0; + for (; $i < $maxNumOfValues; $i++) { + foreach ($this->ordinateSeries as $column => $data) { + $truncatedOrdinateSeries[$column][] = $data[$i]; + } + + $truncatedAbscissaLogos[] = isset($this->abscissaLogos[$i]) ? $this->abscissaLogos[$i] : null; + $truncatedAbscissaSeries[] = $this->abscissaSeries[$i]; + } + + for (; $i < $abscissaSeriesCount; $i++) { + foreach ($this->ordinateSeries as $column => $data) { + $sumOfOthers[$column] += $data[$i]; + } + } + + foreach ($this->ordinateSeries as $column => $data) { + $truncatedOrdinateSeries[$column][] = $sumOfOthers[$column]; + } + + $truncatedAbscissaSeries[] = Piwik::translate('General_Others'); + $this->abscissaSeries = $truncatedAbscissaSeries; + $this->ordinateSeries = $truncatedOrdinateSeries; + $this->abscissaLogos = $truncatedAbscissaLogos; + } + + // blank characters are used to pad labels so the logo can be displayed + $paddingText = ''; + $paddingWidth = 0; + if ($maxLogoWidth > 0) { + while ($paddingWidth < $maxLogoWidth + self::LOGO_MIN_RIGHT_MARGIN) { + $paddingText .= self::PADDING_CHARS; + list($paddingWidth, $paddingHeight) = $this->getTextWidthHeight($paddingText); + } + } + + // determine the maximum label width according to the minimum comfortable graph size + $gridRightMargin = $this->getGridRightMargin($horizontalGraph = true); + $minGraphSize = ($this->width - $gridRightMargin) / 2; + + $metricLegendWidth = 0; + foreach ($this->ordinateLabels as $column => $label) { + list($textWidth, $textHeight) = $this->getTextWidthHeight($label); + $metricLegendWidth += $textWidth; + } + + $legendWidth = $metricLegendWidth + ((self::HORIZONTAL_LEGEND_LEFT_MARGIN + self::LEGEND_SQUARE_WIDTH) * $numberOfSeries); + if ($this->showLegend) { + if ($legendWidth > $minGraphSize) { + $minGraphSize = $legendWidth; + } + } + + $gridLeftMarginWithoutLabels = $this->getGridLeftMargin($horizontalGraph = true, $withLabel = false); + $labelWidthLimit = + $this->width + - $gridLeftMarginWithoutLabels + - $gridRightMargin + - $paddingWidth + - $minGraphSize; + + // truncate labels if needed + foreach ($this->abscissaSeries as &$label) { + $label = $this->truncateLabel($label, $labelWidthLimit); + } + + $gridLeftMarginBeforePadding = $this->getGridLeftMargin($horizontalGraph = true, $withLabel = true); + + // pad labels for logo space + foreach ($this->abscissaSeries as &$label) { + $label .= $paddingText; + } + + $this->initGridChart( + $displayVerticalGridLines = false, + $bulletType = LEGEND_FAMILY_BOX, + $horizontalGraph = true, + $showTicks = false, + $verticalLegend + ); + + $valueColor = $this->textColor; + $this->pImage->drawBarChart( + array( + 'DisplayValues' => true, + 'Interleave' => self::INTERLEAVE, + 'DisplayR' => $valueColor['R'], + 'DisplayG' => $valueColor['G'], + 'DisplayB' => $valueColor['B'], + ) + ); + +// // display icons + $graphData = $this->pData->getData(); + $numberOfRows = count($this->abscissaSeries); + $logoInterleave = $this->getGraphHeight(true, $verticalLegend) / $numberOfRows; + for ($i = 0; $i < $numberOfRows; $i++) { + if (isset($this->abscissaLogos[$i])) { + $logoPath = $this->abscissaLogos[$i]; + + if (isset($logoPathToHeight[$logoPath])) { + $logoHeight = $logoPathToHeight[$logoPath]; + + $pathInfo = pathinfo($logoPath); + $logoExtension = strtoupper($pathInfo['extension']); + $drawingFunction = 'drawFrom' . $logoExtension; + + $logoYPosition = + ($logoInterleave * $i) + + $this->getGridTopMargin(true, $verticalLegend) + + $graphData['Axis'][1]['Margin'] + - $logoHeight / 2 + + 1; + + if (method_exists($this->pImage, $drawingFunction)) { + $this->pImage->$drawingFunction( + $gridLeftMarginBeforePadding, + $logoYPosition, + $logoPath + ); + } + } + } + } + } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph/Pie.php b/www/analytics/plugins/ImageGraph/StaticGraph/Pie.php new file mode 100644 index 00000000..af54c638 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph/Pie.php @@ -0,0 +1,27 @@ +initPieGraph(false); + + $this->pieChart->draw2DPie( + $this->xPosition, + $this->yPosition, + $this->pieConfig + ); + } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph/Pie3D.php b/www/analytics/plugins/ImageGraph/StaticGraph/Pie3D.php new file mode 100644 index 00000000..81ec8c93 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph/Pie3D.php @@ -0,0 +1,27 @@ +initPieGraph(true); + + $this->pieChart->draw3DPie( + $this->xPosition, + $this->yPosition, + $this->pieConfig + ); + } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph/PieGraph.php b/www/analytics/plugins/ImageGraph/StaticGraph/PieGraph.php new file mode 100644 index 00000000..d28ca501 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph/PieGraph.php @@ -0,0 +1,128 @@ + '3C5A69', + self::SLICE_COLOR_KEY . '2' => '679BB5', + self::SLICE_COLOR_KEY . '3' => '695A3C', + self::SLICE_COLOR_KEY . '4' => 'B58E67', + self::SLICE_COLOR_KEY . '5' => '8AA68A', + self::SLICE_COLOR_KEY . '6' => 'A4D2A6', + ); + } + + protected function initPieGraph($showLegend) + { + $this->truncateSmallValues(); + $this->initpData(); + $this->initpImage(); + + if ($this->height > $this->width) { + $radius = ($this->width / 2) - self::RADIUS_MARGIN; + } else { + $radius = ($this->height / 2) - self::RADIUS_MARGIN; + } + + $this->pieChart = new pPie($this->pImage, $this->pData); + + $numberOfSlices = count($this->abscissaSeries); + $numberOfAvailableColors = count($this->colors); + for ($i = 0; $i < $numberOfSlices; $i++) { + $this->pieChart->setSliceColor($i, $this->colors[self::SLICE_COLOR_KEY . (($i % $numberOfAvailableColors) + 1)]); + } + + // max abscissa label width is used to set the pie right margin + list($abscissaMaxWidth, $abscissaMaxHeight) = $this->getMaximumTextWidthHeight($this->abscissaSeries); + + $this->xPosition = $this->width - $radius - $abscissaMaxWidth - self::PIE_RIGHT_MARGIN; + $this->yPosition = $this->height / 2; + + if ($showLegend) { + $this->pieChart->drawPieLegend(15, 40, array("Alpha" => 20)); + } + + $this->pieConfig = + array( + 'Radius' => $radius, + 'DrawLabels' => true, + 'DataGapAngle' => self::SECTOR_GAP, + 'DataGapRadius' => self::SECTOR_GAP, + ); + } + + /** + * this method logic is close to Piwik's core filter_truncate. + * it uses a threshold to determine if an abscissa value should be drawn on the PIE + * discarded abscissa values are summed in the 'other' abscissa value + * + * if this process is not perform, pChart will draw pie slices that are too small to see + */ + private function truncateSmallValues() + { + $metricColumns = array_keys($this->ordinateSeries); + $metricColumn = $metricColumns[0]; + + $ordinateValuesSum = 0; + foreach ($this->ordinateSeries[$metricColumn] as $ordinateValue) { + $ordinateValuesSum += $ordinateValue; + } + + $truncatedOrdinateSeries[$metricColumn] = array(); + $truncatedAbscissaSeries = array(); + $smallValuesSum = 0; + + $ordinateValuesCount = count($this->ordinateSeries[$metricColumn]); + for ($i = 0; $i < $ordinateValuesCount - 1; $i++) { + $ordinateValue = $this->ordinateSeries[$metricColumn][$i]; + if ($ordinateValue / $ordinateValuesSum > 0.01) { + $truncatedOrdinateSeries[$metricColumn][] = $ordinateValue; + $truncatedAbscissaSeries[] = $this->abscissaSeries[$i]; + } else { + $smallValuesSum += $ordinateValue; + } + } + + $smallValuesSum += $this->ordinateSeries[$metricColumn][$ordinateValuesCount - 1]; + if (($smallValuesSum / $ordinateValuesSum) > 0.01) { + $truncatedOrdinateSeries[$metricColumn][] = $smallValuesSum; + $truncatedAbscissaSeries[] = end($this->abscissaSeries); + } + + $this->ordinateSeries = $truncatedOrdinateSeries; + $this->abscissaSeries = $truncatedAbscissaSeries; + } +} diff --git a/www/analytics/plugins/ImageGraph/StaticGraph/VerticalBar.php b/www/analytics/plugins/ImageGraph/StaticGraph/VerticalBar.php new file mode 100644 index 00000000..c5146d7c --- /dev/null +++ b/www/analytics/plugins/ImageGraph/StaticGraph/VerticalBar.php @@ -0,0 +1,35 @@ +initGridChart( + $displayVerticalGridLines = false, + $bulletType = LEGEND_FAMILY_BOX, + $horizontalGraph = false, + $showTicks = true, + $verticalLegend = false + ); + + $this->pImage->drawBarChart( + array( + 'Interleave' => self::INTERLEAVE, + ) + ); + } +} diff --git a/www/analytics/plugins/ImageGraph/fonts/tahoma.ttf b/www/analytics/plugins/ImageGraph/fonts/tahoma.ttf new file mode 100644 index 00000000..ca5ab1b9 Binary files /dev/null and b/www/analytics/plugins/ImageGraph/fonts/tahoma.ttf differ diff --git a/www/analytics/plugins/ImageGraph/templates/index.twig b/www/analytics/plugins/ImageGraph/templates/index.twig new file mode 100644 index 00000000..b910e551 --- /dev/null +++ b/www/analytics/plugins/ImageGraph/templates/index.twig @@ -0,0 +1,6 @@ +{% for plot in titleAndUrls %} +

          {{ plot.0 }}

          + + + +{% endfor %} \ No newline at end of file diff --git a/www/analytics/plugins/ImageGraph/templates/testAllSizes.twig b/www/analytics/plugins/ImageGraph/templates/testAllSizes.twig new file mode 100644 index 00000000..c00c023d --- /dev/null +++ b/www/analytics/plugins/ImageGraph/templates/testAllSizes.twig @@ -0,0 +1,55 @@ +{% extends 'dashboard.twig' %} + +{% block content %} + {% set showSitesSelection=true %} + +
          +

          {{ 'ImageGraph_ImageGraph'|translate }} ::: {{ siteName }}

          + +
          + {% include '@CoreHome/_periodSelect.twig' %} +
          + +
          +
          + + + + + + + + + {% for type in graphTypes %} + + {% endfor %} + + {% for report in availableReports %} + {% if report.imageGraphUrl is defined %} + + + + {% for type in graphTypes %} + + {% endfor %} + + {% endif %} + {% endfor %} + +
          CategoryName{{ type }}
          {{ report.category }}{{ report.name }} +

          Graph {{ type }} for all supported sizes

          + {% for sizes in graphSizes %} +

          {{ sizes.0 }} + x {{ sizes.1 }} {% if sizes.2 is defined %} (scaled down to {{ sizes.3 }} x {{ sizes.4 }}){% endif %}

          + + {% endfor %} +
          +
          + +
          +
          +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/API.php b/www/analytics/plugins/Insights/API.php new file mode 100644 index 00000000..c36402a6 --- /dev/null +++ b/www/analytics/plugins/Insights/API.php @@ -0,0 +1,350 @@ +model = new Model(); + } + + private function getOverviewReports() + { + $reports = array(); + + /** + * Triggered to gather all reports to be displayed in the "Insight" and "Movers And Shakers" overview reports. + * Plugins that want to add new reports to the overview should subscribe to this event and add reports to the + * incoming array. API parameters can be configured as an array optionally. + * + * **Example** + * + * public function addReportToInsightsOverview(&$reports) + * { + * $reports['Actions_getPageUrls'] = array(); + * $reports['Actions_getDownloads'] = array('flat' => 1, 'minGrowthPercent' => 60); + * } + * + * @param array &$reports An array containing a report unique id as key and an array of API parameters as + * values. + */ + Piwik::postEvent('Insights.addReportToOverview', array(&$reports)); + + return $reports; + } + + /** + * Detects whether insights can be generated for this date/period combination or not. + * @param string $date eg 'today', '2012-12-12' + * @param string $period eg 'day' or 'week' + * + * @return bool + */ + public function canGenerateInsights($date, $period) + { + Piwik::checkUserHasSomeViewAccess(); + + try { + $model = new Model(); + $lastDate = $model->getLastDate($date, $period, 1); + } catch (\Exception $e) { + return false; + } + + if (empty($lastDate)) { + return false; + } + + return true; + } + + /** + * Generates insights for a set of reports. Plugins can add their own reports to be included in the insights + * overview by listening to the {@hook Insights.addReportToOverview} event. + * + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * + * @return DataTable\Map A map containing a dataTable for each insight report. See {@link getInsights()} for more + * information + */ + public function getInsightsOverview($idSite, $period, $date, $segment = false) + { + Piwik::checkUserHasViewAccess($idSite); + + $defaultParams = array( + 'limitIncreaser' => 3, + 'limitDecreaser' => 3, + 'minImpactPercent' => 1, + 'minGrowthPercent' => 25, + ); + + $map = $this->generateOverviewReport('getInsights', $idSite, $period, $date, $segment, $defaultParams); + + return $map; + } + + /** + * Detects the movers and shakers for a set of reports. Plugins can add their own reports to be included in this + * overview by listening to the {@hook Insights.addReportToOverview} event. + * + * @param int $idSite + * @param string $period + * @param string $date + * @param bool|string $segment + * + * @return DataTable\Map A map containing a dataTable for each movers and shakers report. See + * {@link getMoversAndShakers()} for more information + */ + public function getMoversAndShakersOverview($idSite, $period, $date, $segment = false) + { + Piwik::checkUserHasViewAccess($idSite); + + $defaultParams = array( + 'limitIncreaser' => 4, + 'limitDecreaser' => 4 + ); + + $map = $this->generateOverviewReport('getMoversAndShakers', $idSite, $period, $date, $segment, $defaultParams); + + return $map; + } + + private function generateOverviewReport($method, $idSite, $period, $date, $segment, array $defaultParams) + { + $tableManager = DataTable\Manager::getInstance(); + + /** @var DataTable[] $tables */ + $tables = array(); + foreach ($this->getOverviewReports() as $reportId => $reportParams) { + if (!empty($reportParams)) { + foreach ($defaultParams as $key => $defaultParam) { + if (!array_key_exists($key, $reportParams)) { + $reportParams[$key] = $defaultParam; + } + } + } + + $firstTableId = $tableManager->getMostRecentTableId(); + $table = $this->requestApiMethod($method, $idSite, $period, $date, $reportId, $segment, $reportParams); + $reportTableIds[] = $table->getId(); + $tableManager->deleteTablesExceptIgnored($reportTableIds, $firstTableId); + + $tables[] = $table; + } + + $map = new DataTable\Map(); + + foreach ($tables as $table) { + $map->addTable($table, $table->getMetadata('reportName')); + } + + return $map; + } + + /** + * Detects the movers and shakers of a given date / report combination. A mover and shakers has an higher impact + * than other rows on average. For instance if a sites pageviews increase by 10% a page that increased by 40% at the + * same time contributed significantly more to the success than the average of 10%. + * + * @param int $idSite + * @param string $period + * @param string $date + * @param string $reportUniqueId eg 'Actions_getPageUrls'. An id like 'Goals_getVisitsUntilConversion_idGoal--4' works as well. + * @param bool|string $segment + * @param int $comparedToXPeriods + * @param int $limitIncreaser Value '0' ignores all increasers + * @param int $limitDecreaser Value '0' ignores all decreasers + * + * @return DataTable + * + * @throws \Exception In case a report having the given ID does not exist + * @throws \Exception In case the report exists but does not return a dataTable + */ + public function getMoversAndShakers($idSite, $period, $date, $reportUniqueId, $segment = false, + $comparedToXPeriods = 1, $limitIncreaser = 4, $limitDecreaser = 4) + { + Piwik::checkUserHasViewAccess(array($idSite)); + + $metric = 'nb_visits'; + $orderBy = InsightReport::ORDER_BY_ABSOLUTE; + + $reportMetadata = $this->model->getReportByUniqueId($idSite, $reportUniqueId); + + if (empty($reportMetadata)) { + throw new \Exception('A report having the ID ' . $reportUniqueId . ' does not exist'); + } + + $totalValue = $this->model->getTotalValue($idSite, $period, $date, $metric); + $currentReport = $this->model->requestReport($idSite, $period, $date, $reportUniqueId, $metric, $segment); + $this->checkReportIsValid($currentReport); + + $lastDate = $this->model->getLastDate($date, $period, $comparedToXPeriods); + $lastTotalValue = $this->model->getTotalValue($idSite, $period, $lastDate, $metric); + $lastReport = $this->model->requestReport($idSite, $period, $lastDate, $reportUniqueId, $metric, $segment); + $this->checkReportIsValid($lastReport); + + $insight = new InsightReport(); + return $insight->generateMoverAndShaker($reportMetadata, $period, $date, $lastDate, $metric, $currentReport, $lastReport, $totalValue, $lastTotalValue, $orderBy, $limitIncreaser, $limitDecreaser); + } + + /** + * Generates insights by comparing the report for a given date/period with a different date and calculating the + * difference. The API can exclude rows which growth is not good enough or did not have enough impact. + * + * @param int $idSite + * @param string $period + * @param string $date + * @param string $reportUniqueId eg 'Actions_getPageUrls'. An id like 'Goals_getVisitsUntilConversion_idGoal--4' works as well. + * @param bool|string $segment + * @param int $limitIncreaser Value '0' ignores all increasers + * @param int $limitDecreaser Value '0' ignores all decreasers + * @param string $filterBy By default all rows will be ignored. If given only 'movers', 'new' or 'disappeared' will be returned. + * @param int $minImpactPercent The minimum impact in percent. Eg '2%' of 1000 visits means the change / + * increase / decrease has to be at least 20 visits. Usually the '2%' are based on the total + * amount of visits but for reports having way less visits the metric total is used. Eg A page + * has 1000 visits but only 100 visits having keywords. In this case a minimum impact of '2%' evaluates to 2 and not 20. + * @param int $minGrowthPercent The amount of percent a row has to increase or decrease at least compared to the previous period. + * If value is '20' the growth has to be either at least '+20%' or '-20%' and lower. + * @param int $comparedToXPeriods The report will be compared to X periods before. + * @param string $orderBy Orders the rows by 'absolute', 'relative' or 'importance'. + * + * @return DataTable + * + * @throws \Exception In case a report having the given ID does not exist + * @throws \Exception In case the report exists but does not return a dataTable + */ + public function getInsights( + $idSite, $period, $date, $reportUniqueId, $segment = false, $limitIncreaser = 5, $limitDecreaser = 5, + $filterBy = '', $minImpactPercent = 2, $minGrowthPercent = 20, + $comparedToXPeriods = 1, $orderBy = 'absolute') + { + Piwik::checkUserHasViewAccess(array($idSite)); + + $metric = 'nb_visits'; + + $reportMetadata = $this->model->getReportByUniqueId($idSite, $reportUniqueId); + + if (empty($reportMetadata)) { + throw new \Exception('A report having the ID ' . $reportUniqueId . ' does not exist'); + } + + $totalValue = $this->model->getTotalValue($idSite, $period, $date, $metric); + $currentReport = $this->model->requestReport($idSite, $period, $date, $reportUniqueId, $metric, $segment); + $this->checkReportIsValid($currentReport); + + $lastDate = $this->model->getLastDate($date, $period, $comparedToXPeriods); + $lastTotalValue = $this->model->getTotalValue($idSite, $period, $lastDate, $metric); + $lastReport = $this->model->requestReport($idSite, $period, $lastDate, $reportUniqueId, $metric, $segment); + $this->checkReportIsValid($lastReport); + + $minGrowthPercentPositive = abs($minGrowthPercent); + $minGrowthPercentNegative = -1 * $minGrowthPercentPositive; + + $relevantTotal = $this->model->getRelevantTotalValue($currentReport, $metric, $totalValue); + + $minMoversPercent = -1; + $minNewPercent = -1; + $minDisappearedPercent = -1; + + switch ($filterBy) { + case self::FILTER_BY_MOVERS: + $minMoversPercent = $minImpactPercent; + break; + case self::FILTER_BY_NEW: + $minNewPercent = $minImpactPercent; + break; + case self::FILTER_BY_DISAPPEARED: + $minDisappearedPercent = $minImpactPercent; + break; + default: + $minMoversPercent = $minImpactPercent; + $minNewPercent = $minImpactPercent; + $minDisappearedPercent = $minImpactPercent; + } + + $insight = new InsightReport(); + $table = $insight->generateInsight($reportMetadata, $period, $date, $lastDate, $metric, $currentReport, $lastReport, $relevantTotal, $minMoversPercent, $minNewPercent, $minDisappearedPercent, $minGrowthPercentPositive, $minGrowthPercentNegative, $orderBy, $limitIncreaser, $limitDecreaser); + $insight->markMoversAndShakers($table, $currentReport, $lastReport, $totalValue, $lastTotalValue); + + return $table; + } + + private function checkReportIsValid($report) + { + if (!($report instanceof DataTable)) { + throw new \Exception('Insight can be only generated for reports returning a dataTable'); + } + } + + private function requestApiMethod($method, $idSite, $period, $date, $reportId, $segment, $additionalParams) + { + $params = array( + 'method' => 'Insights.' . $method, + 'idSite' => $idSite, + 'date' => $date, + 'period' => $period, + 'format' => 'original', + 'reportUniqueId' => $reportId, + ); + + if (!empty($segment)) { + $params['segment'] = $segment; + } + + if (!empty($additionalParams)) { + foreach ($additionalParams as $key => $value) { + $params[$key] = $value; + } + } + + $request = new ApiRequest($params); + return $request->process(); + } + +} diff --git a/www/analytics/plugins/Insights/Controller.php b/www/analytics/plugins/Insights/Controller.php new file mode 100644 index 00000000..649cb056 --- /dev/null +++ b/www/analytics/plugins/Insights/Controller.php @@ -0,0 +1,84 @@ +prepareWidgetView('insightsOverviewWidget.twig'); + $view->reports = $this->requestApiReport('getInsightsOverview'); + + return $view->render(); + } + + public function getOverallMoversAndShakers() + { + $view = $this->prepareWidgetView('moversAndShakersOverviewWidget.twig'); + $view->reports = $this->requestApiReport('getMoversAndShakersOverview'); + + return $view->render(); + } + + private function prepareWidgetView($template) + { + if (!$this->canGenerateInsights()) { + + $view = new View('@Insights/cannotDisplayReport.twig'); + $this->setBasicVariablesView($view); + return $view; + } + + $view = new View('@Insights/' . $template); + $this->setBasicVariablesView($view); + + $view->properties = array( + 'order_by' => InsightReport::ORDER_BY_ABSOLUTE + ); + + return $view; + } + + private function requestApiReport($apiReport) + { + if (!$this->canGenerateInsights()) { + return; + } + + $idSite = Common::getRequestVar('idSite', null, 'int'); + $period = Common::getRequestVar('period', null, 'string'); + $date = Common::getRequestVar('date', null, 'string'); + + return API::getInstance()->$apiReport($idSite, $period, $date); + } + + private function canGenerateInsights() + { + $period = Common::getRequestVar('period', null, 'string'); + $date = Common::getRequestVar('date', null, 'string'); + + return API::getInstance()->canGenerateInsights($date, $period); + } +} diff --git a/www/analytics/plugins/Insights/DataTable/Filter/ExcludeLowValue.php b/www/analytics/plugins/Insights/DataTable/Filter/ExcludeLowValue.php new file mode 100644 index 00000000..659785de --- /dev/null +++ b/www/analytics/plugins/Insights/DataTable/Filter/ExcludeLowValue.php @@ -0,0 +1,56 @@ +columnToRead = $columnToRead; + $this->minimumValue = $minimumValue; + $this->columnToCheckToBeTrue = $columnToCheckToBeTrue; + } + + public function filter($table) + { + if (!$this->minimumValue) { + return; + } + + foreach ($table->getRows() as $key => $row) { + + if ($this->columnToCheckToBeTrue && !$row->getColumn($this->columnToCheckToBeTrue)) { + continue; + } + + $value = $row->getColumn($this->columnToRead); + + if ($this->minimumValue > abs($value)) { + $table->deleteRow($key); + } + } + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/DataTable/Filter/Insight.php b/www/analytics/plugins/Insights/DataTable/Filter/Insight.php new file mode 100644 index 00000000..699d9a66 --- /dev/null +++ b/www/analytics/plugins/Insights/DataTable/Filter/Insight.php @@ -0,0 +1,119 @@ +currentDataTable = $currentDataTable; + $this->considerMovers = $considerMovers; + $this->considerNew = $considerNew; + $this->considerDisappeared = $considerDisappeared; + } + + public function filter($table) + { + foreach ($this->currentDataTable->getRows() as $row) { + $this->addRowIfNewOrMover($table, $row); + } + + if ($this->considerDisappeared) { + foreach ($this->pastDataTable->getRows() as $row) { + $this->addRowIfDisappeared($table, $row); + } + } + } + + private function addRowIfDisappeared(DataTable $table, DataTable\Row $row) + { + if ($this->getRowFromTable($this->currentDataTable, $row)) { + return; + } + + $newValue = 0; + $oldValue = $row->getColumn($this->columnValueToRead); + $difference = $newValue - $oldValue; + + if ($oldValue == 0 && $newValue == 0) { + $growthPercentage = '0%'; + } else { + $growthPercentage = '-100%'; + } + + $this->addRow($table, $row, $growthPercentage, $newValue, $oldValue, $difference, $isDisappeared = true); + } + + private function addRowIfNewOrMover(DataTable $table, DataTable\Row $row) + { + $pastRow = $this->getPastRowFromCurrent($row); + + if (!$pastRow && !$this->considerNew) { + return; + } elseif ($pastRow && !$this->considerMovers) { + return; + } + + $isNew = false; + $isMover = false; + $isDisappeared = false; + + if (!$pastRow) { + $isNew = true; + $oldValue = 0; + } else { + $isMover = true; + $oldValue = $pastRow->getColumn($this->columnValueToRead); + } + + $difference = $this->getDividend($row); + if ($difference === false) { + return; + } + + $newValue = $row->getColumn($this->columnValueToRead); + $divisor = $this->getDivisor($row); + + $growthPercentage = $this->formatValue($difference, $divisor); + + $this->addRow($table, $row, $growthPercentage, $newValue, $oldValue, $difference, $isDisappeared, $isNew, $isMover); + } + + private function getRowFromTable(DataTable $table, DataTable\Row $row) + { + return $table->getRowFromLabel($row->getColumn('label')); + } + + private function addRow(DataTable $table, DataTable\Row $row, $growthPercentage, $newValue, $oldValue, $difference, $disappeared = false, $isNew = false, $isMover = false) + { + $columns = $row->getColumns(); + $columns['growth_percent'] = $growthPercentage; + $columns['growth_percent_numeric'] = str_replace('%', '', $growthPercentage); + $columns['grown'] = '-' != substr($growthPercentage, 0 , 1); + $columns['value_old'] = $oldValue; + $columns['value_new'] = $newValue; + $columns['difference'] = $difference; + $columns['importance'] = abs($difference); + $columns['isDisappeared'] = $disappeared; + $columns['isNew'] = $isNew; + $columns['isMover'] = $isMover; + + $table->addRowFromArray(array(DataTable\Row::COLUMNS => $columns)); + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/DataTable/Filter/Limit.php b/www/analytics/plugins/Insights/DataTable/Filter/Limit.php new file mode 100644 index 00000000..4097721e --- /dev/null +++ b/www/analytics/plugins/Insights/DataTable/Filter/Limit.php @@ -0,0 +1,54 @@ +columnToRead = $columnToRead; + $this->limitPositive = (int) $limitPositiveValues; + $this->limitNegative = (int) $limitNegativeValues; + } + + public function filter($table) + { + $countIncreaser = 0; + $countDecreaser = 0; + + foreach ($table->getRows() as $key => $row) { + + if ($row->getColumn($this->columnToRead) >= 0) { + + $countIncreaser++; + + if ($countIncreaser > $this->limitPositive) { + $table->deleteRow($key); + } + + } else { + $countDecreaser++; + + if ($countDecreaser > $this->limitNegative) { + $table->deleteRow($key); + } + + } + } + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/DataTable/Filter/MinGrowth.php b/www/analytics/plugins/Insights/DataTable/Filter/MinGrowth.php new file mode 100644 index 00000000..21a1b549 --- /dev/null +++ b/www/analytics/plugins/Insights/DataTable/Filter/MinGrowth.php @@ -0,0 +1,51 @@ +columnToRead = $columnToRead; + $this->minPositiveValue = $minPositiveValue; + $this->minNegativeValue = $minNegativeValue; + } + + public function filter($table) + { + if (!$this->minPositiveValue && !$this->minNegativeValue) { + return; + } + + foreach ($table->getRows() as $key => $row) { + + $growthNumeric = $row->getColumn($this->columnToRead); + + if ($growthNumeric >= $this->minPositiveValue && $growthNumeric >= 0) { + continue; + } elseif ($growthNumeric <= $this->minNegativeValue && $growthNumeric < 0) { + continue; + } + + $table->deleteRow($key); + } + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/DataTable/Filter/OrderBy.php b/www/analytics/plugins/Insights/DataTable/Filter/OrderBy.php new file mode 100644 index 00000000..6eff36cc --- /dev/null +++ b/www/analytics/plugins/Insights/DataTable/Filter/OrderBy.php @@ -0,0 +1,92 @@ +columnsToCheck = array($columnToRead, $columnSecondOrder, $columnThirdOrder); + } + + public function filter($table) + { + if (!$table->getRowsCount()) { + return; + } + + $table->sort(array($this, 'sort'), $this->columnsToCheck[0]); + } + + public function sort(Row $a, Row $b) + { + foreach ($this->columnsToCheck as $column) { + if ($column) { + + $valA = $a->getColumn($column); + $valB = $b->getColumn($column); + $sort = $this->sortVal($valA, $valB); + + if (isset($sort)) { + return $sort; + } + } + } + + return 0; + } + + private function sortVal($valA, $valB) + { + if ((!isset($valA) || $valA === false) && (!isset($valB) || $valB === false)) { + return 0; + } + + if (!isset($valA) || $valA === false) { + return 1; + } + + if (!isset($valB) || $valB === false) { + return -1; + } + + if ($valA === $valB) { + return null; + } + + if ($valA >= 0 && $valB < 0) { + return -1; + } + + if ($valA < 0 && $valB < 0) { + return $valA < $valB ? -1 : 1; + } + + if ($valA != $valB) { + return $valA < $valB ? 1 : -1; + } + + return null; + } + +} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/InsightReport.php b/www/analytics/plugins/Insights/InsightReport.php new file mode 100644 index 00000000..5310ff11 --- /dev/null +++ b/www/analytics/plugins/Insights/InsightReport.php @@ -0,0 +1,288 @@ +getTotalEvolution($totalValue, $lastTotalValue); + + $minMoversPercent = 1; + + if ($totalEvolution >= 100) { + // eg change from 50 to 150 = 200% + $factor = (int) ceil($totalEvolution / 500); + $minGrowthPercentPositive = $totalEvolution + ($factor * 40); // min +240% + $minGrowthPercentNegative = -70; // min -70% + $minDisappearedPercent = 8; // min 12 + $minNewPercent = min(($totalEvolution / 100) * 3, 10); // min 6% = min 10 of total visits up to max 10% + + } elseif ($totalEvolution >= 0) { + // eg change from 50 to 75 = 50% + $minGrowthPercentPositive = $totalEvolution + 20; // min 70% + $minGrowthPercentNegative = -1 * $minGrowthPercentPositive; // min -70% + $minDisappearedPercent = 7; + $minNewPercent = 5; + } else { + // eg change from 50 to 25 = -50% + $minGrowthPercentNegative = $totalEvolution - 20; // min -70% + $minGrowthPercentPositive = abs($minGrowthPercentNegative); // min 70% + $minDisappearedPercent = 7; + $minNewPercent = 5; + } + + if ($totalValue < 200 && $totalValue != 0) { + // force at least a change of 2 visits + $minMoversPercent = (int) ceil(2 / ($totalValue / 100)); + $minNewPercent = max($minNewPercent, $minMoversPercent); + $minDisappearedPercent = max($minDisappearedPercent, $minMoversPercent); + } + + $dataTable = $this->generateInsight($reportMetadata, $period, $date, $lastDate, $metric, $currentReport, $lastReport, $totalValue, $minMoversPercent, $minNewPercent, $minDisappearedPercent, $minGrowthPercentPositive, $minGrowthPercentNegative, $orderBy, $limitIncreaser, $limitDecreaser); + + $this->addMoversAndShakersMetadata($dataTable, $totalValue, $lastTotalValue); + + return $dataTable; + } + + /** + * Extends an already generated insight report by adding a column "isMoverAndShaker" whether a row is also a + * "Mover and Shaker" or not. + * + * Avoids the need to fetch all reports again when we already have the currentReport/lastReport + */ + public function markMoversAndShakers(DataTable $insight, $currentReport, $lastReport, $totalValue, $lastTotalValue) + { + if (!$insight->getRowsCount()) { + return; + } + + $limitIncreaser = max($insight->getRowsCount(), 3); + $limitDecreaser = max($insight->getRowsCount(), 3); + + $lastDate = $insight->getMetadata('lastDate'); + $date = $insight->getMetadata('date'); + $period = $insight->getMetadata('period'); + $metric = $insight->getMetadata('metric'); + $orderBy = $insight->getMetadata('orderBy'); + $reportMetadata = $insight->getMetadata('report'); + + $shakers = $this->generateMoverAndShaker($reportMetadata, $period, $date, $lastDate, $metric, $currentReport, $lastReport, $totalValue, $lastTotalValue, $orderBy, $limitIncreaser, $limitDecreaser); + + foreach ($insight->getRows() as $row) { + $label = $row->getColumn('label'); + + if ($shakers->getRowFromLabel($label)) { + $row->setColumn('isMoverAndShaker', true); + } else { + $row->setColumn('isMoverAndShaker', false); + } + } + + $this->addMoversAndShakersMetadata($insight, $totalValue, $lastTotalValue); + } + + /** + * @param array $reportMetadata + * @param string $period + * @param string $date + * @param string $lastDate + * @param string $metric + * @param DataTable $currentReport + * @param DataTable $lastReport + * @param int $totalValue + * @param int $minMoversPercent Exclude rows who moved and the difference is not at least min percent + * visits of totalVisits. -1 excludes movers. + * @param int $minNewPercent Exclude rows who are new and the difference is not at least min percent + * visits of totalVisits. -1 excludes all new. + * @param int $minDisappearedPercent Exclude rows who are disappeared and the difference is not at least min + * percent visits of totalVisits. -1 excludes all disappeared. + * @param int $minGrowthPercentPositive The actual growth of a row must be at least percent compared to the + * previous value (not total value) + * @param int $minGrowthPercentNegative The actual growth of a row must be lower percent compared to the + * previous value (not total value) + * @param string $orderBy Order by absolute, relative, importance + * @param int $limitIncreaser + * @param int $limitDecreaser + * + * @return DataTable + */ + public function generateInsight($reportMetadata, $period, $date, $lastDate, $metric, $currentReport, $lastReport, $totalValue, $minMoversPercent, $minNewPercent, $minDisappearedPercent, $minGrowthPercentPositive, $minGrowthPercentNegative, $orderBy, $limitIncreaser, $limitDecreaser) + { + $minChangeMovers = $this->getMinVisits($totalValue, $minMoversPercent); + $minIncreaseNew = $this->getMinVisits($totalValue, $minNewPercent); + $minDecreaseDisappeared = $this->getMinVisits($totalValue, $minDisappearedPercent); + + $dataTable = new DataTable(); + $dataTable->filter( + 'Piwik\Plugins\Insights\DataTable\Filter\Insight', + array( + $currentReport, + $lastReport, + $metric, + $considerMovers = (-1 !== $minMoversPercent), + $considerNew = (-1 !== $minNewPercent), + $considerDisappeared = (-1 !== $minDisappearedPercent) + ) + ); + + $dataTable->filter( + 'Piwik\Plugins\Insights\DataTable\Filter\MinGrowth', + array( + 'growth_percent_numeric', + $minGrowthPercentPositive, + $minGrowthPercentNegative + ) + ); + + if ($minIncreaseNew) { + $dataTable->filter( + 'Piwik\Plugins\Insights\DataTable\Filter\ExcludeLowValue', + array( + 'difference', + $minIncreaseNew, + 'isNew' + ) + ); + } + + if ($minChangeMovers) { + $dataTable->filter( + 'Piwik\Plugins\Insights\DataTable\Filter\ExcludeLowValue', + array( + 'difference', + $minChangeMovers, + 'isMover' + ) + ); + } + + if ($minDecreaseDisappeared) { + $dataTable->filter( + 'Piwik\Plugins\Insights\DataTable\Filter\ExcludeLowValue', + array( + 'difference', + $minDecreaseDisappeared, + 'isDisappeared' + ) + ); + } + + $dataTable->filter( + 'Piwik\Plugins\Insights\DataTable\Filter\OrderBy', + array( + $this->getOrderByColumn($orderBy), + $orderBy === self::ORDER_BY_RELATIVE ? $this->getOrderByColumn(self::ORDER_BY_ABSOLUTE) : $this->getOrderByColumn(self::ORDER_BY_RELATIVE), + $metric + ) + ); + + $dataTable->filter( + 'Piwik\Plugins\Insights\DataTable\Filter\Limit', + array( + 'growth_percent_numeric', + $limitIncreaser, + $limitDecreaser + ) + ); + + $metricName = $metric; + if (!empty($reportMetadata['metrics'][$metric])) { + $metricName = $reportMetadata['metrics'][$metric]; + } + + $dataTable->setMetadataValues(array( + 'reportName' => $reportMetadata['name'], + 'metricName' => $metricName, + 'date' => $date, + 'lastDate' => $lastDate, + 'period' => $period, + 'report' => $reportMetadata, + 'totalValue' => $totalValue, + 'orderBy' => $orderBy, + 'metric' => $metric, + 'minChangeMovers' => $minChangeMovers, + 'minIncreaseNew' => $minIncreaseNew, + 'minDecreaseDisappeared' => $minDecreaseDisappeared, + 'minGrowthPercentPositive' => $minGrowthPercentPositive, + 'minGrowthPercentNegative' => $minGrowthPercentNegative, + 'minMoversPercent' => $minMoversPercent, + 'minNewPercent' => $minNewPercent, + 'minDisappearedPercent' => $minDisappearedPercent + )); + + return $dataTable; + } + + private function getOrderByColumn($orderBy) + { + if (self::ORDER_BY_RELATIVE == $orderBy) { + $orderByColumn = 'growth_percent_numeric'; + } elseif (self::ORDER_BY_ABSOLUTE == $orderBy) { + $orderByColumn = 'difference'; + } elseif (self::ORDER_BY_IMPORTANCE == $orderBy) { + $orderByColumn = 'importance'; + } else { + throw new \Exception('Unsupported orderBy'); + } + + return $orderByColumn; + } + + private function getMinVisits($totalValue, $percent) + { + if ($percent <= 0) { + return 0; + } + + $minVisits = ceil(($totalValue / 100) * $percent); + + return (int) $minVisits; + } + + private function addMoversAndShakersMetadata(DataTable $dataTable, $totalValue, $lastTotalValue) + { + $totalEvolution = $this->getTotalEvolution($totalValue, $lastTotalValue); + + $dataTable->setMetadata('lastTotalValue', $lastTotalValue); + $dataTable->setMetadata('evolutionTotal', $totalEvolution); + $dataTable->setMetadata('evolutionDifference', $totalValue - $lastTotalValue); + } + + private function getTotalEvolution($totalValue, $lastTotalValue) + { + return Piwik::getPercentageSafe($totalValue - $lastTotalValue, $lastTotalValue, 1); + } +} diff --git a/www/analytics/plugins/Insights/Insights.php b/www/analytics/plugins/Insights/Insights.php new file mode 100644 index 00000000..9c915ce9 --- /dev/null +++ b/www/analytics/plugins/Insights/Insights.php @@ -0,0 +1,51 @@ + 'addWidgets', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'ViewDataTable.addViewDataTable' => 'getAvailableVisualizations' + ); + } + + public function getAvailableVisualizations(&$visualizations) + { + $visualizations[] = __NAMESPACE__ . '\\Visualizations\\Insight'; + } + + public function addWidgets() + { + WidgetsList::add('Insights_WidgetCategory', 'Insights_OverviewWidgetTitle', 'Insights', 'getInsightsOverview'); + WidgetsList::add('Insights_WidgetCategory', 'Insights_MoversAndShakersWidgetTitle', 'Insights', 'getOverallMoversAndShakers'); + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/Insights/stylesheets/insightVisualization.less"; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/Insights/javascripts/insightsDataTable.js"; + } + +} diff --git a/www/analytics/plugins/Insights/Model.php b/www/analytics/plugins/Insights/Model.php new file mode 100644 index 00000000..4245a48a --- /dev/null +++ b/www/analytics/plugins/Insights/Model.php @@ -0,0 +1,120 @@ +getReportByUniqueId($idSite, $reportUniqueId); + + $params = array( + 'method' => $report['module'] . '.' . $report['action'], + 'format' => 'original', + 'idSite' => $idSite, + 'period' => $period, + 'date' => $date, + 'filter_limit' => 1000, + 'showColumns' => $metric + ); + + if (!empty($segment)) { + $params['segment'] = $segment; + } + + if (!empty($report['parameters']) && is_array($report['parameters'])) { + $params = array_merge($params, $report['parameters']); + } + + $request = new ApiRequest($params); + $table = $request->process(); + + return $table; + } + + public function getLastDate($date, $period, $comparedToXPeriods) + { + $pastDate = Range::getDateXPeriodsAgo(abs($comparedToXPeriods), $date, $period); + + if (empty($pastDate[0])) { + throw new \Exception('Not possible to compare this date/period combination'); + } + + return $pastDate[0]; + } + + /** + * Returns either the $totalValue (eg 5500 visits) or the total value of the report + * (eg 2500 visits of 5500 total visits as for instance only 2500 visits came to the website using a search engine). + * + * If the metric total (2500) is much lower than $totalValue, the metric total will be returned, otherwise the + * $totalValue + */ + public function getRelevantTotalValue(DataTable $currentReport, $metric, $totalValue) + { + $totalMetric = $this->getMetricTotalValue($currentReport, $metric); + + if ($totalMetric > $totalValue) { + return $totalMetric; + } + + if (($totalMetric * 2) < $totalValue) { + return $totalMetric; + } + + return $totalValue; + } + + public function getTotalValue($idSite, $period, $date, $metric) + { + $visits = VisitsSummaryAPI::getInstance()->get($idSite, $period, $date, false, array($metric)); + $firstRow = $visits->getFirstRow(); + + if (empty($firstRow)) { + return 0; + } + + $totalValue = $firstRow->getColumn($metric); + + return (int) $totalValue; + } + + public function getMetricTotalValue(DataTable $currentReport, $metric) + { + $totals = $currentReport->getMetadata('totals'); + + if (!empty($totals[$metric])) { + $totalValue = (int) $totals[$metric]; + } else { + $totalValue = 0; + } + + return $totalValue; + } + + public function getReportByUniqueId($idSite, $reportUniqueId) + { + $processedReport = new ProcessedReport(); + $report = $processedReport->getReportMetadataByUniqueId($idSite, $reportUniqueId); + + return $report; + } +} diff --git a/www/analytics/plugins/Insights/Visualizations/Insight.php b/www/analytics/plugins/Insights/Visualizations/Insight.php new file mode 100644 index 00000000..0c24c1f5 --- /dev/null +++ b/www/analytics/plugins/Insights/Visualizations/Insight.php @@ -0,0 +1,121 @@ +requestConfig->filter_limit) { + $this->requestConfig->filter_limit = 10; + } + + $report = $this->requestConfig->apiMethodToRequestDataTable; + $report = str_replace('.', '_', $report); + + $this->requestConfig->apiMethodToRequestDataTable = 'Insights.getInsights'; + + $this->requestConfig->request_parameters_to_modify = array( + 'reportUniqueId' => $report, + 'minImpactPercent' => $this->requestConfig->min_impact_percent, + 'minGrowthPercent' => $this->requestConfig->min_growth_percent, + 'comparedToXPeriods' => $this->requestConfig->compared_to_x_periods_ago, + 'orderBy' => $this->requestConfig->order_by, + 'filterBy' => $this->requestConfig->filter_by, + 'limitIncreaser' => $this->getLimitIncrease(), + 'limitDecreaser' => $this->getLimitDecrease(), + ); + } + + private function getLimitIncrease() + { + $filterLimit = $this->requestConfig->filter_limit; + $limitIncrease = 0; + + if ($this->requestConfig->limit_increaser && !$this->requestConfig->limit_decreaser) { + $limitIncrease = $filterLimit; + } elseif ($this->requestConfig->limit_increaser && $this->requestConfig->limit_decreaser) { + $limitIncrease = round($filterLimit / 2); + } + + return $limitIncrease; + } + + private function getLimitDecrease() + { + $filterLimit = $this->requestConfig->filter_limit; + $limitDecrease = $filterLimit - $this->getLimitIncrease(); + + return abs($limitDecrease); + } + + public static function getDefaultRequestConfig() + { + return new Insight\RequestConfig(); + } + + public function isThereDataToDisplay() + { + return true; + } + + public function beforeRender() + { + $this->config->datatable_js_type = 'InsightsDataTable'; + $this->config->show_limit_control = true; + $this->config->show_pagination_control = false; + $this->config->show_offset_information = false; + $this->config->show_search = false; + + if (!self::canDisplayViewDataTable($this)) { + $this->assignTemplateVar('cannotDisplayReport', true); + return; + } + + $period = Common::getRequestVar('period', null, 'string'); + $this->assignTemplateVar('period', $period); + } + + public static function canDisplayViewDataTable(ViewDataTable $view) + { + $period = Common::getRequestVar('period', null, 'string'); + $date = Common::getRequestVar('date', null, 'string'); + + $canGenerateInsights = API::getInstance()->canGenerateInsights($date, $period); + + if (!$canGenerateInsights) { + return false; + } + + return parent::canDisplayViewDataTable($view); + } +} diff --git a/www/analytics/plugins/Insights/Visualizations/Insight/RequestConfig.php b/www/analytics/plugins/Insights/Visualizations/Insight/RequestConfig.php new file mode 100644 index 00000000..ab771a51 --- /dev/null +++ b/www/analytics/plugins/Insights/Visualizations/Insight/RequestConfig.php @@ -0,0 +1,45 @@ +disable_generic_filters = true; + $this->disable_queued_filters = true; + + $properties = array( + 'min_growth_percent', + 'order_by', + 'compared_to_x_periods_ago', + 'filter_by', + 'limit_increaser', + 'limit_decreaser', + 'filter_limit' + ); + + $this->addPropertiesThatShouldBeAvailableClientSide($properties); + $this->addPropertiesThatCanBeOverwrittenByQueryParams($properties); + } + +} diff --git a/www/analytics/plugins/Insights/images/idea.png b/www/analytics/plugins/Insights/images/idea.png new file mode 100644 index 00000000..644bbc93 Binary files /dev/null and b/www/analytics/plugins/Insights/images/idea.png differ diff --git a/www/analytics/plugins/Insights/javascripts/insightsDataTable.js b/www/analytics/plugins/Insights/javascripts/insightsDataTable.js new file mode 100644 index 00000000..7c2ca331 --- /dev/null +++ b/www/analytics/plugins/Insights/javascripts/insightsDataTable.js @@ -0,0 +1,118 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + + (function ($, require) { + + var exports = require('piwik/UI'), + DataTable = exports.DataTable, + dataTablePrototype = DataTable.prototype; + + var UIControl = exports.UIControl; + + function getValueFromEvent(event) + { + return event.target.value ? event.target.value : $(event.target).attr('value'); + } + + /** + * UI control that handles extra functionality for Actions datatables. + * + * @constructor + */ + exports.InsightsDataTable = function (element) { + this.parentAttributeParent = ''; + this.parentId = ''; + this.disabledRowDom = {}; // to handle double click on '+' row + + if ($(element).attr('data-table-onlyinsightsinit')) { + // overview-widget + UIControl.call(this, element); + this._init($(element)); + this.workingDivId = this._createDivId(); + $(element).attr('id', this.workingDivId); + + } else { + DataTable.call(this, element); + } + }; + + $.extend(exports.InsightsDataTable.prototype, dataTablePrototype, { + + handleRowActions: function () {}, + + _init: function (domElem) { + this.initShowIncreaseOrDecrease(domElem); + this.initOrderBy(domElem); + this.initComparedToXPeriodsAgo(domElem); + this.initFilterBy(domElem); + this.setFixWidthToMakeEllipsisWork(domElem); + }, + + setFixWidthToMakeEllipsisWork: function (domElem) { + var width = domElem.width(); + if (width) { + $('td.label', domElem).width(parseInt(width * 0.50, 10)); + } + }, + + _changeParameter: function (params) { + + var widgetParams = {}; + + for (var index in params) { + if (params.hasOwnProperty(index)) { + this.param[index] = params[index]; + widgetParams[index] = params[index]; + } + } + + this.notifyWidgetParametersChange(this.$element, widgetParams); + }, + + _changeParameterAndReload: function (params) { + this._changeParameter(params); + this.reloadAjaxDataTable(true); + }, + + initShowIncreaseOrDecrease: function (domElem) { + var self = this; + $('[name=showIncreaseOrDecrease]', domElem).bind('change', function (event) { + var value = getValueFromEvent(event); + + self._changeParameterAndReload({ + limit_increaser: (value == 'both' || value == 'increase') ? '5' : '0', + limit_decreaser: (value == 'both' || value == 'decrease') ? '5' : '0' + }); + }); + }, + + initOrderBy: function (domElem) { + var self = this; + $('[name=orderBy]', domElem).bind('change', function (event) { + self._changeParameterAndReload({order_by: getValueFromEvent(event)}); + }); + $('th[name=orderBy]', domElem).bind('click', function (event) { + self._changeParameterAndReload({order_by: getValueFromEvent(event)}); + }); + }, + + initComparedToXPeriodsAgo: function (domElem) { + var self = this; + $('[name=comparedToXPeriodsAgo]', domElem).bind('change', function (event) { + self._changeParameterAndReload({compared_to_x_periods_ago: getValueFromEvent(event)}); + }); + }, + + initFilterBy: function (domElem) { + var self = this; + $('[name=filterBy]', domElem).bind('change', function (event) { + self._changeParameterAndReload({filter_by: getValueFromEvent(event)}); + }); + } + }); + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/Insights/plugin.json b/www/analytics/plugins/Insights/plugin.json new file mode 100644 index 00000000..985eca4c --- /dev/null +++ b/www/analytics/plugins/Insights/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "Insights", + "version": "0.1.0", + "description": "Get insights", + "theme": false, + "license": "GPL v3+", + "homepage": "http://piwik.org", + "authors": [ + { + "name": "Piwik", + "email": "hello@piwik.org", + "homepage": "http://piwik.org" + } + ] +} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/stylesheets/insightVisualization.less b/www/analytics/plugins/Insights/stylesheets/insightVisualization.less new file mode 100644 index 00000000..0d09f179 --- /dev/null +++ b/www/analytics/plugins/Insights/stylesheets/insightVisualization.less @@ -0,0 +1,49 @@ +.dataTableVizInsight { + th.orderBy { + cursor:pointer; + } +} + +.dataTableVizInsight .dataTableFeatures, +.insightsDataTable { + + .controls { + padding: 10px; + padding-bottom: 0px; + } + + .controlSeparator { + height: 1px; + border: 0px; + background-color: #cccccc; + } + + th.orderBy { + width: 20%; + } + + th.orderBy.active { + font-weight:bold; + } + + .title { + word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + width: inherit; + display: block; + } + + .grown { + color:green; + } + + .notGrown { + color:red; + } + + .isMoverAndShaker { + font-weight:bold; + } + +} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/templates/cannotDisplayReport.twig b/www/analytics/plugins/Insights/templates/cannotDisplayReport.twig new file mode 100644 index 00000000..7c996e28 --- /dev/null +++ b/www/analytics/plugins/Insights/templates/cannotDisplayReport.twig @@ -0,0 +1,3 @@ +
          + {{ 'Insights_DatePeriodCombinationNotSupported'|translate }} +
          \ No newline at end of file diff --git a/www/analytics/plugins/Insights/templates/insightControls.twig b/www/analytics/plugins/Insights/templates/insightControls.twig new file mode 100644 index 00000000..e5fe930c --- /dev/null +++ b/www/analytics/plugins/Insights/templates/insightControls.twig @@ -0,0 +1,65 @@ +
          + + {% if period != 'range' %} + + {{ 'Insights_ControlComparedToDescription'|translate }} + + {% if period == 'day' %} + + {% elseif period == 'month' %} + + {% elseif period == 'week' %} + {{ 'Insights_WeekComparedToPreviousWeek'|translate }} + {% elseif period == 'year' %} + {{ 'Insights_YearComparedToPreviousYear'|translate }} + {% endif %} + {% endif %} + +
          + + {{ 'Insights_Filter'|translate }} + + + + +
          \ No newline at end of file diff --git a/www/analytics/plugins/Insights/templates/insightVisualization.twig b/www/analytics/plugins/Insights/templates/insightVisualization.twig new file mode 100644 index 00000000..6dd35e36 --- /dev/null +++ b/www/analytics/plugins/Insights/templates/insightVisualization.twig @@ -0,0 +1,34 @@ +{% if cannotDisplayReport is defined and cannotDisplayReport %} + {% include "@Insights/cannotDisplayReport.twig" %} +{% else %} + {% set metadata = dataTable.getAllTableMetadata%} + {% set consideredGrowth = 'Insights_TitleConsideredInsightsGrowth'|translate(metadata.minGrowthPercentPositive, metadata.lastDate|prettyDate(metadata.period)) %} + {% set consideredChanges = '' %} + + {% if metadata.minChangeMovers and metadata.minChangeMovers > 1 %} + {% set consideredChanges = 'Insights_IgnoredChanges'|translate(metadata.minChangeMovers) %} + {% endif %} + +
          + {% if dataTable.getRowsCount %} + + + + {% include "@Insights/table_header.twig" %} + + + + {% for row in dataTable.getRows %} + {% include "@Insights/table_row.twig" %} + {% endfor %} + +
          + {% else %} +
          + {{ 'Insights_NoResultMatchesCriteria'|translate }} +
          + {% endif %} + + {% include "@Insights/insightControls.twig" %} +
          +{% endif %} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/templates/insightsOverviewWidget.twig b/www/analytics/plugins/Insights/templates/insightsOverviewWidget.twig new file mode 100644 index 00000000..cf8bfe16 --- /dev/null +++ b/www/analytics/plugins/Insights/templates/insightsOverviewWidget.twig @@ -0,0 +1,5 @@ +{% set allMetadata = reports.getFirstRow.getAllTableMetadata %} +{% set consideredGrowth = 'Insights_TitleConsideredInsightsGrowth'|translate(allMetadata.minGrowthPercentPositive, allMetadata.lastDate|prettyDate(allMetadata.period)) %} +{% set consideredChanges = '' %} + +{% include "@Insights/overviewWidget.twig" %} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/templates/moversAndShakersOverviewWidget.twig b/www/analytics/plugins/Insights/templates/moversAndShakersOverviewWidget.twig new file mode 100644 index 00000000..f2326df1 --- /dev/null +++ b/www/analytics/plugins/Insights/templates/moversAndShakersOverviewWidget.twig @@ -0,0 +1,6 @@ +{% set allMetadata = reports.getFirstRow.getAllTableMetadata %} + +{% set consideredGrowth = 'Insights_TitleConsideredMoversAndShakersGrowth'|translate(allMetadata.metricName, allMetadata.lastTotalValue, allMetadata.totalValue, allMetadata.lastDate|prettyDate(allMetadata.period), allMetadata.evolutionTotal) %} +{% set consideredChanges = 'Insights_TitleConsideredMoversAndShakersChanges'|translate(allMetadata.minGrowthPercentPositive, allMetadata.minGrowthPercentNegative, allMetadata.minNewPercent, allMetadata.minIncreaseNew, allMetadata.minDisappearedPercent, allMetadata.minDecreaseDisappeared, allMetadata.totalValue) %} + +{% include "@Insights/overviewWidget.twig" %} \ No newline at end of file diff --git a/www/analytics/plugins/Insights/templates/overviewWidget.twig b/www/analytics/plugins/Insights/templates/overviewWidget.twig new file mode 100644 index 00000000..9c1f0767 --- /dev/null +++ b/www/analytics/plugins/Insights/templates/overviewWidget.twig @@ -0,0 +1,38 @@ +
          + {% if reports.getColumns|length > 0 %} + + + {% for dataTable in reports.getDataTables() if dataTable.getRowsCount > 0 %} + {% set metadata = dataTable.getAllTableMetadata %} + + + {% include "@Insights/table_header.twig" %} + + + + {% for row in dataTable.getRows %} + {% include "@Insights/table_row.twig" %} + {% endfor %} + + + {% endfor %} +
          + + + + {% else %} + +
          + {{ 'Insights_NoResultMatchesCriteria'|translate }} +
          + + {% endif %} +
          \ No newline at end of file diff --git a/www/analytics/plugins/Insights/templates/table_header.twig b/www/analytics/plugins/Insights/templates/table_header.twig new file mode 100644 index 00000000..7b30d06d --- /dev/null +++ b/www/analytics/plugins/Insights/templates/table_header.twig @@ -0,0 +1,13 @@ + + + {{ metadata.reportName }} + + + {{ metadata.metricName }} + + + {{ 'MultiSites_Evolution'|translate }} + + \ No newline at end of file diff --git a/www/analytics/plugins/Insights/templates/table_row.twig b/www/analytics/plugins/Insights/templates/table_row.twig new file mode 100644 index 00000000..ab681e08 --- /dev/null +++ b/www/analytics/plugins/Insights/templates/table_row.twig @@ -0,0 +1,29 @@ +{% if row.getColumn('isDisappeared') %} + {% set rowTitle = 'Insights_TitleRowDisappearedDetails'|translate(row.getColumn('label'), row.getColumn('value_old'), metadata.date|prettyDate(metadata.period), metadata.lastDate|prettyDate(metadata.period)) %} +{% elseif row.getColumn('isNew') %} + {% set rowTitle = 'Insights_TitleRowNewDetails'|translate(row.getColumn('label'), row.getColumn('value_new'), metadata.lastDate|prettyDate(metadata.period)) %} +{% else %} + {% set rowTitle = 'Insights_TitleRowChangeDetails'|translate(row.getColumn('label'), row.getColumn('value_old'), metadata.lastDate|prettyDate(metadata.period), row.getColumn('value_new'), metadata.date|prettyDate(metadata.period), metadata.metricName) %} +{% endif %} + +{% set rowTitleShaker = '' %} +{% if row.getColumn('isMoverAndShaker') %} + {% set rowTitleShaker = 'Insights_TitleRowMoverAndShaker'|translate %} +{% endif %} + + + + + {{ row.getColumn('label') }} + + + + {% if row.getColumn('grown') %} + +{{ row.getColumn('difference') }} + +{{ row.getColumn('growth_percent') }} + {% else %} + {{ row.getColumn('difference') }} + {{ row.getColumn('growth_percent') }} + {% endif %} + \ No newline at end of file diff --git a/www/analytics/plugins/Installation/Controller.php b/www/analytics/plugins/Installation/Controller.php new file mode 100644 index 00000000..163f7be7 --- /dev/null +++ b/www/analytics/plugins/Installation/Controller.php @@ -0,0 +1,1122 @@ + 'Installation_Welcome', + 'systemCheck' => 'Installation_SystemCheck', + 'databaseSetup' => 'Installation_DatabaseSetup', + 'databaseCheck' => 'Installation_DatabaseCheck', + 'tablesCreation' => 'Installation_Tables', + 'generalSetup' => 'Installation_SuperUser', + 'firstWebsiteSetup' => 'Installation_SetupWebsite', + 'trackingCode' => 'General_JsTrackingTag', + 'finished' => 'Installation_Congratulations', + ); + + protected $session; + + public function __construct() + { + $this->session = new SessionNamespace('Installation'); + if (!isset($this->session->currentStepDone)) { + $this->session->currentStepDone = ''; + $this->session->skipThisStep = array(); + } + } + + protected static function initServerFilesForSecurity() + { + if (SettingsServer::isIIS()) { + ServerFilesGenerator::createWebConfigFiles(); + } else { + ServerFilesGenerator::createHtAccessFiles(); + } + ServerFilesGenerator::createWebRootFiles(); + } + + /** + * Get installation steps + * + * @return array installation steps + */ + public function getInstallationSteps() + { + return $this->steps; + } + + /** + * Get default action (first installation step) + * + * @return string function name + */ + function getDefaultAction() + { + $steps = array_keys($this->steps); + return $steps[0]; + } + + /** + * Installation Step 1: Welcome + */ + function welcome($message = false) + { + // Delete merged js/css files to force regenerations based on updated activated plugin list + Filesystem::deleteAllCacheOnUpdate(); + + $view = new View( + '@Installation/welcome', + $this->getInstallationSteps(), + __FUNCTION__ + ); + + $view->newInstall = !$this->isFinishedInstallation(); + $view->errorMessage = $message; + $this->skipThisStep(__FUNCTION__); + $view->showNextStep = $view->newInstall; + $this->session->currentStepDone = __FUNCTION__; + return $view->render(); + } + + /** + * Installation Step 2: System Check + */ + function systemCheck() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + + $view = new View( + '@Installation/systemCheck', + $this->getInstallationSteps(), + __FUNCTION__ + ); + $this->skipThisStep(__FUNCTION__); + + $view->duringInstall = true; + + $this->setupSystemCheckView($view); + $this->session->general_infos = $view->infos['general_infos']; + $this->session->general_infos['salt'] = Common::generateUniqId(); + + // make sure DB sessions are used if the filesystem is NFS + if ($view->infos['is_nfs']) { + $this->session->general_infos['session_save_handler'] = 'dbtable'; + } + + $view->showNextStep = !$view->problemWithSomeDirectories + && $view->infos['phpVersion_ok'] + && count($view->infos['adapters']) + && !count($view->infos['missing_extensions']) + && !count($view->infos['missing_functions']); + // On the system check page, if all is green, display Next link at the top + $view->showNextStepAtTop = $view->showNextStep; + + $this->session->currentStepDone = __FUNCTION__; + + return $view->render(); + } + + /** + * Installation Step 3: Database Set-up + * @throws Exception|Zend_Db_Adapter_Exception + */ + function databaseSetup() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + + // case the user hits the back button + $this->session->skipThisStep = array( + 'firstWebsiteSetup' => false, + 'trackingCode' => false, + ); + + $this->skipThisStep(__FUNCTION__); + + $view = new View( + '@Installation/databaseSetup', + $this->getInstallationSteps(), + __FUNCTION__ + ); + + $view->showNextStep = false; + + $form = new FormDatabaseSetup(); + + if ($form->validate()) { + try { + $dbInfos = $form->createDatabaseObject(); + $this->session->databaseCreated = true; + + DbHelper::checkDatabaseVersion(); + $this->session->databaseVersionOk = true; + + $this->createConfigFileIfNeeded($dbInfos); + + $this->redirectToNextStep(__FUNCTION__); + } catch (Exception $e) { + $view->errorMessage = Common::sanitizeInputValue($e->getMessage()); + } + } + $view->addForm($form); + + return $view->render(); + } + + /** + * Installation Step 4: Database Check + */ + function databaseCheck() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + $view = new View( + '@Installation/databaseCheck', + $this->getInstallationSteps(), + __FUNCTION__ + ); + + $error = false; + $this->skipThisStep(__FUNCTION__); + + if (isset($this->session->databaseVersionOk) + && $this->session->databaseVersionOk === true + ) { + $view->databaseVersionOk = true; + } else { + $error = true; + } + + if (isset($this->session->databaseCreated) + && $this->session->databaseCreated === true + ) { + $view->databaseName = Config::getInstance()->database['dbname']; + $view->databaseCreated = true; + } else { + $error = true; + } + + $db = Db::get(); + + try { + $db->checkClientVersion(); + } catch (Exception $e) { + $view->clientVersionWarning = $e->getMessage(); + $error = true; + } + + if (!DbHelper::isDatabaseConnectionUTF8()) { + Config::getInstance()->database['charset'] = 'utf8'; + Config::getInstance()->forceSave(); + } + + $view->showNextStep = true; + $this->session->currentStepDone = __FUNCTION__; + + if ($error === false) { + $this->redirectToNextStep(__FUNCTION__); + } + return $view->render(); + } + + /** + * Installation Step 5: Table Creation + */ + function tablesCreation() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + + $view = new View( + '@Installation/tablesCreation', + $this->getInstallationSteps(), + __FUNCTION__ + ); + $this->skipThisStep(__FUNCTION__); + + if (Common::getRequestVar('deleteTables', 0, 'int') == 1) { + Db::dropAllTables(); + $view->existingTablesDeleted = true; + + // when the user decides to drop the tables then we dont skip the next steps anymore + // workaround ZF-1743 + $tmp = $this->session->skipThisStep; + $tmp['firstWebsiteSetup'] = false; + $tmp['trackingCode'] = false; + $this->session->skipThisStep = $tmp; + } + + $tablesInstalled = DbHelper::getTablesInstalled(); + $view->tablesInstalled = ''; + + if (count($tablesInstalled) > 0) { + + // we have existing tables + $view->tablesInstalled = implode(', ', $tablesInstalled); + $view->someTablesInstalled = true; + + Access::getInstance(); + Piwik::setUserHasSuperUserAccess(); + if ($this->hasEnoughTablesToReuseDb($tablesInstalled) && + count(APISitesManager::getInstance()->getAllSitesId()) > 0 && + count(APIUsersManager::getInstance()->getUsers()) > 0 + ) { + $view->showReuseExistingTables = true; + // when the user reuses the same tables we skip the website creation step + // workaround ZF-1743 + $tmp = $this->session->skipThisStep; + $tmp['firstWebsiteSetup'] = true; + $tmp['trackingCode'] = true; + $this->session->skipThisStep = $tmp; + } + } else { + DbHelper::createTables(); + DbHelper::createAnonymousUser(); + + Updater::recordComponentSuccessfullyUpdated('core', Version::VERSION); + $view->tablesCreated = true; + $view->showNextStep = true; + } + + $this->session->currentStepDone = __FUNCTION__; + return $view->render(); + } + + function reuseTables() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + + $steps = $this->getInstallationSteps(); + $steps['tablesCreation'] = 'Installation_ReusingTables'; + + $view = new View( + '@Installation/reuseTables', + $steps, + 'tablesCreation' + ); + + Access::getInstance(); + Piwik::setUserHasSuperUserAccess(); + + $updater = new Updater(); + $componentsWithUpdateFile = CoreUpdater::getComponentUpdates($updater); + + if (empty($componentsWithUpdateFile)) { + $this->session->currentStepDone = 'tablesCreation'; + $this->redirectToNextStep('tablesCreation'); + return ''; + } + + $oldVersion = Option::get('version_core'); + + $result = CoreUpdater::updateComponents($updater, $componentsWithUpdateFile); + + $view->coreError = $result['coreError']; + $view->warningMessages = $result['warnings']; + $view->errorMessages = $result['errors']; + $view->deactivatedPlugins = $result['deactivatedPlugins']; + $view->currentVersion = Version::VERSION; + $view->oldVersion = $oldVersion; + $view->showNextStep = true; + + $this->session->currentStepDone = 'tablesCreation'; + + return $view->render(); + } + + /** + * Installation Step 6: General Set-up (superuser login/password/email and subscriptions) + */ + function generalSetup() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + + $view = new View( + '@Installation/generalSetup', + $this->getInstallationSteps(), + __FUNCTION__ + ); + $this->skipThisStep(__FUNCTION__); + + $form = new FormGeneralSetup(); + + if ($form->validate()) { + + try { + $this->createSuperUser($form->getSubmitValue('login'), + $form->getSubmitValue('password'), + $form->getSubmitValue('email')); + + $url = Config::getInstance()->General['api_service_url']; + $url .= '/1.0/subscribeNewsletter/'; + $params = array( + 'email' => $form->getSubmitValue('email'), + 'security' => $form->getSubmitValue('subscribe_newsletter_security'), + 'community' => $form->getSubmitValue('subscribe_newsletter_community'), + 'url' => Url::getCurrentUrlWithoutQueryString(), + ); + if ($params['security'] == '1' + || $params['community'] == '1' + ) { + if (!isset($params['security'])) { + $params['security'] = '0'; + } + if (!isset($params['community'])) { + $params['community'] = '0'; + } + $url .= '?' . http_build_query($params, '', '&'); + try { + Http::sendHttpRequest($url, $timeout = 2); + } catch (Exception $e) { + // e.g., disable_functions = fsockopen; allow_url_open = Off + } + } + $this->redirectToNextStep(__FUNCTION__); + + } catch (Exception $e) { + $view->errorMessage = $e->getMessage(); + } + } + + $view->addForm($form); + + return $view->render(); + } + + /** + * Installation Step 7: Configure first web-site + */ + public function firstWebsiteSetup() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + + $view = new View( + '@Installation/firstWebsiteSetup', + $this->getInstallationSteps(), + __FUNCTION__ + ); + $this->skipThisStep(__FUNCTION__); + + $form = new FormFirstWebsiteSetup(); + if (!isset($this->session->generalSetupSuccessMessage)) { + $view->displayGeneralSetupSuccess = true; + $this->session->generalSetupSuccessMessage = true; + } + + $this->initObjectsToCallAPI(); + if ($form->validate()) { + $name = Common::unsanitizeInputValue($form->getSubmitValue('siteName')); + $url = Common::unsanitizeInputValue($form->getSubmitValue('url')); + $ecommerce = (int)$form->getSubmitValue('ecommerce'); + + try { + $result = APISitesManager::getInstance()->addSite($name, $url, $ecommerce); + $this->session->site_idSite = $result; + $this->session->site_name = $name; + $this->session->site_url = $url; + + $this->redirectToNextStep(__FUNCTION__); + } catch (Exception $e) { + $view->errorMessage = $e->getMessage(); + } + } + $view->addForm($form); + return $view->render(); + } + + /** + * Installation Step 8: Display JavaScript tracking code + */ + public function trackingCode() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + + $view = new View( + '@Installation/trackingCode', + $this->getInstallationSteps(), + __FUNCTION__ + ); + $this->skipThisStep(__FUNCTION__); + + if (!isset($this->session->firstWebsiteSetupSuccessMessage)) { + $view->displayfirstWebsiteSetupSuccess = true; + $this->session->firstWebsiteSetupSuccessMessage = true; + } + $siteName = $this->session->site_name; + $idSite = $this->session->site_idSite; + + // Load the Tracking code and help text from the SitesManager + $viewTrackingHelp = new \Piwik\View('@SitesManager/_displayJavascriptCode'); + $viewTrackingHelp->displaySiteName = $siteName; + $viewTrackingHelp->jsTag = Piwik::getJavascriptCode($idSite, Url::getCurrentUrlWithoutFileName()); + $viewTrackingHelp->idSite = $idSite; + $viewTrackingHelp->piwikUrl = Url::getCurrentUrlWithoutFileName(); + + $view->trackingHelp = $viewTrackingHelp->render(); + $view->displaySiteName = $siteName; + + $view->showNextStep = true; + + $this->session->currentStepDone = __FUNCTION__; + return $view->render(); + } + + /** + * Installation Step 9: Finished! + */ + public function finished() + { + $this->checkPreviousStepIsValid(__FUNCTION__); + + $view = new View( + '@Installation/finished', + $this->getInstallationSteps(), + __FUNCTION__ + ); + $this->skipThisStep(__FUNCTION__); + + if (!$this->isFinishedInstallation()) { + $this->addTrustedHosts(); + $this->markInstallationAsCompleted(); + } + + $view->showNextStep = false; + + $this->session->currentStepDone = __FUNCTION__; + $output = $view->render(); + + $this->session->unsetAll(); + + return $output; + } + + /** + * This controller action renders an admin tab that runs the installation + * system check, so people can see if there are any issues w/ their running + * Piwik installation. + * + * This admin tab is only viewable by the Super User. + */ + public function systemCheckPage() + { + Piwik::checkUserHasSuperUserAccess(); + + $view = new View( + '@Installation/systemCheckPage', + $this->getInstallationSteps(), + __FUNCTION__ + ); + $this->setBasicVariablesView($view); + + $view->duringInstall = false; + + $this->setupSystemCheckView($view); + + $infos = $view->infos; + $infos['extra'] = self::performAdminPageOnlySystemCheck(); + $view->infos = $infos; + + return $view->render(); + } + + /** + * Instantiate access and log objects + */ + protected function initObjectsToCallAPI() + { + Piwik::setUserHasSuperUserAccess(); + } + + /** + * Write configuration file from session-store + */ + protected function createConfigFileIfNeeded($dbInfos) + { + $config = Config::getInstance(); + + try { + // expect exception since config.ini.php doesn't exist yet + $config->checkLocalConfigFound(); + + } catch (Exception $e) { + + if (!empty($this->session->general_infos)) { + $config->General = $this->session->general_infos; + } + } + + $config->General['installation_in_progress'] = 1; + $config->database = $dbInfos; + $config->forceSave(); + + unset($this->session->general_infos); + } + + /** + * Write configuration file from session-store + */ + protected function markInstallationAsCompleted() + { + $config = Config::getInstance(); + unset($config->General['installation_in_progress']); + $config->forceSave(); + } + + /** + * Save language selection in session-store + */ + public function saveLanguage() + { + $language = Common::getRequestVar('language'); + LanguagesManager::setLanguageForSession($language); + Url::redirectToReferrer(); + } + + /** + * Prints out the CSS for installer/updater + * + * During installation and update process, we load a minimal Less file. + * At this point Piwik may not be setup yet to write files in tmp/assets/ + * so in this case we compile and return the string on every request. + */ + public function getBaseCss() + { + @header('Content-Type: text/css'); + return AssetManager::getInstance()->getCompiledBaseCss()->getContent(); + } + + /** + * The previous step is valid if it is either + * - any step before (OK to go back) + * - the current step (case when validating a form) + * If step is invalid, then exit. + * + * @param string $currentStep Current step + */ + protected function checkPreviousStepIsValid($currentStep) + { + $error = false; + + if (empty($this->session->currentStepDone)) { + $error = true; + } else if ($currentStep == 'finished' && $this->session->currentStepDone == 'finished') { + // ok to refresh this page or use language selector + } else if ($currentStep == 'reuseTables' && in_array($this->session->currentStepDone, array('tablesCreation', 'reuseTables'))) { + // this is ok, we cannot add 'reuseTables' to steps as it would appear in the menu otherwise + } else { + if ($this->isFinishedInstallation()) { + $error = true; + } + + $steps = array_keys($this->steps); + + // the currentStep + $currentStepId = array_search($currentStep, $steps); + + // the step before + $previousStepId = array_search($this->session->currentStepDone, $steps); + + // not OK if currentStepId > previous+1 + if ($currentStepId > $previousStepId + 1) { + $error = true; + } + } + if ($error) { + \Piwik\Plugins\Login\Controller::clearSession(); + $message = Piwik::translate('Installation_ErrorInvalidState', + array('
          ', + '', + '', + '') + ); + Piwik::exitWithErrorMessage($message); + } + } + + /** + * Redirect to next step + * + * @param string $currentStep Current step + * @return void + */ + protected function redirectToNextStep($currentStep) + { + $steps = array_keys($this->steps); + $this->session->currentStepDone = $currentStep; + $nextStep = $steps[1 + array_search($currentStep, $steps)]; + Piwik::redirectToModule('Installation', $nextStep); + } + + /** + * Skip this step (typically to mark the current function as completed) + * + * @param string $step function name + */ + protected function skipThisStep($step) + { + $skipThisStep = $this->session->skipThisStep; + if (isset($skipThisStep[$step]) && $skipThisStep[$step]) { + $this->redirectToNextStep($step); + } + } + + /** + * Extract host from URL + * + * @param string $url URL + * + * @return string|false + */ + protected function extractHost($url) + { + $urlParts = parse_url($url); + if (isset($urlParts['host']) && strlen($host = $urlParts['host'])) { + return $host; + } + + return false; + } + + /** + * Add trusted hosts + */ + protected function addTrustedHosts() + { + $trustedHosts = array(); + + // extract host from the request header + if (($host = $this->extractHost('http://' . Url::getHost())) !== false) { + $trustedHosts[] = $host; + } + + // extract host from first web site + if (($host = $this->extractHost(urldecode($this->session->site_url))) !== false) { + $trustedHosts[] = $host; + } + + $trustedHosts = array_unique($trustedHosts); + if (count($trustedHosts)) { + + $general = Config::getInstance()->General; + $general['trusted_hosts'] = $trustedHosts; + Config::getInstance()->General = $general; + + Config::getInstance()->forceSave(); + } + } + + /** + * Get system information + */ + public static function getSystemInformation() + { + global $piwik_minimumPHPVersion; + $minimumMemoryLimit = Config::getInstance()->General['minimum_memory_limit']; + + $infos = array(); + + $infos['general_infos'] = array(); + + + + $directoriesToCheck = array( + '/tmp/', + '/tmp/assets/', + '/tmp/cache/', + '/tmp/climulti/', + '/tmp/latest/', + '/tmp/logs/', + '/tmp/sessions/', + '/tmp/tcpdf/', + '/tmp/templates_c/', + ); + + if (!DbHelper::isInstalled()) { + // at install, need /config to be writable (so we can create config.ini.php) + $directoriesToCheck[] = '/config/'; + } + + $infos['directories'] = Filechecks::checkDirectoriesWritable($directoriesToCheck); + + $infos['can_auto_update'] = Filechecks::canAutoUpdate(); + + self::initServerFilesForSecurity(); + + $infos['phpVersion_minimum'] = $piwik_minimumPHPVersion; + $infos['phpVersion'] = PHP_VERSION; + $infos['phpVersion_ok'] = version_compare($piwik_minimumPHPVersion, $infos['phpVersion']) === -1; + + // critical errors + $extensions = @get_loaded_extensions(); + $needed_extensions = array( + 'zlib', + 'SPL', + 'iconv', + 'json', + 'mbstring', + ); + // HHVM provides the required subset of Reflection but lists Reflections as missing + if (!defined('HHVM_VERSION')) { + $needed_extensions[] = 'Reflection'; + } + $infos['needed_extensions'] = $needed_extensions; + $infos['missing_extensions'] = array(); + foreach ($needed_extensions as $needed_extension) { + if (!in_array($needed_extension, $extensions)) { + $infos['missing_extensions'][] = $needed_extension; + } + } + + // Special case for mbstring + if (!function_exists('mb_get_info') + || ((int)ini_get('mbstring.func_overload')) != 0) { + $infos['missing_extensions'][] = 'mbstring'; + } + + $infos['pdo_ok'] = false; + if (in_array('PDO', $extensions)) { + $infos['pdo_ok'] = true; + } + + $infos['adapters'] = Adapter::getAdapters(); + + $needed_functions = array( + 'debug_backtrace', + 'create_function', + 'eval', + 'gzcompress', + 'gzuncompress', + 'pack', + ); + $infos['needed_functions'] = $needed_functions; + $infos['missing_functions'] = array(); + foreach ($needed_functions as $needed_function) { + if (!self::functionExists($needed_function)) { + $infos['missing_functions'][] = $needed_function; + } + } + + + // warnings + $desired_extensions = array( + 'json', + 'libxml', + 'dom', + 'SimpleXML', + ); + $infos['desired_extensions'] = $desired_extensions; + $infos['missing_desired_extensions'] = array(); + foreach ($desired_extensions as $desired_extension) { + if (!in_array($desired_extension, $extensions)) { + $infos['missing_desired_extensions'][] = $desired_extension; + } + } + $desired_functions = array( + 'set_time_limit', + 'mail', + 'parse_ini_file', + 'glob', + ); + $infos['desired_functions'] = $desired_functions; + $infos['missing_desired_functions'] = array(); + foreach ($desired_functions as $desired_function) { + if (!self::functionExists($desired_function)) { + $infos['missing_desired_functions'][] = $desired_function; + } + } + + $infos['openurl'] = Http::getTransportMethod(); + + $infos['gd_ok'] = SettingsServer::isGdExtensionEnabled(); + + + $serverSoftware = isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : ''; + $infos['serverVersion'] = addslashes($serverSoftware); + $infos['serverOs'] = @php_uname(); + $infos['serverTime'] = date('H:i:s'); + + $infos['memoryMinimum'] = $minimumMemoryLimit; + + $infos['memory_ok'] = true; + $infos['memoryCurrent'] = ''; + + $raised = SettingsServer::raiseMemoryLimitIfNecessary(); + if (($memoryValue = SettingsServer::getMemoryLimitValue()) > 0) { + $infos['memoryCurrent'] = $memoryValue . 'M'; + $infos['memory_ok'] = $memoryValue >= $minimumMemoryLimit; + } + + $infos['isWindows'] = SettingsServer::isWindows(); + + $integrityInfo = Filechecks::getFileIntegrityInformation(); + $infos['integrity'] = $integrityInfo[0]; + + $infos['integrityErrorMessages'] = array(); + if (isset($integrityInfo[1])) { + if ($infos['integrity'] == false) { + $infos['integrityErrorMessages'][] = Piwik::translate('General_FileIntegrityWarningExplanation'); + } + $infos['integrityErrorMessages'] = array_merge($infos['integrityErrorMessages'], array_slice($integrityInfo, 1)); + } + + $infos['timezone'] = SettingsServer::isTimezoneSupportEnabled(); + + $infos['tracker_status'] = Common::getRequestVar('trackerStatus', 0, 'int'); + + $infos['protocol'] = ProxyHeaders::getProtocolInformation(); + if (!\Piwik\ProxyHttp::isHttps() && $infos['protocol'] !== null) { + $infos['general_infos']['assume_secure_protocol'] = '1'; + } + if (count($headers = ProxyHeaders::getProxyClientHeaders()) > 0) { + $infos['general_infos']['proxy_client_headers'] = $headers; + } + if (count($headers = ProxyHeaders::getProxyHostHeaders()) > 0) { + $infos['general_infos']['proxy_host_headers'] = $headers; + } + + // check if filesystem is NFS, if it is file based sessions won't work properly + $infos['is_nfs'] = Filesystem::checkIfFileSystemIsNFS(); + $infos = self::enrichSystemChecks($infos); + + return $infos; + } + + + /** + * @param $infos + * @return mixed + */ + public static function enrichSystemChecks($infos) + { + // determine whether there are any errors/warnings from the checks done above + $infos['has_errors'] = false; + $infos['has_warnings'] = false; + if (in_array(0, $infos['directories']) // if a directory is not writable + || !$infos['phpVersion_ok'] + || !empty($infos['missing_extensions']) + || empty($infos['adapters']) + || !empty($infos['missing_functions']) + ) { + $infos['has_errors'] = true; + } + + if ( !empty($infos['missing_desired_extensions']) + || !$infos['gd_ok'] + || !$infos['memory_ok'] + || !empty($infos['integrityErrorMessages']) + || !$infos['timezone'] // if timezone support isn't available + || $infos['tracker_status'] != 0 + || $infos['is_nfs'] + ) { + $infos['has_warnings'] = true; + } + return $infos; + } + + /** + * Test if function exists. Also handles case where function is disabled via Suhosin. + * + * @param string $functionName Function name + * @return bool True if function exists (not disabled); False otherwise. + */ + public static function functionExists($functionName) + { + // eval() is a language construct + if ($functionName == 'eval') { + // does not check suhosin.executor.eval.whitelist (or blacklist) + if (extension_loaded('suhosin')) { + return @ini_get("suhosin.executor.disable_eval") != "1"; + } + return true; + } + + $exists = function_exists($functionName); + if (extension_loaded('suhosin')) { + $blacklist = @ini_get("suhosin.executor.func.blacklist"); + if (!empty($blacklist)) { + $blacklistFunctions = array_map('strtolower', array_map('trim', explode(',', $blacklist))); + return $exists && !in_array($functionName, $blacklistFunctions); + } + } + return $exists; + } + + /** + * Utility function, sets up a view that will display system check info. + * + * @param View $view + */ + private function setupSystemCheckView($view) + { + $view->infos = self::getSystemInformation(); + + $view->helpMessages = array( + 'zlib' => 'Installation_SystemCheckZlibHelp', + 'SPL' => 'Installation_SystemCheckSplHelp', + 'iconv' => 'Installation_SystemCheckIconvHelp', + 'mbstring' => 'Installation_SystemCheckMbstringHelp', + 'Reflection' => 'Required extension that is built in PHP, see http://www.php.net/manual/en/book.reflection.php', + 'json' => 'Installation_SystemCheckWarnJsonHelp', + 'libxml' => 'Installation_SystemCheckWarnLibXmlHelp', + 'dom' => 'Installation_SystemCheckWarnDomHelp', + 'SimpleXML' => 'Installation_SystemCheckWarnSimpleXMLHelp', + 'set_time_limit' => 'Installation_SystemCheckTimeLimitHelp', + 'mail' => 'Installation_SystemCheckMailHelp', + 'parse_ini_file' => 'Installation_SystemCheckParseIniFileHelp', + 'glob' => 'Installation_SystemCheckGlobHelp', + 'debug_backtrace' => 'Installation_SystemCheckDebugBacktraceHelp', + 'create_function' => 'Installation_SystemCheckCreateFunctionHelp', + 'eval' => 'Installation_SystemCheckEvalHelp', + 'gzcompress' => 'Installation_SystemCheckGzcompressHelp', + 'gzuncompress' => 'Installation_SystemCheckGzuncompressHelp', + 'pack' => 'Installation_SystemCheckPackHelp', + 'php5-json' => 'Installation_SystemCheckJsonHelp', + ); + + $view->problemWithSomeDirectories = (false !== array_search(false, $view->infos['directories'])); + } + + /** + * Performs extra system checks for the 'System Check' admin page. These + * checks are not performed during Installation. + * + * The following checks are performed: + * - Check for whether LOAD DATA INFILE can be used. The result of the check + * is stored in $result['load_data_infile_available']. The error message is + * stored in $result['load_data_infile_error']. + * + * - Check whether geo location is setup correctly + * + * @return array + */ + public static function performAdminPageOnlySystemCheck() + { + $result = array(); + self::checkLoadDataInfile($result); + self::checkGeolocation($result); + return $result; + } + + + private static function checkGeolocation(&$result) + { + $currentProviderId = LocationProvider::getCurrentProviderId(); + $allProviders = LocationProvider::getAllProviderInfo(); + $isRecommendedProvider = in_array($currentProviderId, array( LocationProvider\GeoIp\Php::ID, $currentProviderId == LocationProvider\GeoIp\Pecl::ID)); + $isProviderInstalled = ($allProviders[$currentProviderId]['status'] == LocationProvider::INSTALLED); + + $result['geolocation_using_non_recommended'] = $result['geolocation_ok'] = false; + if ($isRecommendedProvider && $isProviderInstalled) { + $result['geolocation_ok'] = true; + } elseif ($isProviderInstalled) { + $result['geolocation_using_non_recommended'] = true; + } + } + + private static function checkLoadDataInfile(&$result) + { + // check if LOAD DATA INFILE works + $optionTable = Common::prefixTable('option'); + $testOptionNames = array('test_system_check1', 'test_system_check2'); + + $result['load_data_infile_available'] = false; + try { + $result['load_data_infile_available'] = Db\BatchInsert::tableInsertBatch( + $optionTable, + array('option_name', 'option_value'), + array( + array($testOptionNames[0], '1'), + array($testOptionNames[1], '2'), + ), + $throwException = true + ); + } catch (Exception $ex) { + $result['load_data_infile_error'] = str_replace("\n", "
          ", $ex->getMessage()); + } + + // delete the temporary rows that were created + Db::exec("DELETE FROM `$optionTable` WHERE option_name IN ('" . implode("','", $testOptionNames) . "')"); + } + + private function createSuperUser($login, $password, $email) + { + $this->initObjectsToCallAPI(); + + $api = APIUsersManager::getInstance(); + $api->addUser($login, $password, $email); + + $this->initObjectsToCallAPI(); + $api->setSuperUserAccess($login, true); + } + + private function isFinishedInstallation() + { + $isConfigFileFound = file_exists(Config::getInstance()->getLocalPath()); + + if (!$isConfigFileFound) { + return false; + } + + $general = Config::getInstance()->General; + + $isInstallationInProgress = false; + if (array_key_exists('installation_in_progress', $general)) { + $isInstallationInProgress = (bool) $general['installation_in_progress']; + } + + return !$isInstallationInProgress; + } + + private function hasEnoughTablesToReuseDb($tablesInstalled) + { + if (empty($tablesInstalled) || !is_array($tablesInstalled)) { + return false; + } + + $archiveTables = ArchiveTableCreator::getTablesArchivesInstalled(); + $baseTablesInstalled = count($tablesInstalled) - count($archiveTables); + $minimumCountPiwikTables = 12; + + return $baseTablesInstalled >= $minimumCountPiwikTables; + } + +} diff --git a/www/analytics/plugins/Installation/FormDatabaseSetup.php b/www/analytics/plugins/Installation/FormDatabaseSetup.php new file mode 100644 index 00000000..ce4df518 --- /dev/null +++ b/www/analytics/plugins/Installation/FormDatabaseSetup.php @@ -0,0 +1,315 @@ + 'off'), $trackSubmit); + } + + function init() + { + HTML_QuickForm2_Factory::registerRule('checkValidFilename', 'Piwik\Plugins\Installation\FormDatabaseSetup_Rule_checkValidFilename'); + + $checkUserPrivilegesClass = 'Piwik\Plugins\Installation\Rule_checkUserPrivileges'; + HTML_QuickForm2_Factory::registerRule('checkUserPrivileges', $checkUserPrivilegesClass); + + $availableAdapters = Adapter::getAdapters(); + $adapters = array(); + foreach ($availableAdapters as $adapter => $port) { + $adapters[$adapter] = $adapter; + } + + $this->addElement('text', 'host') + ->setLabel(Piwik::translate('Installation_DatabaseSetupServer')) + ->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_DatabaseSetupServer'))); + + $user = $this->addElement('text', 'username') + ->setLabel(Piwik::translate('Installation_DatabaseSetupLogin')); + $user->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_DatabaseSetupLogin'))); + $requiredPrivileges = Rule_checkUserPrivileges::getRequiredPrivilegesPretty(); + $user->addRule('checkUserPrivileges', + Piwik::translate('Installation_InsufficientPrivilegesMain', $requiredPrivileges . '

          ') . + Piwik::translate('Installation_InsufficientPrivilegesHelp')); + + $this->addElement('password', 'password') + ->setLabel(Piwik::translate('General_Password')); + + $item = $this->addElement('text', 'dbname') + ->setLabel(Piwik::translate('Installation_DatabaseSetupDatabaseName')); + $item->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_DatabaseSetupDatabaseName'))); + $item->addRule('checkValidFilename', Piwik::translate('General_NotValid', Piwik::translate('Installation_DatabaseSetupDatabaseName'))); + + $this->addElement('text', 'tables_prefix') + ->setLabel(Piwik::translate('Installation_DatabaseSetupTablePrefix')) + ->addRule('checkValidFilename', Piwik::translate('General_NotValid', Piwik::translate('Installation_DatabaseSetupTablePrefix'))); + + $this->addElement('select', 'adapter') + ->setLabel(Piwik::translate('Installation_DatabaseSetupAdapter')) + ->loadOptions($adapters) + ->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_DatabaseSetupAdapter'))); + + $this->addElement('submit', 'submit', array('value' => Piwik::translate('General_Next') . ' »', 'class' => 'submit')); + + // default values + $this->addDataSource(new HTML_QuickForm2_DataSource_Array(array( + 'host' => '127.0.0.1', + 'tables_prefix' => 'piwik_', + ))); + } + + /** + * Creates database object based on form data. + * + * @throws Exception|Zend_Db_Adapter_Exception + * @return array The database connection info. Can be passed into Piwik::createDatabaseObject. + */ + public function createDatabaseObject() + { + $dbname = $this->getSubmitValue('dbname'); + if (empty($dbname)) // disallow database object creation w/ no selected database + { + throw new Exception("No database name"); + } + + $adapter = $this->getSubmitValue('adapter'); + $port = Adapter::getDefaultPortForAdapter($adapter); + + $dbInfos = array( + 'host' => $this->getSubmitValue('host'), + 'username' => $this->getSubmitValue('username'), + 'password' => $this->getSubmitValue('password'), + 'dbname' => $dbname, + 'tables_prefix' => $this->getSubmitValue('tables_prefix'), + 'adapter' => $adapter, + 'port' => $port, + 'schema' => Config::getInstance()->database['schema'], + 'type' => Config::getInstance()->database['type'] + ); + + if (($portIndex = strpos($dbInfos['host'], '/')) !== false) { + // unix_socket=/path/sock.n + $dbInfos['port'] = substr($dbInfos['host'], $portIndex); + $dbInfos['host'] = ''; + } else if (($portIndex = strpos($dbInfos['host'], ':')) !== false) { + // host:port + $dbInfos['port'] = substr($dbInfos['host'], $portIndex + 1); + $dbInfos['host'] = substr($dbInfos['host'], 0, $portIndex); + } + + try { + @Db::createDatabaseObject($dbInfos); + } catch (Zend_Db_Adapter_Exception $e) { + $db = Adapter::factory($adapter, $dbInfos, $connect = false); + + // database not found, we try to create it + if ($db->isErrNo($e, '1049')) { + $dbInfosConnectOnly = $dbInfos; + $dbInfosConnectOnly['dbname'] = null; + @Db::createDatabaseObject($dbInfosConnectOnly); + @DbHelper::createDatabase($dbInfos['dbname']); + + // select the newly created database + @Db::createDatabaseObject($dbInfos); + } else { + throw $e; + } + } + + return $dbInfos; + } +} + +/** + * Validation rule that checks that the supplied DB user has enough privileges. + * + * The following privileges are required for Piwik to run: + * - CREATE + * - ALTER + * - SELECT + * - INSERT + * - UPDATE + * - DELETE + * - DROP + * - CREATE TEMPORARY TABLES + * + */ +class Rule_checkUserPrivileges extends HTML_QuickForm2_Rule +{ + const TEST_TABLE_NAME = 'piwik_test_table'; + const TEST_TEMP_TABLE_NAME = 'piwik_test_table_temp'; + + /** + * Checks that the DB user entered in the form has the necessary privileges for Piwik + * to run. + */ + public function validateOwner() + { + // try and create the database object + try { + $this->createDatabaseObject(); + } catch (Exception $ex) { + if ($this->isAccessDenied($ex)) { + return false; + } else { + return true; // if we can't create the database object, skip this validation + } + } + + $db = Db::get(); + + try { + // try to drop tables before running privilege tests + $this->dropExtraTables($db); + } catch (Exception $ex) { + if ($this->isAccessDenied($ex)) { + return false; + } else { + throw $ex; + } + } + + // check each required privilege by running a query that uses it + foreach (self::getRequiredPrivileges() as $privilegeType => $queries) { + if (!is_array($queries)) { + $queries = array($queries); + } + + foreach ($queries as $sql) { + try { + if (in_array($privilegeType, array('SELECT'))) { + $db->fetchAll($sql); + } else { + $db->exec($sql); + } + } catch (Exception $ex) { + if ($this->isAccessDenied($ex)) { + return false; + } else { + throw new Exception("Test SQL failed to execute: $sql\nError: " . $ex->getMessage()); + } + } + } + } + + // remove extra tables that were created + $this->dropExtraTables($db); + + return true; + } + + /** + * Returns an array describing the database privileges required for Piwik to run. The + * array maps privilege names with one or more SQL queries that can be used to test + * if the current user has the privilege. + * + * NOTE: LOAD DATA INFILE & LOCK TABLES privileges are not **required** so they're + * not checked. + * + * @return array + */ + public static function getRequiredPrivileges() + { + return array( + 'CREATE' => 'CREATE TABLE ' . self::TEST_TABLE_NAME . ' ( + id INT AUTO_INCREMENT, + value INT, + PRIMARY KEY (id), + KEY index_value (value) + )', + 'ALTER' => 'ALTER TABLE ' . self::TEST_TABLE_NAME . ' + ADD COLUMN other_value INT DEFAULT 0', + 'SELECT' => 'SELECT * FROM ' . self::TEST_TABLE_NAME, + 'INSERT' => 'INSERT INTO ' . self::TEST_TABLE_NAME . ' (value) VALUES (123)', + 'UPDATE' => 'UPDATE ' . self::TEST_TABLE_NAME . ' SET value = 456 WHERE id = 1', + 'DELETE' => 'DELETE FROM ' . self::TEST_TABLE_NAME . ' WHERE id = 1', + 'DROP' => 'DROP TABLE ' . self::TEST_TABLE_NAME, + 'CREATE TEMPORARY TABLES' => 'CREATE TEMPORARY TABLE ' . self::TEST_TEMP_TABLE_NAME . ' ( + id INT AUTO_INCREMENT, + PRIMARY KEY (id) + )', + ); + } + + /** + * Returns a string description of the database privileges required for Piwik to run. + * + * @return string + */ + public static function getRequiredPrivilegesPretty() + { + return implode('
          ', array_keys(self::getRequiredPrivileges())); + } + + /** + * Checks if an exception that was thrown after running a query represents an 'access denied' + * error. + * + * @param Exception $ex The exception to check. + * @return bool + */ + private function isAccessDenied($ex) + { + //NOte: this code is duplicated in Tracker.php error handler + return $ex->getCode() == 1044 || $ex->getCode() == 42000; + } + + /** + * Creates a database object using the connection information entered in the form. + * + * @return array + */ + private function createDatabaseObject() + { + return $this->owner->getContainer()->createDatabaseObject(); + } + + /** + * Drops the tables created by the privilege checking queries, if they exist. + * + * @param \Piwik\Db $db The database object to use. + */ + private function dropExtraTables($db) + { + $db->query('DROP TABLE IF EXISTS ' . self::TEST_TABLE_NAME . ', ' . self::TEST_TEMP_TABLE_NAME); + } +} + +/** + * Filename check for prefix/DB name + * + */ +class FormDatabaseSetup_Rule_checkValidFilename extends HTML_QuickForm2_Rule +{ + function validateOwner() + { + $prefix = $this->owner->getValue(); + return empty($prefix) + || Filesystem::isValidFilename($prefix); + } +} + diff --git a/www/analytics/plugins/Installation/FormFirstWebsiteSetup.php b/www/analytics/plugins/Installation/FormFirstWebsiteSetup.php new file mode 100644 index 00000000..750781fe --- /dev/null +++ b/www/analytics/plugins/Installation/FormFirstWebsiteSetup.php @@ -0,0 +1,89 @@ +getTimezonesList(); + $timezones = array_merge(array('No timezone' => Piwik::translate('SitesManager_SelectACity')), $timezones); + + $this->addElement('text', 'siteName') + ->setLabel(Piwik::translate('Installation_SetupWebSiteName')) + ->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_SetupWebSiteName'))); + + $url = $this->addElement('text', 'url') + ->setLabel(Piwik::translate('Installation_SetupWebSiteURL')); + $url->setAttribute('style', 'color:rgb(153, 153, 153);'); + $url->setAttribute('onfocus', $javascriptOnClickUrlExample); + $url->setAttribute('onclick', $javascriptOnClickUrlExample); + $url->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_SetupWebSiteURL'))); + + $tz = $this->addElement('select', 'timezone') + ->setLabel(Piwik::translate('Installation_Timezone')) + ->loadOptions($timezones); + $tz->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_Timezone'))); + $tz->addRule('checkTimezone', Piwik::translate('General_NotValid', Piwik::translate('Installation_Timezone'))); + $tz = $this->addElement('select', 'ecommerce') + ->setLabel(Piwik::translate('Goals_Ecommerce')) + ->loadOptions(array( + 0 => Piwik::translate('SitesManager_NotAnEcommerceSite'), + 1 => Piwik::translate('SitesManager_EnableEcommerce'), + )); + + $this->addElement('submit', 'submit', array('value' => Piwik::translate('General_Next') . ' »', 'class' => 'submit')); + + // default values + $this->addDataSource(new HTML_QuickForm2_DataSource_Array(array( + 'url' => $urlExample, + ))); + } +} + +/** + * Timezone validation rule + * + */ +class Rule_isValidTimezone extends HTML_QuickForm2_Rule +{ + function validateOwner() + { + try { + $timezone = $this->owner->getValue(); + if (!empty($timezone)) { + API::getInstance()->setDefaultTimezone($timezone); + } + } catch (\Exception $e) { + return false; + } + return true; + } +} diff --git a/www/analytics/plugins/Installation/FormGeneralSetup.php b/www/analytics/plugins/Installation/FormGeneralSetup.php new file mode 100644 index 00000000..146c1227 --- /dev/null +++ b/www/analytics/plugins/Installation/FormGeneralSetup.php @@ -0,0 +1,105 @@ + 'off'), $trackSubmit); + } + + function init() + { + HTML_QuickForm2_Factory::registerRule('checkLogin', 'Piwik\Plugins\Installation\Rule_isValidLoginString'); + HTML_QuickForm2_Factory::registerRule('checkEmail', 'Piwik\Plugins\Installation\Rule_isValidEmailString'); + + $login = $this->addElement('text', 'login') + ->setLabel(Piwik::translate('Installation_SuperUserLogin')); + $login->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_SuperUserLogin'))); + $login->addRule('checkLogin'); + + $password = $this->addElement('password', 'password') + ->setLabel(Piwik::translate('Installation_Password')); + $password->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_Password'))); + $pwMinLen = UsersManager::PASSWORD_MIN_LENGTH; + $pwMaxLen = UsersManager::PASSWORD_MAX_LENGTH; + $pwLenInvalidMessage = Piwik::translate('UsersManager_ExceptionInvalidPassword', array($pwMinLen, $pwMaxLen)); + $password->addRule('length', $pwLenInvalidMessage, array('min' => $pwMinLen, 'max' => $pwMaxLen)); + + $passwordBis = $this->addElement('password', 'password_bis') + ->setLabel(Piwik::translate('Installation_PasswordRepeat')); + $passwordBis->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_PasswordRepeat'))); + $passwordBis->addRule('eq', Piwik::translate('Installation_PasswordDoNotMatch'), $password); + + $email = $this->addElement('text', 'email') + ->setLabel(Piwik::translate('Installation_Email')); + $email->addRule('required', Piwik::translate('General_Required', Piwik::translate('Installation_Email'))); + $email->addRule('checkEmail', Piwik::translate('UsersManager_ExceptionInvalidEmail')); + + $this->addElement('checkbox', 'subscribe_newsletter_security', null, array( + 'content' => '  ' . Piwik::translate('Installation_SecurityNewsletter'), + )); + + $this->addElement('checkbox', 'subscribe_newsletter_community', null, array( + 'content' => '  ' . Piwik::translate('Installation_CommunityNewsletter'), + )); + + $this->addElement('submit', 'submit', array('value' => Piwik::translate('General_Next') . ' »', 'class' => 'submit')); + + // default values + $this->addDataSource(new HTML_QuickForm2_DataSource_Array(array( + 'subscribe_newsletter_community' => 1, + 'subscribe_newsletter_security' => 1, + ))); + } +} + +/** + * Login id validation rule + * + */ +class Rule_isValidLoginString extends HTML_QuickForm2_Rule +{ + function validateOwner() + { + try { + $login = $this->owner->getValue(); + if (!empty($login)) { + Piwik::checkValidLoginString($login); + } + } catch (\Exception $e) { + $this->setMessage($e->getMessage()); + return false; + } + return true; + } +} + +/** + * Email address validation rule + * + */ +class Rule_isValidEmailString extends HTML_QuickForm2_Rule +{ + function validateOwner() + { + return Piwik::isValidEmailString($this->owner->getValue()); + } +} diff --git a/www/analytics/plugins/Installation/Installation.php b/www/analytics/plugins/Installation/Installation.php new file mode 100644 index 00000000..eff0fde6 --- /dev/null +++ b/www/analytics/plugins/Installation/Installation.php @@ -0,0 +1,122 @@ + 'dispatch', + 'Config.badConfigurationFile' => 'dispatch', + 'Request.dispatch' => 'dispatchIfNotInstalledYet', + 'Menu.Admin.addItems' => 'addMenu', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + ); + return $hooks; + } + + public function dispatchIfNotInstalledYet(&$module, &$action, &$parameters) + { + $general = Config::getInstance()->General; + + if (empty($general['installation_in_progress'])) { + return; + } + + if ($module == 'Installation') { + return; + } + + $module = 'Installation'; + + if (!$this->isAllowedAction($action)) { + $action = 'welcome'; + } + + $parameters = array(); + } + + public function setControllerToLoad($newControllerName) + { + $this->installationControllerName = $newControllerName; + } + + protected function getInstallationController() + { + return new $this->installationControllerName(); + } + + /** + * @param \Exception|null $exception + */ + public function dispatch($exception = null) + { + if ($exception) { + $message = $exception->getMessage(); + } else { + $message = ''; + } + + Translate::loadCoreTranslation(); + + $action = Common::getRequestVar('action', 'welcome', 'string'); + + if ($this->isAllowedAction($action)) { + echo FrontController::getInstance()->dispatch('Installation', $action, array($message)); + } else { + Piwik::exitWithErrorMessage(Piwik::translate('Installation_NoConfigFound')); + } + + exit; + } + + /** + * Adds the 'System Check' admin page if the user is the Super User. + */ + public function addMenu() + { + MenuAdmin::addEntry('Installation_SystemCheck', + array('module' => 'Installation', 'action' => 'systemCheckPage'), + Piwik::hasUserSuperUserAccess(), + $order = 15); + } + + /** + * Adds CSS files to list of CSS files for asset manager. + */ + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/Installation/stylesheets/systemCheckPage.less"; + } + + private function isAllowedAction($action) + { + $controller = $this->getInstallationController(); + $isActionWhiteListed = in_array($action, array('saveLanguage', 'getBaseCss', 'reuseTables')); + + return in_array($action, array_keys($controller->getInstallationSteps())) + || $isActionWhiteListed; + } +} diff --git a/www/analytics/plugins/Installation/ServerFilesGenerator.php b/www/analytics/plugins/Installation/ServerFilesGenerator.php new file mode 100644 index 00000000..65085c71 --- /dev/null +++ b/www/analytics/plugins/Installation/ServerFilesGenerator.php @@ -0,0 +1,132 @@ +\nAllow from all\nRequire all granted\n\n\n\nAllow from all\nRequire all granted\n\n\n\nAllow from all\nRequire all granted\n\n"; + $deny = "\nDeny from all\nRequire all denied\n\n\n\nDeny from all\nRequire all denied\n\n\n\nDeny from all\nRequire all denied\n\n"; + + // more selective allow/deny filters + $allowAny = "\n" . $allow . "Satisfy any\n\n"; + $allowStaticAssets = "\n" . $allow . "Satisfy any\n\n"; + $denyDirectPhp = "\n" . $deny . "\n"; + + $directoriesToProtect = array( + '/js' => $allowAny, + '/libs' => $denyDirectPhp . $allowStaticAssets, + '/vendor' => $denyDirectPhp . $allowStaticAssets, + '/plugins' => $denyDirectPhp . $allowStaticAssets, + '/misc/user' => $denyDirectPhp . $allowStaticAssets, + ); + foreach ($directoriesToProtect as $directoryToProtect => $content) { + Filesystem::createHtAccess(PIWIK_INCLUDE_PATH . $directoryToProtect, $overwrite = true, $content); + } + } + + /** + * Generate IIS web.config files to restrict access + * + * Note: for IIS 7 and above + */ + public static function createWebConfigFiles() + { + @file_put_contents(PIWIK_INCLUDE_PATH . '/web.config', + ' + + + + + + + + + + + + + + + + + + + + + + + + + + + + +'); + + // deny direct access to .php files + $directoriesToProtect = array( + '/libs', + '/vendor', + '/plugins', + ); + foreach ($directoriesToProtect as $directoryToProtect) { + @file_put_contents(PIWIK_INCLUDE_PATH . $directoryToProtect . '/web.config', + ' + + + + + + + + + + +'); + } + } + + /** + * Generate default robots.txt, favicon.ico, etc to suppress + * 404 (Not Found) errors in the web server logs, if Piwik + * is installed in the web root (or top level of subdomain). + * + * @see misc/crossdomain.xml + */ + public static function createWebRootFiles() + { + $filesToCreate = array( + '/robots.txt', + '/favicon.ico', + ); + foreach ($filesToCreate as $file) { + @file_put_contents(PIWIK_DOCUMENT_ROOT . $file, ''); + } + } +} diff --git a/www/analytics/plugins/Installation/View.php b/www/analytics/plugins/Installation/View.php new file mode 100644 index 00000000..ea0e0911 --- /dev/null +++ b/www/analytics/plugins/Installation/View.php @@ -0,0 +1,51 @@ +steps = array_keys($installationSteps); + $this->allStepsTitle = array_values($installationSteps); + $this->currentStepName = $currentStepName; + $this->showNextStep = false; + } + + public function render() + { + // prepare the all steps templates + $this->currentStepId = array_search($this->currentStepName, $this->steps); + $this->totalNumberOfSteps = count($this->steps); + + $this->percentDone = round(($this->currentStepId) * 100 / ($this->totalNumberOfSteps - 1)); + $this->percentToDo = 100 - $this->percentDone; + + $this->nextModuleName = ''; + if (isset($this->steps[$this->currentStepId + 1])) { + $this->nextModuleName = $this->steps[$this->currentStepId + 1]; + } + $this->previousModuleName = ''; + if (isset($this->steps[$this->currentStepId - 1])) { + $this->previousModuleName = $this->steps[$this->currentStepId - 1]; + } + $this->previousPreviousModuleName = ''; + if (isset($this->steps[$this->currentStepId - 2])) { + $this->previousPreviousModuleName = $this->steps[$this->currentStepId - 2]; + } + + return parent::render(); + } +} diff --git a/www/analytics/plugins/Installation/javascripts/installation.js b/www/analytics/plugins/Installation/javascripts/installation.js new file mode 100644 index 00000000..8bfc92a0 --- /dev/null +++ b/www/analytics/plugins/Installation/javascripts/installation.js @@ -0,0 +1,8 @@ +$(function () { + $('#toFade').fadeOut(4000, function () { $(this).show().css({visibility: 'hidden'}); }); + $('input:first').focus(); + $('#progressbar').progressbar({ + value: parseInt($('#progressbar').attr('data-progress')) + }); + $('code').click(function () { $(this).select(); }); +}); \ No newline at end of file diff --git a/www/analytics/plugins/Installation/stylesheets/installation.css b/www/analytics/plugins/Installation/stylesheets/installation.css new file mode 100644 index 00000000..ece67834 --- /dev/null +++ b/www/analytics/plugins/Installation/stylesheets/installation.css @@ -0,0 +1,250 @@ +div.both { + clear: both; +} + +body { + background-color: #FFFBF9; + text-align: center; + font-family: Arial, Georgia, "Times New Roman", Times, serif; + font-size: 17px; +} + +p { + margin-bottom: 5px; + margin-top: 10px; +} + +#title { + font-size: 50px; + color: #284F92; + vertical-align: text-bottom; +} + +#subtitle { + color: #666; + font-size: 27px; + padding-left: 20px; + vertical-align: sub; +} + +#logo { + padding: 20px 30px; + padding-bottom: 35px; +} +#logo img { + height:40px; +} + +#installationPage #logo { + height: auto; + +} + +h2 { + font-size: 20px; + color: #666666; + border-bottom: 1px solid #DADADA; + padding: 0 0 7px; +} + +h3 { + margin-top: 10px; + font-size: 17px; + color: #3F5163; +} + +code { + font-size: 80%; +} + +.topBarElem { + font-family: arial, sans-serif !important; + font-size: 13px; + line-height: 1.33; +} + +#topRightBar { + float: right; + margin-top: -60px; +} + +.error { + color: red; + font-size: 100%; + font-weight: bold; + border: 2px solid red; + width: 550px; + padding: 20px; + margin-bottom: 10px; +} + +.error img { + border: 0; + float: right; + margin: 10px; +} + +.success { + color: #26981C; + font-size: 130%; + font-weight: bold; + padding: 10px; +} + +.warn { + color: #D7A006; + font-weight: bold; +} + +.warning { + margin: 10px; + color: #ff5502; + font-size: 130%; + font-weight: bold; + padding: 10px 20px 10px 30px; + border: 1px solid #ff5502; +} + +.warning ul { + list-style: disc; +} + +.success img, .warning img { + border: 0; + vertical-align: bottom; +} + +/* Cadre general */ +#installationPage { + margin: 30px 5px 5px; + text-align: left; +} + +#content { + font-size: 90%; + line-height: 1.4em; + width: 860px; + border: 1px solid #7A5A3F; + margin: auto; + background: #FFFFFF; + padding: 0.2em 2em 2em 2em; + border-radius: 8px; +} + +/* form errors */ +#adminErrors { + color: #FF6E46; + font-size: 120%; +} + +/* listing all the steps */ +#generalInstall { + float: left; + margin-left: 20px; + width: 19%; +} + +#detailInstall { + width: 75%; + float: right; +} + +#generalInstall ul { + list-style-type: decimal; +} + +li.futureStep { + color: #d3d3d3; +} + +li.actualStep { + font-weight: bold; +} + +li.pastStep { + color: #008000; +} + +p.nextStep a { + text-decoration: none; +} + +td { + border: 1px solid rgb(198, 205, 216); + border-top-color: #FFF; + color: #444; + padding: 0.5em 0.5em 0.5em 0.8em; +} + +.submit { + text-align: center; + cursor: pointer; + margin-top: 10px; +} + +.submit input { + margin-top: 15px; + background: transparent url(../../../plugins/Zeitgeist/images/background-submit.png) repeat scroll 0; + font-size: 1.4em; + border-color: #CCCCCC rgb(153, 153, 153) rgb(153, 153, 153) rgb(204, 204, 204); + border-style: double; + border-width: 3px; + color: #333333; + padding: 0.15em; +} + +input { + font-size: 18px; + border-color: #CCCCCC rgb(153, 153, 153) rgb(153, 153, 153) rgb(204, 204, 204); + border-width: 1px; + color: #3A2B16; + padding: 0.15em; +} + +#systemCheckLegend { + border: 1px solid #A5A5A5; + padding: 20px; + color: #727272; + margin-top: 30px; +} + +#systemCheckLegend img { + padding-right: 10px; + vertical-align: middle; + +} + +.reuseTables .error { + margin: 10px 0px; +} + +.reuseTables .warning { + color: #ff5502; + font-size: 100%; + font-weight: bold; + padding: 20px; + border: 2px solid #ff5502; + width: 550px; + margin: 10px 0px; +} + +.reuseTables .warning img { + border: 0; + float: right; + margin: 10px; +} + +.reuseTables ul { + padding-left: 16px; + list-style: disc; +} + +.infos img, .infosServer img { + padding-right: 10px; + vertical-align: middle; +} + +.err { + color: red; + font-weight: bold; +} diff --git a/www/analytics/plugins/Installation/stylesheets/systemCheckPage.less b/www/analytics/plugins/Installation/stylesheets/systemCheckPage.less new file mode 100755 index 00000000..579bd888 --- /dev/null +++ b/www/analytics/plugins/Installation/stylesheets/systemCheckPage.less @@ -0,0 +1,45 @@ +#systemCheckOptional, +#systemCheckRequired { + border: 1px solid #dadada; + width: 100%; + max-width: 900px; +} + +#systemCheckOptional { + margin-bottom: 2em; +} + +#systemCheckOptional td, +#systemCheckRequired td { + padding: 1em .5em 1em 2em; + vertical-align: middle; + font-size: 1.2em; + margin: 0; +} + +#systemCheckOptional tr:nth-child(even), +#systemCheckRequired tr:nth-child(even) { + background-color: #EFEEEC; +} + +#systemCheckOptional tr:nth-child(odd), +#systemCheckRequired tr:nth-child(odd) { + background-color: #F6F5F3; +} + +.error { + color: red; + font-size: 100%; + font-weight: bold; + border: 2px solid red; + width: 550px; + padding: 20px; + margin-bottom: 10px; +} + +.error img { + border: 0; + float: right; + margin: 10px; +} + diff --git a/www/analytics/plugins/Installation/templates/_allSteps.twig b/www/analytics/plugins/Installation/templates/_allSteps.twig new file mode 100644 index 00000000..7f5f8ba5 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/_allSteps.twig @@ -0,0 +1,11 @@ +
            + {% for stepId,stepName in allStepsTitle %} + {% if currentStepId > stepId %} +
          • {{ stepName|translate }}
          • + {% elseif currentStepId == stepId %} +
          • {{ stepName|translate }}
          • + {% else %} +
          • {{ stepName|translate }}
          • + {% endif %} + {% endfor %} +
          diff --git a/www/analytics/plugins/Installation/templates/_integrityDetails.twig b/www/analytics/plugins/Installation/templates/_integrityDetails.twig new file mode 100644 index 00000000..b285bb46 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/_integrityDetails.twig @@ -0,0 +1,40 @@ +{% if warningMessages is not defined %} + {% set warningMessages=infos.integrityErrorMessages %} +{% endif %} + + diff --git a/www/analytics/plugins/Installation/templates/_systemCheckLegend.twig b/www/analytics/plugins/Installation/templates/_systemCheckLegend.twig new file mode 100644 index 00000000..66e1d1b4 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/_systemCheckLegend.twig @@ -0,0 +1,15 @@ +
          + +

          {{ 'Installation_Legend'|translate }}

          +
          + {{ 'General_Warning'|translate }}: {{ 'Installation_SystemCheckWarning'|translate }} +
          + {{ 'General_Error'|translate }} + : {{ 'Installation_SystemCheckError'|translate }}
          + {{ 'General_Ok'|translate }}
          +
          +
          + +

          + {{ 'General_RefreshPage'|translate }} » +

          diff --git a/www/analytics/plugins/Installation/templates/_systemCheckSection.twig b/www/analytics/plugins/Installation/templates/_systemCheckSection.twig new file mode 100755 index 00000000..8f614df7 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/_systemCheckSection.twig @@ -0,0 +1,329 @@ +{% set ok %}{% endset %} +{% set error %}{% endset %} +{% set warning %}{% endset %} +{% set link %}{% endset %} + + + + {% set MinPHP %}{{ 'Installation_SystemCheckPhp'|translate }} > {{ infos.phpVersion_minimum }}{% endset %} + + + + + + + + + {% for adapter, port in infos.adapters %} + + + + + {% endfor %} + {% if infos.adapters|length == 0 %} + + + + {% endif %} + + + + + {% if infos.missing_extensions|length > 0 %} + + + + {% endif %} + + + + + + + + + {% if problemWithSomeDirectories %} + + + + {% endif %} +
          {{ MinPHP }} + {% if infos.phpVersion_ok %} + {{ ok }} + {% else %} + {{ error }} {{ 'General_Error'|translate }}: {{ 'General_Required'|translate(MinPHP)|raw }} + {% endif %} +
          PDO {{ 'Installation_Extension'|translate }} + {% if infos.pdo_ok %} + {{- ok -}} + {% else %} + - + {% endif %} +
          {{ adapter }} {{ 'Installation_Extension'|translate }}{{ ok }}
          + {{ 'Installation_SystemCheckDatabaseHelp'|translate }} +

          + {% if infos.isWindows %} + {{ 'Installation_SystemCheckWinPdoAndMysqliHelp'|translate("

          extension=php_mysqli.dll
          extension=php_pdo.dll
          extension=php_pdo_mysql.dll
          ")|raw|nl2br }} + {% else %} + {{ 'Installation_SystemCheckPdoAndMysqliHelp'|translate("

          --with-mysqli
          --with-pdo-mysql

          ","

          extension=mysqli.so
          extension=pdo.so
          extension=pdo_mysql.so
          ")|raw }} + {% endif %} + {{ 'Installation_RestartWebServer'|translate }} +
          +
          + {{ 'Installation_SystemCheckPhpPdoAndMysqli'|translate("","<\/a>","","<\/a>")|raw|nl2br }} +

          +
          {{ 'Installation_SystemCheckExtensions'|translate }} + {% for needed_extension in infos.needed_extensions %} + {% if needed_extension in infos.missing_extensions %} + {{ error }} + {% set hasError %}1{% endset %} + {% else %} + {{ ok }} + {% endif %} + {{ needed_extension }} +
          + {% endfor %} +
          {% if hasError is defined %}{{ 'Installation_RestartWebServer'|translate }}{% endif %} +
          + {% for missing_extension in infos.missing_extensions %} +

          + {{ helpMessages[missing_extension]|translate }} +

          + {% endfor %} +
          {{ 'Installation_SystemCheckFunctions'|translate }} + {% for needed_function in infos.needed_functions %} + {% if needed_function in infos.missing_functions %} + {{ error }} + {{ needed_function }} + {% set hasError %}1{% endset %} +

          + {{ helpMessages[needed_function]|translate }} +

          + {% else %} + {{ ok }} {{ needed_function }} +
          + {% endif %} + {% endfor %} +
          {% if hasError is defined %}{{ 'Installation_RestartWebServer'|translate }}{% endif %} +
          + {{ 'Installation_SystemCheckWriteDirs'|translate }} + + {% for dir, bool in infos.directories %} + {% if bool %} + {{ ok }} + {% else %} + {{ error }} + {% endif %} + {{ dir }} +
          + {% endfor %} +
          + {{ 'Installation_SystemCheckWriteDirsHelp'|translate }}: + {% for dir,bool in infos.directories %} +
            + {% if not bool %} +
          • +
            chmod a+w {{ dir }}
            +
          • + {% endif %} +
          + {% endfor %} +
          +
          + +

          {{ 'Installation_Optional'|translate }}

          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if duringInstall is empty %} + + + + + {% endif %} + {% if infos.general_infos.assume_secure_protocol is defined %} + + + + + {% endif %} + {% if infos.extra.load_data_infile_available is defined %} + + + + + {% endif %} + +
          {{ 'Installation_SystemCheckFileIntegrity'|translate }} + {% if infos.integrityErrorMessages is empty %} + {{ ok }} + {% else %} + {% if infos.integrity %} + {{ warning }} + {{ infos.integrityErrorMessages[0] }} + {% else %} + {{ error }} + {{ infos.integrityErrorMessages[0] }} + {% endif %} + {% if infos.integrityErrorMessages|length > 1 %} + + {% endif %} + {% endif %} +
          {{ 'Installation_SystemCheckTracker'|translate }} + {% if infos.tracker_status == 0 %} + {{ ok }} + {% else %} + {{ warning }} + {{ infos.tracker_status }} +
          {{ 'Installation_SystemCheckTrackerHelp'|translate }}
          +
          + {{ 'Installation_RestartWebServer'|translate }} + {% endif %} +
          {{ 'Installation_SystemCheckMemoryLimit'|translate }} + {% if infos.memory_ok %} + {{ ok }} {{ infos.memoryCurrent }} + {% else %} + {{ warning }} + {{ infos.memoryCurrent }} +
          + {{ 'Installation_SystemCheckMemoryLimitHelp'|translate }} + {{ 'Installation_RestartWebServer'|translate }} + {% endif %} +
          {{ 'SitesManager_Timezone'|translate }} + {% if infos.timezone %} + {{ ok }} + {% else %} + {{ warning }} + {{ 'SitesManager_AdvancedTimezoneSupportNotFound'|translate }} +
          + Timezone PHP documentation + . + {% endif %} +
          {{ 'Installation_SystemCheckOpenURL'|translate }} + {% if infos.openurl %} + {{ ok }} {{ infos.openurl }} + {% else %} + {{ warning }} + {{ 'Installation_SystemCheckOpenURLHelp'|translate }} + {% endif %} + {% if not infos.can_auto_update %} +
          + {{ warning }} {{ 'Installation_SystemCheckAutoUpdateHelp'|translate }} + {% endif %} +
          {{ 'Installation_SystemCheckGDFreeType'|translate }} + {% if infos.gd_ok %} + {{ ok }} + {% else %} + {{ warning }} {{ 'Installation_SystemCheckGDFreeType'|translate }} +
          + {{ 'Installation_SystemCheckGDHelp'|translate }}
          + {% endif %} +
          {{ 'Installation_SystemCheckOtherExtensions'|translate }} + {% for desired_extension in infos.desired_extensions %} + {% if desired_extension in infos.missing_desired_extensions %} + {{ warning }}{{ desired_extension }} +

          {{ helpMessages[desired_extension]|translate }}

          + {% else %} + {{ ok }} {{ desired_extension }} +
          + {% endif %} + {% endfor %} +
          {{ 'Installation_SystemCheckOtherFunctions'|translate }} + {% for desired_function in infos.desired_functions %} + {% if desired_function in infos.missing_desired_functions %} + {{ warning }} + {{ desired_function }} +

          {{ helpMessages[desired_function]|translate }}

          + {% else %} + {{ ok }} {{ desired_function }} +
          + {% endif %} + {% endfor %} +
          {{ 'Installation_Filesystem'|translate }} + {% if not infos.is_nfs %} + {{ ok }} {{ 'General_Ok'|translate }} +
          + {% else %} + {{ warning }} + {{ 'Installation_NfsFilesystemWarning'|translate }} + {% if duringInstall is not empty %} +

          {{ 'Installation_NfsFilesystemWarningSuffixInstall'|translate }}

          + {% else %} +

          {{ 'Installation_NfsFilesystemWarningSuffixAdmin'|translate }}

          + {% endif %} + {% endif %} +
          {{ 'UserCountry_Geolocation'|translate }} + {% if infos.extra.geolocation_ok %} + {{ ok }} {{ 'General_Ok'|translate }} +
          + {% elseif infos.extra.geolocation_using_non_recommended %} + {{ warning }} + {{ 'UserCountry_GeoIpLocationProviderNotRecomnended'|translate }} + {{ 'UserCountry_GeoIpLocationProviderDesc_ServerBased2'|translate('', '', '', '')|raw }} +
          + {% else %} + {{ warning }} + {{ 'UserCountry_DefaultLocationProviderDesc1'|translate }} + {{ 'UserCountry_DefaultLocationProviderDesc2'|translate('', '', '', '')|raw }} + + {% endif %} +
          {{ 'Installation_SystemCheckSecureProtocol'|translate }} + {{ warning }} {{ infos.protocol }}
          + {{ 'Installation_SystemCheckSecureProtocolHelp'|translate }} +

          + [General]
          + assume_secure_protocol = 1

          +
          {{ 'Installation_DatabaseAbilities'|translate }} + {% if infos.extra.load_data_infile_available %} + {{ ok }} LOAD DATA INFILE +
          + {% else %} + {{ warning }} + LOAD DATA INFILE +
          +
          +

          {{ 'Installation_LoadDataInfileUnavailableHelp'|translate("LOAD DATA INFILE","FILE") }}

          +

          {{ 'Installation_LoadDataInfileRecommended'|translate }}

          + {% if infos.extra.load_data_infile_error is defined %} + {{ 'General_Error'|translate }}: + {{ infos.extra.load_data_infile_error|raw }} + {% endif %} +

          Troubleshooting: FAQ on piwik.org

          + {% endif %} +
          + +{% include "@Installation/_integrityDetails.twig" %} diff --git a/www/analytics/plugins/Installation/templates/databaseCheck.twig b/www/analytics/plugins/Installation/templates/databaseCheck.twig new file mode 100644 index 00000000..36ce2614 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/databaseCheck.twig @@ -0,0 +1,36 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} +{% set ok %}{% endset %} +{% set error %}{% endset %} +{% set warning %}{% endset %} +{% set link %}{% endset %} + +

          {{ 'Installation_DatabaseCheck'|translate }}

          + + + + + + + + + + + {% if clientVersionWarning is defined %} + + + + {% endif %} + + + + +
          {{ 'Installation_DatabaseServerVersion'|translate }}{% if databaseVersionOk is defined %}{{ ok }}{% else %}{{ error }}{% endif %}
          {{ 'Installation_DatabaseClientVersion'|translate }}{% if clientVersionWarning is defined %}{{ warning }}{% else %}{{ ok }}{% endif %}
          + {{ clientVersionWarning }} +
          {{ 'Installation_DatabaseCreation'|translate }}{% if databaseCreated is defined %}{{ ok }}{% else %}{{ error }}{% endif %}
          + +

          + {{ link }} {{ 'Installation_Requirements'|translate }} +

          +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Installation/templates/databaseSetup.twig b/www/analytics/plugins/Installation/templates/databaseSetup.twig new file mode 100644 index 00000000..a6e97db5 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/databaseSetup.twig @@ -0,0 +1,18 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} +

          {{ 'Installation_DatabaseSetup'|translate }}

          + +{% if errorMessage is defined %} +
          + + {{ 'Installation_DatabaseErrorConnect'|translate }}: +
          {{ errorMessage|raw }} + +
          +{% endif %} + +{% if form_data is defined %} + {% include "genericForm.twig" %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Installation/templates/finished.twig b/www/analytics/plugins/Installation/templates/finished.twig new file mode 100644 index 00000000..2f30910c --- /dev/null +++ b/www/analytics/plugins/Installation/templates/finished.twig @@ -0,0 +1,24 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} +

          {{ 'Installation_Congratulations'|translate|raw }}

          + +{{ 'Installation_CongratulationsHelp'|translate|raw }} + + +
          +

          {{ 'Installation_WelcomeToCommunity'|translate }}

          +

          +{{ 'Installation_CollaborativeProject'|translate }} +

          + {{ 'Installation_GetInvolved'|translate('','')|raw }} + {{ 'General_HelpTranslatePiwik'|translate("","<\/a>")|raw }} +

          +

          {{ 'Installation_WeHopeYouWillEnjoyPiwik'|translate }}

          +

          {{ 'Installation_HappyAnalysing'|translate }}

          + +

          + {{ 'General_ContinueToPiwik'|translate }} » + +

          +{% endblock %} diff --git a/www/analytics/plugins/Installation/templates/firstWebsiteSetup.twig b/www/analytics/plugins/Installation/templates/firstWebsiteSetup.twig new file mode 100644 index 00000000..4c2ad9b0 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/firstWebsiteSetup.twig @@ -0,0 +1,27 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} +{% if displayGeneralSetupSuccess is defined %} + + {{ 'Installation_SuperUserSetupSuccess'|translate }} + + +{% endif %} + +

          {{ 'Installation_SetupWebsite'|translate }}

          +

          {{ 'Installation_SiteSetup'|translate }}

          +{% if errorMessage is defined %} +
          + + {{ 'Installation_SetupWebsiteError'|translate }}: +
          - {{ errorMessage|raw }} + +
          +{% endif %} + +{% if form_data is defined %} + {% include "genericForm.twig" %} +{% endif %} +
          +

          {{ 'Installation_SiteSetupFootnote'|translate }}

          +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Installation/templates/generalSetup.twig b/www/analytics/plugins/Installation/templates/generalSetup.twig new file mode 100644 index 00000000..e031d67a --- /dev/null +++ b/www/analytics/plugins/Installation/templates/generalSetup.twig @@ -0,0 +1,18 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} +

          {{ 'Installation_SuperUser'|translate }}

          + +{% if errorMessage is defined %} +
          + + {{ 'Installation_SuperUserSetupError'|translate }}: +
          - {{ errorMessage|raw }} + +
          +{% endif %} + +{% if form_data is defined %} + {% include "genericForm.twig" %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Installation/templates/layout.twig b/www/analytics/plugins/Installation/templates/layout.twig new file mode 100644 index 00000000..76de91ec --- /dev/null +++ b/www/analytics/plugins/Installation/templates/layout.twig @@ -0,0 +1,62 @@ + + + + + Piwik › {{ 'Installation_Installation'|translate }} + + + + + + + + + {% if 'General_LayoutDirection'|translate =='rtl' %} + + {% endif %} + + +
          +
          + +
          +
          + {{ postEvent('Template.topBar')|raw }} +
          +
          + +
          + {% include "@Installation/_allSteps.twig" %} +
          + +
          + {% set nextButton %} +

          + {{ 'General_Next'|translate }} » +

          + {% endset %} + {% if showNextStepAtTop is defined and showNextStepAtTop %} + {{ nextButton }} + {% endif %} + {% block content %}{% endblock %} + {% if showNextStep %} + {{ nextButton }} + {% endif %} +
          + +
          + +
          +
          + +

          {{ 'Installation_InstallationStatus'|translate }}

          + +
          + {{ 'Installation_PercentDone'|translate(percentDone) }} +
          +
          + + diff --git a/www/analytics/plugins/Installation/templates/reuseTables.twig b/www/analytics/plugins/Installation/templates/reuseTables.twig new file mode 100644 index 00000000..e963f0c2 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/reuseTables.twig @@ -0,0 +1,81 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} + +{% set helpMessage %}{{- 'CoreUpdater_HelpMessageContent'|translate('[',']',"
          ")|raw }}{% endset %} + +

          {{ 'Installation_ReusingTables'|translate }}

          + +
          + +{% if coreError %} +
          + {{ 'CoreUpdater_CriticalErrorDuringTheUpgradeProcess'|translate }} +
            + {% for message in errorMessages %} +
          • {{ message }}
          • + {% endfor %} +
          +
          +

          {{ 'CoreUpdater_HelpMessageIntroductionWhenError'|translate }} +

            +
          • {{ helpMessage }}
          • +
          +

          +

          {{ 'CoreUpdater_ErrorDIYHelp'|translate }} +

            +
          • {{ 'CoreUpdater_ErrorDIYHelp_1'|translate }}
          • +
          • {{ 'CoreUpdater_ErrorDIYHelp_2'|translate }}
          • +
          • {{ 'CoreUpdater_ErrorDIYHelp_3'|translate }} (see FAQ)
          • +
          • {{ 'CoreUpdater_ErrorDIYHelp_4'|translate }}
          • +
          • {{ 'CoreUpdater_ErrorDIYHelp_5'|translate }}
          • +
          +

          +{% else %} + {% if warningMessages|length > 0 %} +
          + {{ 'CoreUpdater_WarningMessages'|translate }} +
            + {% for message in warningMessages %} +
          • {{ message }}
          • + {% endfor %} +
          +
          + {% endif %} + + {% if errorMessages|length > 0 %} +
          + {{ 'CoreUpdater_ErrorDuringPluginsUpdates'|translate }} +
            + {% for message in errorMessages %} +
          • {{ message }}
          • + {% endfor %} +
          + + {% if deactivatedPlugins is defined and deactivatedPlugins|length > 0 %} + {% set listOfDeactivatedPlugins=deactivatedPlugins|join(', ') %} +

          + + {{ 'CoreUpdater_WeAutomaticallyDeactivatedTheFollowingPlugins'|translate(listOfDeactivatedPlugins) }} +

          + {% endif %} +
          + {% endif %} + + {% if errorMessages|length > 0 or warningMessages|length > 0 %} +

          {{ 'CoreUpdater_HelpMessageIntroductionWhenWarning'|translate }} +

            +
          • {{ helpMessage }}
          • +
          +

          + {% else %} +
          {{ 'Installation_TablesUpdatedSuccess'|translate(oldVersion, currentVersion) }} +
          +
          + + {% endif %} + +{% endif %} + +
          +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Installation/templates/systemCheck.twig b/www/analytics/plugins/Installation/templates/systemCheck.twig new file mode 100644 index 00000000..0cc06ae4 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/systemCheck.twig @@ -0,0 +1,21 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} +{% if not showNextStep %} + {% include "@Installation/_systemCheckLegend.twig" %} +
          +{% endif %} + +

          {{ 'Installation_SystemCheck'|translate }}

          +
          +{% include "@Installation/_systemCheckSection.twig" %} + +{% if not showNextStep %} +
          +

          +   + {{ 'Installation_Requirements'|translate }} +

          + {% include "@Installation/_systemCheckLegend.twig" %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Installation/templates/systemCheckPage.twig b/www/analytics/plugins/Installation/templates/systemCheckPage.twig new file mode 100755 index 00000000..f6533496 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/systemCheckPage.twig @@ -0,0 +1,20 @@ +{% extends 'admin.twig' %} + +{% block content %} +{% if isSuperUser %} +

          {{ 'Installation_SystemCheck'|translate }}

          +

          + {% if infos.has_errors %} + + {{ 'Installation_SystemCheckSummaryThereWereErrors'|translate('','','','')|raw }} {{ 'Installation_SeeBelowForMoreInfo'|translate }} + {% elseif infos.has_warnings %} + + {{ 'Installation_SystemCheckSummaryThereWereWarnings'|translate }} {{ 'Installation_SeeBelowForMoreInfo'|translate }} + {% else %} + + {{ 'Installation_SystemCheckSummaryNoProblems'|translate }} + {% endif %} +

          + {% include "@Installation/_systemCheckSection.twig" %} +{% endif %} +{% endblock %} diff --git a/www/analytics/plugins/Installation/templates/tablesCreation.twig b/www/analytics/plugins/Installation/templates/tablesCreation.twig new file mode 100644 index 00000000..9d2a3027 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/tablesCreation.twig @@ -0,0 +1,61 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} + +

          {{ 'Installation_Tables'|translate }}

          + +{% if someTablesInstalled is defined %} +
          {{ 'Installation_TablesWithSameNamesFound'|translate("","")|raw }} + +
          + + {% if showReuseExistingTables is defined %} +

          {{ 'Installation_TablesWarningHelp'|translate }}

          +

          {{ 'Installation_TablesReuse'|translate }} »

          + {% else %} +

          « {{ 'Installation_GoBackAndDefinePrefix'|translate }}

          + {% endif %} +

          {{ 'Installation_TablesDelete'|translate }} »

          +{% endif %} + +{% if existingTablesDeleted is defined %} +
          {{ 'Installation_TablesDeletedSuccess'|translate }} +
          +{% endif %} + +{% if tablesCreated is defined %} +
          {{ 'Installation_TablesCreatedSuccess'|translate }} +
          +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Installation/templates/trackingCode.twig b/www/analytics/plugins/Installation/templates/trackingCode.twig new file mode 100644 index 00000000..0a722697 --- /dev/null +++ b/www/analytics/plugins/Installation/templates/trackingCode.twig @@ -0,0 +1,17 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} +{% if displayfirstWebsiteSetupSuccess is defined %} + + {{ 'Installation_SetupWebsiteSetupSuccess'|translate(displaySiteName) }} + + +{% endif %} + +{{ trackingHelp|raw }} +

          +

          {{ 'Installation_LargePiwikInstances'|translate }}

          +{{ 'Installation_JsTagArchivingHelp1'|translate('','')|raw }} +{{ 'General_ReadThisToLearnMore'|translate('','')|raw }} + +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/Installation/templates/welcome.twig b/www/analytics/plugins/Installation/templates/welcome.twig new file mode 100644 index 00000000..5df83aad --- /dev/null +++ b/www/analytics/plugins/Installation/templates/welcome.twig @@ -0,0 +1,45 @@ +{% extends '@Installation/layout.twig' %} + +{% block content %} +

          {{ 'Installation_Welcome'|translate }}

          + +{% if newInstall %} + {{ 'Installation_WelcomeHelp'|translate(totalNumberOfSteps)|raw }} +{% else %} +

          {{ 'Installation_ConfigurationHelp'|translate }}

          +
          +
          + {{ errorMessage }} +
          +{% endif %} + + + +{% if not showNextStep %} +

          + {{ 'General_RefreshPage'|translate }} » +

          +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/LanguagesManager/API.php b/www/analytics/plugins/LanguagesManager/API.php new file mode 100644 index 00000000..33a2ce12 --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/API.php @@ -0,0 +1,270 @@ +40+ translations!). + * This is mostly useful to developers who integrate Piwik API results in their own application. + * + * You can also request the default language to load for a user via "getLanguageForUser", + * or update it via "setLanguageForUser". + * + * @method static \Piwik\Plugins\LanguagesManager\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + protected $availableLanguageNames = null; + protected $languageNames = null; + + /** + * Returns true if specified language is available + * + * @param string $languageCode + * @return bool true if language available; false otherwise + */ + public function isLanguageAvailable($languageCode) + { + return $languageCode !== false + && Filesystem::isValidFilename($languageCode) + && in_array($languageCode, $this->getAvailableLanguages()); + } + + /** + * Return array of available languages + * + * @return array Arry of strings, each containing its ISO language code + */ + public function getAvailableLanguages() + { + if (!is_null($this->languageNames)) { + return $this->languageNames; + } + $path = PIWIK_INCLUDE_PATH . "/lang/"; + $languagesPath = _glob($path . "*.json"); + + $pathLength = strlen($path); + $languages = array(); + if ($languagesPath) { + foreach ($languagesPath as $language) { + $languages[] = substr($language, $pathLength, -strlen('.json')); + } + } + + /** + * Hook called after loading available language files. + * + * Use this hook to customise the list of languagesPath available in Piwik. + * + * @param array + */ + Piwik::postEvent('LanguageManager.getAvailableLanguages', array(&$languages)); + + $this->languageNames = $languages; + return $languages; + } + + /** + * Return information on translations (code, language, % translated, etc) + * + * @return array Array of arrays + */ + public function getAvailableLanguagesInfo() + { + $data = file_get_contents(PIWIK_INCLUDE_PATH . '/lang/en.json'); + $englishTranslation = json_decode($data, true); + + // merge with plugin translations if any + $pluginFiles = glob(sprintf('%s/plugins/*/lang/en.json', PIWIK_INCLUDE_PATH)); + foreach ($pluginFiles AS $file) { + + $data = file_get_contents($file); + $pluginTranslations = json_decode($data, true); + $englishTranslation = array_merge_recursive($englishTranslation, $pluginTranslations); + } + + $filenames = $this->getAvailableLanguages(); + $languagesInfo = array(); + foreach ($filenames as $filename) { + $data = file_get_contents(sprintf('%s/lang/%s.json', PIWIK_INCLUDE_PATH, $filename)); + $translations = json_decode($data, true); + + // merge with plugin translations if any + $pluginFiles = glob(sprintf('%s/plugins/*/lang/%s.json', PIWIK_INCLUDE_PATH, $filename)); + foreach ($pluginFiles AS $file) { + + $data = file_get_contents($file); + $pluginTranslations = json_decode($data, true); + $translations = array_merge_recursive($translations, $pluginTranslations); + } + + $intersect = function ($array, $array2) { + $res = $array; + foreach ($array as $module => $keys) { + if (!isset($array2[$module])) { + unset($res[$module]); + } else { + $res[$module] = array_intersect_key($res[$module], array_filter($array2[$module], 'strlen')); + } + } + return $res; + }; + $translationStringsDone = $intersect($englishTranslation, $translations); + $percentageComplete = count($translationStringsDone, COUNT_RECURSIVE) / count($englishTranslation, COUNT_RECURSIVE); + $percentageComplete = round(100 * $percentageComplete, 0); + $languageInfo = array('code' => $filename, + 'name' => $translations['General']['OriginalLanguageName'], + 'english_name' => $translations['General']['EnglishLanguageName'], + 'translators' => $translations['General']['TranslatorName'], + 'translators_email' => $translations['General']['TranslatorEmail'], + 'percentage_complete' => $percentageComplete . '%', + ); + $languagesInfo[] = $languageInfo; + } + return $languagesInfo; + } + + /** + * Return array of available languages + * + * @return array Arry of array, each containing its ISO language code and name of the language + */ + public function getAvailableLanguageNames() + { + $this->loadAvailableLanguages(); + return $this->availableLanguageNames; + } + + /** + * Returns translation strings by language + * + * @param string $languageCode ISO language code + * @return array|false Array of arrays, each containing 'label' (translation index) and 'value' (translated string); false if language unavailable + */ + public function getTranslationsForLanguage($languageCode) + { + if (!$this->isLanguageAvailable($languageCode)) { + return false; + } + $data = file_get_contents(PIWIK_INCLUDE_PATH . "/lang/$languageCode.json"); + $translations = json_decode($data, true); + $languageInfo = array(); + foreach ($translations as $module => $keys) { + foreach ($keys as $key => $value) { + $languageInfo[] = array( + 'label' => sprintf("%s_%s", $module, $key), + 'value' => $value + ); + } + } + return $languageInfo; + } + + /** + * Returns translation strings by language for given plugin + * + * @param string $pluginName name of plugin + * @param string $languageCode ISO language code + * @return array|false Array of arrays, each containing 'label' (translation index) and 'value' (translated string); false if language unavailable + * + * @ignore + */ + public function getPluginTranslationsForLanguage($pluginName, $languageCode) + { + if (!$this->isLanguageAvailable($languageCode)) { + return false; + } + + $languageFile = PIWIK_INCLUDE_PATH . "/plugins/$pluginName/lang/$languageCode.json"; + + if (!file_exists($languageFile)) { + return false; + } + + $data = file_get_contents($languageFile); + $translations = json_decode($data, true); + $languageInfo = array(); + foreach ($translations as $module => $keys) { + foreach ($keys as $key => $value) { + $languageInfo[] = array( + 'label' => sprintf("%s_%s", $module, $key), + 'value' => $value + ); + } + } + return $languageInfo; + } + + /** + * Returns the language for the user + * + * @param string $login + * @return string + */ + public function getLanguageForUser($login) + { + if($login == 'anonymous') { + return false; + } + Piwik::checkUserHasSuperUserAccessOrIsTheUser($login); + return Db::fetchOne('SELECT language FROM ' . Common::prefixTable('user_language') . + ' WHERE login = ? ', array($login)); + } + + /** + * Sets the language for the user + * + * @param string $login + * @param string $languageCode + * @return bool + */ + public function setLanguageForUser($login, $languageCode) + { + Piwik::checkUserHasSuperUserAccessOrIsTheUser($login); + Piwik::checkUserIsNotAnonymous(); + if (!$this->isLanguageAvailable($languageCode)) { + return false; + } + $paramsBind = array($login, $languageCode, $languageCode); + Db::query('INSERT INTO ' . Common::prefixTable('user_language') . + ' (login, language) + VALUES (?,?) + ON DUPLICATE KEY UPDATE language=?', + $paramsBind); + return true; + } + + private function loadAvailableLanguages() + { + if (!is_null($this->availableLanguageNames)) { + return; + } + + $filenames = $this->getAvailableLanguages(); + $languagesInfo = array(); + foreach ($filenames as $filename) { + $data = file_get_contents(PIWIK_INCLUDE_PATH . "/lang/$filename.json"); + $translations = json_decode($data, true); + $languagesInfo[] = array( + 'code' => $filename, + 'name' => $translations['General']['OriginalLanguageName'], + 'english_name' => $translations['General']['EnglishLanguageName'] + ); + } + $this->availableLanguageNames = $languagesInfo; + } +} diff --git a/www/analytics/plugins/LanguagesManager/Commands/CreatePull.php b/www/analytics/plugins/LanguagesManager/Commands/CreatePull.php new file mode 100644 index 00000000..0aede4ec --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/Commands/CreatePull.php @@ -0,0 +1,220 @@ +setName('translations:createpull') + ->setDescription('Updates translation files') + ->addOption('username', 'u', InputOption::VALUE_OPTIONAL, 'oTrance username') + ->addOption('password', 'p', InputOption::VALUE_OPTIONAL, 'oTrance password') + ->addOption('plugin', 'P', InputOption::VALUE_OPTIONAL, 'optional name of plugin to update translations for'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $changes = shell_exec('git status --porcelain -uno'); + + if (!empty($changes)) { + + $output->writeln("You have uncommited changes. Creating pull request is only available with a clean working directory"); + return; + } + + $unpushedCommits = shell_exec('git log origin/master..HEAD'); + + if (!empty($unpushedCommits)) { + + $output->writeln("You have unpushed commits. Creating pull request is only available with a clean working directory"); + return; + } + + chdir(PIWIK_DOCUMENT_ROOT); + + shell_exec(' + git checkout master > /dev/null 2>&1 + git pull > /dev/null 2>&1 + git submodule init > /dev/null 2>&1 + git submodule update > /dev/null 2>&1 + '); + + $plugin = $input->getOption('plugin'); + if (!empty($plugin)) { + + chdir(PIWIK_DOCUMENT_ROOT.DIRECTORY_SEPARATOR.'plugins'.DIRECTORY_SEPARATOR.$plugin); + shell_exec(' + git checkout master > /dev/null 2>&1 + git pull > /dev/null 2>&1 + '); + } + + // check if branch exists localy and track it if not + $branch = shell_exec('git branch | grep translationupdates'); + + if (empty($branch)) { + + shell_exec('git checkout -b translationupdates origin/translationupdates'); + } + + // switch to branch and update it to latest master + shell_exec(' + git checkout translationupdates > /dev/null 2>&1 + git merge master > /dev/null 2>&1 + git push origin translationupdates > /dev/null 2>&1 + '); + + // update translation files + $command = $this->getApplication()->find('translations:update'); + $arguments = array( + 'command' => 'translations:update', + '--username' => $input->getOption('username'), + '--password' => $input->getOption('password'), + '--plugin' => $plugin + ); + $inputObject = new ArrayInput($arguments); + $inputObject->setInteractive($input->isInteractive()); + $command->run($inputObject, $output); + + shell_exec('git add lang/. > /dev/null 2>&1'); + + if (empty($plugin)) { + foreach (Update::getPluginsInCore() AS $pluginName) { + shell_exec(sprintf('git add plugins/%s/lang/. > /dev/null 2>&1', $pluginName)); + } + } + + $changes = shell_exec('git status --porcelain -uno'); + + if (empty($changes)) { + + $output->writeln("Nothing changed. Everything is already up to date."); + shell_exec('git checkout master > /dev/null 2>&1'); + return; + } + + API::unsetInstance(); // reset languagemanager api (to force refresh of data) + + $stats = shell_exec('git diff --numstat HEAD'); + + preg_match_all('/([0-9]+)\t([0-9]+)\t[a-zA-Z\/]*lang\/([a-z]{2,3})\.json/', $stats, $lineChanges); + + $addedLinesSum = 0; + if (!empty($lineChanges[1])) { + $addedLinesSum = array_sum($lineChanges[1]); + } + + $linesSumByLang = array(); + for($i=0; $igetLanguageInfoByIsoCode($addedFile); + $messages[$addedFile] = sprintf('- Added %s (%s changes / %s translated)\n', $languageInfo['english_name'], $linesSumByLang[$addedFile], $languageInfo['percentage_complete']); + } + $languageCodesTouched = array_merge($languageCodesTouched, $addedFiles[1]); + } + + if (!empty($modifiedFiles[1])) { + foreach ($modifiedFiles[1] AS $modifiedFile) { + $languageInfo = $this->getLanguageInfoByIsoCode($modifiedFile); + $messages[$modifiedFile] = sprintf('- Updated %s (%s changes / %s translated)\n', $languageInfo['english_name'], $linesSumByLang[$modifiedFile], $languageInfo['percentage_complete']); + } + $languageCodesTouched = $modifiedFiles[1]; + } + + $message = implode('', $messages); + + $languageCodesTouched = array_unique($languageCodesTouched, SORT_REGULAR); + + $title = sprintf( + 'Updated %s strings in %u languages (%s)', + $addedLinesSum, + count($languageCodesTouched), + implode(', ', $languageCodesTouched) + ); + + shell_exec('git commit -m "language update ${pluginName} refs #3430"'); + shell_exec('git push'); + shell_exec('git checkout master > /dev/null 2>&1'); + + $this->createPullRequest($output, $title, $message); + } + + private function getLanguageInfoByIsoCode($isoCode) + { + $languages = API::getInstance()->getAvailableLanguagesInfo(); + foreach ($languages AS $languageInfo) { + if ($languageInfo['code'] == $isoCode) { + return $languageInfo; + } + } + return array(); + } + + private function createPullRequest(OutputInterface $output, $title, $message) + { + $dialog = $this->getHelperSet()->get('dialog'); + + while (true) { + + $username = $dialog->ask($output, 'Please provide your github username (to create a pull request using Github API): '); + + $returnCode = shell_exec('curl \ + -X POST \ + -k \ + --silent \ + --write-out %{http_code} \ + --stderr /dev/null \ + -o /dev/null \ + -u '.$username.' \ + --data "{\"title\":\"[automatic translation update] '.$title.'\",\"body\":\"'.$message.'\",\"head\":\"translationupdates\",\"base\":\"master\"}" \ + -H "Accept: application/json" \ + https://api.github.com/repos/piwik/piwik/pulls'); + + switch ($returnCode) { + case 401: + $output->writeln("Pull request failed. Bad credentials... Please try again"); + continue; + + case 422: + $output->writeln("Pull request failed. Unprocessable Entity. Maybe a pull request was already created before."); + return; + + case 201: + case 200: + $output->writeln("Pull request successfully created."); + return; + + default: + $output->writeln("Pull request failed... Please try again"); + } + } + } +} diff --git a/www/analytics/plugins/LanguagesManager/Commands/FetchFromOTrance.php b/www/analytics/plugins/LanguagesManager/Commands/FetchFromOTrance.php new file mode 100644 index 00000000..fc470402 --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/Commands/FetchFromOTrance.php @@ -0,0 +1,172 @@ +setName('translations:fetch') + ->setDescription('Fetches translations files from oTrance to '.self::DOWNLOADPATH) + ->addOption('username', 'u', InputOption::VALUE_OPTIONAL, 'oTrance username') + ->addOption('password', 'p', InputOption::VALUE_OPTIONAL, 'oTrance password'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $output->writeln("Starting to fetch latest language pack"); + + $dialog = $this->getHelperSet()->get('dialog'); + + $cookieFile = self::getDownloadPath() . DIRECTORY_SEPARATOR . 'cookie.txt'; + @unlink($cookieFile); + + $username = $input->getOption('username'); + $password = $input->getOption('password'); + + while (!file_exists($cookieFile)) { + if (empty($username)) { + $username = $dialog->ask($output, 'What is your oTrance username? '); + } + + if (empty($password)) { + $password = $dialog->askHiddenResponse($output, 'What is your oTrance password? '); + } + + // send login request to oTrance and save the login cookie + $curl = curl_init('http://translations.piwik.org/public/index/login'); + curl_setopt($curl, CURLOPT_POSTFIELDS, sprintf("user=%s&pass=%s&autologin=1", $username, $password)); + curl_setopt($curl, CURLOPT_COOKIEJAR, $cookieFile); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_exec($curl); + curl_close($curl); + + if (strpos(file_get_contents($cookieFile), 'oTranCe_autologin') !== false) { + break; + } + + $username = null; + $password = null; + @unlink($cookieFile); + $output->writeln("Invalid oTrance credentials. Please try again..."); + } + + // send request to create a new download package using the cookie file + $createNewPackage = true; + if ($input->isInteractive()) { + $createNewPackage = $dialog->askConfirmation($output, 'Shall we create a new language pack? '); + } + + if ($createNewPackage) { + + $curl = curl_init('http://translations.piwik.org/public/export/update.all'); + curl_setopt($curl, CURLOPT_COOKIEFILE, $cookieFile); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_exec($curl); + curl_close($curl); + } + + // request download page to search for available packages + $curl = curl_init('http://translations.piwik.org/public/downloads/'); + curl_setopt($curl, CURLOPT_COOKIEFILE, $cookieFile); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + $response = curl_exec($curl); + curl_close($curl); + + preg_match_all('/language\_pack\-[0-9]{8}\-[0-9]{6}\.tar\.gz/i', $response, $matches); + + if (empty($matches[0])) { + + $output->writeln("No packages found for download. Please try again."); + return; + } + + $downloadPackage = array_shift($matches[0]); + + $continueWithPackage = true; + if ($input->isInteractive()) { + $continueWithPackage = $dialog->askConfirmation($output, "Found language pack $downloadPackage. Proceed? "); + } + + if (!$continueWithPackage) { + + $output->writeln('Aborted.'); + return; + } + + // download language pack + $packageHandle = fopen(self::getDownloadPath() . DIRECTORY_SEPARATOR . 'language_pack.tar.gz', 'w'); + $curl = curl_init('http://translations.piwik.org/public/downloads/download/file/'.$downloadPackage); + curl_setopt($curl, CURLOPT_COOKIEFILE, self::getDownloadPath() . DIRECTORY_SEPARATOR . 'cookie.txt'); + curl_setopt($curl, CURLOPT_FILE, $packageHandle); + curl_exec($curl); + curl_close($curl); + + @unlink($cookieFile); + + $output->writeln("Extracting package..."); + + $unzipper = Unzip::factory('tar.gz', self::getDownloadPath() . DIRECTORY_SEPARATOR . 'language_pack.tar.gz'); + $unzipper->extract(self::getDownloadPath()); + + @unlink(self::getDownloadPath() . DIRECTORY_SEPARATOR . 'en.php'); + @unlink(self::getDownloadPath() . DIRECTORY_SEPARATOR . 'language_pack.tar.gz'); + + $filesToConvert = _glob(self::getDownloadPath() . DIRECTORY_SEPARATOR . '*.php'); + + $output->writeln("Converting downloaded php files to json"); + + $progress = $this->getHelperSet()->get('progress'); + + $progress->start($output, count($filesToConvert)); + foreach ($filesToConvert AS $filename) { + + require_once $filename; + $basename = explode(".", basename($filename)); + $nested = array(); + foreach ($translations as $key => $value) { + list($plugin, $nkey) = explode("_", $key, 2); + $nested[$plugin][$nkey] = $value; + } + $translations = $nested; + $data = json_encode($translations, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $newFile = sprintf("%s/%s.json", self::getDownloadPath(), $basename[0]); + file_put_contents($newFile, $data); + @unlink($filename); + + $progress->advance(); + } + + $progress->finish(); + + $output->writeln("Finished fetching new language files from oTrance"); + } + + public static function getDownloadPath() { + + $path = PIWIK_DOCUMENT_ROOT . DIRECTORY_SEPARATOR . self::DOWNLOADPATH; + + if (!is_dir($path)) { + mkdir($path); + } + + return $path; + } +} diff --git a/www/analytics/plugins/LanguagesManager/Commands/LanguageCodes.php b/www/analytics/plugins/LanguagesManager/Commands/LanguageCodes.php new file mode 100644 index 00000000..5cca1041 --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/Commands/LanguageCodes.php @@ -0,0 +1,41 @@ +setName('translations:languagecodes') + ->setDescription('Shows available language codes'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $languages = API::getInstance()->getAvailableLanguageNames(); + + $languageCodes = array(); + foreach ($languages AS $languageInfo) { + $languageCodes[] = $languageInfo['code']; + } + + sort($languageCodes); + + $output->writeln("Currently available languages:"); + $output->writeln(implode("\n", $languageCodes)); + } +} diff --git a/www/analytics/plugins/LanguagesManager/Commands/LanguageNames.php b/www/analytics/plugins/LanguagesManager/Commands/LanguageNames.php new file mode 100644 index 00000000..6e74e369 --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/Commands/LanguageNames.php @@ -0,0 +1,41 @@ +setName('translations:languagenames') + ->setDescription('Shows available language names'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $languages = API::getInstance()->getAvailableLanguageNames(); + + $languageNames = array(); + foreach ($languages AS $languageInfo) { + $languageNames[] = $languageInfo['english_name']; + } + + sort($languageNames); + + $output->writeln("Currently available languages:"); + $output->writeln(implode("\n", $languageNames)); + } +} diff --git a/www/analytics/plugins/LanguagesManager/Commands/PluginsWithTranslations.php b/www/analytics/plugins/LanguagesManager/Commands/PluginsWithTranslations.php new file mode 100644 index 00000000..51282f62 --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/Commands/PluginsWithTranslations.php @@ -0,0 +1,39 @@ +setName('translations:plugins') + ->setDescription('Shows all plugins that have own translation files'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $output->writeln("Following plugins contain their own translation files:"); + + $pluginFiles = glob(sprintf('%s/plugins/*/lang/en.json', PIWIK_INCLUDE_PATH)); + $pluginFiles = array_map(function($elem){ + return str_replace(array(sprintf('%s/plugins/', PIWIK_INCLUDE_PATH), '/lang/en.json'), '', $elem); + }, $pluginFiles); + + $output->writeln(join("\n", $pluginFiles)); + } +} diff --git a/www/analytics/plugins/LanguagesManager/Commands/SetTranslations.php b/www/analytics/plugins/LanguagesManager/Commands/SetTranslations.php new file mode 100644 index 00000000..4496e730 --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/Commands/SetTranslations.php @@ -0,0 +1,105 @@ +setName('translations:set') + ->setDescription('Sets new translations for a given language') + ->addOption('code', 'c', InputOption::VALUE_REQUIRED, 'code of the language to set translations for') + ->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'json file to load new translations from') + ->addOption('plugin', 'pl', InputOption::VALUE_OPTIONAL, 'optional name of plugin to set translations for'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $dialog = $this->getHelperSet()->get('dialog'); + + $languageCode = $input->getOption('code'); + $filename = $input->getOption('file'); + + $languageCodes = API::getInstance()->getAvailableLanguages(); + + if (empty($languageCode) || !in_array($languageCode, $languageCodes)) { + $languageCode = $dialog->askAndValidate($output, 'Please provide a valid language code: ', function ($code) use ($languageCodes) { + if (!in_array($code, array_values($languageCodes))) { + throw new \InvalidArgumentException(sprintf('Language code "%s" is invalid.', $code)); + } + + return $code; + }); + } + + if (empty($filename) || !file_exists($filename)) { + $filename = $dialog->askAndValidate($output, 'Please provide a file to load translations from: ', function ($file) { + if (!file_exists($file)) { + throw new \InvalidArgumentException(sprintf('File "%s" does not exist.', $file)); + } + + return $file; + }); + } + + $output->writeln("Starting to import data from '$filename' to language '$languageCode'"); + + $plugin = $input->getOption('plugin'); + $translationWriter = new Writer($languageCode, $plugin); + + $baseTranslations = $translationWriter->getTranslations("en"); + + $translationWriter->addValidator(new NoScripts()); + if (empty($plugin)) { + $translationWriter->addValidator(new CoreTranslations($baseTranslations)); + } + + $translationWriter->addFilter(new ByBaseTranslations($baseTranslations)); + $translationWriter->addFilter(new EmptyTranslations()); + $translationWriter->addFilter(new ByParameterCount($baseTranslations)); + $translationWriter->addFilter(new UnnecassaryWhitespaces($baseTranslations)); + $translationWriter->addFilter(new EncodedEntities()); + + $translationData = file_get_contents($filename); + $translations = json_decode($translationData, true); + + $translationWriter->setTranslations($translations); + + if (!$translationWriter->isValid()) { + $output->writeln("Failed setting translations:" . $translationWriter->getValidationMessage()); + return; + } + + if (!$translationWriter->hasTranslations()) { + $output->writeln("No translations available"); + return; + } + + $translationWriter->save(); + + $output->writeln("Finished."); + } +} diff --git a/www/analytics/plugins/LanguagesManager/Commands/Update.php b/www/analytics/plugins/LanguagesManager/Commands/Update.php new file mode 100644 index 00000000..33d7087b --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/Commands/Update.php @@ -0,0 +1,162 @@ +setName('translations:update') + ->setDescription('Updates translation files') + ->addOption('username', 'u', InputOption::VALUE_OPTIONAL, 'oTrance username') + ->addOption('password', 'p', InputOption::VALUE_OPTIONAL, 'oTrance password') + ->addOption('plugin', 'P', InputOption::VALUE_OPTIONAL, 'optional name of plugin to update translations for'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $dialog = $this->getHelperSet()->get('dialog'); + + $command = $this->getApplication()->find('translations:fetch'); + $arguments = array( + 'command' => 'translations:fetch', + '--username' => $input->getOption('username'), + '--password' => $input->getOption('password') + ); + $inputObject = new ArrayInput($arguments); + $inputObject->setInteractive($input->isInteractive()); + $command->run($inputObject, $output); + + $languages = API::getInstance()->getAvailableLanguageNames(); + + $languageCodes = array(); + foreach ($languages AS $languageInfo) { + $languageCodes[] = $languageInfo['code']; + } + + $plugin = $input->getOption('plugin'); + + $files = _glob(FetchFromOTrance::getDownloadPath() . DIRECTORY_SEPARATOR . '*.json'); + + $output->writeln("Starting to import new language files"); + + if (!$input->isInteractive()) { + $output->writeln("(!) Non interactive mode: New languages will be skipped"); + } + + $progress = $this->getHelperSet()->get('progress'); + + $progress->start($output, count($files)); + + foreach ($files AS $filename) { + + $progress->advance(); + + $code = basename($filename, '.json'); + + if (!in_array($code, $languageCodes)) { + + if (!empty($plugin)) { + + continue; # never create a new language for plugin only + } + + $createNewFile = false; + if ($input->isInteractive()) { + $createNewFile = $dialog->askConfirmation($output, "\nLanguage $code does not exist. Should it be added? ", false); + } + + if (!$createNewFile) { + + continue; # do not create a new file for the language + } + + @touch(PIWIK_DOCUMENT_ROOT . DIRECTORY_SEPARATOR . 'lang' . DIRECTORY_SEPARATOR . $code . '.json'); + API::unsetInstance(); // unset language manager instance, so valid names are refetched + } + + $command = $this->getApplication()->find('translations:set'); + $arguments = array( + 'command' => 'translations:set', + '--code' => $code, + '--file' => $filename, + '--plugin' => $plugin + ); + $inputObject = new ArrayInput($arguments); + $inputObject->setInteractive($input->isInteractive()); + $command->run($inputObject, new NullOutput()); + + // update core modules that aren't in their own repo + if (empty($plugin)) { + + foreach (self::getPluginsInCore() AS $pluginName) { + + // update translation files + $command = $this->getApplication()->find('translations:set'); + $arguments = array( + 'command' => 'translations:set', + '--code' => $code, + '--file' => $filename, + '--plugin' => $pluginName + ); + $inputObject = new ArrayInput($arguments); + $inputObject->setInteractive($input->isInteractive()); + $command->run($inputObject, new NullOutput()); + } + } + } + + $progress->finish(); + $output->writeln("Finished."); + } + + /** + * Returns all plugins having their own translations that are bundled in core + * @return array + */ + public static function getPluginsInCore() + { + static $pluginsInCore; + + if (!empty($pluginsInCore)) { + return $pluginsInCore; + } + + $submodules = shell_exec('git submodule status'); + preg_match_all('/plugins\/([a-zA-z]+) /', $submodules, $matches); + $submodulePlugins = $matches[1]; + + // ignore complete new plugins aswell + $changes = shell_exec('git status'); + preg_match_all('/plugins\/([a-zA-z]+)\/\n/', $changes, $matches); + $newPlugins = $matches[1]; + + $pluginsNotInCore = array_merge($submodulePlugins, $newPlugins); + + $pluginsWithTranslations = glob(sprintf('%s/plugins/*/lang/en.json', PIWIK_INCLUDE_PATH)); + $pluginsWithTranslations = array_map(function($elem){ + return str_replace(array(sprintf('%s/plugins/', PIWIK_INCLUDE_PATH), '/lang/en.json'), '', $elem); + }, $pluginsWithTranslations); + + $pluginsInCore = array_diff($pluginsWithTranslations, $pluginsNotInCore); + + return $pluginsInCore; + } +} diff --git a/www/analytics/plugins/LanguagesManager/Controller.php b/www/analytics/plugins/LanguagesManager/Controller.php new file mode 100644 index 00000000..7240c1fb --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/Controller.php @@ -0,0 +1,37 @@ +checkTokenInUrl(); + } + + LanguagesManager::setLanguageForSession($language); + Url::redirectToReferrer(); + } +} diff --git a/www/analytics/plugins/LanguagesManager/LanguagesManager.php b/www/analytics/plugins/LanguagesManager/LanguagesManager.php new file mode 100644 index 00000000..fcd57b28 --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/LanguagesManager.php @@ -0,0 +1,213 @@ + 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'Menu.Top.addItems' => 'showLanguagesSelector', + 'User.getLanguage' => 'getLanguageToLoad', + 'UsersManager.deleteUser' => 'deleteUserLanguage', + 'Template.topBar' => 'addLanguagesManagerToOtherTopBar', + 'Template.jsGlobalVariables' => 'jsGlobalVariables' + ); + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/Zeitgeist/stylesheets/base.less"; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/LanguagesManager/javascripts/languageSelector.js"; + } + + public function showLanguagesSelector() + { + if (Piwik::isUserIsAnonymous() || !DbHelper::isInstalled()) { + MenuTop::addEntry('LanguageSelector', $this->getLanguagesSelector(), true, $order = 30, true); + } + } + + /** + * Adds the languages drop-down list to topbars other than the main one rendered + * in CoreHome/templates/top_bar.twig. The 'other' topbars are on the Installation + * and CoreUpdater screens. + */ + public function addLanguagesManagerToOtherTopBar(&$str) + { + // piwik object & scripts aren't loaded in 'other' topbars + $str .= ""; + $str .= ""; + $str .= $this->getLanguagesSelector(); + } + + /** + * Adds the languages drop-down list to topbars other than the main one rendered + * in CoreHome/templates/top_bar.twig. The 'other' topbars are on the Installation + * and CoreUpdater screens. + */ + public function jsGlobalVariables(&$str) + { + // piwik object & scripts aren't loaded in 'other' topbars + $str .= "piwik.languageName = '" . self::getLanguageNameForCurrentUser() . "';"; + } + + /** + * Renders and returns the language selector HTML. + * + * @return string + */ + private function getLanguagesSelector() + { + $view = new View("@LanguagesManager/getLanguagesSelector"); + $view->languages = API::getInstance()->getAvailableLanguageNames(); + $view->currentLanguageCode = self::getLanguageCodeForCurrentUser(); + return $view->render(); + } + + function getLanguageToLoad(&$language) + { + if (empty($language)) { + $language = self::getLanguageCodeForCurrentUser(); + } + if (!API::getInstance()->isLanguageAvailable($language)) { + $language = Translate::getLanguageDefault(); + } + } + + public function deleteUserLanguage($userLogin) + { + Db::query('DELETE FROM ' . Common::prefixTable('user_language') . ' WHERE login = ?', $userLogin); + } + + /** + * @throws Exception if non-recoverable error + */ + public function install() + { + $userLanguage = "login VARCHAR( 100 ) NOT NULL , + language VARCHAR( 10 ) NOT NULL , + PRIMARY KEY ( login )"; + DbHelper::createTable('user_language', $userLanguage); + } + + /** + * @throws Exception if non-recoverable error + */ + public function uninstall() + { + Db::dropTables(Common::prefixTable('user_language')); + } + + /** + * @return string Two letters language code, eg. "fr" + */ + static public function getLanguageCodeForCurrentUser() + { + $languageCode = self::getLanguageFromPreferences(); + if (!API::getInstance()->isLanguageAvailable($languageCode)) { + $languageCode = Common::extractLanguageCodeFromBrowserLanguage(Common::getBrowserLanguage(), API::getInstance()->getAvailableLanguages()); + } + if (!API::getInstance()->isLanguageAvailable($languageCode)) { + $languageCode = Translate::getLanguageDefault(); + } + return $languageCode; + } + + /** + * @return string Full english language string, eg. "French" + */ + static public function getLanguageNameForCurrentUser() + { + $languageCode = self::getLanguageCodeForCurrentUser(); + $languages = API::getInstance()->getAvailableLanguageNames(); + foreach ($languages as $language) { + if ($language['code'] === $languageCode) { + return $language['name']; + } + } + return false; + } + + /** + * @return string|false if language preference could not be loaded + */ + static protected function getLanguageFromPreferences() + { + if (($language = self::getLanguageForSession()) != null) { + return $language; + } + + try { + $currentUser = Piwik::getCurrentUserLogin(); + return API::getInstance()->getLanguageForUser($currentUser); + } catch (Exception $e) { + return false; + } + } + + /** + * Returns the language for the session + * + * @return string|null + */ + static public function getLanguageForSession() + { + $cookieName = Config::getInstance()->General['language_cookie_name']; + $cookie = new Cookie($cookieName); + if ($cookie->isCookieFound()) { + return $cookie->get('language'); + } + return null; + } + + /** + * Set the language for the session + * + * @param string $languageCode ISO language code + * @return bool + */ + static public function setLanguageForSession($languageCode) + { + if (!API::getInstance()->isLanguageAvailable($languageCode)) { + return false; + } + + $cookieName = Config::getInstance()->General['language_cookie_name']; + $cookie = new Cookie($cookieName, 0); + $cookie->set('language', $languageCode); + $cookie->save(); + return true; + } +} diff --git a/www/analytics/plugins/LanguagesManager/javascripts/languageSelector.js b/www/analytics/plugins/LanguagesManager/javascripts/languageSelector.js new file mode 100644 index 00000000..e247cded --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/javascripts/languageSelector.js @@ -0,0 +1,72 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready(function () { + + var languageSelector = $("#languageSelection"); + + // no Language sector on the page + if (languageSelector.size() == 0) return; + + languageSelector.find("input").hide(); + var select = $("#language").hide(); + var langSelect = $("") + .insertAfter(select) + .text(select.children(':selected').text()) + .autocomplete({ + delay: 0, + position: { my : "right top", at: "right bottom" }, + minLength: 0, + appendTo: '#languageSelection', + source: function (request, response) { + response(select.children("option").map(function () { + var text = $(this).text(); + return { + label: text, + value: this.value, + title: $(this).attr('title'), + href: $(this).attr('href'), + option: this + }; + })); + }, + select: function (event, ui) { + event.preventDefault(); + ui.item.option.selected = true; + if (ui.item.value) { + langSelect.text(ui.item.label); + $('#languageSelection').find('form').submit(); + } else if (ui.item.href) { + window.open(ui.item.href); + } + } + }) + .click(function () { + // close if already visible + if ($(this).autocomplete("widget").is(":visible")) { + $(this).autocomplete("close"); + return; + } + + // pass empty string as value to search for, displaying all results + $(this).autocomplete("search", ""); + }); + + langSelect.data( "ui-autocomplete" )._renderItem = function( ul, item ) { + $(ul).attr('id', 'languageSelect'); + return $( "
        • " ) + .data( "item.ui-autocomplete", item ) + .append( "
          " + item.label + "" ) + .appendTo( ul ); + }; + + $('body').on('mouseup', function (e) { + if (!$(e.target).parents('#languageSelection').length && !$(e.target).is('#languageSelection') && !$(e.target).parents('#languageSelect').length) { + langSelect.autocomplete("close"); + } + }); +}); diff --git a/www/analytics/plugins/LanguagesManager/templates/getLanguagesSelector.twig b/www/analytics/plugins/LanguagesManager/templates/getLanguagesSelector.twig new file mode 100644 index 00000000..0b602d80 --- /dev/null +++ b/www/analytics/plugins/LanguagesManager/templates/getLanguagesSelector.twig @@ -0,0 +1,17 @@ + + +
          + + {# During installation token_auth is not set #} + {% if token_auth is defined %}{% endif %} + +
          +
          +
          diff --git a/www/analytics/plugins/LeftMenu/plugin.json b/www/analytics/plugins/LeftMenu/plugin.json new file mode 100644 index 00000000..00a8a511 --- /dev/null +++ b/www/analytics/plugins/LeftMenu/plugin.json @@ -0,0 +1,5 @@ +{ + "description": "Position the dashboard menu to the left.", + "theme": true, + "stylesheet": "stylesheets/theme.less" +} \ No newline at end of file diff --git a/www/analytics/plugins/LeftMenu/stylesheets/theme.less b/www/analytics/plugins/LeftMenu/stylesheets/theme.less new file mode 100644 index 00000000..d2de80fc --- /dev/null +++ b/www/analytics/plugins/LeftMenu/stylesheets/theme.less @@ -0,0 +1,139 @@ +#container { + clear: left; +} + +#content.home { + padding-top: 15px; + display:inline-block; + width:100%; +} + +.Menu--dashboard { + padding: 0; + float: left; + width: 240px; + padding-top: 10px; +} + +.Menu--dashboard > .Menu-tabList { + background-image: linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + background-image: -o-linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + background-image: -moz-linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + background-image: -webkit-linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + background-image: -ms-linear-gradient(top, #FECB00 0%, #FE9800 25%, #FE6702 50%, #CA0000 75%, #670002 100%); + + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #FECB00), color-stop(0.25, #FE9800), color-stop(0.5, #FE6702), color-stop(0.75, #CA0000), color-stop(1, #670002)); + + -moz-background-size: 5px 100%; + background-size: 5px 100%; + background-position: 0 0, 100% 0; + background-repeat: no-repeat; +} + +.Menu--dashboard > .Menu-tabList { + padding-left: 5px; + margin-bottom: 0; + margin-top: 0.1em; + border: 1px solid #ddd; + border-radius: 5px; +} + +.Menu--dashboard > .Menu-tabList > li > ul { + min-height: 0; + max-height: 0; + padding: 0; + overflow: hidden; + position: static; + float: none; +} + +.Menu--dashboard > .Menu-tabList > .sfActive > ul { + padding-bottom: 5px; + max-height: 500px; /* That's a hack for CSS transitions */ +} + +.Menu--dashboard > .Menu-tabList li { + list-style: none; + margin: 0; + float: none; + border: 0; + border-radius: 0; + background: transparent; +} + +.Menu--dashboard > .Menu-tabList li a:hover { + text-decoration: underline; +} + +.Menu--dashboard > .Menu-tabList > li > span, +.Menu--dashboard > .Menu-tabList > li > a { + border-bottom: 1px dotted #778; + display: block; + padding: 5px 10px; + font-size: 18px; + line-height: 24px; + color: #7E7363; + text-decoration: none; + float: none; +} + +.Menu--dashboard > .Menu-tabList li li { + float: none; + text-align: left; +} + +.Menu--dashboard > .Menu-tabList li li a { + text-decoration: none; + padding: 0.6em 0.9em; + font: 14px Arial, Helvetica, sans-serif; + display: block; +} + +.Menu--dashboard > .Menu-tabList li li a:link, +.Menu--dashboard > .Menu-tabList li li a:visited { + color: #000; +} + +.Menu--dashboard > .Menu-tabList > .sfActive > a, +.Menu--dashboard > .Menu-tabList > li > a:hover { + background: #f1f1f1; + border-bottom: 1px dotted #777788 !important; +} + +.Menu--dashboard > .Menu-tabList li li a:hover, +.Menu--dashboard > .Menu-tabList li li a.active { + color: #e87500; +} + +.Menu--dashboard > .Menu-tabList > .sfActive .sfHover > a { + color: #E87500; + font-weight: bold; +} + +.Menu--dashboard > .Menu-tabList li li a.current { + background: #defdbb; +} + +/* Fixes */ +.nav_sep { + display: none; +} + +.top_bar_sites_selector { + float: left; +} + +.Menu--dashboard { + clear: left; +} + +.pageWrap { + margin-left: 240px; + border-width: 0; + padding-top: 0; + max-height: none; +} + +.widget:first-child { + margin-top: 0; +} \ No newline at end of file diff --git a/www/analytics/plugins/Live/API.php b/www/analytics/plugins/Live/API.php new file mode 100644 index 00000000..15d3c3e7 --- /dev/null +++ b/www/analytics/plugins/Live/API.php @@ -0,0 +1,699 @@ +Segmentation, + * you will be able to request visits filtered by any criteria. + * + * The method "getLastVisitsDetails" will return extensive data for each visit, which includes: server time, visitId, visitorId, + * visitorType (new or returning), number of pages, list of all pages (and events, file downloaded and outlinks clicked), + * custom variables names and values set to this visit, number of goal conversions (and list of all Goal conversions for this visit, + * with time of conversion, revenue, URL, etc.), but also other attributes such as: days since last visit, days since first visit, + * country, continent, visitor IP, + * provider, referrer used (referrer name, keyword if it was a search engine, full URL), campaign name and keyword, operating system, + * browser, type of screen, resolution, supported browser plugins (flash, java, silverlight, pdf, etc.), various dates & times format to make + * it easier for API users... and more! + * + * With the parameter '&segment=' you can filter the + * returned visits by any criteria (visitor IP, visitor ID, country, keyword used, time of day, etc.). + * + * The method "getCounters" is used to return a simple counter: visits, number of actions, number of converted visits, in the last N minutes. + * + * See also the documentation about Real time widget and visitor level reports in Piwik. + * @method static \Piwik\Plugins\Live\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + const VISITOR_PROFILE_MAX_VISITS_TO_AGGREGATE = 100; + const VISITOR_PROFILE_MAX_VISITS_TO_SHOW = 10; + const VISITOR_PROFILE_DATE_FORMAT = '%day% %shortMonth% %longYear%'; + + /** + * This will return simple counters, for a given website ID, for visits over the last N minutes + * + * @param int $idSite Id Site + * @param int $lastMinutes Number of minutes to look back at + * @param bool|string $segment + * @return array( visits => N, actions => M, visitsConverted => P ) + */ + public function getCounters($idSite, $lastMinutes, $segment = false) + { + Piwik::checkUserHasViewAccess($idSite); + $lastMinutes = (int)$lastMinutes; + + $select = "count(*) as visits, + SUM(log_visit.visit_total_actions) as actions, + SUM(log_visit.visit_goal_converted) as visitsConverted, + COUNT(DISTINCT log_visit.idvisitor) as visitors"; + + $from = "log_visit"; + + list($whereIdSites, $idSites) = $this->getIdSitesWhereClause($idSite); + + $where = $whereIdSites . "AND log_visit.visit_last_action_time >= ?"; + $bind = $idSites; + $bind[] = Date::factory(time() - $lastMinutes * 60)->toString('Y-m-d H:i:s'); + + $segment = new Segment($segment, $idSite); + $query = $segment->getSelectQuery($select, $from, $where, $bind); + + $data = Db::fetchAll($query['sql'], $query['bind']); + + // These could be unset for some reasons, ensure they are set to 0 + if (empty($data[0]['actions'])) { + $data[0]['actions'] = 0; + } + if (empty($data[0]['visitsConverted'])) { + $data[0]['visitsConverted'] = 0; + } + return $data; + } + + /** + * The same functionnality can be obtained using segment=visitorId==$visitorId with getLastVisitsDetails + * + * @deprecated + * @ignore + * @param int $visitorId + * @param int $idSite + * @param int $filter_limit + * @param bool $flat Whether to flatten the visitor details array + * + * @return DataTable + */ + public function getLastVisitsForVisitor($visitorId, $idSite, $filter_limit = 10, $flat = false) + { + Piwik::checkUserHasViewAccess($idSite); + + $countVisitorsToFetch = $filter_limit; + + $table = $this->loadLastVisitorDetailsFromDatabase($idSite, $period = false, $date = false, $segment = false, $countVisitorsToFetch, $visitorId); + $this->addFilterToCleanVisitors($table, $idSite, $flat); + + return $table; + } + + /** + * Returns the last visits tracked in the specified website + * You can define any number of filters: none, one, many or all parameters can be defined + * + * @param int $idSite Site ID + * @param bool|string $period Period to restrict to when looking at the logs + * @param bool|string $date Date to restrict to + * @param bool|int $segment (optional) Number of visits rows to return + * @param bool|int $countVisitorsToFetch (optional) Only return the last X visits. By default the last GET['filter_offset']+GET['filter_limit'] are returned. + * @param bool|int $minTimestamp (optional) Minimum timestamp to restrict the query to (useful when paginating or refreshing visits) + * @param bool $flat + * @param bool $doNotFetchActions + * @return DataTable + */ + public function getLastVisitsDetails($idSite, $period = false, $date = false, $segment = false, $countVisitorsToFetch = false, $minTimestamp = false, $flat = false, $doNotFetchActions = false) + { + if (false === $countVisitorsToFetch) { + $filter_limit = Common::getRequestVar('filter_limit', 10, 'int'); + $filter_offset = Common::getRequestVar('filter_offset', 0, 'int'); + + $countVisitorsToFetch = $filter_limit + $filter_offset; + } + + Piwik::checkUserHasViewAccess($idSite); + $dataTable = $this->loadLastVisitorDetailsFromDatabase($idSite, $period, $date, $segment, $countVisitorsToFetch, $visitorId = false, $minTimestamp); + $this->addFilterToCleanVisitors($dataTable, $idSite, $flat, $doNotFetchActions); + + return $dataTable; + } + + /** + * Returns an array describing a visitor using her last visits (uses a maximum of 100). + * + * @param int $idSite Site ID + * @param bool|false|string $visitorId The ID of the visitor whose profile to retrieve. + * @param bool|false|string $segment + * @param bool $checkForLatLong If true, hasLatLong will appear in the output and be true if + * one of the first 100 visits has a latitude/longitude. + * @return array + */ + public function getVisitorProfile($idSite, $visitorId = false, $segment = false, $checkForLatLong = false) + { + Piwik::checkUserHasViewAccess($idSite); + + if ($visitorId === false) { + $visitorId = $this->getMostRecentVisitorId($idSite, $segment); + } + + $newSegment = ($segment === false ? '' : $segment . ';') . 'visitorId==' . $visitorId; + + $visits = $this->loadLastVisitorDetailsFromDatabase($idSite, $period = false, $date = false, $newSegment, + $numVisitorsToFetch = self::VISITOR_PROFILE_MAX_VISITS_TO_AGGREGATE, + $overrideVisitorId = false, + $minTimestamp = false); + $this->addFilterToCleanVisitors($visits, $idSite, $flat = false, $doNotFetchActions = false, $filterNow = true); + + if ($visits->getRowsCount() == 0) { + return array(); + } + + $isEcommerceEnabled = Site::isEcommerceEnabledFor($idSite); + + $result = array(); + $result['totalVisits'] = 0; + $result['totalVisitDuration'] = 0; + $result['totalActions'] = 0; + $result['totalSearches'] = 0; + $result['totalPageViews'] = 0; + $result['totalGoalConversions'] = 0; + $result['totalConversionsByGoal'] = array(); + + if ($isEcommerceEnabled) { + $result['totalEcommerceConversions'] = 0; + $result['totalEcommerceRevenue'] = 0; + $result['totalEcommerceItems'] = 0; + $result['totalAbandonedCarts'] = 0; + $result['totalAbandonedCartsRevenue'] = 0; + $result['totalAbandonedCartsItems'] = 0; + } + + $countries = array(); + $continents = array(); + $cities = array(); + $siteSearchKeywords = array(); + + $pageGenerationTimeTotal = 0; + + // aggregate all requested visits info for total_* info + foreach ($visits->getRows() as $visit) { + ++$result['totalVisits']; + + $result['totalVisitDuration'] += $visit->getColumn('visitDuration'); + $result['totalActions'] += $visit->getColumn('actions'); + $result['totalGoalConversions'] += $visit->getColumn('goalConversions'); + + // individual goal conversions are stored in action details + foreach ($visit->getColumn('actionDetails') as $action) { + if ($action['type'] == 'goal') { + // handle goal conversion + $idGoal = $action['goalId']; + $idGoalKey = 'idgoal=' . $idGoal; + + if (!isset($result['totalConversionsByGoal'][$idGoalKey])) { + $result['totalConversionsByGoal'][$idGoalKey] = 0; + } + ++$result['totalConversionsByGoal'][$idGoalKey]; + + if (!empty($action['revenue'])) { + if (!isset($result['totalRevenueByGoal'][$idGoalKey])) { + $result['totalRevenueByGoal'][$idGoalKey] = 0; + } + $result['totalRevenueByGoal'][$idGoalKey] += $action['revenue']; + } + } else if ($action['type'] == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER // handle ecommerce order + && $isEcommerceEnabled + ) { + ++$result['totalEcommerceConversions']; + $result['totalEcommerceRevenue'] += $action['revenue']; + $result['totalEcommerceItems'] += $action['items']; + } else if ($action['type'] == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART // handler abandoned cart + && $isEcommerceEnabled + ) { + ++$result['totalAbandonedCarts']; + $result['totalAbandonedCartsRevenue'] += $action['revenue']; + $result['totalAbandonedCartsItems'] += $action['items']; + } + + if (isset($action['siteSearchKeyword'])) { + $keyword = $action['siteSearchKeyword']; + + if (!isset($siteSearchKeywords[$keyword])) { + $siteSearchKeywords[$keyword] = 0; + ++$result['totalSearches']; + } + ++$siteSearchKeywords[$keyword]; + } + + if (isset($action['generationTime'])) { + $pageGenerationTimeTotal += $action['generationTime']; + ++$result['totalPageViews']; + } + } + + $countryCode = $visit->getColumn('countryCode'); + if (!isset($countries[$countryCode])) { + $countries[$countryCode] = 0; + } + ++$countries[$countryCode]; + + $continentCode = $visit->getColumn('continentCode'); + if (!isset($continents[$continentCode])) { + $continents[$continentCode] = 0; + } + ++$continents[$continentCode]; + + if (!array_key_exists($countryCode, $cities)) { + $cities[$countryCode] = array(); + } + $city = $visit->getColumn('city'); + if(!empty($city)) { + $cities[$countryCode][] = $city; + } + } + + // sort countries/continents/search keywords by visit/action + asort($countries); + asort($continents); + arsort($siteSearchKeywords); + + // transform country/continents/search keywords into something that will look good in XML + $result['countries'] = $result['continents'] = $result['searches'] = array(); + + foreach ($countries as $countryCode => $nbVisits) { + + $countryInfo = array('country' => $countryCode, + 'nb_visits' => $nbVisits, + 'flag' => \Piwik\Plugins\UserCountry\getFlagFromCode($countryCode), + 'prettyName' => \Piwik\Plugins\UserCountry\countryTranslate($countryCode)); + if(!empty($cities[$countryCode])) { + $countryInfo['cities'] = array_unique($cities[$countryCode]); + } + $result['countries'][] = $countryInfo; + } + foreach ($continents as $continentCode => $nbVisits) { + $result['continents'][] = array('continent' => $continentCode, + 'nb_visits' => $nbVisits, + 'prettyName' => \Piwik\Plugins\UserCountry\continentTranslate($continentCode)); + } + foreach ($siteSearchKeywords as $keyword => $searchCount) { + $result['searches'][] = array('keyword' => $keyword, + 'searches' => $searchCount); + } + + if ($result['totalPageViews']) { + $result['averagePageGenerationTime'] = + round($pageGenerationTimeTotal / $result['totalPageViews'], $precision = 2); + } + + $result['totalVisitDurationPretty'] = MetricsFormatter::getPrettyTimeFromSeconds($result['totalVisitDuration']); + + // use requested visits for first/last visit info + $rows = $visits->getRows(); + $result['firstVisit'] = $this->getVisitorProfileVisitSummary(end($rows)); + $result['lastVisit'] = $this->getVisitorProfileVisitSummary(reset($rows)); + + // check if requested visits have lat/long + if ($checkForLatLong) { + $result['hasLatLong'] = false; + foreach ($rows as $visit) { + if ($visit->getColumn('latitude') !== false) { // realtime map only checks for latitude + $result['hasLatLong'] = true; + break; + } + } + } + + // save count of visits we queries + $result['visitsAggregated'] = count($rows); + + // use N most recent visits for last_visits + $visits->deleteRowsOffset(self::VISITOR_PROFILE_MAX_VISITS_TO_SHOW); + $result['lastVisits'] = $visits; + + // use the right date format for the pretty server date + $timezone = Site::getTimezoneFor($idSite); + foreach ($result['lastVisits']->getRows() as $visit) { + $dateTimeVisitFirstAction = Date::factory($visit->getColumn('firstActionTimestamp'), $timezone); + + $datePretty = $dateTimeVisitFirstAction->getLocalized(self::VISITOR_PROFILE_DATE_FORMAT); + $visit->setColumn('serverDatePrettyFirstAction', $datePretty); + + $dateTimePretty = $datePretty . ' ' . $visit->getColumn('serverTimePrettyFirstAction'); + $visit->setColumn('serverDateTimePrettyFirstAction', $dateTimePretty); + } + + // get visitor IDs that are adjacent to this one in log_visit + // TODO: make sure order of visitor ids is not changed if a returning visitor visits while the user is + // looking at the popup. + $latestVisitTime = reset($rows)->getColumn('lastActionDateTime'); + $result['nextVisitorId'] = $this->getAdjacentVisitorId($idSite, $visitorId, $latestVisitTime, $segment, $getNext = true); + $result['previousVisitorId'] = $this->getAdjacentVisitorId($idSite, $visitorId, $latestVisitTime, $segment, $getNext = false); + + /** + * Triggered in the Live.getVisitorProfile API method. Plugins can use this event + * to discover and add extra data to visitor profiles. + * + * For example, if an email address is found in a custom variable, a plugin could load the + * gravatar for the email and add it to the visitor profile, causing it to display in the + * visitor profile popup. + * + * The following visitor profile elements can be set to augment the visitor profile popup: + * + * - **visitorAvatar**: A URL to an image to display in the top left corner of the popup. + * - **visitorDescription**: Text to be used as the tooltip of the avatar image. + * + * @param array &$visitorProfile The unaugmented visitor profile info. + */ + Piwik::postEvent('Live.getExtraVisitorDetails', array(&$result)); + + return $result; + } + + /** + * Returns the visitor ID of the most recent visit. + * + * @param int $idSite + * @param bool|string $segment + * @return string + */ + public function getMostRecentVisitorId($idSite, $segment = false) + { + Piwik::checkUserHasViewAccess($idSite); + + $dataTable = $this->loadLastVisitorDetailsFromDatabase( + $idSite, $period = false, $date = false, $segment, $numVisitorsToFetch = 1, + $visitorId = false, $minTimestamp = false + ); + + if (0 >= $dataTable->getRowsCount()) { + return false; + } + + $visitDetails = $dataTable->getFirstRow()->getColumns(); + $visitor = new Visitor($visitDetails); + + return $visitor->getVisitorId(); + } + + /** + * Returns the ID of a visitor that is adjacent to another visitor (by time of last action) + * in the log_visit table. + * + * @param int $idSite The ID of the site whose visits should be looked at. + * @param string $visitorId The ID of the visitor to get an adjacent visitor for. + * @param string $visitLastActionTime The last action time of the latest visit for $visitorId. + * @param string $segment + * @param bool $getNext Whether to retrieve the next visitor or the previous visitor. The next + * visitor will be the visitor that appears chronologically later in the + * log_visit table. The previous visitor will be the visitor that appears + * earlier. + * @return string The hex visitor ID. + */ + private function getAdjacentVisitorId($idSite, $visitorId, $visitLastActionTime, $segment, $getNext) + { + if ($getNext) { + $visitLastActionTimeCondition = "sub.visit_last_action_time <= ?"; + $orderByDir = "DESC"; + } else { + $visitLastActionTimeCondition = "sub.visit_last_action_time >= ?"; + $orderByDir = "ASC"; + } + + $visitLastActionDate = Date::factory($visitLastActionTime); + $dateOneDayAgo = $visitLastActionDate->subDay(1); + $dateOneDayInFuture = $visitLastActionDate->addDay(1); + + $select = "log_visit.idvisitor, MAX(log_visit.visit_last_action_time) as visit_last_action_time"; + $from = "log_visit"; + $where = "log_visit.idsite = ? AND log_visit.idvisitor <> ? AND visit_last_action_time >= ? and visit_last_action_time <= ?"; + $whereBind = array($idSite, @Common::hex2bin($visitorId), $dateOneDayAgo->toString('Y-m-d H:i:s'), $dateOneDayInFuture->toString('Y-m-d H:i:s')); + $orderBy = "MAX(log_visit.visit_last_action_time) $orderByDir"; + $groupBy = "log_visit.idvisitor"; + + $segment = new Segment($segment, $idSite); + $queryInfo = $segment->getSelectQuery($select, $from, $where, $whereBind, $orderBy, $groupBy); + + $sql = "SELECT sub.idvisitor, sub.visit_last_action_time + FROM ({$queryInfo['sql']}) as sub + WHERE $visitLastActionTimeCondition + LIMIT 1"; + $bind = array_merge($queryInfo['bind'], array($visitLastActionTime)); + + $visitorId = Db::fetchOne($sql, $bind); + if (!empty($visitorId)) { + $visitorId = bin2hex($visitorId); + } + return $visitorId; + } + + /** + * Returns a summary for an important visit. Used to describe the first & last visits of a visitor. + * + * @param Row $visit + * @return array + */ + private function getVisitorProfileVisitSummary($visit) + { + $today = Date::today(); + + $serverDate = $visit->getColumn('serverDate'); + return array( + 'date' => $serverDate, + 'prettyDate' => Date::factory($serverDate)->getLocalized(self::VISITOR_PROFILE_DATE_FORMAT), + 'daysAgo' => (int)Date::secondsToDays($today->getTimestamp() - Date::factory($serverDate)->getTimestamp()), + 'referrerType' => $visit->getColumn('referrerType'), + 'referralSummary' => self::getReferrerSummaryForVisit($visit), + ); + } + + /** + * Returns a summary for a visit's referral. + * + * @param Row $visit + * @return bool|mixed|string + * @ignore + */ + public static function getReferrerSummaryForVisit($visit) + { + $referrerType = $visit->getColumn('referrerType'); + if ($referrerType === false + || $referrerType == 'direct' + ) { + $result = Piwik::translate('Referrers_DirectEntry'); + } else if ($referrerType == 'search') { + $result = $visit->getColumn('referrerName'); + + $keyword = $visit->getColumn('referrerKeyword'); + if ($keyword !== false + && $keyword != APIReferrers::getKeywordNotDefinedString() + ) { + $result .= ' (' . $keyword . ')'; + } + } else if ($referrerType == 'campaign') { + $result = Piwik::translate('Referrers_ColumnCampaign') . ' (' . $visit->getColumn('referrerName') . ')'; + } else { + $result = $visit->getColumn('referrerName'); + } + + return $result; + } + + /** + * @deprecated + */ + public function getLastVisits($idSite, $filter_limit = 10, $minTimestamp = false) + { + return $this->getLastVisitsDetails($idSite, $period = false, $date = false, $segment = false, $countVisitorsToFetch = $filter_limit, $minTimestamp, $flat = false); + } + + /** + * For an array of visits, query the list of pages for this visit + * as well as make the data human readable + * @param DataTable $dataTable + * @param int $idSite + * @param bool $flat whether to flatten the array (eg. 'customVariables' names/values will appear in the root array rather than in 'customVariables' key + * @param bool $doNotFetchActions If set to true, we only fetch visit info and not actions (much faster) + * @param bool $filterNow If true, the visitors will be cleaned immediately + */ + private function addFilterToCleanVisitors(DataTable $dataTable, $idSite, $flat = false, $doNotFetchActions = false, $filterNow = false) + { + $filter = 'queueFilter'; + if ($filterNow) { + $filter = 'filter'; + } + + $dataTable->$filter(function ($table) use ($idSite, $flat, $doNotFetchActions) { + /** @var DataTable $table */ + $actionsLimit = (int)Config::getInstance()->General['visitor_log_maximum_actions_per_visit']; + + $website = new Site($idSite); + $timezone = $website->getTimezone(); + $currencies = APISitesManager::getInstance()->getCurrencySymbols(); + + foreach ($table->getRows() as $visitorDetailRow) { + $visitorDetailsArray = Visitor::cleanVisitorDetails($visitorDetailRow->getColumns()); + + $visitor = new Visitor($visitorDetailsArray); + $visitorDetailsArray = $visitor->getAllVisitorDetails(); + + $visitorDetailsArray['siteCurrency'] = $website->getCurrency(); + $visitorDetailsArray['siteCurrencySymbol'] = @$currencies[$visitorDetailsArray['siteCurrency']]; + $visitorDetailsArray['serverTimestamp'] = $visitorDetailsArray['lastActionTimestamp']; + $dateTimeVisit = Date::factory($visitorDetailsArray['lastActionTimestamp'], $timezone); + $visitorDetailsArray['serverTimePretty'] = $dateTimeVisit->getLocalized('%time%'); + $visitorDetailsArray['serverDatePretty'] = $dateTimeVisit->getLocalized(Piwik::translate('CoreHome_ShortDateFormat')); + + $dateTimeVisitFirstAction = Date::factory($visitorDetailsArray['firstActionTimestamp'], $timezone); + $visitorDetailsArray['serverDatePrettyFirstAction'] = $dateTimeVisitFirstAction->getLocalized(Piwik::translate('CoreHome_ShortDateFormat')); + $visitorDetailsArray['serverTimePrettyFirstAction'] = $dateTimeVisitFirstAction->getLocalized('%time%'); + + $visitorDetailsArray['actionDetails'] = array(); + if (!$doNotFetchActions) { + $visitorDetailsArray = Visitor::enrichVisitorArrayWithActions($visitorDetailsArray, $actionsLimit, $timezone); + } + + if ($flat) { + $visitorDetailsArray = Visitor::flattenVisitorDetailsArray($visitorDetailsArray); + } + + $visitorDetailRow->setColumns($visitorDetailsArray); + } + }); + } + + private function loadLastVisitorDetailsFromDatabase($idSite, $period, $date, $segment = false, $countVisitorsToFetch = 100, $visitorId = false, $minTimestamp = false) + { + $where = $whereBind = array(); + + list($whereClause, $idSites) = $this->getIdSitesWhereClause($idSite); + + $where[] = $whereClause; + $whereBind = $idSites; + + $orderBy = "idsite, visit_last_action_time DESC"; + $orderByParent = "sub.visit_last_action_time DESC"; + if (!empty($visitorId)) { + $where[] = "log_visit.idvisitor = ? "; + $whereBind[] = @Common::hex2bin($visitorId); + } + + if (!empty($minTimestamp)) { + $where[] = "log_visit.visit_last_action_time > ? "; + $whereBind[] = date("Y-m-d H:i:s", $minTimestamp); + } + + // If no other filter, only look at the last 24 hours of stats + if (empty($visitorId) + && empty($countVisitorsToFetch) + && empty($period) + && empty($date) + ) { + $period = 'day'; + $date = 'yesterdaySameTime'; + } + + // SQL Filter with provided period + if (!empty($period) && !empty($date)) { + $currentSite = new Site($idSite); + $currentTimezone = $currentSite->getTimezone(); + + $dateString = $date; + if ($period == 'range') { + $processedPeriod = new Range('range', $date); + if ($parsedDate = Range::parseDateRange($date)) { + $dateString = $parsedDate[2]; + } + } else { + $processedDate = Date::factory($date); + if ($date == 'today' + || $date == 'now' + || $processedDate->toString() == Date::factory('now', $currentTimezone)->toString() + ) { + $processedDate = $processedDate->subDay(1); + } + $processedPeriod = Period::factory($period, $processedDate); + } + $dateStart = $processedPeriod->getDateStart()->setTimezone($currentTimezone); + $where[] = "log_visit.visit_last_action_time >= ?"; + $whereBind[] = $dateStart->toString('Y-m-d H:i:s'); + + if (!in_array($date, array('now', 'today', 'yesterdaySameTime')) + && strpos($date, 'last') === false + && strpos($date, 'previous') === false + && Date::factory($dateString)->toString('Y-m-d') != Date::factory('now', $currentTimezone)->toString() + ) { + $dateEnd = $processedPeriod->getDateEnd()->setTimezone($currentTimezone); + $where[] = " log_visit.visit_last_action_time <= ?"; + $dateEndString = $dateEnd->addDay(1)->toString('Y-m-d H:i:s'); + $whereBind[] = $dateEndString; + } + } + + if (count($where) > 0) { + $where = join(" + AND ", $where); + } else { + $where = false; + } + + $segment = new Segment($segment, $idSite); + + // Subquery to use the indexes for ORDER BY + $select = "log_visit.*"; + $from = "log_visit"; + $subQuery = $segment->getSelectQuery($select, $from, $where, $whereBind, $orderBy); + + $sqlLimit = $countVisitorsToFetch >= 1 ? " LIMIT 0, " . (int)$countVisitorsToFetch : ""; + + // Group by idvisit so that a visitor converting 2 goals only appears once + $sql = " + SELECT sub.* + FROM ( + " . $subQuery['sql'] . " + $sqlLimit + ) AS sub + GROUP BY sub.idvisit + ORDER BY $orderByParent + "; + try { + $data = Db::fetchAll($sql, $subQuery['bind']); + } catch (Exception $e) { + echo $e->getMessage(); + exit; + } + + $dataTable = new DataTable(); + $dataTable->addRowsFromSimpleArray($data); + + return $dataTable; + } + + /** + * @param $idSite + * @return array + */ + private function getIdSitesWhereClause($idSite) + { + $idSites = array($idSite); + Piwik::postEvent('Live.API.getIdSitesString', array(&$idSites)); + + $idSitesBind = Common::getSqlStringFieldsArray($idSites); + $whereClause = "log_visit.idsite in ($idSitesBind) "; + return array($whereClause, $idSites); + } +} diff --git a/www/analytics/plugins/Live/Controller.php b/www/analytics/plugins/Live/Controller.php new file mode 100644 index 00000000..900010f1 --- /dev/null +++ b/www/analytics/plugins/Live/Controller.php @@ -0,0 +1,253 @@ +widget(); + } + + public function widget() + { + $view = new View('@Live/index'); + $view->idSite = $this->idSite; + $view = $this->setCounters($view); + $view->liveRefreshAfterMs = (int)Config::getInstance()->General['live_widget_refresh_after_seconds'] * 1000; + $view->visitors = $this->getLastVisitsStart($fetchPlease = true); + $view->liveTokenAuth = Piwik::getCurrentUserTokenAuth(); + return $this->render($view); + } + + public function getSimpleLastVisitCount() + { + $lastMinutes = Config::getInstance()->General[self::SIMPLE_VISIT_COUNT_WIDGET_LAST_MINUTES_CONFIG_KEY]; + + $lastNData = Request::processRequest('Live.getCounters', array('lastMinutes' => $lastMinutes)); + + $view = new View('@Live/getSimpleLastVisitCount'); + $view->lastMinutes = $lastMinutes; + $view->visitors = MetricsFormatter::getPrettyNumber($lastNData[0]['visitors']); + $view->visits = MetricsFormatter::getPrettyNumber($lastNData[0]['visits']); + $view->actions = MetricsFormatter::getPrettyNumber($lastNData[0]['actions']); + $view->refreshAfterXSecs = Config::getInstance()->General['live_widget_refresh_after_seconds']; + $view->translations = array( + 'one_visitor' => Piwik::translate('Live_NbVisitor'), + 'visitors' => Piwik::translate('Live_NbVisitors'), + 'one_visit' => Piwik::translate('General_OneVisit'), + 'visits' => Piwik::translate('General_NVisits'), + 'one_action' => Piwik::translate('General_OneAction'), + 'actions' => Piwik::translate('VisitsSummary_NbActionsDescription'), + 'one_minute' => Piwik::translate('General_OneMinute'), + 'minutes' => Piwik::translate('General_NMinutes') + ); + return $this->render($view); + } + + public function ajaxTotalVisitors() + { + $view = new View('@Live/ajaxTotalVisitors'); + $view = $this->setCounters($view); + $view->idSite = $this->idSite; + return $this->render($view); + } + + private function render(View $view) + { + $rendered = $view->render(); + + return $rendered; + } + + public function indexVisitorLog() + { + $view = new View('@Live/indexVisitorLog.twig'); + $view->filterEcommerce = Common::getRequestVar('filterEcommerce', 0, 'int'); + $view->visitorLog = $this->getLastVisitsDetails(); + return $view->render(); + } + + public function getLastVisitsDetails() + { + return $this->renderReport(__FUNCTION__); + } + + /** + * Widget + */ + public function getVisitorLog() + { + return $this->getLastVisitsDetails(); + } + + public function getLastVisitsStart() + { + // hack, ensure we load today's visits by default + $_GET['date'] = 'today'; + $_GET['period'] = 'day'; + $view = new View('@Live/getLastVisitsStart'); + $view->idSite = $this->idSite; + $api = new Request("method=Live.getLastVisitsDetails&idSite={$this->idSite}&filter_limit=10&format=php&serialize=0&disable_generic_filters=1"); + $visitors = $api->process(); + $view->visitors = $visitors; + + return $this->render($view); + } + + private function setCounters($view) + { + $segment = Request::getRawSegmentFromRequest(); + $last30min = API::getInstance()->getCounters($this->idSite, $lastMinutes = 30, $segment); + $last30min = $last30min[0]; + $today = API::getInstance()->getCounters($this->idSite, $lastMinutes = 24 * 60, $segment); + $today = $today[0]; + $view->visitorsCountHalfHour = $last30min['visits']; + $view->visitorsCountToday = $today['visits']; + $view->pisHalfhour = $last30min['actions']; + $view->pisToday = $today['actions']; + return $view; + } + + /** + * Echo's HTML for visitor profile popup. + */ + public function getVisitorProfilePopup() + { + $idSite = Common::getRequestVar('idSite', null, 'int'); + + $view = new View('@Live/getVisitorProfilePopup.twig'); + $view->idSite = $idSite; + $view->goals = APIGoals::getInstance()->getGoals($idSite); + $view->visitorData = Request::processRequest('Live.getVisitorProfile', array('checkForLatLong' => true)); + $view->exportLink = $this->getVisitorProfileExportLink(); + + if (Common::getRequestVar('showMap', 1) == 1 + && !empty($view->visitorData['hasLatLong']) + && \Piwik\Plugin\Manager::getInstance()->isPluginLoaded('UserCountryMap') + ) { + $view->userCountryMapUrl = $this->getUserCountryMapUrlForVisitorProfile(); + } + + $this->setWidgetizedVisitorProfileUrl($view); + + return $view->render(); + } + + public function getSingleVisitSummary() + { + $view = new View('@Live/getSingleVisitSummary.twig'); + $visits = Request::processRequest('Live.getLastVisitsDetails', array( + 'segment' => 'visitId==' . Common::getRequestVar('visitId'), + 'period' => false, + 'date' => false + )); + $view->visitData = $visits->getFirstRow()->getColumns(); + $view->visitReferralSummary = API::getReferrerSummaryForVisit($visits->getFirstRow()); + $view->showLocation = true; + $this->setWidgetizedVisitorProfileUrl($view); + $view->exportLink = $this->getVisitorProfileExportLink(); + return $view->render(); + } + + public function getVisitList() + { + $startCounter = Common::getRequestVar('filter_offset', 0, 'int'); + $nextVisits = Request::processRequest('Live.getLastVisitsDetails', array( + 'segment' => self::getSegmentWithVisitorId(), + 'filter_limit' => API::VISITOR_PROFILE_MAX_VISITS_TO_SHOW, + 'filter_offset' => $startCounter, + 'period' => false, + 'date' => false + )); + + if (empty($nextVisits)) { + return; + } + + $view = new View('@Live/getVisitList.twig'); + $view->idSite = Common::getRequestVar('idSite', null, 'int'); + $view->startCounter = $startCounter + 1; + $view->visits = $nextVisits; + return $view->render(); + } + + private function getVisitorProfileExportLink() + { + return Url::getCurrentQueryStringWithParametersModified(array( + 'module' => 'API', + 'action' => 'index', + 'method' => 'Live.getVisitorProfile', + 'format' => 'XML', + 'expanded' => 1 + )); + } + + private function setWidgetizedVisitorProfileUrl($view) + { + if (\Piwik\Plugin\Manager::getInstance()->isPluginLoaded('Widgetize')) { + $view->widgetizedLink = Url::getCurrentQueryStringWithParametersModified(array( + 'module' => 'Widgetize', + 'action' => 'iframe', + 'moduleToWidgetize' => 'Live', + 'actionToWidgetize' => 'getVisitorProfilePopup' + )); + } + } + + private function getUserCountryMapUrlForVisitorProfile() + { + $params = array( + 'module' => 'UserCountryMap', + 'action' => 'realtimeMap', + 'segment' => self::getSegmentWithVisitorId(), + 'visitorId' => false, + 'changeVisitAlpha' => 0, + 'removeOldVisits' => 0, + 'realtimeWindow' => 'false', + 'showFooterMessage' => 0, + 'showDateTime' => 0, + 'doNotRefreshVisits' => 1 + ); + return Url::getCurrentQueryStringWithParametersModified($params); + } + + private static function getSegmentWithVisitorId() + { + static $cached = null; + if ($cached === null) { + $segment = Request::getRawSegmentFromRequest(); + if (!empty($segment)) { + $segment = urldecode($segment) . ';'; + } + + $idVisitor = Common::getRequestVar('visitorId', false); + if ($idVisitor === false) { + $idVisitor = Request::processRequest('Live.getMostRecentVisitorId'); + } + + $cached = urlencode($segment . 'visitorId==' . $idVisitor); + } + return $cached; + } +} diff --git a/www/analytics/plugins/Live/Live.php b/www/analytics/plugins/Live/Live.php new file mode 100644 index 00000000..5b390dd5 --- /dev/null +++ b/www/analytics/plugins/Live/Live.php @@ -0,0 +1,77 @@ + 'getJsFiles', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'WidgetsList.addWidgets' => 'addWidget', + 'Menu.Reporting.addItems' => 'addMenu', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + 'ViewDataTable.getDefaultType' => 'getDefaultTypeViewDataTable' + ); + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/Live/stylesheets/live.less"; + $stylesheets[] = "plugins/Live/stylesheets/visitor_profile.less"; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/Live/javascripts/live.js"; + $jsFiles[] = "plugins/Live/javascripts/visitorProfile.js"; + $jsFiles[] = "plugins/Live/javascripts/visitorLog.js"; + } + + public function addMenu() + { + MenuMain::getInstance()->add('General_Visitors', 'Live_VisitorLog', array('module' => 'Live', 'action' => 'indexVisitorLog'), true, $order = 5); + } + + public function addWidget() + { + WidgetsList::add('Live!', 'Live_VisitorsInRealTime', 'Live', 'widget'); + WidgetsList::add('Live!', 'Live_VisitorLog', 'Live', 'getVisitorLog', array('small' => 1)); + WidgetsList::add('Live!', 'Live_RealTimeVisitorCount', 'Live', 'getSimpleLastVisitCount'); + WidgetsList::add('Live!', 'Live_VisitorProfile', 'Live', 'getVisitorProfilePopup'); + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = "Live_VisitorProfile"; + $translationKeys[] = "Live_NoMoreVisits"; + $translationKeys[] = "Live_ShowMap"; + $translationKeys[] = "Live_HideMap"; + $translationKeys[] = "Live_PageRefreshed"; + } + + public function getDefaultTypeViewDataTable(&$defaultViewTypes) + { + $defaultViewTypes['Live.getLastVisitsDetails'] = VisitorLog::ID; + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Live/Visitor.php b/www/analytics/plugins/Live/Visitor.php new file mode 100644 index 00000000..9e13d9b5 --- /dev/null +++ b/www/analytics/plugins/Live/Visitor.php @@ -0,0 +1,992 @@ +details = $visitorRawData; + } + + function getAllVisitorDetails() + { + return array( + 'idSite' => $this->getIdSite(), + 'idVisit' => $this->getIdVisit(), + 'visitIp' => $this->getIp(), + 'visitorId' => $this->getVisitorId(), + 'visitorType' => $this->getVisitorReturning(), + 'visitorTypeIcon' => $this->getVisitorReturningIcon(), + 'visitConverted' => $this->isVisitorGoalConverted(), + 'visitConvertedIcon' => $this->getVisitorGoalConvertedIcon(), + 'visitEcommerceStatus' => $this->getVisitEcommerceStatus(), + 'visitEcommerceStatusIcon' => $this->getVisitEcommerceStatusIcon(), + + 'searches' => $this->getNumberOfSearches(), + 'events' => $this->getNumberOfEvents(), + 'actions' => $this->getNumberOfActions(), + // => false are placeholders to be filled in API later + 'actionDetails' => false, + 'customVariables' => $this->getCustomVariables(), + 'goalConversions' => false, + 'siteCurrency' => false, + 'siteCurrencySymbol' => false, + + // all time entries + 'serverDate' => $this->getServerDate(), + 'visitLocalTime' => $this->getVisitLocalTime(), + 'visitLocalHour' => $this->getVisitLocalHour(), + 'visitServerHour' => $this->getVisitServerHour(), + 'firstActionTimestamp' => $this->getTimestampFirstAction(), + 'lastActionTimestamp' => $this->getTimestampLastAction(), + 'lastActionDateTime' => $this->getDateTimeLastAction(), + + // standard attributes + 'visitDuration' => $this->getVisitLength(), + 'visitDurationPretty' => $this->getVisitLengthPretty(), + 'visitCount' => $this->getVisitCount(), + 'daysSinceLastVisit' => $this->getDaysSinceLastVisit(), + 'daysSinceFirstVisit' => $this->getDaysSinceFirstVisit(), + 'daysSinceLastEcommerceOrder' => $this->getDaysSinceLastEcommerceOrder(), + 'continent' => $this->getContinent(), + 'continentCode' => $this->getContinentCode(), + 'country' => $this->getCountryName(), + 'countryCode' => $this->getCountryCode(), + 'countryFlag' => $this->getCountryFlag(), + 'region' => $this->getRegionName(), + 'regionCode' => $this->getRegionCode(), + 'city' => $this->getCityName(), + 'location' => $this->getPrettyLocation(), + 'latitude' => $this->getLatitude(), + 'longitude' => $this->getLongitude(), + 'provider' => $this->getProvider(), + 'providerName' => $this->getProviderName(), + 'providerUrl' => $this->getProviderUrl(), + + 'referrerType' => $this->getReferrerType(), + 'referrerTypeName' => $this->getReferrerTypeName(), + 'referrerName' => $this->getReferrerName(), + 'referrerKeyword' => $this->getKeyword(), + 'referrerKeywordPosition' => $this->getKeywordPosition(), + 'referrerUrl' => $this->getReferrerUrl(), + 'referrerSearchEngineUrl' => $this->getSearchEngineUrl(), + 'referrerSearchEngineIcon' => $this->getSearchEngineIcon(), + 'operatingSystem' => $this->getOperatingSystem(), + 'operatingSystemCode' => $this->getOperatingSystemCode(), + 'operatingSystemShortName' => $this->getOperatingSystemShortName(), + 'operatingSystemIcon' => $this->getOperatingSystemIcon(), + 'browserFamily' => $this->getBrowserFamily(), + 'browserFamilyDescription' => $this->getBrowserFamilyDescription(), + 'browserName' => $this->getBrowser(), + 'browserIcon' => $this->getBrowserIcon(), + 'browserCode' => $this->getBrowserCode(), + 'browserVersion' => $this->getBrowserVersion(), + 'screenType' => $this->getScreenType(), + 'deviceType' => $this->getDeviceType(), + 'resolution' => $this->getResolution(), + 'screenTypeIcon' => $this->getScreenTypeIcon(), + 'plugins' => $this->getPlugins(), + 'pluginsIcons' => $this->getPluginIcons(), + ); + } + + function getVisitorId() + { + if (isset($this->details['idvisitor'])) { + return bin2hex($this->details['idvisitor']); + } + return false; + } + + function getVisitLocalTime() + { + return $this->details['visitor_localtime']; + } + + function getVisitServerHour() + { + return date('G', strtotime($this->details['visit_last_action_time'])); + } + + function getVisitLocalHour() + { + return date('G', strtotime('2012-12-21 ' . $this->details['visitor_localtime'])); + } + + function getVisitCount() + { + return $this->details['visitor_count_visits']; + } + + function getDaysSinceLastVisit() + { + return $this->details['visitor_days_since_last']; + } + + function getDaysSinceLastEcommerceOrder() + { + return $this->details['visitor_days_since_order']; + } + + function getDaysSinceFirstVisit() + { + return $this->details['visitor_days_since_first']; + } + + function getServerDate() + { + return date('Y-m-d', strtotime($this->details['visit_last_action_time'])); + } + + function getIp() + { + if (isset($this->details['location_ip'])) { + return IP::N2P($this->details['location_ip']); + } + return false; + } + + function getIdVisit() + { + return $this->details['idvisit']; + } + + function getIdSite() + { + return $this->details['idsite']; + } + + function getNumberOfActions() + { + return $this->details['visit_total_actions']; + } + + function getNumberOfEvents() + { + return $this->details['visit_total_events']; + } + + function getNumberOfSearches() + { + return $this->details['visit_total_searches']; + } + + function getVisitLength() + { + return $this->details['visit_total_time']; + } + + function getVisitLengthPretty() + { + return \Piwik\MetricsFormatter::getPrettyTimeFromSeconds($this->details['visit_total_time']); + } + + function getVisitorReturning() + { + $type = $this->details['visitor_returning']; + return $type == 2 + ? 'returningCustomer' + : ($type == 1 + ? 'returning' + : 'new'); + } + + function getVisitorReturningIcon() + { + $type = $this->getVisitorReturning(); + if ($type == 'returning' + || $type == 'returningCustomer' + ) { + return "plugins/Live/images/returningVisitor.gif"; + } + return null; + } + + function getTimestampFirstAction() + { + return strtotime($this->details['visit_first_action_time']); + } + + function getTimestampLastAction() + { + return strtotime($this->details['visit_last_action_time']); + } + + function getCountryCode() + { + return $this->details['location_country']; + } + + function getCountryName() + { + return \Piwik\Plugins\UserCountry\countryTranslate($this->getCountryCode()); + } + + function getCountryFlag() + { + return \Piwik\Plugins\UserCountry\getFlagFromCode($this->getCountryCode()); + } + + function getContinent() + { + return \Piwik\Plugins\UserCountry\continentTranslate($this->getContinentCode()); + } + + function getContinentCode() + { + return Common::getContinent($this->details['location_country']); + } + + function getCityName() + { + if (!empty($this->details['location_city'])) { + return $this->details['location_city']; + } + return null; + } + + public function getRegionName() + { + $region = $this->getRegionCode(); + if ($region != '' && $region != Visit::UNKNOWN_CODE) { + return GeoIp::getRegionNameFromCodes( + $this->details['location_country'], $region); + } + return null; + } + + public function getRegionCode() + { + return $this->details['location_region']; + } + + function getPrettyLocation() + { + $parts = array(); + + $city = $this->getCityName(); + if (!empty($city)) { + $parts[] = $city; + } + $region = $this->getRegionName(); + if (!empty($region)) { + $parts[] = $region; + } + + // add country & return concatenated result + $parts[] = $this->getCountryName(); + return implode(', ', $parts); + } + + function getLatitude() + { + if (!empty($this->details['location_latitude'])) { + return $this->details['location_latitude']; + } + return null; + } + + function getLongitude() + { + if (!empty($this->details['location_longitude'])) { + return $this->details['location_longitude']; + } + return null; + } + + function getCustomVariables() + { + $customVariables = array(); + + $maxCustomVariables = CustomVariables::getMaxCustomVariables(); + + for ($i = 1; $i <= $maxCustomVariables; $i++) { + if (!empty($this->details['custom_var_k' . $i])) { + $customVariables[$i] = array( + 'customVariableName' . $i => $this->details['custom_var_k' . $i], + 'customVariableValue' . $i => $this->details['custom_var_v' . $i], + ); + } + } + return $customVariables; + } + + function getReferrerType() + { + return \Piwik\Plugins\Referrers\getReferrerTypeFromShortName($this->details['referer_type']); + } + + function getReferrerTypeName() + { + return \Piwik\Plugins\Referrers\getReferrerTypeLabel($this->details['referer_type']); + } + + function getKeyword() + { + $keyword = $this->details['referer_keyword']; + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('Referrers') + && $this->getReferrerType() == 'search' + ) { + $keyword = \Piwik\Plugins\Referrers\API::getCleanKeyword($keyword); + } + return urldecode($keyword); + } + + function getReferrerUrl() + { + if ($this->getReferrerType() == 'search') { + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('Referrers') + && $this->details['referer_keyword'] == APIReferrers::LABEL_KEYWORD_NOT_DEFINED + ) { + return 'http://piwik.org/faq/general/#faq_144'; + } // Case URL is google.XX/url.... then we rewrite to the search result page url + elseif ($this->getReferrerName() == 'Google' + && strpos($this->details['referer_url'], '/url') + ) { + $refUrl = @parse_url($this->details['referer_url']); + if (isset($refUrl['host'])) { + $url = \Piwik\Plugins\Referrers\getSearchEngineUrlFromUrlAndKeyword('http://google.com', $this->getKeyword()); + $url = str_replace('google.com', $refUrl['host'], $url); + return $url; + } + } + } + if (\Piwik\UrlHelper::isLookLikeUrl($this->details['referer_url'])) { + return $this->details['referer_url']; + } + return null; + } + + function getKeywordPosition() + { + if ($this->getReferrerType() == 'search' + && strpos($this->getReferrerName(), 'Google') !== false + ) { + $url = @parse_url($this->details['referer_url']); + if (empty($url['query'])) { + return null; + } + $position = UrlHelper::getParameterFromQueryString($url['query'], 'cd'); + if (!empty($position)) { + return $position; + } + } + return null; + } + + function getReferrerName() + { + return urldecode($this->details['referer_name']); + } + + function getSearchEngineUrl() + { + if ($this->getReferrerType() == 'search' + && !empty($this->details['referer_name']) + ) { + return \Piwik\Plugins\Referrers\getSearchEngineUrlFromName($this->details['referer_name']); + } + return null; + } + + function getSearchEngineIcon() + { + $searchEngineUrl = $this->getSearchEngineUrl(); + if (!is_null($searchEngineUrl)) { + return \Piwik\Plugins\Referrers\getSearchEngineLogoFromUrl($searchEngineUrl); + } + return null; + } + + function getPlugins() + { + $plugins = array( + 'config_pdf', + 'config_flash', + 'config_java', + 'config_director', + 'config_quicktime', + 'config_realplayer', + 'config_windowsmedia', + 'config_gears', + 'config_silverlight', + ); + $pluginShortNames = array(); + foreach ($plugins as $plugin) { + if ($this->details[$plugin] == 1) { + $pluginShortName = substr($plugin, 7); + $pluginShortNames[] = $pluginShortName; + } + } + return implode(self::DELIMITER_PLUGIN_NAME, $pluginShortNames); + } + + function getPluginIcons() + { + $pluginNames = $this->getPlugins(); + if (!empty($pluginNames)) { + $pluginNames = explode(self::DELIMITER_PLUGIN_NAME, $pluginNames); + $pluginIcons = array(); + + foreach ($pluginNames as $plugin) { + $pluginIcons[] = array("pluginIcon" => \Piwik\Plugins\UserSettings\getPluginsLogo($plugin), "pluginName" => $plugin); + } + return $pluginIcons; + } + return null; + } + + function getOperatingSystemCode() + { + return $this->details['config_os']; + } + + function getOperatingSystem() + { + return \Piwik\Plugins\UserSettings\getOSLabel($this->details['config_os']); + } + + function getOperatingSystemShortName() + { + return \Piwik\Plugins\UserSettings\getOSShortLabel($this->details['config_os']); + } + + function getOperatingSystemIcon() + { + return \Piwik\Plugins\UserSettings\getOSLogo($this->details['config_os']); + } + + function getBrowserFamilyDescription() + { + return \Piwik\Plugins\UserSettings\getBrowserTypeLabel($this->getBrowserFamily()); + } + + function getBrowserFamily() + { + return \Piwik\Plugins\UserSettings\getBrowserFamily($this->details['config_browser_name']); + } + + function getBrowserCode() + { + return $this->details['config_browser_name']; + } + + function getBrowserVersion() + { + return $this->details['config_browser_version']; + } + + function getBrowser() + { + return \Piwik\Plugins\UserSettings\getBrowserLabel($this->details['config_browser_name'] . ";" . $this->details['config_browser_version']); + } + + function getBrowserIcon() + { + return \Piwik\Plugins\UserSettings\getBrowsersLogo($this->details['config_browser_name'] . ";" . $this->details['config_browser_version']); + } + + function getScreenType() + { + return \Piwik\Plugins\UserSettings\getScreenTypeFromResolution($this->details['config_resolution']); + } + + function getDeviceType() + { + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('DevicesDetection')) { + return \Piwik\Plugins\DevicesDetection\getDeviceTypeLabel($this->details['config_device_type']); + } + return false; + } + + function getResolution() + { + return $this->details['config_resolution']; + } + + function getScreenTypeIcon() + { + return \Piwik\Plugins\UserSettings\getScreensLogo($this->getScreenType()); + } + + function getProvider() + { + if (isset($this->details['location_provider'])) { + return $this->details['location_provider']; + } else { + return Piwik::translate('General_Unknown'); + } + } + + function getProviderName() + { + return \Piwik\Plugins\Provider\getPrettyProviderName($this->getProvider()); + } + + function getProviderUrl() + { + return \Piwik\Plugins\Provider\getHostnameUrl(@$this->details['location_provider']); + } + + function getDateTimeLastAction() + { + return date('Y-m-d H:i:s', strtotime($this->details['visit_last_action_time'])); + } + + function getVisitEcommerceStatusIcon() + { + $status = $this->getVisitEcommerceStatus(); + + if (in_array($status, array('ordered', 'orderedThenAbandonedCart'))) { + return "plugins/Zeitgeist/images/ecommerceOrder.gif"; + } elseif ($status == 'abandonedCart') { + return "plugins/Zeitgeist/images/ecommerceAbandonedCart.gif"; + } + return null; + } + + function getVisitEcommerceStatus() + { + return APIMetadata::getVisitEcommerceStatusFromId($this->details['visit_goal_buyer']); + } + + function getVisitorGoalConvertedIcon() + { + return $this->isVisitorGoalConverted() + ? "plugins/Zeitgeist/images/goal.png" + : null; + } + + function isVisitorGoalConverted() + { + return $this->details['visit_goal_converted']; + } + + /** + * Removes fields that are not meant to be displayed (md5 config hash) + * Or that the user should only access if he is Super User or admin (cookie, IP) + * + * @param array $visitorDetails + * @return array + */ + public static function cleanVisitorDetails($visitorDetails) + { + $toUnset = array('config_id'); + if (Piwik::isUserIsAnonymous()) { + $toUnset[] = 'idvisitor'; + $toUnset[] = 'location_ip'; + } + foreach ($toUnset as $keyName) { + if (isset($visitorDetails[$keyName])) { + unset($visitorDetails[$keyName]); + } + } + + return $visitorDetails; + } + + /** + * The &flat=1 feature is used by API.getSuggestedValuesForSegment + * + * @param $visitorDetailsArray + * @return array + */ + public static function flattenVisitorDetailsArray($visitorDetailsArray) + { + // NOTE: if you flatten more fields from the "actionDetails" array + // ==> also update API/API.php getSuggestedValuesForSegment(), the $segmentsNeedActionsInfo array + + // flatten visit custom variables + if (is_array($visitorDetailsArray['customVariables'])) { + foreach ($visitorDetailsArray['customVariables'] as $thisCustomVar) { + $visitorDetailsArray = array_merge($visitorDetailsArray, $thisCustomVar); + } + unset($visitorDetailsArray['customVariables']); + } + + // flatten page views custom variables + $count = 1; + foreach ($visitorDetailsArray['actionDetails'] as $action) { + if (!empty($action['customVariables'])) { + foreach ($action['customVariables'] as $thisCustomVar) { + foreach ($thisCustomVar as $cvKey => $cvValue) { + $flattenedKeyName = $cvKey . ColumnDelete::APPEND_TO_COLUMN_NAME_TO_KEEP . $count; + $visitorDetailsArray[$flattenedKeyName] = $cvValue; + $count++; + } + } + } + } + + // Flatten Goals + $count = 1; + foreach ($visitorDetailsArray['actionDetails'] as $action) { + if (!empty($action['goalId'])) { + $flattenedKeyName = 'visitConvertedGoalId' . ColumnDelete::APPEND_TO_COLUMN_NAME_TO_KEEP . $count; + $visitorDetailsArray[$flattenedKeyName] = $action['goalId']; + $count++; + } + } + + // Flatten Page Titles/URLs + $count = 1; + foreach ($visitorDetailsArray['actionDetails'] as $action) { + if (!empty($action['url'])) { + $flattenedKeyName = 'pageUrl' . ColumnDelete::APPEND_TO_COLUMN_NAME_TO_KEEP . $count; + $visitorDetailsArray[$flattenedKeyName] = $action['url']; + } + + // API.getSuggestedValuesForSegment + $flatten = array( 'pageTitle', 'siteSearchKeyword', 'eventCategory', 'eventAction', 'eventName', 'eventValue'); + foreach($flatten as $toFlatten) { + if (!empty($action[$toFlatten])) { + $flattenedKeyName = $toFlatten . ColumnDelete::APPEND_TO_COLUMN_NAME_TO_KEEP . $count; + $visitorDetailsArray[$flattenedKeyName] = $action[$toFlatten]; + } + } + $count++; + } + + // Entry/exit pages + $firstAction = $lastAction = false; + foreach ($visitorDetailsArray['actionDetails'] as $action) { + if ($action['type'] == 'action') { + if (empty($firstAction)) { + $firstAction = $action; + } + $lastAction = $action; + } + } + + if (!empty($firstAction['pageTitle'])) { + $visitorDetailsArray['entryPageTitle'] = $firstAction['pageTitle']; + } + if (!empty($firstAction['url'])) { + $visitorDetailsArray['entryPageUrl'] = $firstAction['url']; + } + if (!empty($lastAction['pageTitle'])) { + $visitorDetailsArray['exitPageTitle'] = $lastAction['pageTitle']; + } + if (!empty($lastAction['url'])) { + $visitorDetailsArray['exitPageUrl'] = $lastAction['url']; + } + + + return $visitorDetailsArray; + } + + /** + * @param $visitorDetailsArray + * @param $actionsLimit + * @param $timezone + * @return array + */ + public static function enrichVisitorArrayWithActions($visitorDetailsArray, $actionsLimit, $timezone) + { + $idVisit = $visitorDetailsArray['idVisit']; + + $maxCustomVariables = CustomVariables::getMaxCustomVariables(); + + $sqlCustomVariables = ''; + for ($i = 1; $i <= $maxCustomVariables; $i++) { + $sqlCustomVariables .= ', custom_var_k' . $i . ', custom_var_v' . $i; + } + // The second join is a LEFT join to allow returning records that don't have a matching page title + // eg. Downloads, Outlinks. For these, idaction_name is set to 0 + $sql = " + SELECT + COALESCE(log_action_event_category.type, log_action.type, log_action_title.type) AS type, + log_action.name AS url, + log_action.url_prefix, + log_action_title.name AS pageTitle, + log_action.idaction AS pageIdAction, + log_link_visit_action.server_time as serverTimePretty, + log_link_visit_action.time_spent_ref_action as timeSpentRef, + log_link_visit_action.idlink_va AS pageId, + log_link_visit_action.custom_float + ". $sqlCustomVariables . ", + log_action_event_category.name AS eventCategory, + log_action_event_action.name as eventAction + FROM " . Common::prefixTable('log_link_visit_action') . " AS log_link_visit_action + LEFT JOIN " . Common::prefixTable('log_action') . " AS log_action + ON log_link_visit_action.idaction_url = log_action.idaction + LEFT JOIN " . Common::prefixTable('log_action') . " AS log_action_title + ON log_link_visit_action.idaction_name = log_action_title.idaction + LEFT JOIN " . Common::prefixTable('log_action') . " AS log_action_event_category + ON log_link_visit_action.idaction_event_category = log_action_event_category.idaction + LEFT JOIN " . Common::prefixTable('log_action') . " AS log_action_event_action + ON log_link_visit_action.idaction_event_action = log_action_event_action.idaction + WHERE log_link_visit_action.idvisit = ? + ORDER BY server_time ASC + LIMIT 0, $actionsLimit + "; + $actionDetails = Db::fetchAll($sql, array($idVisit)); + + foreach ($actionDetails as $actionIdx => &$actionDetail) { + $actionDetail =& $actionDetails[$actionIdx]; + $customVariablesPage = array(); + + $maxCustomVariables = CustomVariables::getMaxCustomVariables(); + + for ($i = 1; $i <= $maxCustomVariables; $i++) { + if (!empty($actionDetail['custom_var_k' . $i])) { + $cvarKey = $actionDetail['custom_var_k' . $i]; + $cvarKey = static::getCustomVariablePrettyKey($cvarKey); + $customVariablesPage[$i] = array( + 'customVariablePageName' . $i => $cvarKey, + 'customVariablePageValue' . $i => $actionDetail['custom_var_v' . $i], + ); + } + unset($actionDetail['custom_var_k' . $i]); + unset($actionDetail['custom_var_v' . $i]); + } + if (!empty($customVariablesPage)) { + $actionDetail['customVariables'] = $customVariablesPage; + } + + + if($actionDetail['type'] == Action::TYPE_EVENT_CATEGORY) { + // Handle Event + if(strlen($actionDetail['pageTitle']) > 0) { + $actionDetail['eventName'] = $actionDetail['pageTitle']; + } + + unset($actionDetail['pageTitle']); + + } else if ($actionDetail['type'] == Action::TYPE_SITE_SEARCH) { + // Handle Site Search + $actionDetail['siteSearchKeyword'] = $actionDetail['pageTitle']; + unset($actionDetail['pageTitle']); + } + + // Event value / Generation time + if($actionDetail['type'] == Action::TYPE_EVENT_CATEGORY) { + if(strlen($actionDetail['custom_float']) > 0) { + $actionDetail['eventValue'] = round($actionDetail['custom_float'], self::EVENT_VALUE_PRECISION); + } + } elseif ($actionDetail['custom_float'] > 0) { + $actionDetail['generationTime'] = \Piwik\MetricsFormatter::getPrettyTimeFromSeconds($actionDetail['custom_float'] / 1000); + } + unset($actionDetail['custom_float']); + + if($actionDetail['type'] != Action::TYPE_EVENT_CATEGORY) { + unset($actionDetail['eventCategory']); + unset($actionDetail['eventAction']); + } + + // Reconstruct url from prefix + $actionDetail['url'] = Tracker\PageUrl::reconstructNormalizedUrl($actionDetail['url'], $actionDetail['url_prefix']); + unset($actionDetail['url_prefix']); + + // Set the time spent for this action (which is the timeSpentRef of the next action) + if (isset($actionDetails[$actionIdx + 1])) { + $actionDetail['timeSpent'] = $actionDetails[$actionIdx + 1]['timeSpentRef']; + $actionDetail['timeSpentPretty'] = \Piwik\MetricsFormatter::getPrettyTimeFromSeconds($actionDetail['timeSpent']); + } + unset($actionDetails[$actionIdx]['timeSpentRef']); // not needed after timeSpent is added + + } + + // If the visitor converted a goal, we shall select all Goals + $sql = " + SELECT + 'goal' as type, + goal.name as goalName, + goal.idgoal as goalId, + goal.revenue as revenue, + log_conversion.idlink_va as goalPageId, + log_conversion.server_time as serverTimePretty, + log_conversion.url as url + FROM " . Common::prefixTable('log_conversion') . " AS log_conversion + LEFT JOIN " . Common::prefixTable('goal') . " AS goal + ON (goal.idsite = log_conversion.idsite + AND + goal.idgoal = log_conversion.idgoal) + AND goal.deleted = 0 + WHERE log_conversion.idvisit = ? + AND log_conversion.idgoal > 0 + ORDER BY server_time ASC + LIMIT 0, $actionsLimit + "; + $goalDetails = Db::fetchAll($sql, array($idVisit)); + + $sql = "SELECT + case idgoal when " . GoalManager::IDGOAL_CART . " then '" . Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART . "' else '" . Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER . "' end as type, + idorder as orderId, + " . LogAggregator::getSqlRevenue('revenue') . " as revenue, + " . LogAggregator::getSqlRevenue('revenue_subtotal') . " as revenueSubTotal, + " . LogAggregator::getSqlRevenue('revenue_tax') . " as revenueTax, + " . LogAggregator::getSqlRevenue('revenue_shipping') . " as revenueShipping, + " . LogAggregator::getSqlRevenue('revenue_discount') . " as revenueDiscount, + items as items, + + log_conversion.server_time as serverTimePretty + FROM " . Common::prefixTable('log_conversion') . " AS log_conversion + WHERE idvisit = ? + AND idgoal <= " . GoalManager::IDGOAL_ORDER . " + ORDER BY server_time ASC + LIMIT 0, $actionsLimit"; + $ecommerceDetails = Db::fetchAll($sql, array($idVisit)); + + foreach ($ecommerceDetails as &$ecommerceDetail) { + if ($ecommerceDetail['type'] == Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART) { + unset($ecommerceDetail['orderId']); + unset($ecommerceDetail['revenueSubTotal']); + unset($ecommerceDetail['revenueTax']); + unset($ecommerceDetail['revenueShipping']); + unset($ecommerceDetail['revenueDiscount']); + } + + // 25.00 => 25 + foreach ($ecommerceDetail as $column => $value) { + if (strpos($column, 'revenue') !== false) { + if ($value == round($value)) { + $ecommerceDetail[$column] = round($value); + } + } + } + } + + // Enrich ecommerce carts/orders with the list of products + usort($ecommerceDetails, array('static', 'sortByServerTime')); + foreach ($ecommerceDetails as $key => &$ecommerceConversion) { + $sql = "SELECT + log_action_sku.name as itemSKU, + log_action_name.name as itemName, + log_action_category.name as itemCategory, + " . LogAggregator::getSqlRevenue('price') . " as price, + quantity as quantity + FROM " . Common::prefixTable('log_conversion_item') . " + INNER JOIN " . Common::prefixTable('log_action') . " AS log_action_sku + ON idaction_sku = log_action_sku.idaction + LEFT JOIN " . Common::prefixTable('log_action') . " AS log_action_name + ON idaction_name = log_action_name.idaction + LEFT JOIN " . Common::prefixTable('log_action') . " AS log_action_category + ON idaction_category = log_action_category.idaction + WHERE idvisit = ? + AND idorder = ? + AND deleted = 0 + LIMIT 0, $actionsLimit + "; + $bind = array($idVisit, isset($ecommerceConversion['orderId']) + ? $ecommerceConversion['orderId'] + : GoalManager::ITEM_IDORDER_ABANDONED_CART + ); + + $itemsDetails = Db::fetchAll($sql, $bind); + foreach ($itemsDetails as &$detail) { + if ($detail['price'] == round($detail['price'])) { + $detail['price'] = round($detail['price']); + } + } + $ecommerceConversion['itemDetails'] = $itemsDetails; + } + + $actions = array_merge($actionDetails, $goalDetails, $ecommerceDetails); + + usort($actions, array('static', 'sortByServerTime')); + + $visitorDetailsArray['actionDetails'] = $actions; + foreach ($visitorDetailsArray['actionDetails'] as &$details) { + switch ($details['type']) { + case 'goal': + $details['icon'] = 'plugins/Zeitgeist/images/goal.png'; + break; + case Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_ORDER: + case Piwik::LABEL_ID_GOAL_IS_ECOMMERCE_CART: + $details['icon'] = 'plugins/Zeitgeist/images/' . $details['type'] . '.gif'; + break; + case Action::TYPE_DOWNLOAD: + $details['type'] = 'download'; + $details['icon'] = 'plugins/Zeitgeist/images/download.png'; + break; + case Action::TYPE_OUTLINK: + $details['type'] = 'outlink'; + $details['icon'] = 'plugins/Zeitgeist/images/link.gif'; + break; + case Action::TYPE_SITE_SEARCH: + $details['type'] = 'search'; + $details['icon'] = 'plugins/Zeitgeist/images/search_ico.png'; + break; + case Action::TYPE_EVENT_CATEGORY: + $details['type'] = 'event'; + $details['icon'] = 'plugins/Zeitgeist/images/event.png'; + break; + default: + $details['type'] = 'action'; + $details['icon'] = null; + break; + } + // Convert datetimes to the site timezone + $dateTimeVisit = Date::factory($details['serverTimePretty'], $timezone); + $details['serverTimePretty'] = $dateTimeVisit->getLocalized(Piwik::translate('CoreHome_ShortDateFormat') . ' %time%'); + } + $visitorDetailsArray['goalConversions'] = count($goalDetails); + return $visitorDetailsArray; + } + + private static function getCustomVariablePrettyKey($key) + { + $rename = array( + Tracker\ActionSiteSearch::CVAR_KEY_SEARCH_CATEGORY => Piwik::translate('Actions_ColumnSearchCategory'), + Tracker\ActionSiteSearch::CVAR_KEY_SEARCH_COUNT => Piwik::translate('Actions_ColumnSearchResultsCount'), + ); + if (isset($rename[$key])) { + return $rename[$key]; + } + return $key; + } + + private static function sortByServerTime($a, $b) + { + $ta = strtotime($a['serverTimePretty']); + $tb = strtotime($b['serverTimePretty']); + return $ta < $tb + ? -1 + : ($ta == $tb + ? 0 + : 1); + } +} diff --git a/www/analytics/plugins/Live/VisitorLog.php b/www/analytics/plugins/Live/VisitorLog.php new file mode 100644 index 00000000..6dbddd8c --- /dev/null +++ b/www/analytics/plugins/Live/VisitorLog.php @@ -0,0 +1,107 @@ +requestConfig->addPropertiesThatShouldBeAvailableClientSide(array( + 'filter_limit', + 'filter_offset', + 'filter_sort_column', + 'filter_sort_order', + )); + + $this->requestConfig->filter_sort_column = 'idVisit'; + $this->requestConfig->filter_sort_order = 'asc'; + $this->requestConfig->filter_limit = 20; + $this->requestConfig->disable_generic_filters = true; + + $offset = Common::getRequestVar('filter_offset', 0); + $limit = Common::getRequestVar('filter_limit', $this->requestConfig->filter_limit); + + $this->config->filters[] = array('Limit', array($offset, $limit)); + } + + /** + * Configure visualization. + */ + public function beforeRender() + { + $this->config->datatable_js_type = 'VisitorLog'; + $this->config->enable_sort = false; + $this->config->show_search = false; + $this->config->show_exclude_low_population = false; + $this->config->show_offset_information = false; + $this->config->show_all_views_icons = false; + $this->config->show_table_all_columns = false; + $this->config->show_export_as_rss_feed = false; + + $this->config->documentation = Piwik::translate('Live_VisitorLogDocumentation', array('
          ', '
          ')); + + $filterEcommerce = Common::getRequestVar('filterEcommerce', 0, 'int'); + $this->config->custom_parameters = array( + // set a very high row count so that the next link in the footer of the data table is always shown + 'totalRows' => 10000000, + + 'filterEcommerce' => $filterEcommerce, + 'pageUrlNotDefined' => Piwik::translate('General_NotDefined', Piwik::translate('Actions_ColumnPageURL')), + + 'smallWidth' => 1 == Common::getRequestVar('small', 0, 'int'), + ); + + $this->config->footer_icons = array( + array( + 'class' => 'tableAllColumnsSwitch', + 'buttons' => array( + array( + 'id' => static::ID, + 'title' => Piwik::translate('Live_LinkVisitorLog'), + 'icon' => 'plugins/Zeitgeist/images/table.png' + ) + ) + ) + ); + + // determine if each row has ecommerce activity or not + if ($filterEcommerce) { + $this->dataTable->filter( + 'ColumnCallbackAddMetadata', + array( + 'actionDetails', + 'hasEcommerce', + function ($actionDetails) use ($filterEcommerce) { + foreach ($actionDetails as $action) { + $isEcommerceOrder = $action['type'] == 'ecommerceOrder' + && $filterEcommerce == \Piwik\Plugins\Goals\Controller::ECOMMERCE_LOG_SHOW_ORDERS; + $isAbandonedCart = $action['type'] == 'ecommerceAbandonedCart' + && $filterEcommerce == \Piwik\Plugins\Goals\Controller::ECOMMERCE_LOG_SHOW_ABANDONED_CARTS; + if($isAbandonedCart || $isEcommerceOrder) { + return true; + } + } + return false; + } + ) + ); + } + } +} diff --git a/www/analytics/plugins/Live/images/avatar_frame.png b/www/analytics/plugins/Live/images/avatar_frame.png new file mode 100644 index 00000000..23eccfd5 Binary files /dev/null and b/www/analytics/plugins/Live/images/avatar_frame.png differ diff --git a/www/analytics/plugins/Live/images/file0.png b/www/analytics/plugins/Live/images/file0.png new file mode 100644 index 00000000..ab6632cb Binary files /dev/null and b/www/analytics/plugins/Live/images/file0.png differ diff --git a/www/analytics/plugins/Live/images/file1.png b/www/analytics/plugins/Live/images/file1.png new file mode 100644 index 00000000..db212e57 Binary files /dev/null and b/www/analytics/plugins/Live/images/file1.png differ diff --git a/www/analytics/plugins/Live/images/file2.png b/www/analytics/plugins/Live/images/file2.png new file mode 100644 index 00000000..28a9a30b Binary files /dev/null and b/www/analytics/plugins/Live/images/file2.png differ diff --git a/www/analytics/plugins/Live/images/file3.png b/www/analytics/plugins/Live/images/file3.png new file mode 100644 index 00000000..10b60f93 Binary files /dev/null and b/www/analytics/plugins/Live/images/file3.png differ diff --git a/www/analytics/plugins/Live/images/file4.png b/www/analytics/plugins/Live/images/file4.png new file mode 100644 index 00000000..67cb7edd Binary files /dev/null and b/www/analytics/plugins/Live/images/file4.png differ diff --git a/www/analytics/plugins/Live/images/file5.png b/www/analytics/plugins/Live/images/file5.png new file mode 100644 index 00000000..d273922e Binary files /dev/null and b/www/analytics/plugins/Live/images/file5.png differ diff --git a/www/analytics/plugins/Live/images/file6.png b/www/analytics/plugins/Live/images/file6.png new file mode 100644 index 00000000..d1d5da56 Binary files /dev/null and b/www/analytics/plugins/Live/images/file6.png differ diff --git a/www/analytics/plugins/Live/images/file7.png b/www/analytics/plugins/Live/images/file7.png new file mode 100644 index 00000000..f1aefce0 Binary files /dev/null and b/www/analytics/plugins/Live/images/file7.png differ diff --git a/www/analytics/plugins/Live/images/file8.png b/www/analytics/plugins/Live/images/file8.png new file mode 100644 index 00000000..a441b8f4 Binary files /dev/null and b/www/analytics/plugins/Live/images/file8.png differ diff --git a/www/analytics/plugins/Live/images/file9.png b/www/analytics/plugins/Live/images/file9.png new file mode 100644 index 00000000..6a162c56 Binary files /dev/null and b/www/analytics/plugins/Live/images/file9.png differ diff --git a/www/analytics/plugins/Live/images/paperclip.png b/www/analytics/plugins/Live/images/paperclip.png new file mode 100644 index 00000000..b38d33ca Binary files /dev/null and b/www/analytics/plugins/Live/images/paperclip.png differ diff --git a/www/analytics/plugins/Live/images/pause.gif b/www/analytics/plugins/Live/images/pause.gif new file mode 100644 index 00000000..5954f9f5 Binary files /dev/null and b/www/analytics/plugins/Live/images/pause.gif differ diff --git a/www/analytics/plugins/Live/images/pause_disabled.gif b/www/analytics/plugins/Live/images/pause_disabled.gif new file mode 100644 index 00000000..98b79f74 Binary files /dev/null and b/www/analytics/plugins/Live/images/pause_disabled.gif differ diff --git a/www/analytics/plugins/Live/images/play.gif b/www/analytics/plugins/Live/images/play.gif new file mode 100644 index 00000000..87eded43 Binary files /dev/null and b/www/analytics/plugins/Live/images/play.gif differ diff --git a/www/analytics/plugins/Live/images/play_disabled.gif b/www/analytics/plugins/Live/images/play_disabled.gif new file mode 100644 index 00000000..ef69513d Binary files /dev/null and b/www/analytics/plugins/Live/images/play_disabled.gif differ diff --git a/www/analytics/plugins/Live/images/returningVisitor.gif b/www/analytics/plugins/Live/images/returningVisitor.gif new file mode 100644 index 00000000..bc71867e Binary files /dev/null and b/www/analytics/plugins/Live/images/returningVisitor.gif differ diff --git a/www/analytics/plugins/Live/images/unknown_avatar.jpg b/www/analytics/plugins/Live/images/unknown_avatar.jpg new file mode 100644 index 00000000..dfd7ec81 Binary files /dev/null and b/www/analytics/plugins/Live/images/unknown_avatar.jpg differ diff --git a/www/analytics/plugins/Live/images/visitorProfileLaunch.png b/www/analytics/plugins/Live/images/visitorProfileLaunch.png new file mode 100644 index 00000000..42aa7d5d Binary files /dev/null and b/www/analytics/plugins/Live/images/visitorProfileLaunch.png differ diff --git a/www/analytics/plugins/Live/images/visitor_profile_background.jpg b/www/analytics/plugins/Live/images/visitor_profile_background.jpg new file mode 100644 index 00000000..082d637d Binary files /dev/null and b/www/analytics/plugins/Live/images/visitor_profile_background.jpg differ diff --git a/www/analytics/plugins/Live/images/visitor_profile_close.png b/www/analytics/plugins/Live/images/visitor_profile_close.png new file mode 100644 index 00000000..ae132b7b Binary files /dev/null and b/www/analytics/plugins/Live/images/visitor_profile_close.png differ diff --git a/www/analytics/plugins/Live/images/visitor_profile_gradient.png b/www/analytics/plugins/Live/images/visitor_profile_gradient.png new file mode 100644 index 00000000..ac5068b5 Binary files /dev/null and b/www/analytics/plugins/Live/images/visitor_profile_gradient.png differ diff --git a/www/analytics/plugins/Live/javascripts/live.js b/www/analytics/plugins/Live/javascripts/live.js new file mode 100644 index 00000000..5539a2ab --- /dev/null +++ b/www/analytics/plugins/Live/javascripts/live.js @@ -0,0 +1,288 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * jQueryUI widget for Live visitors widget + */ + +(function ($) { + $.widget('piwik.liveWidget', { + + /** + * Default settings for widgetPreview + */ + options:{ + // Maximum numbers of rows to display in widget + maxRows: 10, + // minimal time in microseconds to wait between updates + interval: 3000, + // maximum time to wait between requests + maxInterval: 300000, + // url params to use for data request + dataUrlParams: null, + // callback triggered on a successful update (content of widget changed) + onUpdate: null, + // speed for fade animation + fadeInSpeed: 'slow' + }, + + /** + * current updateInterval used + */ + currentInterval: null, + + /** + * identifies if content has updated (eg new visits/views) + */ + updated: false, + + /** + * window timeout interval + */ + updateInterval: null, + + /** + * identifies if the liveWidget ist started or not + */ + isStarted: true, + + /** + * Update the widget + * + * @return void + */ + _update: function () { + + this.updated = false; + + var that = this; + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(this.options.dataUrlParams, 'GET'); + ajaxRequest.setFormat('html'); + ajaxRequest.setCallback(function (r) { + that._parseResponse(r); + + // add default interval to last interval if not updated or reset to default if so + if (!that.updated) { + that.currentInterval += that.options.interval; + } else { + that.currentInterval = that.options.interval; + if (that.options.onUpdate) that.options.onUpdate(); + } + + // check new interval doesn't reach the defined maximum + if (that.options.maxInterval < that.currentInterval) { + that.currentInterval = that.options.maxInterval; + } + + if (that.isStarted) { + window.clearTimeout(that.updateInterval); + if ($(that.element).closest('body').length) { + that.updateInterval = window.setTimeout(function() { that._update() }, that.currentInterval); + } + } + }); + ajaxRequest.send(false); + }, + + /** + * Parses the given response and updates the widget if newer content is available + * + * @return void + */ + _parseResponse: function (data) { + if (!data || !data.length) { + this.updated = false; + return; + } + + var items = $('li', $(data)); + for (var i = items.length; i--;) { + this._parseItem(items[i]); + } + }, + + /** + * Parses the given item and updates or adds an entry to the list + * + * @param item to parse + * @return void + */ + _parseItem: function (item) { + var visitId = $(item).attr('id'); + if ($('#' + visitId, this.element).length) { + if ($('#' + visitId, this.element).html() != $(item).html()) { + this.updated = true; + } + $('#' + visitId, this.element).remove(); + $(this.element).prepend(item); + } else { + this.updated = true; + $(item).hide(); + $(this.element).prepend(item); + $(item).fadeIn(this.options.fadeInSpeed); + } + // remove rows if there are more than the maximum + $('li:gt(' + (this.options.maxRows - 1) + ')', this.element).remove(); + }, + + /** + * Constructor + * + * @return void + */ + _create: function () { + + if (!this.options.dataUrlParams) { + console && console.error('liveWidget error: dataUrlParams needs to be defined in settings.'); + return; + } + + this.currentInterval = this.options.interval; + + var self = this; + + this.updateInterval = window.setTimeout(function() { self._update(); }, this.currentInterval); + }, + + /** + * Stops requests if widget is destroyed + */ + _destroy: function () { + + this.stop(); + }, + + /** + * Triggers an update for the widget + * + * @return void + */ + update: function () { + this._update(); + }, + + /** + * Starts the automatic update cycle + * + * @return void + */ + start: function () { + this.isStarted = true; + this.currentInterval = 0; + this._update(); + }, + + /** + * Stops the automatic update cycle + * + * @return void + */ + stop: function () { + this.isStarted = false; + window.clearTimeout(this.updateInterval); + }, + + /** + * Set the interval for refresh + * + * @param {int} interval new interval for refresh + * @return void + */ + setInterval: function (interval) { + this.currentInterval = interval; + } + }); +})(jQuery); + + +$(function() { + var refreshWidget = function (element, refreshAfterXSecs) { + // if the widget has been removed from the DOM, abort + if ($(element).parent().length == 0) { + return; + } + + var lastMinutes = $(element).attr('data-last-minutes') || 3, + translations = JSON.parse($(element).attr('data-translations')); + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams({ + module: 'API', + method: 'Live.getCounters', + format: 'json', + lastMinutes: lastMinutes + }, 'get'); + ajaxRequest.setFormat('json'); + ajaxRequest.setCallback(function (data) { + data = data[0]; + + // set text and tooltip of visitors count metric + var visitors = data['visitors']; + if (visitors == 1) { + var visitorsCountMessage = translations['one_visitor']; + } + else { + var visitorsCountMessage = translations['visitors'].replace('%s', visitors); + } + $('.simple-realtime-visitor-counter', element) + .attr('title', visitorsCountMessage) + .find('div').text(visitors); + + // set text of individual metrics spans + var metrics = $('.simple-realtime-metric', element); + + var visitsText = data['visits'] == 1 + ? translations['one_visit'] : translations['visits'].replace('%s', data['visits']); + $(metrics[0]).text(visitsText); + + var actionsText = data['actions'] == 1 + ? translations['one_action'] : translations['actions'].replace('%s', data['actions']); + $(metrics[1]).text(actionsText); + + var lastMinutesText = lastMinutes == 1 + ? translations['one_minute'] : translations['minutes'].replace('%s', lastMinutes); + $(metrics[2]).text(lastMinutesText); + + // schedule another request + setTimeout(function () { refreshWidget(element, refreshAfterXSecs); }, refreshAfterXSecs * 1000); + }); + ajaxRequest.send(true); + }; + + var exports = require("piwik/Live"); + exports.initSimpleRealtimeVisitorWidget = function () { + $('.simple-realtime-visitor-widget').each(function() { + var $this = $(this), + refreshAfterXSecs = $this.attr('data-refreshAfterXSecs'); + if ($this.attr('data-inited')) { + return; + } + + $this.attr('data-inited', 1); + + setTimeout(function() { refreshWidget($this, refreshAfterXSecs ); }, refreshAfterXSecs * 1000); + }); + }; +}); + + +var pauseImage = "plugins/Live/images/pause.gif"; +var pauseDisabledImage = "plugins/Live/images/pause_disabled.gif"; +var playImage = "plugins/Live/images/play.gif"; +var playDisabledImage = "plugins/Live/images/play_disabled.gif"; +function onClickPause() { + $('#pauseImage').attr('src', pauseImage); + $('#playImage').attr('src', playDisabledImage); + return $('#visitsLive').liveWidget('stop'); +} +function onClickPlay() { + $('#playImage').attr('src', playImage); + $('#pauseImage').attr('src', pauseDisabledImage); + return $('#visitsLive').liveWidget('start'); +} diff --git a/www/analytics/plugins/Live/javascripts/visitorLog.js b/www/analytics/plugins/Live/javascripts/visitorLog.js new file mode 100644 index 00000000..2287e564 --- /dev/null +++ b/www/analytics/plugins/Live/javascripts/visitorLog.js @@ -0,0 +1,89 @@ +/** + * Piwik - Web Analytics + * + * Visitor profile popup control. + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var exports = require('piwik/UI'), + DataTable = exports.DataTable, + dataTablePrototype = DataTable.prototype; + + /** + * DataTable UI class for jqPlot graph datatable visualizations. + * + * @constructor + */ + exports.VisitorLog = function (element) { + DataTable.call(this, element); + }; + + $.extend(exports.VisitorLog.prototype, dataTablePrototype, { + + /** + * Initializes this class. + */ + init: function () { + dataTablePrototype.init.call(this); + + // Replace duplicated page views by a NX count instead of using too much vertical space + $("ol.visitorLog").each(function () { + var prevelement; + var prevhtml; + var counter = 0; + $(this).find("li").each(function () { + counter++; + $(this).val(counter); + var current = $(this).html(); + if (current == prevhtml) { + var repeat = prevelement.find(".repeat"); + if (repeat.length) { + repeat.html((parseInt(repeat.html()) + 1) + "x"); + } else { + prevelement.append($("2x").attr({'class': 'repeat', 'title': _pk_translate('Live_PageRefreshed')})); + } + $(this).hide(); + } else { + prevhtml = current; + prevelement = $(this); + } + + var $this = $(this); + var tooltipIsOpened = false; + + $('a', $this).on('focus', function () { + // see http://dev.piwik.org/trac/ticket/4099 + if (tooltipIsOpened) { + $this.tooltip('close'); + } + }); + + $this.tooltip({ + track: true, + show: false, + hide: false, + content: function() { + var title = $(this).attr('title'); + return $('').text( title ).html().replace(/\n/g, '
          '); + }, + tooltipClass: 'small', + open: function() { tooltipIsOpened = true; }, + close: function() { tooltipIsOpened = false; } + }); + }); + }); + + // launch visitor profile on visitor profile link click + this.$element.on('click', '.visitor-log-visitor-profile-link', function (e) { + e.preventDefault(); + broadcast.propagateNewPopoverParameter('visitorProfile', $(this).attr('data-visitor-id')); + return false; + }); + } + }); + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/Live/javascripts/visitorProfile.js b/www/analytics/plugins/Live/javascripts/visitorProfile.js new file mode 100644 index 00000000..9b0a5eb4 --- /dev/null +++ b/www/analytics/plugins/Live/javascripts/visitorProfile.js @@ -0,0 +1,287 @@ +/** + * Piwik - Web Analytics + * + * Visitor profile popup control. + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function ($, require) { + + var piwik = require('piwik'), + exports = require('piwik/UI'), + UIControl = exports.UIControl; + + /** + * Sets up and handles events for the visitor profile popup. + * + * @param {Element} element The HTML element returned by the Live.getVisitorLog controller + * action. Should have the CSS class 'visitor-profile'. + * @constructor + */ + var VisitorProfileControl = function (element) { + UIControl.call(this, element); + this._setupControl(); + this._bindEventCallbacks(); + }; + + /** + * Initializes all elements w/ the .visitor-profile CSS class as visitor profile popups, + * if the element has not already been initialized. + */ + VisitorProfileControl.initElements = function () { + UIControl.initElements(this, '.visitor-profile'); + }; + + /** + * Shows the visitor profile popover for a visitor ID. This should not be called directly. + * Instead broadcast.propagateNewPopoverParameter('visitorProfile', visitorId) should be + * called. This would make sure the popover would be opened if the URL is copied and pasted + * in a new tab/window. + * + * @param {String} visitorId The string visitor ID. + */ + VisitorProfileControl.showPopover = function (visitorId) { + var url = 'module=Live&action=getVisitorProfilePopup&visitorId=' + encodeURIComponent(visitorId); + + // if there is already a map shown on the screen, do not show the map in the popup. kartograph seems + // to only support showing one map at a time. + if ($('.RealTimeMap').length > 0) { + url += '&showMap=0'; + } + + Piwik_Popover.createPopupAndLoadUrl(url, _pk_translate('Live_VisitorProfile'), 'visitor-profile-popup'); + }; + + $.extend(VisitorProfileControl.prototype, UIControl.prototype, { + + _setupControl: function () { + // focus the popup so it will accept key events + this.$element.focus(); + + // highlight the first visit + $('.visitor-profile-visits>li:first-child', this.$element).addClass('visitor-profile-current-visit'); + }, + + _bindEventCallbacks: function () { + var self = this, + $element = this.$element; + + $element.on('click', '.visitor-profile-close', function (e) { + e.preventDefault(); + Piwik_Popover.close(); + return false; + }); + + $element.on('click', '.visitor-profile-more-info>a', function (e) { + e.preventDefault(); + self._loadMoreVisits(); + return false; + }); + + $element.on('click', '.visitor-profile-see-more-cvars>a', function (e) { + e.preventDefault(); + $('.visitor-profile-extra-cvars', $element).slideToggle(); + return false; + }); + + $element.on('click', '.visitor-profile-visit-title-row', function () { + self._loadIndividualVisitDetails($('h2', this)); + }); + + $element.on('click', '.visitor-profile-prev-visitor', function (e) { + e.preventDefault(); + self._loadPreviousVisitor(); + return false; + }); + + $element.on('click', '.visitor-profile-next-visitor', function (e) { + e.preventDefault(); + self._loadNextVisitor(); + return false; + }); + + $element.on('keydown', function (e) { + if (e.which == 37) { // on <- key press, load previous visitor + self._loadPreviousVisitor(); + } else if (e.which == 39) { // on -> key press, load next visitor + self._loadNextVisitor(); + } + }); + + $element.on('click', '.visitor-profile-show-map', function (e) { + e.preventDefault(); + self.toggleMap(); + return false; + }); + + // append token_auth dynamically to export link + $element.on('mousedown', '.visitor-profile-export', function (e) { + var url = $(this).attr('href'); + if (url.indexOf('&token_auth=') == -1) { + $(this).attr('href', url + '&token_auth=' + piwik.token_auth); + } + }); + + // on hover, show export link (chrome won't let me do this via css :( ) + $element.on('mouseenter mouseleave', '.visitor-profile-id', function (e) { + var $exportLink = $(this).find('.visitor-profile-export'); + if ($exportLink.css('visibility') == 'hidden') { + $exportLink.css('visibility', 'visible'); + } else { + $exportLink.css('visibility', 'hidden'); + } + }); + }, + + toggleMap: function () { + var $element = this.$element, + $map = $('.visitor-profile-map', $element); + if (!$map.children().length) { // if the map hasn't been loaded, load it + this._loadMap($map); + return; + } + + if ($map.is(':hidden')) { // show the map if it is hidden + if ($map.height() < 1) { + $map.resize(); + } + + $map.slideDown('slow'); + var newLabel = 'Live_HideMap'; + + piwikHelper.lazyScrollTo($('.visitor-profile-location', $element)[0], 400); + } else { // hide the map if it is shown + $map.slideUp('slow'); + var newLabel = 'Live_ShowMap'; + } + + newLabel = _pk_translate(newLabel).replace(' ', '\xA0'); + $('.visitor-profile-show-map', $element).text('(' + newLabel + ')'); + }, + + _loadMap: function ($map) { + var self = this; + + var ajax = new ajaxHelper(); + ajax.setUrl($map.attr('data-href')); + ajax.setCallback(function (response) { + $map.html(response); + self.toggleMap(); + }); + ajax.setFormat('html'); + ajax.setLoadingElement($('.visitor-profile-location > p > .loadingPiwik', self.$element)); + ajax.send(); + }, + + _loadMoreVisits: function () { + var self = this, + $element = this.$element; + + var loading = $('.visitor-profile-more-info > .loadingPiwik', $element); + loading.show(); + + var ajax = new ajaxHelper(); + ajax.addParams({ + module: 'Live', + action: 'getVisitList', + period: '', + date: '', + visitorId: $element.attr('data-visitor-id'), + filter_offset: $('.visitor-profile-visits>li', $element).length + }, 'GET'); + ajax.setCallback(function (response) { + if (response == "") { // no more visits left + self._showNoMoreVisitsSpan(); + } else { + response = $(response); + loading.hide(); + + $('.visitor-profile-visits', $element).append(response); + if (response.filter('li').length < 10) { + self._showNoMoreVisitsSpan(); + } + + piwikHelper.lazyScrollTo($(response)[0], 400, true); + } + }); + ajax.setFormat('html'); + ajax.send(); + }, + + _showNoMoreVisitsSpan: function () { + var noMoreSpan = $('').text(_pk_translate('Live_NoMoreVisits')).addClass('visitor-profile-no-visits'); + $('.visitor-profile-more-info', this.$element).html(noMoreSpan); + }, + + _loadIndividualVisitDetails: function ($visitElement) { + var self = this, + $element = this.$element, + visitId = $visitElement.attr('data-idvisit'); + + $('.visitor-profile-avatar .loadingPiwik', $element).css('display', 'inline-block'); + piwikHelper.lazyScrollTo($('.visitor-profile-avatar', $element)[0], 400); + + var ajax = new ajaxHelper(); + ajax.addParams({ + module: 'Live', + action: 'getSingleVisitSummary', + visitId: visitId, + idSite: piwik.idSite + }, 'GET'); + ajax.setCallback(function (response) { + $('.visitor-profile-avatar .loadingPiwik', $element).hide(); + + $('.visitor-profile-current-visit', $element).removeClass('visitor-profile-current-visit'); + $visitElement.closest('li').addClass('visitor-profile-current-visit'); + + var $latestVisitSection = $('.visitor-profile-latest-visit', $element); + $latestVisitSection + .html(response) + .parent() + .effect('highlight', {color: '#FFFFCB'}, 1200); + }); + ajax.setFormat('html'); + ajax.send(); + }, + + _loadPreviousVisitor: function () { + this._gotoAdjacentVisitor(this.$element.attr('data-prev-visitor')); + }, + + _loadNextVisitor: function () { + this._gotoAdjacentVisitor(this.$element.attr('data-next-visitor')); + }, + + _gotoAdjacentVisitor: function (idVisitor) { + if (!idVisitor) { + return; + } + + if (this._inPopover()) { + broadcast.propagateNewPopoverParameter('visitorProfile', idVisitor); + } else if (this._inWidget()) { + this.$element.closest('[widgetid]').dashboardWidget('reload', false, true, {visitorId: idVisitor}); + } + }, + + _getFirstVisitId: function () { + return $('.visitor-profile-visits>li:first-child>h2', this.$element).attr('data-idvisit'); + }, + + _inPopover: function () { + return !! this.$element.closest('#Piwik_Popover').length; + }, + + _inWidget: function () { + return !! this.$element.closest('.widget').length; + } + }); + + exports.VisitorProfileControl = VisitorProfileControl; + + // add the popup handler that creates a visitor profile + broadcast.addPopoverHandler('visitorProfile', VisitorProfileControl.showPopover); + +})(jQuery, require); \ No newline at end of file diff --git a/www/analytics/plugins/Live/stylesheets/live.less b/www/analytics/plugins/Live/stylesheets/live.less new file mode 100644 index 00000000..6dc6c43a --- /dev/null +++ b/www/analytics/plugins/Live/stylesheets/live.less @@ -0,0 +1,220 @@ +#visitsLive { + text-align: left; + font-size: 90%; + color: #444; +} + +#visitsLive .datetime, #visitsLive .country, #visitsLive .referrer, #visitsLive .settings, #visitsLive .returning { + border-bottom: 1px solid #d3d1c5; + border-right: 1px solid #d3d1c5; + padding: 5px 5px 5px 12px; +} + +#visitsLive .datetime { + background: #E4E2D7; + border-top: 1px solid #d3d1c5; + margin: 0; + text-align: left; +} + +#visitsLive .country { + background: #FFF url(plugins/CoreHome/images/bullet1.gif) no-repeat scroll 0 0; +} + +#visitsLive .referrer { + background: #F9FAFA none repeat scroll 0 0; +} + +#visitsLive .referrer:hover { + background: #FFFFF7; +} + +#visitsLive .pagesTitle { + display: block; + float: left; +} + +#visitsLive .settings { + background: #FFF none repeat scroll 0 0; +} + +#visitsLive .settings a { + text-decoration: none; +} + +#visitsLive .returning { + background: #F9FAFA none repeat scroll 0 0; +} + +.visitsLiveFooter img { + vertical-align: middle; +} + +.visitsLiveFooter { + line-height: 2.5em; +} + +.dataTableVizVisitorLog table img { + margin: 0 3px 0 0; +} + +.visitsLiveFooter a.rightLink { + float: right; + padding-right: 20px; +} + +#visitsLive .datetime a { + text-decoration: none; +} + +table.dataTable td.highlightField { + background-color: #FFFFCB !important; +} + +ol.visitorLog { + list-style: decimal inside none; +} + +.truncated-text-line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display:inline-block; + max-width:90%; + overflow: -moz-hidden-unscrollable; +} + +ol.visitorLog li { + margin-bottom: 4px; +} + +#visitsLive img { + vertical-align: middle; +} + +.visitorRank img { + vertical-align: text-bottom; +} + +.iconPadding { + margin-left: 4px; + margin-right: 4px; +} + +.visitorRank { + margin-left: 15px; + border: 1px solid #D8D8D8; + color: #474747; + border-radius: 3px; + padding: 3px 5px; +} + +#visitsLive .visitorRank { + padding: 2px; + border: none; + margin-left: 5px; +} + +.hash { + color: #BBB; + font-size: 9pt; + margin-right: 2px; +} + +.repeat { + font-weight: bold; + border: 1px solid #444; + border-radius: 3px; + padding: 2px; +} + +.dataTableVizVisitorLog hr { + background: none repeat scroll 0 0 transparent; + border: 0 none #000; + border-bottom: 1px solid #ccc; + color: #eee; + margin: 0 2em 0.5em; + padding: 0 0 0.5em; +} + +.simple-realtime-visitor-widget { + text-align: center; +} + +.simple-realtime-visitor-counter { + background-color: #F1F0EB; + border-radius: 10px; + display: inline-block; + margin: 2em 0 1em 0; + padding: 3em; +} + +.simple-realtime-visitor-counter > div { + font-size: 4.0em; + color: #444; +} + +.simple-realtime-metric { + font-style: italic; + font-weight: bold; + color: #333; +} + +.simple-realtime-elaboration { + margin: 1em 2em 1em 2em; + color: #666; + display: inline-block; +} + +ol.visitorLog p { + margin:0; + padding:0; +} + +.dataTableVizVisitorLog table.dataTable .column { + white-space: normal; + padding: 12px 5px; +} +.dataTableVizVisitorLog table.dataTable .label { + white-space: nowrap; +} + +.dataTableVizVisitorLog .dataTableWrapper { + width:100%; +} + +.visitor-log-page-list { + position:relative; +} +.dataTableVizVisitorLog tr:hover .visitor-log-visitor-profile-link { + display:inline; +} +a.visitor-log-visitor-profile-link { + position:absolute; + display:none; + right:8px; + top:0px; + font-style:italic; + font-size:13px; + + img { + margin-top:-2px; + } +} + +.visitorLog,.visitor-profile-actions { + > li > div { + display:inline-block; + width:85%; + vertical-align:top; + } +} + +.action-list-action-icon { + float:left; + margin-top:6px; +} + +.action-list-url { + display:block; +} diff --git a/www/analytics/plugins/Live/stylesheets/visitor_profile.less b/www/analytics/plugins/Live/stylesheets/visitor_profile.less new file mode 100644 index 00000000..254eb02f --- /dev/null +++ b/www/analytics/plugins/Live/stylesheets/visitor_profile.less @@ -0,0 +1,547 @@ +.visitor-profile { + position:relative; + width:1150px; + border:1px solid #a19e96; + border-radius:5px; + background:url(../images/visitor_profile_background.jpg) repeat; + box-shadow:5px 5px 5px rgba(0,0,0,0.22); + text-align:left; + + h1 { + font-size:18px; + color:#7e7363; + text-shadow:0 1px 0 rgba(255,255,255,1); + margin:9px 0 0 0; + padding:0; + + a { + font-size:12px; + margin-left:3px; + } + } + + p { + font-size:14px; + color:#5e5e5c; + line-height:20px; + } + + h2 { + display:inline-block; + font-size:14px; + margin:0 0 0 5px; + padding:0; + font-weight:bold; + color:black; + } + + span.truncated-text-line { + display:inline-block; + } +} + +.visitor-profile-summary,.visitor-profile-important-visits,.visitor-profile-avatar,.visitor-profile-visits-container { + span, strong { + display:inline; + font-size:14px; + color:#5e5e5c; + line-height:19px; + padding-left:4px; + } +} + +.visitor-profile-widget-link { + color:#5e5e5c; +} + +.visitor-profile-widget-link:hover { + text-decoration:underline; +} + +.visitor-profile-export { + float:right; + margin-top:3px; +} + +.visitor-profile-close { + position:absolute; + right:-17px; + top:-16px; + height:35px; + width:35px; + background:url(../images/visitor_profile_close.png) no-repeat; +} + +.visitor-profile a { + text-decoration:none; + color:#255792; +} + +.visitor-profile > div { + width:100%; +} + +.visitor-profile-info { + height:auto; + border-top:2px solid #f6f6f6; + border-bottom:1px solid #d1cec8; + border-radius:5px 5px 0 0; + box-shadow:inset 0 25px 15px -10px #e0e0e0, inset 0 -25px 15px -10px #e0e0e0; + + > div { // row + border-bottom:1px solid #d1cec8; + + > div { // columns + vertical-align:top; + height:auto; + display:inline-block; + } + } + + .visitor-profile-overview { // first column + width:574.5px; + border-left:none; + margin:0 -3px 0 0; + border-right:1px solid #d1cec8; + } + .visitor-profile-visits-info { // last column + width:573.5px; + border-bottom:none; + margin:0 0 0 -3px; + border-left:1px solid #d1cec8; + } +} + +.visitor-profile-summary,.visitor-profile-important-visits,.visitor-profile-avatar,.visitor-profile-location { + border-bottom:1px solid #d1cec8; +} + +.visitor-profile-avatar > div { + position:relative; + float:left; + min-height:145px; + margin:12px 15px 0 0; + padding-bottom:4px; +} + +.visitor-profile-avatar > div:first-child { + width:166px; + margin-right:0; + padding-left:16px; + + > .visitor-profile-image-frame { + width:149px; + height:154px; + background:url(../images/avatar_frame.png) no-repeat; + + > img { // avatar image + width:122px; + height:120px; + margin:11px 0 0 12px; + } + } + + > img { // paperclip image + position:absolute; + top:-20px; + left:3px; + z-index:2; + } +} + +.visitor-profile-avatar > div:last-child { + margin-right:0; +} + +.visitor-profile-avatar h1 { + display:inline-block; +} + +.visitor-profile-extra-cvars { + border-top: 1px solid #d1cec8; +} + +.visitor-profile-more-info { + height:18px; + border-radius:0 0 5px 5px; + text-align:center; + padding:0 0 13px; + + > a { + font-size:14px; + text-decoration:none; + color:#255792; + text-shadow:0 1px 0 rgba(255,255,255,1); + } + + > .loadingPiwik { + padding:0 0 0 4px; + } +} + +.visitor-profile-latest-visit { + position:relative; +} + +.visitor-profile-latest-visit-column { + padding-top:6px; + display:inline-block; + vertical-align:top; +} + +.visitor-profile-browser { + margin-left: 5px; + display:inline-block; +} + +.visitor-profile-os { + display:inline-block; +} + +.visitor-profile-latest-visit-column:last-child { + margin-left:9px; +} + +.visitor-profile-avatar ul { + width:178px; +} + +.visitor-profile-avatar ul li { + display:inline-block; + min-height:24px; + border-bottom:1px solid #d1cec8; + width:100%; +} + +.visitor-profile-id { + height:24px; +} + +.visitor-profile-avatar ul li:last-child { + border-bottom:none; +} + +.visitor-profile-avatar ul li:first-child { + border-bottom:1px solid #d1cec8; // make sure there is a border if only one item is shown in the list +} + +.visitor-profile-map { + padding:0 21px 13px 21px; + + .dataTableFeatures,.no_data { + display:none !important; + } +} + +.visitor-profile-map > div { + border-radius:2px; + background-color:#fff; +} + +.visitor-profile-show-map { + font-size:13px; + font-style:italic; +} + +.visitor-profile-summary,.visitor-profile-important-visits { + overflow:hidden; + padding:5px 0 0 22px; +} + +.visitor-profile-summary { + padding-bottom:18px; +} + +.visitor-profile-important-visits { + padding-bottom:16px; + + > div > div > p:first-child > strong { + padding-left:0; + } +} + +.visitor-profile-summary > div { + margin-top:6px; + margin-right:1em; +} + +.visitor-profile-summary strong { + padding-left:0; +} + +.visitor-profile-important-visits { + > div { + float:left; + width:265px; + height:100%; + + > div { + margin-top:13px; + } + } + + span { + padding-left:0; + } +} + +.visitor-profile-location { + padding:10px 0 4px 19px; + + p { + margin:13px 0; + font-size:14px; + } +} + +.visitor-profile-location>p>.loadingPiwik { + padding:0 0 0 4px; +} + +.visitor-profile-pages-visited { + height:42px; + overflow-y:auto; + position:relative; + margin-right:10px; + border-bottom:none!important; + padding: 8px 18px 10px 13px; + + h1 { + margin-left:6px; + } +} + +.visitor-profile-visits-container { + overflow-y:auto; + position:relative; + margin-right:10px; + border-bottom:none!important; + padding:0 18px 0 13px; + + .action-list-action-icon { + margin-right:4px; + } + + ol { + > li { + display:block; + font-size:12px; + font-weight:700; + line-height:25px; + padding:0 0 10px 13px; + + span { + font-size:13px; + font-weight:700; + line-height:25px; + padding-left:0; + } + } + } + + ol.visitor-profile-visits > li { + margin:0 0 10px 13px; + padding:0; + + > div { + margin:0 6px 0 6px; + } + } + + ol.visitor-profile-actions { + counter-reset:item; + list-style-type:none; + + > li:before { + content:counter(item) " "; + counter-increment:item; + } + } + + ol li ol { + border-top:1px solid #d1cec8; + } + + ol > li > ol > li { + margin-left:-12px; + } + + ol li ol li { + display:block; + color:#5e5e5c; + font-size:13px; + line-height:22px; + padding-top:1px; + padding-bottom:1px; + } + + ol li ol li { + padding-bottom:4px; + } + + ol > li ol li span { + padding-left:4px; + } + + ol > li ol li { + .action-list-url { + margin-left:4px; + line-height:14px; + font-size:13px; + } + + > div > .action-list-url { + line-height:23px; + } + } + + ol > li ol li img { + margin-left:7px; + } + + // overrides for _actionsDetails styles + strong { + font-size:13px; + line-height:25px; + } +} + +.visitor-profile-current-visit { + background-color:#FAFACF; +} + +.visitor-profile-date { + float:right; + font-size:13px; + line-height:26px; +} + +.visitor-profile-fog { + height:25px; + width:546px; + position:absolute; + bottom:51px; + right:28px; + background:url(../images/visitor_profile_gradient.png) repeat-x; +} + +// popup css +.visitor-profile-popup { + width: 1151px; + height: auto; + padding: 0; + + > .ui-dialog-titlebar { + display: none; + } + + > #Piwik_Popover { + padding: 0; + margin: 0; + overflow: visible; + } +} + +span.visitor-profile-goal-name { + font-style:italic; + font-size:14px; +} + +.visitor-profile-see-more-cvars { + text-align:center; + + > a { + font-size:11px; + display:inline-block; + color:#5e5e5c; + } +} + +.visitor-profile-visit-title-row { + cursor:pointer; +} + +.visitor-profile-visit-title-row:hover { + background-color:#FAFACF; +} + +.visitor-profile-avatar .loadingPiwik { + padding:0; + margin:0; +} + +.visitor-profile-visits-info { + position: relative; +} + +div.visitor-profile-navigation { + height:auto; + min-height:inherit; + font-size:12px; + float:none; + display:block; + padding:0 0 0 22px; +} + +.visitor-profile-header { + position:relative; + + > .reportDocumentationIcon { + display:none; + margin:0; + width:14px; + background-size:14px; + } +} + +.visitor-profile-prev-visitor { + display:none; + position:absolute; + right:100%; + bottom:0; + margin-right:2px; +} + +a.visitor-profile-next-visitor,a.visitor-profile-prev-visitor { + display:none; + color:#7e7363; +} + +.visitor-profile-avatar:hover { + .visitor-profile-next-visitor,.visitor-profile-prev-visitor,.reportDocumentationIcon { + display:inline-block; + } +} + +.visitor-profile-no-visits { + color:#999; + font-size:13px; +} + +.visitor-profile-latest-visit-loc { + display:inline-block; + position:absolute; + right:4px; + top:-24px; +} + +// overrides for the widgetized visitor profile +.widget .visitor-profile { + min-width: 100% !important; + + p { + padding-bottom: 0; + } + + .visitor-profile-close { + display:none; + } + + .visitor-profile-info { + > div { // row + > div { // columns + min-width:50% !important; + } + } + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Live/templates/_actionsList.twig b/www/analytics/plugins/Live/templates/_actionsList.twig new file mode 100644 index 00000000..23b13a15 --- /dev/null +++ b/www/analytics/plugins/Live/templates/_actionsList.twig @@ -0,0 +1,121 @@ +{% for action in actionDetails %} + {% set customVariablesTooltip %} + {% if action.customVariables is defined %} + {{ 'CustomVariables_CustomVariables'|translate }} + {% for id,customVariable in action.customVariables %} + {% set name = 'customVariablePageName' ~ id %} + {% set value = 'customVariablePageValue' ~ id %} + - {{ customVariable[name]|raw }} {% if customVariable[value]|length > 0 %} = {{ customVariable[value]|raw }}{% endif %} + {% endfor %} + {% endif %} + {% endset %} + {% if not clientSideParameters.filterEcommerce or action.type == 'ecommerceOrder' or action.type == 'ecommerceAbandonedCart' %} +
        • +
          + {% if action.type == 'ecommerceOrder' or action.type == 'ecommerceAbandonedCart' %} + {# Ecommerce Abandoned Cart / Ecommerce Order #} + + {% if action.type == 'ecommerceOrder' %} + {{ 'Goals_EcommerceOrder'|translate }} + ({{ action.orderId }}) + {% else %} + {{'Goals_AbandonedCart'|translate}} + + {# TODO: would be nice to have the icons Orders / Cart in the ecommerce log footer #} + {% endif %} +

          + + {% if action.type == 'ecommerceOrder' %} +{# spacing is important for tooltip to look nice #} +{% set ecommerceOrderTooltip %}{{ 'General_ColumnRevenue'|translate }}: {{ action.revenue|money(clientSideParameters.idSite)|raw }} +{% if action.revenueSubTotal is not empty %} - {{ 'General_Subtotal'|translate }}: {{ action.revenueSubTotal|money(clientSideParameters.idSite)|raw }}{% endif %} + +{% if action.revenueTax is not empty %} - {{ 'General_Tax'|translate }}: {{ action.revenueTax|money(clientSideParameters.idSite)|raw }}{% endif %} + +{% if action.revenueShipping is not empty %} - {{ 'General_Shipping'|translate }}: {{ action.revenueShipping|money(clientSideParameters.idSite)|raw }}{% endif %} + +{% if action.revenueDiscount is not empty %} - {{ 'General_Discount'|translate }}: {{ action.revenueDiscount|money(clientSideParameters.idSite)|raw }}{% endif %} +{% endset %} + {{ 'General_ColumnRevenue'|translate }}: + {% else %} + {% set revenueLeft %}{{ 'General_ColumnRevenue'|translate }}{% endset %} + {{ 'Goals_LeftInCart'|translate(revenueLeft) }}: + {% endif %} + {{ action.revenue|money(clientSideParameters.idSite)|raw }} + {% if action.type == 'ecommerceOrder' %} + + {% endif %}, {{ 'General_Quantity'|translate }}: {{ action.items }} + + {# Ecommerce items in Cart/Order #} + {% if action.itemDetails is not empty %} +

            + {% for product in action.itemDetails %} +
          • + {{ product.itemSKU }}{% if product.itemName is not empty %}: {{ product.itemName }}{% endif %} + {% if product.itemCategory is not empty %} ({{ product.itemCategory }}){% endif %} + , + {{ 'General_Quantity'|translate }}: {{ product.quantity }}, + {{ 'General_Price'|translate }}: {{ product.price|money(clientSideParameters.idSite)|raw }} +
          • + {% endfor %} +
          + {% endif %} + +

          + {% elseif action.goalName is not defined%} + {# Page view / Download / Outlink / Event #} + {% if action.pageTitle|default(false) is not empty %} + {{ action.pageTitle }} + {% endif %} + {% if action.siteSearchKeyword is defined %} + {% if action.type == 'search' %} + + {% endif %} + {{ action.siteSearchKeyword }} + {% endif %} + {% if action.eventCategory|default(false) is not empty %} + + {{ action.eventCategory }} - {{ action.eventAction }} {% if action.eventName is defined %}- {{ action.eventName }}{% endif %} {% if action.eventValue is defined %}- {{ action.eventValue }}{% endif %} + {% endif %} + {% if action.url is not empty %} + {% if action.type == 'action' and action.pageTitle|default(false) is not empty %}

          {% endif %} + {% if action.type == 'download' or action.type == 'outlink' %} + + {% endif %} + + {% if action.eventCategory|default(false) is not empty %} + (url) + {% else %} + {{ action.url }} + {% endif %} + + {% if action.type == 'action' and action.pageTitle|default(false) is not empty %}

          {% endif %} + {% elseif action.type != 'search' and action.type != 'event' %} +

          + {{ clientSideParameters.pageUrlNotDefined }} +

          + {% endif %} + {% else %} + {# Goal conversion #} + + {{ action.goalName }} + {% if action.revenue > 0 %}, {{ 'General_ColumnRevenue'|translate }}: + {{ action.revenue|money(clientSideParameters.idSite)|raw }} + {% endif %} + {% endif %} +
          +
        • + {% endif %} +{% endfor %} \ No newline at end of file diff --git a/www/analytics/plugins/Live/templates/_dataTableViz_visitorLog.twig b/www/analytics/plugins/Live/templates/_dataTableViz_visitorLog.twig new file mode 100644 index 00000000..e8072366 --- /dev/null +++ b/www/analytics/plugins/Live/templates/_dataTableViz_visitorLog.twig @@ -0,0 +1,202 @@ +{% set displayVisitorsInOwnColumn = (isWidget ? false : true) %} +{% set displayReferrersInOwnColumn = (clientSideParameters.smallWidth ? false : true) %} + + + + + + {% if displayVisitorsInOwnColumn %} + + {% endif %} + {% if displayReferrersInOwnColumn %} + + {% endif %} + + + + +{% set cycleIndex=0 %} +{% for visitor in dataTable.getRows() %} + {% set breakBeforeVisitorRank = (visitor.getColumn('visitEcommerceStatusIcon') and visitor.getColumn('visitorTypeIcon')) ? true : false %} + {% set visitorColumnContent %} + +   + {% if visitor.getColumn('plugins') %} + + {% else %} + + {% endif %} +   + + {% if visitor.getColumn('visitorTypeIcon') %} +  - + + {% endif %} + {% if not displayVisitorsInOwnColumn or breakBeforeVisitorRank %}

          {% endif %} + {% if visitor.getColumn('visitConverted') %} + + + # + {{ visitor.getColumn('goalConversions') }} + {% if visitor.getColumn('visitEcommerceStatusIcon') %} +  - + + {% endif %} + + {% endif %} + {% if not displayVisitorsInOwnColumn %}

          {% endif %} + {% if displayVisitorsInOwnColumn %} + {% if visitor.getColumn('pluginsIcons')|length > 0 %} +
          + {{ 'General_Plugins'|translate }}: + {% for pluginIcon in visitor.getColumn('pluginsIcons') %} + {{ pluginIcon.pluginName|capitalize(true) }} + {% endfor %} + {% endif %} + {% endif %} + {% endset %} + + {% set referrerColumnContent %} +
          + {% if visitor.getColumn('referrerType') == 'website' %} + {{ 'Referrers_ColumnWebsite'|translate }}: + + {{ visitor.getColumn('referrerName') }} + + {% endif %} + {% if visitor.getColumn('referrerType') == 'campaign' %} + {{ 'Referrers_ColumnCampaign'|translate }} +
          + {{ visitor.getColumn('referrerName') }} + {% if visitor.getColumn('referrerKeyword') is not empty %} - {{ visitor.getColumn('referrerKeyword') }}{% endif %} + {% endif %} + {% if visitor.getColumn('referrerType') == 'search' %} + {%- set keywordNotDefined = 'General_NotDefined'|translate('General_ColumnKeyword'|translate) -%} + {%- set showKeyword = visitor.getColumn('referrerKeyword') is not empty and visitor.getColumn('referrerKeyword') != keywordNotDefined -%} + {% if visitor.getColumn('searchEngineIcon') %} + {{ visitor.getColumn('referrerName') }} + {% endif %} + {{ visitor.getColumn('referrerName') }} + {% if showKeyword %}{{ 'Referrers_Keywords'|translate }}: +
          + + "{{ visitor.getColumn('referrerKeyword') }}" + {% endif %} + {% set keyword %}{{ visitor.getColumn('referrerKeyword') }}{% endset %} + {% set searchName %}{{ visitor.getColumn('referrerName') }}{% endset %} + {% set position %}#{{ visitor.getColumn('referrerKeywordPosition') }}{% endset %} + {% if visitor.getColumn('referrerKeywordPosition') %} + + # + {{ visitor.getColumn('referrerKeywordPosition') }} + + {% endif %} + {% endif %} + {% if visitor.getColumn('referrerType') == 'direct' %}{{ 'Referrers_DirectEntry'|translate }}{% endif %} +
          + {% endset %} + + + {% set visitorRow %} + + {% set cycleIndex=cycleIndex+1 %} + + + + {% if displayVisitorsInOwnColumn %} + + {% endif %} + + {% if displayReferrersInOwnColumn %} + + {% endif %} + + + + {% endset %} + + {% if not clientSideParameters.filterEcommerce or visitor.getMetadata('hasEcommerce') %} + {{ visitorRow }} + {% endif %} +{% endfor %} + + +
          +
          {{ 'General_Date'|translate }}
          +
          +
          {{ 'General_Visitors'|translate }}
          +
          +
          {{ 'Live_Referrer_URL'|translate }}
          +
          +
          {{ 'General_ColumnNbActions'|translate }}
          +
          + + {{ visitor.getColumn('serverDatePrettyFirstAction') }} + {% if isWidget %}
          {% else %}-{% endif %} {{ visitor.getColumn('serverTimePrettyFirstAction') }}
          + {% if visitor.getColumn('visitIp') is not empty %} +
          + + IP: {{ visitor.getColumn('visitIp') }}{% endif %} + + {% if visitor.getColumn('provider') and visitor.getColumn('providerName')!='IP' %} +
          + {{ 'Provider_ColumnProvider'|translate }}: + + {{ visitor.getColumn('providerName') }} + + {% endif %} + {% if visitor.getColumn('customVariables') %} +
          + {% for id,customVariable in visitor.getColumn('customVariables') %} + {% set name='customVariableName' ~ id %} + {% set value='customVariableValue' ~ id %} +
          + + {{ customVariable[name]|truncate(30) }} + + {% if customVariable[value]|length > 0 %}: {{ customVariable[value]|truncate(50) }}{% endif %} + {% endfor %} + {% endif %} + {% if not displayVisitorsInOwnColumn %} +
          + {{ visitorColumnContent }} + {% endif %} + {% if not displayReferrersInOwnColumn %} +
          + {{ referrerColumnContent }} + {% endif %} +
          + {{ visitorColumnContent }} + + {{ referrerColumnContent }} + +
          + {% if visitor.getColumn('visitorId') is not empty %} + + {{ 'Live_ViewVisitorProfile'|translate }} + + {% endif %} + + {{ visitor.getColumn('actionDetails')|length }} + {% if visitor.getColumn('actionDetails')|length <= 1 %} + {{ 'General_Action'|translate }} + {% else %} + {{ 'General_Actions'|translate }} + {% endif %} + {% if visitor.getColumn('visitDuration') > 0 %}- {{ visitor.getColumn('visitDurationPretty')|raw }}{% endif %} + +
          +
            + {% include "@Live/_actionsList.twig" with {'actionDetails': visitor.getColumn('actionDetails')} %} +
          +
          +
          \ No newline at end of file diff --git a/www/analytics/plugins/Live/templates/_totalVisitors.twig b/www/analytics/plugins/Live/templates/_totalVisitors.twig new file mode 100644 index 00000000..c1d70c7c --- /dev/null +++ b/www/analytics/plugins/Live/templates/_totalVisitors.twig @@ -0,0 +1,29 @@ +
          + + + + + + + + + + + + + + + + + + + + +
          +
          {{ 'General_Date'|translate }}
          +
          +
          {{ 'General_ColumnNbVisits'|translate }}
          +
          +
          {{ 'General_ColumnPageviews'|translate }}
          +
          {{ 'Live_LastHours'|translate(24) }}{{ visitorsCountToday }}{{ pisToday }}
          {{ 'Live_LastMinutes'|translate(30) }}{{ visitorsCountHalfHour }}{{ pisHalfhour }}
          +
          diff --git a/www/analytics/plugins/Live/templates/ajaxTotalVisitors.twig b/www/analytics/plugins/Live/templates/ajaxTotalVisitors.twig new file mode 100644 index 00000000..e3f91219 --- /dev/null +++ b/www/analytics/plugins/Live/templates/ajaxTotalVisitors.twig @@ -0,0 +1 @@ +{% include "@Live/_totalVisitors.twig" %} \ No newline at end of file diff --git a/www/analytics/plugins/Live/templates/getLastVisitsStart.twig b/www/analytics/plugins/Live/templates/getLastVisitsStart.twig new file mode 100644 index 00000000..ebe207f8 --- /dev/null +++ b/www/analytics/plugins/Live/templates/getLastVisitsStart.twig @@ -0,0 +1,144 @@ +{# some users view thousands of pages which can crash the browser viewing Live! #} +{% set maxPagesDisplayedByVisitor=100 %} + + + \ No newline at end of file diff --git a/www/analytics/plugins/Live/templates/getSimpleLastVisitCount.twig b/www/analytics/plugins/Live/templates/getSimpleLastVisitCount.twig new file mode 100644 index 00000000..7bb82101 --- /dev/null +++ b/www/analytics/plugins/Live/templates/getSimpleLastVisitCount.twig @@ -0,0 +1,21 @@ +
          +
          +
          {{ visitors }}
          +
          +
          + +
          + {% set visitsMessage %} + {% if visits == 1 %}{{ 'General_OneVisit'|translate }}{% else %}{{ 'General_NVisits'|translate(visits) }}{% endif %} + {% endset %} + {% set actionsMessage %} + {% if actions == 1 %}{{ 'General_OneAction'|translate }}{% else %}{{ 'VisitsSummary_NbActionsDescription'|translate(actions) }}{% endif %} + {% endset %} + {% set minutesMessage %} + {% if lastMinutes == 1 %}{{ 'General_OneMinute'|translate }}{% else %}{{ 'General_NMinutes'|translate(lastMinutes) }}{% endif %} + {% endset %} + + {{ 'Live_SimpleRealTimeWidget_Message'|translate(visitsMessage,actionsMessage,minutesMessage) | raw }} +
          +
          + \ No newline at end of file diff --git a/www/analytics/plugins/Live/templates/getSingleVisitSummary.twig b/www/analytics/plugins/Live/templates/getSingleVisitSummary.twig new file mode 100644 index 00000000..292902db --- /dev/null +++ b/www/analytics/plugins/Live/templates/getSingleVisitSummary.twig @@ -0,0 +1,62 @@ +{% macro customVar(id, customVariable) %} + {% set name='customVariableName' ~ id %} + {% set value='customVariableValue' ~ id %} +
        • {{ customVariable[name]|truncate(30) }}{% if customVariable[value]|length > 0 %}{{ customVariable[value]|truncate(50) }}{% endif %}
        • +{% endmacro %} +{% import _self as macros %} +{% if showLocation|default(false) %} +
          +  {% if visitData.city is not empty %}{{ visitData.city }}{% else %}{{ visitData.country }}{% endif %} +
          +{% endif %} +
          +
            +
          • + {{ 'General_IP'|translate }}{{ visitData.visitIp }} +
          • +
          • + {{ 'General_Id'|translate|upper }} + {% if widgetizedLink is defined %}{% endif %} + {{ visitData.visitorId }} + {% if widgetizedLink is defined %}{% endif %} + +
          • +
          • +
            + {{ visitData.browserName|split(' ')[0] }} +
            +
            + {{ visitData.operatingSystemShortName }} +
            +
          • +
          • {{ 'UserSettings_ColumnResolution'|translate }}{{ visitData.resolution }}
          • + {% if visitReferralSummary is defined %} + {%- set keywordNotDefined = 'General_NotDefined'|translate('General_ColumnKeyword'|translate) -%} +
          • + {{ 'General_DateRangeFrom'|translate }} + {{ visitReferralSummary }} +
          • + {% endif %} +
          +
          +
          +
            + {% for id,customVariable in visitData.customVariables %} + {% if loop.index0 < 4 %} + {{ macros.customVar(id, customVariable) }} + {% endif %} + {% endfor %} +
          + {% if visitData.customVariables|length > 4 %} + +

          + {% endif %} +
          diff --git a/www/analytics/plugins/Live/templates/getVisitList.twig b/www/analytics/plugins/Live/templates/getVisitList.twig new file mode 100644 index 00000000..3f6978e5 --- /dev/null +++ b/www/analytics/plugins/Live/templates/getVisitList.twig @@ -0,0 +1,17 @@ +{% for visitInfo in visits.getRows() %} +
        • +
          +

          {{ 'General_Visit'|translate }} #{{ startCounter }}

          {% if visitInfo.getColumn('visitDuration') != 0 %} - ({{ visitInfo.getColumn('visitDurationPretty')|raw }}){% endif %}{{ visitInfo.getColumn('serverDatePrettyFirstAction') }}
          +
            + {% include "@Live/_actionsList.twig" with {'actionDetails': visitInfo.getColumn('actionDetails'), + 'clientSideParameters': { + 'filterEcommerce': false, + 'idSite': idSite, + 'pageUrlNotDefined': 'General_NotDefined'|translate('Actions_ColumnPageURL'|translate) + }, + 'overrideLinkStyle': false} %} +
          +
          +
        • +{% set startCounter = startCounter + 1 %} +{% endfor %} \ No newline at end of file diff --git a/www/analytics/plugins/Live/templates/getVisitorProfilePopup.twig b/www/analytics/plugins/Live/templates/getVisitorProfilePopup.twig new file mode 100644 index 00000000..b3159a37 --- /dev/null +++ b/www/analytics/plugins/Live/templates/getVisitorProfilePopup.twig @@ -0,0 +1,149 @@ +
          + +
          +
          +
          +
          +
          +
          + {{ visitorData.visitorDescription|default('') }} +
          + +
          +
          +
          + {% if visitorData.previousVisitorId is not empty %}{% endif %} +

          {{ 'Live_VisitorProfile'|translate }}

          + + {% if visitorData.nextVisitorId is not empty %}{% endif %} +
          +
          + {% include "@Live/getSingleVisitSummary.twig" with {'visitData': visitorData.lastVisits.getFirstRow().getColumns(), 'showLocation': false} %} +
          +
          +

          +
          +
          +

          {{ 'General_Summary'|translate }}

          +
          +

          {{ 'Live_VisitSummary'|translate('' ~ visitorData.totalVisitDurationPretty ~ '', '', '', '', visitorData.totalActions, visitorData.totalVisits, '')|raw }}

          +

          {% if visitorData.totalGoalConversions %}{% endif %}{{ 'Live_ConvertedNGoals'|translate(visitorData.totalGoalConversions) }}{% if visitorData.totalGoalConversions %}{% endif %} + {%- if visitorData.totalGoalConversions %} ( + {%- for idGoal, totalConversions in visitorData.totalConversionsByGoal -%} + {%- set idGoal = idGoal[7:] -%} + {%- if not loop.first %}, {% endif -%}{{- totalConversions }} {{ goals[idGoal]['name'] -}} + {%- endfor -%} + ){% endif %}.

          + {% if visitorData.totalEcommerceConversions|default(0) > 0 or visitorData.totalAbandonedCarts|default(0) > 0%} +

          + {{ 'Goals_Ecommerce'|translate }}: + {%- if visitorData.totalEcommerceConversions|default(0) > 0 %} {{ 'Live_EcommerceSummaryConversions'|translate('', visitorData.totalEcommerceConversions, visitorData.totalEcommerceRevenue|money(idSite), '', visitorData.totalEcommerceItems)|raw }} + {%- endif -%} + {%- if visitorData.totalAbandonedCarts|default(0) > 0 %} {{ 'Live_AbandonedCartSummary'|translate('', visitorData.totalAbandonedCarts, '', visitorData.totalAbandonedCartsItems, '', visitorData.totalAbandonedCartsRevenue|money(idSite), '')|raw }}{%- endif -%} +

          + {% endif %} + {% if visitorData.totalSearches|default(0) %} +

          + {{ 'Actions_WidgetSearchKeywords'|translate }}: + {%- for entry in visitorData.searches %} {{ entry.keyword }}{% if not loop.last %},{% endif %}{% endfor %} +

          + {% endif %} + {% if visitorData.averagePageGenerationTime is defined %} +

          + {{ 'Live_AveragePageGenerationTime'|translate('' ~ visitorData.averagePageGenerationTime ~ 's')|raw }} +

          + {% endif %} +
          +
          +
          + {%- set keywordNotDefined = 'General_NotDefined'|translate('General_ColumnKeyword'|translate) -%} +
          +

          {% if visitorData.visitsAggregated == 100 %}{{ 'General_Visit'|translate }}# 100{% else %}{{ 'Live_FirstVisit'|translate }}{% endif %}

          +
          +

          {{ visitorData.firstVisit.prettyDate }} - {{ 'UserCountryMap_DaysAgo'|translate(visitorData.firstVisit.daysAgo) }}

          +

          {{ 'General_FromReferrer'|translate }}: + {{ visitorData.firstVisit.referralSummary }}

          +
          +
          + {% if visitorData.lastVisits.getRowsCount() != 1 %} +
          +

          {{ 'Live_LastVisit'|translate }}

          +
          +

          {{ visitorData.lastVisit.prettyDate }} - {{ 'UserCountryMap_DaysAgo'|translate(visitorData.lastVisit.daysAgo) }}

          +

          {{ 'General_FromReferrer'|translate }}: + {{ visitorData.lastVisit.referralSummary }}

          +
          +
          + {% endif %} +
          +
          +
          +

          {{ 'UserCountry_Location'|translate }}

          +

          + {%- for entry in visitorData.countries -%} + + {% set entryCity -%} + {% if entry.cities is defined and 1 == entry.cities|length and entry.cities|join -%} + {{ entry.cities|join }} + {%- elseif entry.cities is defined and 1 < entry.cities|length -%} + {{ 'UserCountry_FromDifferentCities'|translate }} + {%- endif %} + {%- endset %} + + {% set entryVisits -%} + + {% if entry.nb_visits == 1 -%} + {{ 'General_OneVisit'|translate }} + {%- else -%} + {{ 'General_NVisits'|translate(entry.nb_visits) }} + {%- endif -%} + + {%- endset %} + + {% set entryCountry -%} + {%- if entryCity -%} + {{ 'UserCountry_CityAndCountry'|translate(entryCity, entry.prettyName)|raw }} + {%- else -%} + {{ entry.prettyName }} + {%- endif -%} + +   + {%- endset %} + + {{- 'General_XFromY'|translate(entryVisits, entryCountry)|raw -}}{% if not loop.last %}, {% endif %} + {%- endfor %} + ({{ 'Live_ShowMap'|translate|replace({' ': ' '})|raw }}) +

          + +
          +
          +
          +
          +
          +

          {{ 'Live_VisitedPages'|translate }}

          +
          +
          +
            + {% include "@Live/getVisitList.twig" with {'visits': visitorData.lastVisits, 'startCounter': 1} %} +
          +
          +
          + {% if visitorData.lastVisits.getRowsCount() >= constant("Piwik\\Plugins\\Live\\API::VISITOR_PROFILE_MAX_VISITS_TO_SHOW") %} + {{ 'Live_LoadMoreVisits'|translate }} + {% else %} + {{ 'Live_NoMoreVisits'|translate }} + {% endif %} +
          +
          +
          +
          +
          + \ No newline at end of file diff --git a/www/analytics/plugins/Live/templates/index.twig b/www/analytics/plugins/Live/templates/index.twig new file mode 100644 index 00000000..489ef266 --- /dev/null +++ b/www/analytics/plugins/Live/templates/index.twig @@ -0,0 +1,49 @@ + + +{% include "@Live/_totalVisitors.twig" %} + +{{ visitors|raw }} + +{% spaceless %} +
          + + + + + + + {% if not disableLink %} +   + {{ 'Live_LinkVisitorLog'|translate }} + {% endif %} +
          +{% endspaceless %} diff --git a/www/analytics/plugins/Live/templates/indexVisitorLog.twig b/www/analytics/plugins/Live/templates/indexVisitorLog.twig new file mode 100644 index 00000000..c856fa72 --- /dev/null +++ b/www/analytics/plugins/Live/templates/indexVisitorLog.twig @@ -0,0 +1,3 @@ +

          {% if filterEcommerce %}{{ 'Goals_EcommerceLog'|translate }}{% else %}{{ 'Live_VisitorLog'|translate }}{% endif %}

          + +{{ visitorLog|raw }} diff --git a/www/analytics/plugins/Login/Auth.php b/www/analytics/plugins/Login/Auth.php new file mode 100644 index 00000000..158d4e09 --- /dev/null +++ b/www/analytics/plugins/Login/Auth.php @@ -0,0 +1,137 @@ +login)) { + $model = new Model(); + $user = $model->getUserByTokenAuth($this->token_auth); + + if (!empty($user['login'])) { + $code = $user['superuser_access'] ? AuthResult::SUCCESS_SUPERUSER_AUTH_CODE : AuthResult::SUCCESS; + + return new AuthResult($code, $user['login'], $this->token_auth); + } + } else if (!empty($this->login)) { + $model = new Model(); + $user = $model->getUser($this->login); + + if (!empty($user['token_auth']) + && (($this->getHashTokenAuth($this->login, $user['token_auth']) === $this->token_auth) + || $user['token_auth'] === $this->token_auth) + ) { + $this->setTokenAuth($user['token_auth']); + $code = !empty($user['superuser_access']) ? AuthResult::SUCCESS_SUPERUSER_AUTH_CODE : AuthResult::SUCCESS; + + return new AuthResult($code, $this->login, $user['token_auth']); + } + } + + return new AuthResult(AuthResult::FAILURE, $this->login, $this->token_auth); + } + + /** + * Authenticates the user and initializes the session. + */ + public function initSession($login, $md5Password, $rememberMe) + { + $tokenAuth = API::getInstance()->getTokenAuth($login, $md5Password); + + $this->setLogin($login); + $this->setTokenAuth($tokenAuth); + $authResult = $this->authenticate(); + + $authCookieName = Config::getInstance()->General['login_cookie_name']; + $authCookieExpiry = $rememberMe ? time() + Config::getInstance()->General['login_cookie_expire'] : 0; + $authCookiePath = Config::getInstance()->General['login_cookie_path']; + $cookie = new Cookie($authCookieName, $authCookieExpiry, $authCookiePath); + if (!$authResult->wasAuthenticationSuccessful()) { + $cookie->delete(); + throw new Exception(Piwik::translate('Login_LoginPasswordNotCorrect')); + } + + $cookie->set('login', $login); + $cookie->set('token_auth', $this->getHashTokenAuth($login, $authResult->getTokenAuth())); + $cookie->setSecure(ProxyHttp::isHttps()); + $cookie->setHttpOnly(true); + $cookie->save(); + + @Session::regenerateId(); + + // remove password reset entry if it exists + Login::removePasswordResetInfo($login); + } + + /** + * Accessor to set login name + * + * @param string $login user login + */ + public function setLogin($login) + { + $this->login = $login; + } + + /** + * Accessor to set authentication token + * + * @param string $token_auth authentication token + */ + public function setTokenAuth($token_auth) + { + $this->token_auth = $token_auth; + } + + /** + * Accessor to compute the hashed authentication token + * + * @param string $login user login + * @param string $token_auth authentication token + * @return string hashed authentication token + */ + public function getHashTokenAuth($login, $token_auth) + { + return md5($login . $token_auth); + } +} diff --git a/www/analytics/plugins/Login/Controller.php b/www/analytics/plugins/Login/Controller.php new file mode 100644 index 00000000..f5067dea --- /dev/null +++ b/www/analytics/plugins/Login/Controller.php @@ -0,0 +1,462 @@ +login(); + } + + /** + * Login form + * + * @param string $messageNoAccess Access error message + * @param bool $infoMessage + * @internal param string $currentUrl Current URL + * @return string + */ + function login($messageNoAccess = null, $infoMessage = false) + { + $form = new FormLogin(); + if ($form->validate()) { + $nonce = $form->getSubmitValue('form_nonce'); + if (Nonce::verifyNonce('Login.login', $nonce)) { + $login = $form->getSubmitValue('form_login'); + $password = $form->getSubmitValue('form_password'); + $rememberMe = $form->getSubmitValue('form_rememberme') == '1'; + $md5Password = md5($password); + try { + $this->authenticateAndRedirect($login, $md5Password, $rememberMe); + } catch (Exception $e) { + $messageNoAccess = $e->getMessage(); + } + } else { + $messageNoAccess = $this->getMessageExceptionNoAccess(); + } + } + + $view = new View('@Login/login'); + $view->AccessErrorString = $messageNoAccess; + $view->infoMessage = nl2br($infoMessage); + $view->addForm($form); + $this->configureView($view); + self::setHostValidationVariablesView($view); + + return $view->render(); + } + + /** + * Configure common view properties + * + * @param View $view + */ + private function configureView($view) + { + $this->setBasicVariablesView($view); + + $view->linkTitle = Piwik::getRandomTitle(); + + // crsf token: don't trust the submitted value; generate/fetch it from session data + $view->nonce = Nonce::getNonce('Login.login'); + } + + /** + * Form-less login + * @see how to use it on http://piwik.org/faq/how-to/#faq_30 + * @throws Exception + * @return void + */ + function logme() + { + $password = Common::getRequestVar('password', null, 'string'); + if (strlen($password) != 32) { + throw new Exception(Piwik::translate('Login_ExceptionPasswordMD5HashExpected')); + } + + $login = Common::getRequestVar('login', null, 'string'); + if (Piwik::hasTheUserSuperUserAccess($login)) { + throw new Exception(Piwik::translate('Login_ExceptionInvalidSuperUserAccessAuthenticationMethod', array("logme"))); + } + + $currentUrl = 'index.php'; + + if (($idSite = Common::getRequestVar('idSite', false, 'int')) !== false) { + $currentUrl .= '?idSite=' . $idSite; + } + + $urlToRedirect = Common::getRequestVar('url', $currentUrl, 'string'); + $urlToRedirect = Common::unsanitizeInputValue($urlToRedirect); + + $this->authenticateAndRedirect($login, $password, false, $urlToRedirect); + } + + /** + * Authenticate user and password. Redirect if successful. + * + * @param string $login user name + * @param string $md5Password md5 hash of password + * @param bool $rememberMe Remember me? + * @param string $urlToRedirect URL to redirect to, if successfully authenticated + * @return string failure message if unable to authenticate + */ + protected function authenticateAndRedirect($login, $md5Password, $rememberMe, $urlToRedirect = false) + { + Nonce::discardNonce('Login.login'); + + \Piwik\Registry::get('auth')->initSession($login, $md5Password, $rememberMe); + + if(empty($urlToRedirect)) { + $urlToRedirect = Url::getCurrentUrlWithoutQueryString(); + } + + Url::redirectToUrl($urlToRedirect); + } + + protected function getMessageExceptionNoAccess() + { + $message = Piwik::translate('Login_InvalidNonceOrHeadersOrReferrer', array('', '')); + // Should mention trusted_hosts or link to FAQ + return $message; + } + + /** + * Reset password action. Stores new password as hash and sends email + * to confirm use. + * + * @param none + */ + function resetPassword() + { + $infoMessage = null; + $formErrors = null; + + $form = new FormResetPassword(); + if ($form->validate()) { + $nonce = $form->getSubmitValue('form_nonce'); + if (Nonce::verifyNonce('Login.login', $nonce)) { + $formErrors = $this->resetPasswordFirstStep($form); + if (empty($formErrors)) { + $infoMessage = Piwik::translate('Login_ConfirmationLinkSent'); + } + } else { + $formErrors = array($this->getMessageExceptionNoAccess()); + } + } else { + // if invalid, display error + $formData = $form->getFormData(); + $formErrors = $formData['errors']; + } + + $view = new View('@Login/resetPassword'); + $view->infoMessage = $infoMessage; + $view->formErrors = $formErrors; + + return $view->render(); + } + + /** + * Saves password reset info and sends confirmation email. + * + * @param QuickForm2 $form + * @return array Error message(s) if an error occurs. + */ + private function resetPasswordFirstStep($form) + { + $loginMail = $form->getSubmitValue('form_login'); + $password = $form->getSubmitValue('form_password'); + + // check the password + try { + UsersManager::checkPassword($password); + } catch (Exception $ex) { + return array($ex->getMessage()); + } + + // get the user's login + if ($loginMail === 'anonymous') { + return array(Piwik::translate('Login_InvalidUsernameEmail')); + } + + $user = self::getUserInformation($loginMail); + if ($user === null) { + return array(Piwik::translate('Login_InvalidUsernameEmail')); + } + + $login = $user['login']; + + // if valid, store password information in options table, then... + Login::savePasswordResetInfo($login, $password); + + // ... send email with confirmation link + try { + $this->sendEmailConfirmationLink($user); + } catch (Exception $ex) { + // remove password reset info + Login::removePasswordResetInfo($login); + + return array($ex->getMessage() . Piwik::translate('Login_ContactAdmin')); + } + + return null; + } + + /** + * Sends email confirmation link for a password reset request. + * + * @param array $user User info for the requested password reset. + */ + private function sendEmailConfirmationLink($user) + { + $login = $user['login']; + $email = $user['email']; + + // construct a password reset token from user information + $resetToken = self::generatePasswordResetToken($user); + + $ip = IP::getIpFromHeader(); + $url = Url::getCurrentUrlWithoutQueryString() + . "?module=Login&action=confirmResetPassword&login=" . urlencode($login) + . "&resetToken=" . urlencode($resetToken); + + // send email with new password + $mail = new Mail(); + $mail->addTo($email, $login); + $mail->setSubject(Piwik::translate('Login_MailTopicPasswordChange')); + $bodyText = str_replace( + '\n', + "\n", + sprintf(Piwik::translate('Login_MailPasswordChangeBody'), $login, $ip, $url) + ) . "\n"; + $mail->setBodyText($bodyText); + + $fromEmailName = Config::getInstance()->General['login_password_recovery_email_name']; + $fromEmailAddress = Config::getInstance()->General['login_password_recovery_email_address']; + $mail->setFrom($fromEmailAddress, $fromEmailName); + @$mail->send(); + } + + /** + * Password reset confirmation action. Finishes the password reset process. + * Users visit this action from a link supplied in an email. + */ + public function confirmResetPassword() + { + $errorMessage = null; + + $login = Common::getRequestVar('login', ''); + $resetToken = Common::getRequestVar('resetToken', ''); + + try { + // get password reset info & user info + $user = self::getUserInformation($login); + if ($user === null) { + throw new Exception(Piwik::translate('Login_InvalidUsernameEmail')); + } + + // check that the reset token is valid + $resetPassword = Login::getPasswordToResetTo($login); + if ($resetPassword === false || !self::isValidToken($resetToken, $user)) { + throw new Exception(Piwik::translate('Login_InvalidOrExpiredToken')); + } + + // reset password of user + $this->setNewUserPassword($user, $resetPassword); + } catch (Exception $ex) { + $errorMessage = $ex->getMessage(); + } + + if (is_null($errorMessage)) // if success, show login w/ success message + { + $this->redirectToIndex(Piwik::getLoginPluginName(), 'resetPasswordSuccess'); + return; + } else { + // show login page w/ error. this will keep the token in the URL + return $this->login($errorMessage); + } + } + + /** + * Sets the password for a user. + * + * @param array $user User info. + * @param string $passwordHash The hashed password to use. + * @throws Exception + */ + private function setNewUserPassword($user, $passwordHash) + { + if (strlen($passwordHash) !== 32) // sanity check + { + throw new Exception( + "setNewUserPassword called w/ incorrect password hash. Something has gone terribly wrong."); + } + + API::getInstance()->updateUser( + $user['login'], $passwordHash, $email = false, $alias = false, $isPasswordHashed = true); + } + + /** + * The action used after a password is successfully reset. Displays the login + * screen with an extra message. A separate action is used instead of returning + * the HTML in confirmResetPassword so the resetToken won't be in the URL. + */ + public function resetPasswordSuccess() + { + return $this->login($errorMessage = null, $infoMessage = Piwik::translate('Login_PasswordChanged')); + } + + /** + * Get user information + * + * @param string $loginMail user login or email address + * @return array ("login" => '...', "email" => '...', "password" => '...') or null, if user not found + */ + protected function getUserInformation($loginMail) + { + Piwik::setUserHasSuperUserAccess(); + + $user = null; + if (API::getInstance()->userExists($loginMail)) { + $user = API::getInstance()->getUser($loginMail); + } else if (API::getInstance()->userEmailExists($loginMail)) { + $user = API::getInstance()->getUserByEmail($loginMail); + } + + return $user; + } + + /** + * Generate a password reset token. Expires in (roughly) 24 hours. + * + * @param array $user user information + * @param int $timestamp Unix timestamp + * @return string generated token + */ + protected function generatePasswordResetToken($user, $timestamp = null) + { + /* + * Piwik does not store the generated password reset token. + * This avoids a database schema change and SQL queries to store, retrieve, and purge (expired) tokens. + */ + if (!$timestamp) { + $timestamp = time() + 24 * 60 * 60; /* +24 hrs */ + } + + $expiry = strftime('%Y%m%d%H', $timestamp); + $token = $this->generateHash( + $expiry . $user['login'] . $user['email'], + $user['password'] + ); + return $token; + } + + /** + * Validate token. + * + * @param string $token + * @param array $user user information + * @return bool true if valid, false otherwise + */ + protected function isValidToken($token, $user) + { + $now = time(); + + // token valid for 24 hrs (give or take, due to the coarse granularity in our strftime format string) + for ($i = 0; $i <= 24; $i++) { + $generatedToken = self::generatePasswordResetToken($user, $now + $i * 60 * 60); + if ($generatedToken === $token) { + return true; + } + } + + // fails if token is invalid, expired, password already changed, other user information has changed, ... + return false; + } + + /** + * Clear session information + * + * @param none + * @return void + */ + static public function clearSession() + { + $authCookieName = Config::getInstance()->General['login_cookie_name']; + $cookie = new Cookie($authCookieName); + $cookie->delete(); + + Session::expireSessionCookie(); + } + + /** + * Logout current user + * + * @param none + * @return void + */ + public function logout() + { + self::clearSession(); + + $logoutUrl = @Config::getInstance()->General['login_logout_url']; + if (empty($logoutUrl)) { + Piwik::redirectToModule('CoreHome'); + } else { + Url::redirectToUrl($logoutUrl); + } + } +} diff --git a/www/analytics/plugins/Login/FormLogin.php b/www/analytics/plugins/Login/FormLogin.php new file mode 100644 index 00000000..8c5e0b1e --- /dev/null +++ b/www/analytics/plugins/Login/FormLogin.php @@ -0,0 +1,44 @@ +addElement('text', 'form_login') + ->addRule('required', Piwik::translate('General_Required', Piwik::translate('General_Username'))); + + $this->addElement('password', 'form_password') + ->addRule('required', Piwik::translate('General_Required', Piwik::translate('General_Password'))); + + $this->addElement('hidden', 'form_nonce'); + + $this->addElement('checkbox', 'form_rememberme'); + + $this->addElement('submit', 'submit'); + + // default values + $this->addDataSource(new HTML_QuickForm2_DataSource_Array(array( + 'form_rememberme' => 0, + ))); + } +} diff --git a/www/analytics/plugins/Login/FormResetPassword.php b/www/analytics/plugins/Login/FormResetPassword.php new file mode 100644 index 00000000..89f277d2 --- /dev/null +++ b/www/analytics/plugins/Login/FormResetPassword.php @@ -0,0 +1,40 @@ +addElement('text', 'form_login') + ->addRule('required', Piwik::translate('General_Required', Piwik::translate('General_Username'))); + + $password = $this->addElement('password', 'form_password'); + $password->addRule('required', Piwik::translate('General_Required', Piwik::translate('General_Password'))); + + $passwordBis = $this->addElement('password', 'form_password_bis'); + $passwordBis->addRule('required', Piwik::translate('General_Required', Piwik::translate('Login_PasswordRepeat'))); + $passwordBis->addRule('eq', Piwik::translate('Login_PasswordsDoNotMatch'), $password); + + $this->addElement('hidden', 'form_nonce'); + + $this->addElement('submit', 'submit'); + } +} diff --git a/www/analytics/plugins/Login/Login.php b/www/analytics/plugins/Login/Login.php new file mode 100644 index 00000000..b6bb429d --- /dev/null +++ b/www/analytics/plugins/Login/Login.php @@ -0,0 +1,154 @@ + 'initAuthenticationObject', + 'User.isNotAuthorized' => 'noAccess', + 'API.Request.authenticate' => 'ApiRequestAuthenticate', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles' + ); + return $hooks; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/Login/javascripts/login.js"; + } + + /** + * Redirects to Login form with error message. + * Listens to User.isNotAuthorized hook. + */ + public function noAccess(Exception $exception) + { + $exceptionMessage = $exception->getMessage(); + + echo FrontController::getInstance()->dispatch('Login', 'login', array($exceptionMessage)); + } + + /** + * Set login name and authentication token for API request. + * Listens to API.Request.authenticate hook. + */ + public function ApiRequestAuthenticate($tokenAuth) + { + \Piwik\Registry::get('auth')->setLogin($login = null); + \Piwik\Registry::get('auth')->setTokenAuth($tokenAuth); + } + + static protected function isModuleIsAPI() + { + return Piwik::getModule() === 'API' + && (Piwik::getAction() == '' || Piwik::getAction() == 'index'); + } + + /** + * Initializes the authentication object. + * Listens to Request.initAuthenticationObject hook. + */ + function initAuthenticationObject($activateCookieAuth = false) + { + $auth = new Auth(); + \Piwik\Registry::set('auth', $auth); + + $this->initAuthenticationFromCookie($auth, $activateCookieAuth); + } + + /** + * @param $auth + */ + public static function initAuthenticationFromCookie(\Piwik\Auth $auth, $activateCookieAuth) + { + if(self::isModuleIsAPI() && !$activateCookieAuth) { + return; + } + + $authCookieName = Config::getInstance()->General['login_cookie_name']; + $authCookieExpiry = 0; + $authCookiePath = Config::getInstance()->General['login_cookie_path']; + $authCookie = new Cookie($authCookieName, $authCookieExpiry, $authCookiePath); + $defaultLogin = 'anonymous'; + $defaultTokenAuth = 'anonymous'; + if ($authCookie->isCookieFound()) { + $defaultLogin = $authCookie->get('login'); + $defaultTokenAuth = $authCookie->get('token_auth'); + } + $auth->setLogin($defaultLogin); + $auth->setTokenAuth($defaultTokenAuth); + } + + /** + * Stores password reset info for a specific login. + * + * @param string $login The user login for whom a password change was requested. + * @param string $password The new password to set. + */ + public static function savePasswordResetInfo($login, $password) + { + $optionName = self::getPasswordResetInfoOptionName($login); + $optionData = UsersManager::getPasswordHash($password); + + Option::set($optionName, $optionData); + } + + /** + * Removes stored password reset info if it exists. + * + * @param string $login The user login to check for. + */ + public static function removePasswordResetInfo($login) + { + $optionName = self::getPasswordResetInfoOptionName($login); + Option::delete($optionName); + } + + /** + * Gets password hash stored in password reset info. + * + * @param string $login The user login to check for. + * @return string|false The hashed password or false if no reset info exists. + */ + public static function getPasswordToResetTo($login) + { + $optionName = self::getPasswordResetInfoOptionName($login); + return Option::get($optionName); + } + + /** + * Gets the option name for the option that will store a user's password change + * request. + * + * @param string $login The user login for whom a password change was requested. + * @return string + */ + public static function getPasswordResetInfoOptionName($login) + { + return $login . '_reset_password_info'; + } +} diff --git a/www/analytics/plugins/Login/javascripts/login.js b/www/analytics/plugins/Login/javascripts/login.js new file mode 100755 index 00000000..6200fc59 --- /dev/null +++ b/www/analytics/plugins/Login/javascripts/login.js @@ -0,0 +1,100 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +(function ($) { + + $(function() { + var switchForm = function (fromFormId, toFormId, message, callback) { + var fromLoginInputId = '#' + fromFormId + '_login', + toLoginInputId = '#' + toFormId + '_login', + toPasswordInputId = '#' + toFormId + '_password', + fromLoginNavId = '#' + fromFormId + '_nav', + toLoginNavId = '#' + toFormId + '_nav'; + + if ($(toLoginInputId).val() === '') { + $(toLoginInputId).val($(fromLoginInputId).val()); + } + + // hide the bottom portion of the login screen & show the password reset bits + $('#' + fromFormId + ',#message_container').fadeOut(500, function () { + // show lost password instructions + $('#message_container').html(message); + + $(fromLoginNavId).hide(); + $(toLoginNavId).show(); + $('#' + toFormId + ',#message_container').fadeIn(500, function () { + // focus on login or password control based on whether a login exists + if ($(toLoginInputId).val() === '') { + $(toLoginInputId).focus(); + } + else { + $(toPasswordInputId).focus(); + } + + if (callback) { + callback(); + } + }); + }); + }; + + // 'lost your password?' on click + $('#login_form_nav').click(function (e) { + e.preventDefault(); + switchForm('login_form', 'reset_form', $('#lost_password_instructions').html()); + return false; + }); + + // 'cancel' on click + $('#reset_form_nav,#alternate_reset_nav').click(function (e) { + e.preventDefault(); + $('#alternate_reset_nav').hide(); + switchForm('reset_form', 'login_form', ''); + return false; + }); + + // password reset on submit + $('#reset_form_submit').click(function (e) { + e.preventDefault(); + + var ajaxDone = function (response) { + $('.loadingPiwik').hide(); + + var isSuccess = response.indexOf('id="login_error"') === -1, + fadeOutIds = '#message_container'; + if (isSuccess) { + fadeOutIds += ',#reset_form,#reset_form_nav'; + } + + $(fadeOutIds).fadeOut(300, function () { + if (isSuccess) { + $('#alternate_reset_nav').show(); + } + + $('#message_container').html(response).fadeIn(300); + }); + }; + + $('.loadingPiwik').show(); + + // perform reset password request + $.ajax({ + type: 'POST', + url: 'index.php', + dataType: 'html', + async: true, + error: function () { ajaxDone('
          HTTP Error
          '); }, + success: ajaxDone, // Callback when the request succeeds + data: $('#reset_form').serialize() + }); + + return false; + }); + + $('#login_form_login').focus(); + }); + +}(jQuery)); diff --git a/www/analytics/plugins/Login/stylesheets/login.css b/www/analytics/plugins/Login/stylesheets/login.css new file mode 100644 index 00000000..ab472c11 --- /dev/null +++ b/www/analytics/plugins/Login/stylesheets/login.css @@ -0,0 +1,197 @@ +/* LOGO +***********************/ +#logo { + float: none; + margin: 100px auto 0 auto; + width: 240px; + position: relative; +} + +#logo .description { + position: absolute; + left: -40px !important; + top: -30px !important; + -webkit-transform: rotate(-6deg); + -moz-transform: rotate(-6deg); + -ms-transform: rotate(-6deg); + -o-transform: rotate(-6deg); +} + +#logo .description a { + font: 16px/16px 'Patrick Hand'; + color: #666666; + right: auto; + text-decoration: none; +} + +#logo .description .arrow { + background: url(../../Zeitgeist/images/affix-arrow.png); + width: 50px; + height: 68px; + position: absolute; + left: -35px; +} + +#logo img { + border: 0; + vertical-align: bottom; + height: auto; + width: 240px; + margin-right: 20px; +} + +#logo .h1 { + font-family: Georgia, "Times New Roman", Times, serif; + font-weight: normal; + color: #136F8B; + font-size: 45pt; + text-transform: none; +} + + +/* LAYOUT +***********************/ +#loginPage a { + text-decoration: none; +} + +.loadingPiwik { + float: left; + margin-left: 16px; +} + +.loginSection { + background-color: #fafafa; + width: 360px; + padding: 30px; + margin: 50px auto 0 auto; + border-radius: 3px; + -webkit-box-shadow: 0 0 2px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, .2); + -moz-box-shadow: 0 0 2px rgba(0, 0, 0, 0.2), 1px 1px 0 rgba(0, 0, 0, .1); + box-shadow: 0 0 2px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, .2); +} + +.loginSection h1 { + text-align: center; + color: #666; + margin: 0 0 30px 0; + font: normal 26px/1 Verdana, Helvetica; + position: relative; +} + + +/* FORM +***********************/ +.loginSection form { + margin: 0 5px; + padding: 15px; + position: relative; +} + +.loginSection form div { + margin-bottom: 24px; +} + +.loginSection .submit { + margin-top: 0; +} + +.loginSection fieldset { + border: 0; +} + +.loginSection fieldset.actions { + line-height: 35px; + width: 315px; + margin-top: 10px; +} + +#login_form_rememberme { + vertical-align: middle; +} + + +/* FIELDS +***********************/ +.loginSection form input[type="text"], +.loginSection form input[type="password"] { + font-size: 20px; + padding: 10px 15px 10px 45px; + margin: 0 0 15px 0; + width: 253px; /* 258 + 2 + 55 = 315 */ + border: 1px solid #ccc; + border-radius: 5px; + color: #555; + -moz-box-shadow: 0 1px 1px #ccc inset, 0 1px 0 #fff; + -webkit-box-shadow: 0 1px 1px #ccc inset, 0 1px 0 #fff; + box-shadow: 0 1px 1px #ccc inset, 0 1px 0 #fff; +} + +#login_form_password, +#reset_form_password, +#reset_form_password_bis, +#login_form_login, +#reset_form_login { + background: #fff url(../../Zeitgeist/images/login-sprite.png) no-repeat; +} + +#login_form_password, +#reset_form_password, +#reset_form_password_bis { + background-position: 10px -51px; +} + +#login_form_login, +#reset_form_login { + background-position: 10px 11px; +} + + +/* MESSAGE +***********************/ +#loginPage .message_error, +#loginPage .message { + margin: 0 auto; + border: 1px solid; + padding: 12px; +} + +#loginPage .message_error { + background-color: #ffebe8; + border-color: #c00; +} + +#loginPage .message { + background-color: #ffffe0; + border-color: #e6db55; +} + + +/* NAVIGATION +***********************/ +#nav, +#piwik { + margin: 0 0 0 8px; + padding: 16px; +} + +#nav { + text-align: center; +} + +#nav a:hover { + text-decoration: underline; +} + +#loginPage #nav a { + color: #777; +} + +#loginPage #piwik a { + color: #CDCDCD; +} + +/* IE < 9 will use this */ +html.old-ie .ie-hide { + display: none; +} diff --git a/www/analytics/plugins/Login/templates/login.twig b/www/analytics/plugins/Login/templates/login.twig new file mode 100644 index 00000000..70f922b4 --- /dev/null +++ b/www/analytics/plugins/Login/templates/login.twig @@ -0,0 +1,160 @@ + + + + + + + {% if isCustomLogo == false %}Piwik › {% endif %}{{ 'Login_LogIn'|translate }} + + + {% autoescape false %} + {{ includeAssets({"type": "css"}) }} + {% endautoescape %} + + + + + {% include "_jsCssIncludes.twig" %} + + + + + + + + + {{ postEvent("Template.beforeTopBar", "login") }} + {{ postEvent("Template.beforeContent", "login") }} + + {% include "_iframeBuster.twig" %} + +
          +
          + + + +
          + + {# untrusted host warning #} + {% if (isValidHost is defined and invalidHostMessage is defined and isValidHost == false) %} + {% include '@CoreHome/_warningInvalidHost.twig' %} + {% else %} +
          + {% if form_data.errors %} +
          + {% for data in form_data.errors %} + {{ 'General_Error'|translate }}: {{ data|raw }}
          + {% endfor %} +
          + {% endif %} + + {% if AccessErrorString %} +
          + {{ 'General_Error'|translate }}: {{ AccessErrorString|raw }}
          +
          + {% endif %} + + {% if infoMessage %} +

          {{ infoMessage|raw }}

          + {% endif %} +
          +
          +

          {{ 'Login_LogIn'|translate }}

          +
          + + + +
          + +
          + + + +
          +
          + + + {% if poweredByPiwik is defined %} +

          + {{ poweredByPiwik }} +

          + {% endif %} + + {% endif %} +
          + + diff --git a/www/analytics/plugins/Login/templates/resetPassword.twig b/www/analytics/plugins/Login/templates/resetPassword.twig new file mode 100755 index 00000000..4a4debe9 --- /dev/null +++ b/www/analytics/plugins/Login/templates/resetPassword.twig @@ -0,0 +1,12 @@ +{% if infoMessage is defined and infoMessage is not empty %} +

          {{ infoMessage }}

          +{% endif %} +{% if formErrors is defined and formErrors is not empty %} +

          + {% for data in formErrors %} + {{ 'General_Error'|translate }} + : {{ data }} +
          + {% endfor %} +

          +{% endif %} \ No newline at end of file diff --git a/www/analytics/plugins/MobileMessaging/API.php b/www/analytics/plugins/MobileMessaging/API.php new file mode 100644 index 00000000..aba3edba --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/API.php @@ -0,0 +1,432 @@ +getSMSAPICredential(); + return isset($credential[MobileMessaging::API_KEY_OPTION]); + } + + private function getSMSAPICredential() + { + $settings = $this->getCredentialManagerSettings(); + return array( + MobileMessaging::PROVIDER_OPTION => + isset($settings[MobileMessaging::PROVIDER_OPTION]) ? $settings[MobileMessaging::PROVIDER_OPTION] : null, + MobileMessaging::API_KEY_OPTION => + isset($settings[MobileMessaging::API_KEY_OPTION]) ? $settings[MobileMessaging::API_KEY_OPTION] : null, + ); + } + + /** + * return the SMS API Provider for the current user + * + * @return string SMS API Provider + */ + public function getSMSProvider() + { + $this->checkCredentialManagementRights(); + $credential = $this->getSMSAPICredential(); + return $credential[MobileMessaging::PROVIDER_OPTION]; + } + + /** + * set the SMS API credential + * + * @param string $provider SMS API provider + * @param string $apiKey API Key + * + * @return bool true if SMS API credential were validated and saved, false otherwise + */ + public function setSMSAPICredential($provider, $apiKey) + { + $this->checkCredentialManagementRights(); + + $smsProviderInstance = self::getSMSProviderInstance($provider); + $smsProviderInstance->verifyCredential($apiKey); + + $settings = $this->getCredentialManagerSettings(); + + $settings[MobileMessaging::PROVIDER_OPTION] = $provider; + $settings[MobileMessaging::API_KEY_OPTION] = $apiKey; + + $this->setCredentialManagerSettings($settings); + + return true; + } + + /** + * add phone number + * + * @param string $phoneNumber + * + * @return bool true + */ + public function addPhoneNumber($phoneNumber) + { + Piwik::checkUserIsNotAnonymous(); + + $phoneNumber = self::sanitizePhoneNumber($phoneNumber); + + $verificationCode = ""; + for ($i = 0; $i < self::VERIFICATION_CODE_LENGTH; $i++) { + $verificationCode .= mt_rand(0, 9); + } + + $smsText = Piwik::translate( + 'MobileMessaging_VerificationText', + array( + $verificationCode, + Piwik::translate('General_Settings'), + Piwik::translate('MobileMessaging_SettingsMenu') + ) + ); + + $this->sendSMS($smsText, $phoneNumber, self::SMS_FROM); + + $phoneNumbers = $this->retrievePhoneNumbers(); + $phoneNumbers[$phoneNumber] = $verificationCode; + $this->savePhoneNumbers($phoneNumbers); + + $this->increaseCount(MobileMessaging::PHONE_NUMBER_VALIDATION_REQUEST_COUNT_OPTION, $phoneNumber); + + return true; + } + + /** + * sanitize phone number + * + * @ignore + * @param string $phoneNumber + * @return string sanitized phone number + */ + public static function sanitizePhoneNumber($phoneNumber) + { + return str_replace(' ', '', $phoneNumber); + } + + /** + * send a SMS + * + * @param string $content + * @param string $phoneNumber + * @param string $from + * @return bool true + * @ignore + */ + public function sendSMS($content, $phoneNumber, $from) + { + Piwik::checkUserIsNotAnonymous(); + + $credential = $this->getSMSAPICredential(); + $SMSProvider = self::getSMSProviderInstance($credential[MobileMessaging::PROVIDER_OPTION]); + $SMSProvider->sendSMS( + $credential[MobileMessaging::API_KEY_OPTION], + $content, + $phoneNumber, + $from + ); + + $this->increaseCount(MobileMessaging::SMS_SENT_COUNT_OPTION, $phoneNumber); + + return true; + } + + /** + * get remaining credit + * + * @return string remaining credit + */ + public function getCreditLeft() + { + $this->checkCredentialManagementRights(); + + $credential = $this->getSMSAPICredential(); + $SMSProvider = self::getSMSProviderInstance($credential[MobileMessaging::PROVIDER_OPTION]); + return $SMSProvider->getCreditLeft( + $credential[MobileMessaging::API_KEY_OPTION] + ); + } + + /** + * remove phone number + * + * @param string $phoneNumber + * + * @return bool true + */ + public function removePhoneNumber($phoneNumber) + { + Piwik::checkUserIsNotAnonymous(); + + $phoneNumbers = $this->retrievePhoneNumbers(); + unset($phoneNumbers[$phoneNumber]); + $this->savePhoneNumbers($phoneNumbers); + + /** + * Triggered after a phone number has been deleted. This event should be used to clean up any data that is + * related to the now deleted phone number. The ScheduledReports plugin, for example, uses this event to remove + * the phone number from all reports to make sure no text message will be sent to this phone number. + * + * **Example** + * + * public function deletePhoneNumber($phoneNumber) + * { + * $this->unsubscribePhoneNumberFromScheduledReport($phoneNumber); + * } + * + * @param string $phoneNumber The phone number that was just deleted. + */ + Piwik::postEvent('MobileMessaging.deletePhoneNumber', array($phoneNumber)); + + return true; + } + + private function retrievePhoneNumbers() + { + $settings = $this->getCurrentUserSettings(); + + $phoneNumbers = array(); + if (isset($settings[MobileMessaging::PHONE_NUMBERS_OPTION])) { + $phoneNumbers = $settings[MobileMessaging::PHONE_NUMBERS_OPTION]; + } + + return $phoneNumbers; + } + + private function savePhoneNumbers($phoneNumbers) + { + $settings = $this->getCurrentUserSettings(); + + $settings[MobileMessaging::PHONE_NUMBERS_OPTION] = $phoneNumbers; + + $this->setCurrentUserSettings($settings); + } + + private function increaseCount($option, $phoneNumber) + { + $settings = $this->getCurrentUserSettings(); + + $counts = array(); + if (isset($settings[$option])) { + $counts = $settings[$option]; + } + + $countToUpdate = 0; + if (isset($counts[$phoneNumber])) { + $countToUpdate = $counts[$phoneNumber]; + } + + $counts[$phoneNumber] = $countToUpdate + 1; + + $settings[$option] = $counts; + + $this->setCurrentUserSettings($settings); + } + + /** + * validate phone number + * + * @param string $phoneNumber + * @param string $verificationCode + * + * @return bool true if validation code is correct, false otherwise + */ + public function validatePhoneNumber($phoneNumber, $verificationCode) + { + Piwik::checkUserIsNotAnonymous(); + + $phoneNumbers = $this->retrievePhoneNumbers(); + + if (isset($phoneNumbers[$phoneNumber])) { + if ($verificationCode == $phoneNumbers[$phoneNumber]) { + + $phoneNumbers[$phoneNumber] = null; + $this->savePhoneNumbers($phoneNumbers); + return true; + } + } + + return false; + } + + /** + * get phone number list + * + * @return array $phoneNumber => $isValidated + * @ignore + */ + public function getPhoneNumbers() + { + Piwik::checkUserIsNotAnonymous(); + + $rawPhoneNumbers = $this->retrievePhoneNumbers(); + + $phoneNumbers = array(); + foreach ($rawPhoneNumbers as $phoneNumber => $verificationCode) { + $phoneNumbers[$phoneNumber] = self::isActivated($verificationCode); + } + + return $phoneNumbers; + } + + /** + * get activated phone number list + * + * @return array $phoneNumber + * @ignore + */ + public function getActivatedPhoneNumbers() + { + Piwik::checkUserIsNotAnonymous(); + + $phoneNumbers = $this->retrievePhoneNumbers(); + + $activatedPhoneNumbers = array(); + foreach ($phoneNumbers as $phoneNumber => $verificationCode) { + if (self::isActivated($verificationCode)) { + $activatedPhoneNumbers[] = $phoneNumber; + } + } + + return $activatedPhoneNumbers; + } + + private static function isActivated($verificationCode) + { + return $verificationCode === null; + } + + /** + * delete the SMS API credential + * + * @return bool true + */ + public function deleteSMSAPICredential() + { + $this->checkCredentialManagementRights(); + + $settings = $this->getCredentialManagerSettings(); + + $settings[MobileMessaging::API_KEY_OPTION] = null; + + $this->setCredentialManagerSettings($settings); + + return true; + } + + private function checkCredentialManagementRights() + { + $this->getDelegatedManagement() ? Piwik::checkUserIsNotAnonymous() : Piwik::checkUserHasSuperUserAccess(); + } + + private function setUserSettings($user, $settings) + { + Option::set( + $user . MobileMessaging::USER_SETTINGS_POSTFIX_OPTION, + Common::json_encode($settings) + ); + } + + private function setCurrentUserSettings($settings) + { + $this->setUserSettings(Piwik::getCurrentUserLogin(), $settings); + } + + private function setCredentialManagerSettings($settings) + { + $this->setUserSettings($this->getCredentialManagerLogin(), $settings); + } + + private function getCredentialManagerLogin() + { + return $this->getDelegatedManagement() ? Piwik::getCurrentUserLogin() : ''; + } + + private function getUserSettings($user) + { + $optionIndex = $user . MobileMessaging::USER_SETTINGS_POSTFIX_OPTION; + $userSettings = Option::get($optionIndex); + + if (empty($userSettings)) { + $userSettings = array(); + } else { + $userSettings = Common::json_decode($userSettings, true); + } + + return $userSettings; + } + + private function getCredentialManagerSettings() + { + return $this->getUserSettings($this->getCredentialManagerLogin()); + } + + private function getCurrentUserSettings() + { + return $this->getUserSettings(Piwik::getCurrentUserLogin()); + } + + /** + * Specify if normal users can manage their own SMS API credential + * + * @param bool $delegatedManagement false if SMS API credential only manageable by super admin, true otherwise + */ + public function setDelegatedManagement($delegatedManagement) + { + Piwik::checkUserHasSuperUserAccess(); + Option::set(MobileMessaging::DELEGATED_MANAGEMENT_OPTION, $delegatedManagement); + } + + /** + * Determine if normal users can manage their own SMS API credential + * + * @return bool false if SMS API credential only manageable by super admin, true otherwise + */ + public function getDelegatedManagement() + { + Piwik::checkUserHasSomeViewAccess(); + return Option::get(MobileMessaging::DELEGATED_MANAGEMENT_OPTION) == 'true'; + } +} diff --git a/www/analytics/plugins/MobileMessaging/APIException.php b/www/analytics/plugins/MobileMessaging/APIException.php new file mode 100644 index 00000000..129bac1f --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/APIException.php @@ -0,0 +1,19 @@ +isSuperUser = Piwik::hasUserSuperUserAccess(); + + $mobileMessagingAPI = API::getInstance(); + $view->delegatedManagement = $mobileMessagingAPI->getDelegatedManagement(); + $view->credentialSupplied = $mobileMessagingAPI->areSMSAPICredentialProvided(); + $view->accountManagedByCurrentUser = $view->isSuperUser || $view->delegatedManagement; + $view->strHelpAddPhone = Piwik::translate('MobileMessaging_Settings_PhoneNumbers_HelpAdd', array(Piwik::translate('General_Settings'), Piwik::translate('MobileMessaging_SettingsMenu'))); + if ($view->credentialSupplied && $view->accountManagedByCurrentUser) { + $view->provider = $mobileMessagingAPI->getSMSProvider(); + $view->creditLeft = $mobileMessagingAPI->getCreditLeft(); + } + + $view->smsProviders = SMSProvider::$availableSMSProviders; + + // construct the list of countries from the lang files + $countries = array(); + foreach (Common::getCountriesList() as $countryCode => $continentCode) { + if (isset(CountryCallingCodes::$countryCallingCodes[$countryCode])) { + $countries[$countryCode] = + array( + 'countryName' => \Piwik\Plugins\UserCountry\countryTranslate($countryCode), + 'countryCallingCode' => CountryCallingCodes::$countryCallingCodes[$countryCode], + ); + } + } + $view->countries = $countries; + + $view->defaultCountry = Common::getCountry( + LanguagesManager::getLanguageCodeForCurrentUser(), + true, + IP::getIpFromHeader() + ); + + $view->phoneNumbers = $mobileMessagingAPI->getPhoneNumbers(); + + $this->setBasicVariablesView($view); + + return $view->render(); + } +} diff --git a/www/analytics/plugins/MobileMessaging/CountryCallingCodes.php b/www/analytics/plugins/MobileMessaging/CountryCallingCodes.php new file mode 100644 index 00000000..c7c8f6ec --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/CountryCallingCodes.php @@ -0,0 +1,270 @@ + '376', + 'ae' => '971', + 'af' => '93', + 'ag' => '1268', // @wikipedia original value: 1 268 + 'ai' => '1264', // @wikipedia original value: 1 264 + 'al' => '355', + 'am' => '374', + 'ao' => '244', +// 'aq' => 'MISSING CODE', // @wikipedia In Antarctica dialing is dependent on the parent country of each base + 'ar' => '54', + 'as' => '1684', // @wikipedia original value: 1 684 + 'at' => '43', + 'au' => '61', + 'aw' => '297', + 'ax' => '358', + 'az' => '994', + 'ba' => '387', + 'bb' => '1246', // @wikipedia original value: 1 246 + 'bd' => '880', + 'be' => '32', + 'bf' => '226', + 'bg' => '359', + 'bh' => '973', + 'bi' => '257', + 'bj' => '229', + 'bl' => '590', + 'bm' => '1441', // @wikipedia original value: 1 441 + 'bn' => '673', + 'bo' => '591', + 'bq' => '5997', // @wikipedia original value: 599 7 + 'br' => '55', + 'bs' => '1242', // @wikipedia original value: 1 242 + 'bt' => '975', +// 'bv' => 'MISSING CODE', + 'bw' => '267', + 'by' => '375', + 'bz' => '501', + 'ca' => '1', + 'cc' => '61', + 'cd' => '243', + 'cf' => '236', + 'cg' => '242', + 'ch' => '41', + 'ci' => '225', + 'ck' => '682', + 'cl' => '56', + 'cm' => '237', + 'cn' => '86', + 'co' => '57', + 'cr' => '506', + 'cu' => '53', + 'cv' => '238', + 'cw' => '5999', // @wikipedia original value: 599 9 + 'cx' => '61', + 'cy' => '357', + 'cz' => '420', + 'de' => '49', + 'dj' => '253', + 'dk' => '45', + 'dm' => '1767', // @wikipedia original value: 1 767 +// 'do' => 'MISSING CODE', // @wikipedia original values: 1 809, 1 829, 1 849 + 'dz' => '213', + 'ec' => '593', + 'ee' => '372', + 'eg' => '20', + 'eh' => '212', + 'er' => '291', + 'es' => '34', + 'et' => '251', + 'fi' => '358', + 'fj' => '679', + 'fk' => '500', + 'fm' => '691', + 'fo' => '298', + 'fr' => '33', + 'ga' => '241', + 'gb' => '44', + 'gd' => '1473', // @wikipedia original value: 1 473 + 'ge' => '995', + 'gf' => '594', + 'gg' => '44', + 'gh' => '233', + 'gi' => '350', + 'gl' => '299', + 'gm' => '220', + 'gn' => '224', + 'gp' => '590', + 'gq' => '240', + 'gr' => '30', + 'gs' => '500', + 'gt' => '502', + 'gu' => '1671', // @wikipedia original value: 1 671 + 'gw' => '245', + 'gy' => '592', + 'hk' => '852', +// 'hm' => 'MISSING CODE', + 'hn' => '504', + 'hr' => '385', + 'ht' => '509', + 'hu' => '36', + 'id' => '62', + 'ie' => '353', + 'il' => '972', + 'im' => '44', + 'in' => '91', + 'io' => '246', + 'iq' => '964', + 'ir' => '98', + 'is' => '354', + 'it' => '39', + 'je' => '44', + 'jm' => '1876', // @wikipedia original value: 1 876 + 'jo' => '962', + 'jp' => '81', + 'ke' => '254', + 'kg' => '996', + 'kh' => '855', + 'ki' => '686', + 'km' => '269', + 'kn' => '1869', // @wikipedia original value: 1 869 + 'kp' => '850', + 'kr' => '82', + 'kw' => '965', + 'ky' => '1345', // @wikipedia original value: 1 345 +// 'kz' => 'MISSING CODE', // @wikipedia original values: 7 6, 7 7 + 'la' => '856', + 'lb' => '961', + 'lc' => '1758', // @wikipedia original value: 1 758 + 'li' => '423', + 'lk' => '94', + 'lr' => '231', + 'ls' => '266', + 'lt' => '370', + 'lu' => '352', + 'lv' => '371', + 'ly' => '218', + 'ma' => '212', + 'mc' => '377', + 'md' => '373', + 'me' => '382', + 'mf' => '590', + 'mg' => '261', + 'mh' => '692', + 'mk' => '389', + 'ml' => '223', + 'mm' => '95', + 'mn' => '976', + 'mo' => '853', + 'mp' => '1670', // @wikipedia original value: 1 670 + 'mq' => '596', + 'mr' => '222', + 'ms' => '1664', // @wikipedia original value: 1 664 + 'mt' => '356', + 'mu' => '230', + 'mv' => '960', + 'mw' => '265', + 'mx' => '52', + 'my' => '60', + 'mz' => '258', + 'na' => '264', + 'nc' => '687', + 'ne' => '227', + 'nf' => '672', + 'ng' => '234', + 'ni' => '505', + 'nl' => '31', + 'no' => '47', + 'np' => '977', + 'nr' => '674', + 'nu' => '683', + 'nz' => '64', + 'om' => '968', + 'pa' => '507', + 'pe' => '51', + 'pf' => '689', + 'pg' => '675', + 'ph' => '63', + 'pk' => '92', + 'pl' => '48', + 'pm' => '508', + 'pn' => '672', +// 'pr' => 'MISSING CODE', // @wikipedia original values: 1 787, 1 939 + 'ps' => '970', + 'pt' => '351', + 'pw' => '680', + 'py' => '595', + 'qa' => '974', + 're' => '262', + 'ro' => '40', + 'rs' => '381', + 'ru' => '7', + 'rw' => '250', + 'sa' => '966', + 'sb' => '677', + 'sc' => '248', + 'sd' => '249', + 'se' => '46', + 'sg' => '65', + 'sh' => '290', + 'si' => '386', + 'sj' => '47', + 'sk' => '421', + 'sl' => '232', + 'sm' => '378', + 'sn' => '221', + 'so' => '252', + 'sr' => '597', + 'ss' => '211', + 'st' => '239', + 'sv' => '503', + 'sx' => '1721', //@wikipedia original value: 1 721 + 'sy' => '963', + 'sz' => '268', + 'tc' => '1649', // @wikipedia original value: 1 649 + 'td' => '235', +// 'tf' => 'MISSING CODE', + 'tg' => '228', + 'th' => '66', +// 'ti' => 'MISSING CODE', + 'tj' => '992', + 'tk' => '690', + 'tl' => '670', + 'tm' => '993', + 'tn' => '216', + 'to' => '676', + 'tr' => '90', + 'tt' => '1868', // @wikipedia original value: 1 868 + 'tv' => '688', + 'tw' => '886', + 'tz' => '255', + 'ua' => '380', + 'ug' => '256', +// 'um' => 'MISSING CODE', + 'us' => '1', + 'uy' => '598', + 'uz' => '998', +// 'va' => 'MISSING CODE', // @wikipedia original values: 39 066, assigned 379 + 'vc' => '1784', // @wikipedia original value: 1 784 + 've' => '58', + 'vg' => '1284', // @wikipedia original value: 1 284 + 'vi' => '1340', // @wikipedia original value: 1 340 + 'vn' => '84', + 'vu' => '678', + 'wf' => '681', + 'ws' => '685', + 'ye' => '967', + 'yt' => '262', + 'za' => '27', + 'zm' => '260', + 'zw' => '263' + ); +} diff --git a/www/analytics/plugins/MobileMessaging/GSMCharset.php b/www/analytics/plugins/MobileMessaging/GSMCharset.php new file mode 100644 index 00000000..1fa4778d --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/GSMCharset.php @@ -0,0 +1,158 @@ + 1, + '£' => 1, + '$' => 1, + '¥' => 1, + 'è' => 1, + 'é' => 1, + 'ù' => 1, + 'ì' => 1, + 'ò' => 1, + 'Ç' => 1, + 'Ø' => 1, + 'ø' => 1, + 'Å' => 1, + 'å' => 1, + '∆' => 1, + '_' => 1, + 'Φ' => 1, + 'Γ' => 1, + 'Λ' => 1, + 'Ω' => 1, + 'Π' => 1, + 'Ψ' => 1, + 'Σ' => 1, + 'Θ' => 1, + 'Ξ' => 1, + 'Æ' => 1, + 'æ' => 1, + 'ß' => 1, + 'É' => 1, + ' ' => 1, + '!' => 1, + '"' => 1, + '#' => 1, + '¤' => 1, + '%' => 1, + '&' => 1, + '\'' => 1, + '(' => 1, + ')' => 1, + '*' => 1, + '+' => 1, + ',' => 1, + '-' => 1, + '.' => 1, + '/' => 1, + '0' => 1, + '1' => 1, + '2' => 1, + '3' => 1, + '4' => 1, + '5' => 1, + '6' => 1, + '7' => 1, + '8' => 1, + '9' => 1, + ':' => 1, + ';' => 1, + '<' => 1, + '=' => 1, + '>' => 1, + '?' => 1, + '¡' => 1, + 'A' => 1, + 'B' => 1, + 'C' => 1, + 'D' => 1, + 'E' => 1, + 'F' => 1, + 'G' => 1, + 'H' => 1, + 'I' => 1, + 'J' => 1, + 'K' => 1, + 'L' => 1, + 'M' => 1, + 'N' => 1, + 'O' => 1, + 'P' => 1, + 'Q' => 1, + 'R' => 1, + 'S' => 1, + 'T' => 1, + 'U' => 1, + 'V' => 1, + 'W' => 1, + 'X' => 1, + 'Y' => 1, + 'Z' => 1, + 'Ä' => 1, + 'Ö' => 1, + 'Ñ' => 1, + 'Ü' => 1, + '§' => 1, + '¿' => 1, + 'a' => 1, + 'b' => 1, + 'c' => 1, + 'd' => 1, + 'e' => 1, + 'f' => 1, + 'g' => 1, + 'h' => 1, + 'i' => 1, + 'j' => 1, + 'k' => 1, + 'l' => 1, + 'm' => 1, + 'n' => 1, + 'o' => 1, + 'p' => 1, + 'q' => 1, + 'r' => 1, + 's' => 1, + 't' => 1, + 'u' => 1, + 'v' => 1, + 'w' => 1, + 'x' => 1, + 'y' => 1, + 'z' => 1, + 'ä' => 1, + 'ö' => 1, + 'ñ' => 1, + 'ü' => 1, + 'à' => 1, + + // Extended GSM Characters, weight = 2 + '^' => 2, + '{' => 2, + '}' => 2, + '\\' => 2, + '[' => 2, + '~' => 2, + ']' => 2, + '|' => 2, + '€' => 2, + ); +} diff --git a/www/analytics/plugins/MobileMessaging/MobileMessaging.php b/www/analytics/plugins/MobileMessaging/MobileMessaging.php new file mode 100644 index 00000000..f36a6c6c --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/MobileMessaging.php @@ -0,0 +1,257 @@ + true, + ); + + static private $managedReportTypes = array( + self::MOBILE_TYPE => 'plugins/MobileMessaging/images/phone.png' + ); + + static private $managedReportFormats = array( + self::SMS_FORMAT => 'plugins/MobileMessaging/images/phone.png' + ); + + static private $availableReports = array( + array( + 'module' => 'MultiSites', + 'action' => 'getAll', + ), + array( + 'module' => 'MultiSites', + 'action' => 'getOne', + ), + ); + + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'Menu.Admin.addItems' => 'addMenu', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'ScheduledReports.getReportParameters' => 'getReportParameters', + 'ScheduledReports.validateReportParameters' => 'validateReportParameters', + 'ScheduledReports.getReportMetadata' => 'getReportMetadata', + 'ScheduledReports.getReportTypes' => 'getReportTypes', + 'ScheduledReports.getReportFormats' => 'getReportFormats', + 'ScheduledReports.getRendererInstance' => 'getRendererInstance', + 'ScheduledReports.getReportRecipients' => 'getReportRecipients', + 'ScheduledReports.allowMultipleReports' => 'allowMultipleReports', + 'ScheduledReports.sendReport' => 'sendReport', + 'Template.reportParametersScheduledReports' => 'template_reportParametersScheduledReports', + ); + } + + function addMenu() + { + MenuAdmin::addEntry('MobileMessaging_SettingsMenu', + array('module' => 'MobileMessaging', 'action' => 'index'), + true, + $order = 12 + ); + } + + /** + * Get JavaScript files + */ + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/MobileMessaging/javascripts/MobileMessagingSettings.js"; + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/MobileMessaging/stylesheets/MobileMessagingSettings.less"; + } + + public function validateReportParameters(&$parameters, $reportType) + { + if (self::manageEvent($reportType)) { + // phone number validation + $availablePhoneNumbers = APIMobileMessaging::getInstance()->getActivatedPhoneNumbers(); + + $phoneNumbers = $parameters[self::PHONE_NUMBERS_PARAMETER]; + foreach ($phoneNumbers as $key => $phoneNumber) { + //when a wrong phone number is supplied we silently discard it + if (!in_array($phoneNumber, $availablePhoneNumbers)) { + unset($phoneNumbers[$key]); + } + } + + // 'unset' seems to transform the array to an associative array + $parameters[self::PHONE_NUMBERS_PARAMETER] = array_values($phoneNumbers); + } + } + + public function getReportMetadata(&$availableReportMetadata, $reportType, $idSite) + { + if (self::manageEvent($reportType)) { + foreach (self::$availableReports as $availableReport) { + $reportMetadata = APIPlugins::getInstance()->getMetadata( + $idSite, + $availableReport['module'], + $availableReport['action'] + ); + + if ($reportMetadata != null) { + $reportMetadata = reset($reportMetadata); + $availableReportMetadata[] = $reportMetadata; + } + } + } + } + + public function getReportTypes(&$reportTypes) + { + $reportTypes = array_merge($reportTypes, self::$managedReportTypes); + } + + public function getReportFormats(&$reportFormats, $reportType) + { + if (self::manageEvent($reportType)) { + $reportFormats = array_merge($reportFormats, self::$managedReportFormats); + } + } + + public function getReportParameters(&$availableParameters, $reportType) + { + if (self::manageEvent($reportType)) { + $availableParameters = self::$availableParameters; + } + } + + public function getRendererInstance(&$reportRenderer, $reportType, $outputType, $report) + { + if (self::manageEvent($reportType)) { + if (\Piwik\Plugin\Manager::getInstance()->isPluginActivated('MultiSites')) { + $reportRenderer = new Sms(); + } else { + $reportRenderer = new ReportRendererException( + Piwik::translate('MobileMessaging_MultiSites_Must_Be_Activated') + ); + } + } + } + + public function allowMultipleReports(&$allowMultipleReports, $reportType) + { + if (self::manageEvent($reportType)) { + $allowMultipleReports = false; + } + } + + public function getReportRecipients(&$recipients, $reportType, $report) + { + if (self::manageEvent($reportType)) { + $recipients = $report['parameters'][self::PHONE_NUMBERS_PARAMETER]; + } + } + + public function sendReport($reportType, $report, $contents, $filename, $prettyDate, $reportSubject, $reportTitle, + $additionalFiles) + { + if (self::manageEvent($reportType)) { + $parameters = $report['parameters']; + $phoneNumbers = $parameters[self::PHONE_NUMBERS_PARAMETER]; + + // 'All Websites' is one character above the limit, use 'Reports' instead + if ($reportSubject == Piwik::translate('General_MultiSitesSummary')) { + $reportSubject = Piwik::translate('General_Reports'); + } + + $mobileMessagingAPI = APIMobileMessaging::getInstance(); + foreach ($phoneNumbers as $phoneNumber) { + $mobileMessagingAPI->sendSMS( + $contents, + $phoneNumber, + $reportSubject + ); + } + } + } + + static public function template_reportParametersScheduledReports(&$out) + { + if (Piwik::isUserIsAnonymous()) { + return; + } + + $view = new View('@MobileMessaging/reportParametersScheduledReports'); + $view->reportType = self::MOBILE_TYPE; + $view->phoneNumbers = APIMobileMessaging::getInstance()->getActivatedPhoneNumbers(); + $out .= $view->render(); + } + + private static function manageEvent($reportType) + { + return in_array($reportType, array_keys(self::$managedReportTypes)); + } + + function install() + { + $delegatedManagement = Option::get(self::DELEGATED_MANAGEMENT_OPTION); + if (empty($delegatedManagement)) { + Option::set(self::DELEGATED_MANAGEMENT_OPTION, self::DELEGATED_MANAGEMENT_OPTION_DEFAULT); + } + } + + function deactivate() + { + // delete all mobile reports + $APIScheduledReports = APIScheduledReports::getInstance(); + $reports = $APIScheduledReports->getReports(); + + foreach ($reports as $report) { + if ($report['type'] == MobileMessaging::MOBILE_TYPE) { + $APIScheduledReports->deleteReport($report['idreport']); + } + } + } + + public function uninstall() + { + // currently the UI does not allow to delete a plugin + // when it becomes available, all the MobileMessaging settings (API credentials, phone numbers, etc..) should be removed from the option table + return; + } +} diff --git a/www/analytics/plugins/MobileMessaging/ReportRenderer/ReportRendererException.php b/www/analytics/plugins/MobileMessaging/ReportRenderer/ReportRendererException.php new file mode 100644 index 00000000..822057d1 --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/ReportRenderer/ReportRendererException.php @@ -0,0 +1,71 @@ +rendering = $exception; + } + + public function setLocale($locale) + { + // nothing to do + } + + public function sendToDisk($filename) + { + return ReportRenderer::writeFile( + $filename, + Sms::SMS_FILE_EXTENSION, + $this->rendering + ); + } + + public function sendToBrowserDownload($filename) + { + ReportRenderer::sendToBrowser( + $filename, + Sms::SMS_FILE_EXTENSION, + Sms::SMS_CONTENT_TYPE, + $this->rendering + ); + } + + public function sendToBrowserInline($filename) + { + ReportRenderer::inlineToBrowser( + Sms::SMS_CONTENT_TYPE, + $this->rendering + ); + } + + public function getRenderedReport() + { + return $this->rendering; + } + + public function renderFrontPage($reportTitle, $prettyDate, $description, $reportMetadata, $segment) + { + // nothing to do + } + + public function renderReport($processedReport) + { + // nothing to do + } +} diff --git a/www/analytics/plugins/MobileMessaging/ReportRenderer/Sms.php b/www/analytics/plugins/MobileMessaging/ReportRenderer/Sms.php new file mode 100644 index 00000000..d8a2d24c --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/ReportRenderer/Sms.php @@ -0,0 +1,131 @@ +rendering); + } + + public function sendToBrowserDownload($filename) + { + ReportRenderer::sendToBrowser($filename, self::SMS_FILE_EXTENSION, self::SMS_CONTENT_TYPE, $this->rendering); + } + + public function sendToBrowserInline($filename) + { + ReportRenderer::inlineToBrowser(self::SMS_CONTENT_TYPE, $this->rendering); + } + + public function getRenderedReport() + { + return $this->rendering; + } + + public function renderFrontPage($reportTitle, $prettyDate, $description, $reportMetadata, $segment) + { + // nothing to do + } + + public function renderReport($processedReport) + { + $isGoalPluginEnabled = Common::isGoalPluginEnabled(); + $prettyDate = $processedReport['prettyDate']; + $reportData = $processedReport['reportData']; + + $evolutionMetrics = array(); + $multiSitesAPIMetrics = API::getApiMetrics($enhanced = true); + foreach ($multiSitesAPIMetrics as $metricSettings) { + $evolutionMetrics[] = $metricSettings[API::METRIC_EVOLUTION_COL_NAME_KEY]; + } + + $floatRegex = self::FLOAT_REGEXP; + // no decimal for all metrics to shorten SMS content (keeps the monetary sign for revenue metrics) + $reportData->filter( + 'ColumnCallbackReplace', + array( + array_merge(array_keys($multiSitesAPIMetrics), $evolutionMetrics), + function ($value) use ($floatRegex) { + return preg_replace_callback( + $floatRegex, + function ($matches) { + return round($matches[0]); + }, + $value + ); + } + ) + ); + + // evolution metrics formatting : + // - remove monetary, percentage and white spaces to shorten SMS content + // (this is also needed to be able to test $value != 0 and see if there is an evolution at all in SMSReport.twig) + // - adds a plus sign + $reportData->filter( + 'ColumnCallbackReplace', + array( + $evolutionMetrics, + function ($value) use ($floatRegex) { + $matched = preg_match($floatRegex, $value, $matches); + return $matched ? sprintf("%+d", $matches[0]) : $value; + } + ) + ); + + $dataRows = $reportData->getRows(); + $reportMetadata = $processedReport['reportMetadata']; + $reportRowsMetadata = $reportMetadata->getRows(); + + $siteHasECommerce = array(); + foreach ($reportRowsMetadata as $rowMetadata) { + $idSite = $rowMetadata->getColumn('idsite'); + $siteHasECommerce[$idSite] = Site::isEcommerceEnabledFor($idSite); + } + + $view = new View('@MobileMessaging/SMSReport'); + $view->assign("isGoalPluginEnabled", $isGoalPluginEnabled); + $view->assign("reportRows", $dataRows); + $view->assign("reportRowsMetadata", $reportRowsMetadata); + $view->assign("prettyDate", $prettyDate); + $view->assign("siteHasECommerce", $siteHasECommerce); + $view->assign("displaySiteName", $processedReport['metadata']['action'] == 'getAll'); + + // segment + $segment = $processedReport['segment']; + $displaySegment = ($segment != null); + $view->assign("displaySegment", $displaySegment); + if ($displaySegment) { + $view->assign("segmentName", $segment['name']); + } + + $this->rendering .= $view->render(); + } +} diff --git a/www/analytics/plugins/MobileMessaging/SMSProvider.php b/www/analytics/plugins/MobileMessaging/SMSProvider.php new file mode 100644 index 00000000..15fb2263 --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/SMSProvider.php @@ -0,0 +1,174 @@ + 'You can use to send SMS Reports from Piwik.
          + +
          About Clockwork:
            +
          • Clockwork gives you fast, reliable high quality worldwide SMS delivery, over 450 networks in every corner of the globe. +
          • Cost per SMS message is around ~0.08USD (0.06EUR). +
          • Most countries and networks are supported but we suggest you check the latest position on their coverage map here. +
          • +
          + ', + ); + + /** + * Return the SMSProvider associated to the provider name $providerName + * + * @throws Exception If the provider is unknown + * @param string $providerName + * @return \Piwik\Plugins\MobileMessaging\SMSProvider + */ + static public function factory($providerName) + { + $className = __NAMESPACE__ . '\\SMSProvider\\' . $providerName; + + try { + Loader::loadClass($className); + return new $className; + } catch (Exception $e) { + throw new Exception( + Piwik::translate( + 'MobileMessaging_Exception_UnknownProvider', + array($providerName, implode(', ', array_keys(self::$availableSMSProviders))) + ) + ); + } + } + + /** + * Assert whether a given String contains UCS2 characters + * + * @param string $string + * @return bool true if $string contains UCS2 characters + */ + static public function containsUCS2Characters($string) + { + $GSMCharsetAsString = implode(array_keys(GSMCharset::$GSMCharset)); + + foreach (self::mb_str_split($string) as $char) { + if (mb_strpos($GSMCharsetAsString, $char) === false) { + return true; + } + } + + return false; + } + + /** + * Truncate $string and append $appendedString at the end if $string can not fit the + * the $maximumNumberOfConcatenatedSMS. + * + * @param string $string String to truncate + * @param int $maximumNumberOfConcatenatedSMS + * @param string $appendedString + * @return string original $string or truncated $string appended with $appendedString + */ + static public function truncate($string, $maximumNumberOfConcatenatedSMS, $appendedString = 'MobileMessaging_SMS_Content_Too_Long') + { + $appendedString = Piwik::translate($appendedString); + + $smsContentContainsUCS2Chars = self::containsUCS2Characters($string); + $maxCharsAllowed = self::maxCharsAllowed($maximumNumberOfConcatenatedSMS, $smsContentContainsUCS2Chars); + $sizeOfSMSContent = self::sizeOfSMSContent($string, $smsContentContainsUCS2Chars); + + if ($sizeOfSMSContent <= $maxCharsAllowed) return $string; + + $smsContentContainsUCS2Chars = $smsContentContainsUCS2Chars || self::containsUCS2Characters($appendedString); + $maxCharsAllowed = self::maxCharsAllowed($maximumNumberOfConcatenatedSMS, $smsContentContainsUCS2Chars); + $sizeOfSMSContent = self::sizeOfSMSContent($string . $appendedString, $smsContentContainsUCS2Chars); + + $sizeToTruncate = $sizeOfSMSContent - $maxCharsAllowed; + + $subStrToTruncate = ''; + $subStrSize = 0; + $reversedStringChars = array_reverse(self::mb_str_split($string)); + for ($i = 0; $subStrSize < $sizeToTruncate; $i++) { + $subStrToTruncate = $reversedStringChars[$i] . $subStrToTruncate; + $subStrSize = self::sizeOfSMSContent($subStrToTruncate, $smsContentContainsUCS2Chars); + } + + return preg_replace('/' . preg_quote($subStrToTruncate, '/') . '$/', $appendedString, $string); + } + + static private function mb_str_split($string) + { + return preg_split('//u', $string, -1, PREG_SPLIT_NO_EMPTY); + } + + static private function sizeOfSMSContent($smsContent, $containsUCS2Chars) + { + if ($containsUCS2Chars) return mb_strlen($smsContent, 'UTF-8'); + + $sizeOfSMSContent = 0; + foreach (self::mb_str_split($smsContent) as $char) { + $sizeOfSMSContent += GSMCharset::$GSMCharset[$char]; + } + return $sizeOfSMSContent; + } + + static private function maxCharsAllowed($maximumNumberOfConcatenatedSMS, $containsUCS2Chars) + { + $maxCharsInOneUniqueSMS = $containsUCS2Chars ? self::MAX_UCS2_CHARS_IN_ONE_UNIQUE_SMS : self::MAX_GSM_CHARS_IN_ONE_UNIQUE_SMS; + $maxCharsInOneConcatenatedSMS = $containsUCS2Chars ? self::MAX_UCS2_CHARS_IN_ONE_CONCATENATED_SMS : self::MAX_GSM_CHARS_IN_ONE_CONCATENATED_SMS; + + $uniqueSMS = $maximumNumberOfConcatenatedSMS == 1; + + return $uniqueSMS ? + $maxCharsInOneUniqueSMS : + $maxCharsInOneConcatenatedSMS * $maximumNumberOfConcatenatedSMS; + } + + /** + * verify the SMS API credential + * + * @param string $apiKey API Key + * @return bool true if SMS API credential are valid, false otherwise + */ + abstract public function verifyCredential($apiKey); + + /** + * get remaining credits + * + * @param string $apiKey API Key + * @return string remaining credits + */ + abstract public function getCreditLeft($apiKey); + + /** + * send SMS + * + * @param string $apiKey + * @param string $smsText + * @param string $phoneNumber + * @param string $from + * @return bool true + */ + abstract public function sendSMS($apiKey, $smsText, $phoneNumber, $from); +} diff --git a/www/analytics/plugins/MobileMessaging/SMSProvider/Clockwork.php b/www/analytics/plugins/MobileMessaging/SMSProvider/Clockwork.php new file mode 100644 index 00000000..16c2068e --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/SMSProvider/Clockwork.php @@ -0,0 +1,108 @@ +getCreditLeft($apiKey); + + return true; + } + + public function sendSMS($apiKey, $smsText, $phoneNumber, $from) + { + $from = substr($from, 0, self::MAXIMUM_FROM_LENGTH); + + $smsText = self::truncate($smsText, self::MAXIMUM_CONCATENATED_SMS); + + $additionalParameters = array( + 'To' => str_replace('+', '', $phoneNumber), + 'Content' => $smsText, + 'From' => $from, + 'Long' => 1, + 'MsgType' => self::containsUCS2Characters($smsText) ? 'UCS2' : 'TEXT', + ); + + $this->issueApiCall( + $apiKey, + self::SEND_SMS_RESOURCE, + $additionalParameters + ); + } + + private function issueApiCall($apiKey, $resource, $additionalParameters = array()) + { + $accountParameters = array( + 'Key' => $apiKey, + ); + + $parameters = array_merge($accountParameters, $additionalParameters); + + $url = self::BASE_API_URL + . $resource + . '?' . http_build_query($parameters, '', '&'); + + $timeout = self::SOCKET_TIMEOUT; + + try { + $result = Http::sendHttpRequestBy( + Http::getTransportMethod(), + $url, + $timeout, + $userAgent = null, + $destinationPath = null, + $file = null, + $followDepth = 0, + $acceptLanguage = false, + $acceptInvalidSslCertificate = true + ); + } catch (Exception $e) { + $result = self::ERROR_STRING . " " . $e->getMessage(); + } + + if (strpos($result, self::ERROR_STRING) !== false) { + throw new APIException( + 'Clockwork API returned the following error message : ' . $result + ); + } + + return $result; + } + + public function getCreditLeft($apiKey) + { + return $this->issueApiCall( + $apiKey, + self::CHECK_CREDIT_RESOURCE + ); + } +} diff --git a/www/analytics/plugins/MobileMessaging/SMSProvider/StubbedProvider.php b/www/analytics/plugins/MobileMessaging/SMSProvider/StubbedProvider.php new file mode 100644 index 00000000..266c0336 --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/SMSProvider/StubbedProvider.php @@ -0,0 +1,34 @@ + 0) { + countryToSelect.attr('selected', 'selected'); + } + else { + $(countriesSelector + ' option:selected').removeAttr('selected'); + } + } + } + + function displayAccountForm() { + $(accountFormSelector).show(); + } + + function validatePhoneNumber(event) { + var phoneNumberContainer = $(event.target).parent(); + var verificationCodeContainer = phoneNumberContainer.find(verificationCodeSelector); + var verificationCode = verificationCodeContainer.val(); + var phoneNumber = phoneNumberContainer.find(phoneNumberSelector).html(); + + if (verificationCode != null && verificationCode != '') { + var success = + function (response) { + + var UI = require('piwik/UI'); + var notification = new UI.Notification(); + + $(phoneNumberActivatedSelector).hide(); + if (!response.value) { + var message = $(invalidActivationCodeMsgSelector).html(); + notification.show(message, { + context: 'error', + id: 'MobileMessaging_ValidatePhoneNumber', + style: {marginTop: '10px'} + }); + } + else { + var message = $(phoneNumberActivatedSelector).html(); + notification.show(message, { + context: 'success', + id: 'MobileMessaging_ValidatePhoneNumber', + style: {marginTop: '10px'} + }); + + $(verificationCodeContainer).remove(); + $(phoneNumberContainer).find(validatePhoneNumberSubmitSelector).remove(); + $(phoneNumberContainer).find(formDescriptionSelector).remove(); + } + + notification.scrollToNotification(); + }; + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'MobileMessaging.validatePhoneNumber' + }, 'GET'); + ajaxHandler.addParams({phoneNumber: phoneNumber, verificationCode: verificationCode}, 'POST'); + ajaxHandler.setCallback(success); + ajaxHandler.setLoadingElement(ajaxLoadingSelector); + ajaxHandler.setErrorElement(invalidVerificationCodeAjaxErrorSelector); + ajaxHandler.send(true); + } + } + + function removePhoneNumber(event) { + var phoneNumberContainer = $(event.target).parent(); + var phoneNumber = phoneNumberContainer.find(phoneNumberSelector).html(); + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'MobileMessaging.removePhoneNumber' + }, 'GET'); + ajaxHandler.addParams({phoneNumber: phoneNumber}, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(ajaxLoadingSelector); + ajaxHandler.setErrorElement(ajaxErrorsSelector); + ajaxHandler.send(true); + } + + function updateSuspiciousPhoneNumberMessage() { + var newPhoneNumber = $(newPhoneNumberSelector).val(); + + // check if number starts with 0 + if ($.trim(newPhoneNumber).lastIndexOf('0', 0) === 0) { + $(suspiciousPhoneNumberSelector).show(); + } + else { + $(suspiciousPhoneNumberSelector).hide(); + } + } + + function addPhoneNumber() { + var newPhoneNumber = $(newPhoneNumberSelector).val(); + var countryCallingCode = $(countryCallingCodeSelector).val(); + + var phoneNumber = '+' + countryCallingCode + newPhoneNumber; + + if (newPhoneNumber != null && newPhoneNumber != '') { + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'MobileMessaging.addPhoneNumber' + }, 'GET'); + ajaxHandler.addParams({phoneNumber: phoneNumber}, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(ajaxLoadingSelector); + ajaxHandler.setErrorElement(ajaxErrorsSelector); + ajaxHandler.send(true); + } + } + + function updateCountryCallingCode() { + $(countryCallingCodeSelector).val($(countriesSelector + ' option:selected').val()); + } + + function updateProviderDescription() { + $(providerDescriptionsSelector).hide(); + $('#' + $(providersSelector + ' option:selected').val() + providerDescriptionsSelector).show(); + } + + function updateDelegatedManagement() { + setDelegatedManagement(getDelegatedManagement()); + } + + function confirmDeleteApiAccount() { + piwikHelper.modalConfirm(confirmDeleteAccountSelector, {yes: deleteApiAccount}); + } + + function deleteApiAccount() { + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'MobileMessaging.deleteSMSAPICredential' + }, 'GET'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(ajaxLoadingSelector); + ajaxHandler.setErrorElement(ajaxErrorsSelector); + ajaxHandler.send(true); + } + + function updateApiAccount() { + + var provider = $(providersSelector + ' option:selected').val(); + var apiKey = $(apiKeySelector).val(); + + if (apiKey != '') { + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'MobileMessaging.setSMSAPICredential' + }, 'GET'); + ajaxHandler.addParams({provider: provider, apiKey: apiKey}, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(ajaxLoadingSelector); + ajaxHandler.setErrorElement(ajaxErrorsSelector); + ajaxHandler.send(true); + } + } + + function setDelegatedManagement(delegatedManagement) { + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'MobileMessaging.setDelegatedManagement' + }, 'GET'); + ajaxHandler.addParams({delegatedManagement: delegatedManagement}, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(ajaxLoadingSelector); + ajaxHandler.setErrorElement(ajaxErrorsSelector); + ajaxHandler.send(true); + } + + function getDelegatedManagement() { + return $(delegatedManagementSelector + ':checked').val(); + } + + /************************************************************ + * Public data and methods + ************************************************************/ + + return { + + /** + * Initialize UI events + */ + initUIEvents: function () { + initUIEvents(); + } + }; + +}()); + +$(document).ready(function () { + MobileMessagingSettings.initUIEvents(); +}); diff --git a/www/analytics/plugins/MobileMessaging/stylesheets/MobileMessagingSettings.less b/www/analytics/plugins/MobileMessaging/stylesheets/MobileMessagingSettings.less new file mode 100644 index 00000000..028c8fa8 --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/stylesheets/MobileMessagingSettings.less @@ -0,0 +1,13 @@ +#accountForm ul { + list-style: circle; + margin-left: 17px; + line-height: 1.5em; +} + +.providerDescription { + border: 2px dashed #C5BDAD; + border-radius: 16px 16px 16px 16px; + margin-left: 24px; + padding: 11px; + width: 600px; +} \ No newline at end of file diff --git a/www/analytics/plugins/MobileMessaging/templates/SMSReport.twig b/www/analytics/plugins/MobileMessaging/templates/SMSReport.twig new file mode 100644 index 00000000..6bd52bbc --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/templates/SMSReport.twig @@ -0,0 +1,48 @@ +{{ prettyDate }}{% if displaySegment %}, {{ segmentName }}{% endif %}. {% if false %}{% endif %} + +{%- if reportRows is empty -%} + {{ 'CoreHome_ThereIsNoDataForThisReport'|translate }} +{%- endif -%} + +{%- for rowId, row in reportRows -%} + {%- set rowMetrics=row.columns -%} + {%- set rowMetadata=reportRowsMetadata[rowId].columns -%} + + {%- if displaySiteName -%}{{ rowMetrics.label|raw }}: {% endif -%} + + {# visits #} + {{- rowMetrics.nb_visits }} {{ 'General_ColumnNbVisits'|translate }} + {%- if rowMetrics.visits_evolution != 0 %} ({{ rowMetrics.visits_evolution }}%){%- endif -%} + + {%- if rowMetrics.nb_visits != 0 -%} + {#- actions -#} + , {{ rowMetrics.nb_actions }} {{ 'General_ColumnNbActions'|translate }} + {%- if rowMetrics.actions_evolution != 0 %} ({{ rowMetrics.actions_evolution }}%){%- endif -%} + + {%- if isGoalPluginEnabled -%} + + {# goal metrics #} + {%- if rowMetrics.nb_conversions != 0 -%} + , {{ 'General_ColumnRevenue'|translate }}: {{ rowMetrics.revenue|raw }} + {%- if rowMetrics.revenue_evolution != 0 %} ({{ rowMetrics.revenue_evolution }}%){%- endif -%} + + , {{ rowMetrics.nb_conversions }} {{ 'Goals_GoalConversions'|translate }} + {%- if rowMetrics.nb_conversions_evolution != 0 %} ({{ rowMetrics.nb_conversions_evolution }}%){%- endif -%} + {%- endif -%} + + {# eCommerce metrics #} + {%- if siteHasECommerce[rowMetadata.idsite] -%} + + , {{ 'General_ProductRevenue'|translate }}: {{ rowMetrics.ecommerce_revenue|raw }} + {%- if rowMetrics.ecommerce_revenue_evolution != 0 %} ({{ rowMetrics.ecommerce_revenue_evolution }}%){%- endif -%} + + , {{ rowMetrics.orders }} {{ 'General_EcommerceOrders'|translate }} + {%- if rowMetrics.orders_evolution != 0 %} ({{ rowMetrics.orders_evolution }}%){%- endif -%} + {%- endif -%} + + {%- endif -%} + + {%- endif -%} + + {%- if not loop.last -%}. {% endif -%} +{%- endfor -%} diff --git a/www/analytics/plugins/MobileMessaging/templates/index.twig b/www/analytics/plugins/MobileMessaging/templates/index.twig new file mode 100644 index 00000000..7b643b1d --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/templates/index.twig @@ -0,0 +1,200 @@ +{% extends 'admin.twig' %} + +{% block content %} + {% if accountManagedByCurrentUser %} +

          {{ 'MobileMessaging_Settings_SMSAPIAccount'|translate }}

          + {% if credentialSupplied %} + {{ 'MobileMessaging_Settings_CredentialProvided'|translate(provider) }} + {{ creditLeft }} +
          + {{ 'MobileMessaging_Settings_UpdateOrDeleteAccount'|translate("","","","")|raw }} + {% else %} + {{ 'MobileMessaging_Settings_PleaseSignUp'|translate }} + {% endif %} +
          +
          + {{ 'MobileMessaging_Settings_SMSProvider'|translate }} + + + {{ 'MobileMessaging_Settings_APIKey'|translate }} + + + + + {% for smsProvider, description in smsProviders %} +
          + {{ description|raw }} +
          + {% endfor %} + +
          + {% endif %} + + {% import 'ajaxMacros.twig' as ajax %} + +
          + {{ ajax.errorDiv('ajaxErrorMobileMessagingSettings') }} +
          + +

          {{ 'MobileMessaging_PhoneNumbers'|translate }}

          + {% if not credentialSupplied %} + {% if accountManagedByCurrentUser %} + {{ 'MobileMessaging_Settings_CredentialNotProvided'|translate }} + {% else %} + {{ 'MobileMessaging_Settings_CredentialNotProvidedByAdmin'|translate }} + {% endif %} + {% else %} + {{ 'MobileMessaging_Settings_PhoneNumbers_Help'|translate }} +
          +
          + + + + + + + + + + +
          + {{ 'MobileMessaging_Settings_PhoneNumbers_Add'|translate }} +

          + + + + +   + + +
          + + {{ 'MobileMessaging_Settings_CountryCode'|translate }} + {{ 'MobileMessaging_Settings_PhoneNumber'|translate }} +

          + + {{ 'MobileMessaging_Settings_PhoneNumbers_CountryCode_Help'|translate }} + + + +
          + {% import 'macros.twig' as piwik %} + {{ piwik.inlineHelp(strHelpAddPhone) }} +
          + + {% if phoneNumbers|length > 0 %} +
          +
          + {{ 'MobileMessaging_Settings_ManagePhoneNumbers'|translate }} +
          +
          + {% endif %} + + {{ ajax.errorDiv('invalidVerificationCodeAjaxError') }} + + + + + +
            + {% for phoneNumber, validated in phoneNumbers %} +
          • + {{ phoneNumber }} + {% if not validated %} + + + {% endif %} + + {% if not validated %} +
            + {{ 'MobileMessaging_Settings_VerificationCodeJustSent'|translate }} + {% endif %} +
            +
            +
          • + {% endfor %} +
          + +
          + {% endif %} + + {% if isSuperUser %} +

          {{ 'MobileMessaging_Settings_SuperAdmin'|translate }}

          + + + + +
          {{ 'MobileMessaging_Settings_LetUsersManageAPICredential'|translate }} +
          + +
          +
          + + +
          +
          + {% endif %} + + {{ ajax.loadingDiv('ajaxLoadingMobileMessagingSettings') }} + +
          +

          {{ 'MobileMessaging_Settings_DeleteAccountConfirm'|translate }}

          + + +
          + +{% endblock %} diff --git a/www/analytics/plugins/MobileMessaging/templates/reportParametersScheduledReports.twig b/www/analytics/plugins/MobileMessaging/templates/reportParametersScheduledReports.twig new file mode 100644 index 00000000..5d69b2d9 --- /dev/null +++ b/www/analytics/plugins/MobileMessaging/templates/reportParametersScheduledReports.twig @@ -0,0 +1,61 @@ + + + + {{ 'MobileMessaging_PhoneNumbers'|translate }} + + +
          + {% if phoneNumbers|length == 0 %} + {{ 'MobileMessaging_MobileReport_NoPhoneNumbers'|translate }} + {% else %} + {% for phoneNumber in phoneNumbers %} + +
          + {% endfor %} + {{ 'MobileMessaging_MobileReport_AdditionalPhoneNumbers'|translate }} + {% endif %} + {{ 'MobileMessaging_MobileReport_MobileMessagingSettingsLink'|translate }} +
          + + + + diff --git a/www/analytics/plugins/Morpheus/images/add.png b/www/analytics/plugins/Morpheus/images/add.png new file mode 100644 index 00000000..c61a5576 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/add.png differ diff --git a/www/analytics/plugins/Morpheus/images/annotations.png b/www/analytics/plugins/Morpheus/images/annotations.png new file mode 100644 index 00000000..7b6909c1 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/annotations.png differ diff --git a/www/analytics/plugins/Morpheus/images/annotations_starred.png b/www/analytics/plugins/Morpheus/images/annotations_starred.png new file mode 100644 index 00000000..0410a5ce Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/annotations_starred.png differ diff --git a/www/analytics/plugins/Morpheus/images/bullet.png b/www/analytics/plugins/Morpheus/images/bullet.png new file mode 100644 index 00000000..7fcb88be Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/bullet.png differ diff --git a/www/analytics/plugins/Morpheus/images/calendar.gif b/www/analytics/plugins/Morpheus/images/calendar.gif new file mode 100644 index 00000000..ec93ddf2 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/calendar.gif differ diff --git a/www/analytics/plugins/Morpheus/images/chart_bar.png b/www/analytics/plugins/Morpheus/images/chart_bar.png new file mode 100644 index 00000000..816c1af5 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/chart_bar.png differ diff --git a/www/analytics/plugins/Morpheus/images/chart_line_edit.png b/www/analytics/plugins/Morpheus/images/chart_line_edit.png new file mode 100644 index 00000000..2b1ed689 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/chart_line_edit.png differ diff --git a/www/analytics/plugins/Morpheus/images/chart_pie.png b/www/analytics/plugins/Morpheus/images/chart_pie.png new file mode 100644 index 00000000..5b7c866b Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/chart_pie.png differ diff --git a/www/analytics/plugins/Morpheus/images/cities.png b/www/analytics/plugins/Morpheus/images/cities.png new file mode 100644 index 00000000..08f0ad54 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/cities.png differ diff --git a/www/analytics/plugins/Morpheus/images/close.png b/www/analytics/plugins/Morpheus/images/close.png new file mode 100644 index 00000000..b56233df Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/close.png differ diff --git a/www/analytics/plugins/Morpheus/images/configure-highlight.png b/www/analytics/plugins/Morpheus/images/configure-highlight.png new file mode 100644 index 00000000..723b853c Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/configure-highlight.png differ diff --git a/www/analytics/plugins/Morpheus/images/configure.png b/www/analytics/plugins/Morpheus/images/configure.png new file mode 100644 index 00000000..2f60fa81 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/configure.png differ diff --git a/www/analytics/plugins/Morpheus/images/datepicker_arr_l.png b/www/analytics/plugins/Morpheus/images/datepicker_arr_l.png new file mode 100644 index 00000000..eb25d57c Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/datepicker_arr_l.png differ diff --git a/www/analytics/plugins/Morpheus/images/datepicker_arr_r.png b/www/analytics/plugins/Morpheus/images/datepicker_arr_r.png new file mode 100644 index 00000000..1d0a9f7d Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/datepicker_arr_r.png differ diff --git a/www/analytics/plugins/Morpheus/images/export.png b/www/analytics/plugins/Morpheus/images/export.png new file mode 100644 index 00000000..137a019f Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/export.png differ diff --git a/www/analytics/plugins/Morpheus/images/forms-sprite.png b/www/analytics/plugins/Morpheus/images/forms-sprite.png new file mode 100644 index 00000000..21511aa7 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/forms-sprite.png differ diff --git a/www/analytics/plugins/Morpheus/images/goal.png b/www/analytics/plugins/Morpheus/images/goal.png new file mode 100644 index 00000000..1381ee38 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/goal.png differ diff --git a/www/analytics/plugins/Morpheus/images/help.png b/www/analytics/plugins/Morpheus/images/help.png new file mode 100644 index 00000000..436991c9 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/help.png differ diff --git a/www/analytics/plugins/Morpheus/images/ico_delete.png b/www/analytics/plugins/Morpheus/images/ico_delete.png new file mode 100644 index 00000000..b1385879 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/ico_delete.png differ diff --git a/www/analytics/plugins/Morpheus/images/ico_edit.png b/www/analytics/plugins/Morpheus/images/ico_edit.png new file mode 100644 index 00000000..fda6e1d0 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/ico_edit.png differ diff --git a/www/analytics/plugins/Morpheus/images/icon-calendar.gif b/www/analytics/plugins/Morpheus/images/icon-calendar.gif new file mode 100644 index 00000000..bf901666 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/icon-calendar.gif differ diff --git a/www/analytics/plugins/Morpheus/images/image.png b/www/analytics/plugins/Morpheus/images/image.png new file mode 100644 index 00000000..da6ec482 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/image.png differ diff --git a/www/analytics/plugins/Morpheus/images/info.png b/www/analytics/plugins/Morpheus/images/info.png new file mode 100644 index 00000000..0be95bbb Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/info.png differ diff --git a/www/analytics/plugins/Morpheus/images/link.gif b/www/analytics/plugins/Morpheus/images/link.gif new file mode 100644 index 00000000..543c76d9 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/link.gif differ diff --git a/www/analytics/plugins/Morpheus/images/loading-blue.gif b/www/analytics/plugins/Morpheus/images/loading-blue.gif new file mode 100644 index 00000000..37bed6f3 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/loading-blue.gif differ diff --git a/www/analytics/plugins/Morpheus/images/logo-header.png b/www/analytics/plugins/Morpheus/images/logo-header.png new file mode 100644 index 00000000..6e7b861b Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/logo-header.png differ diff --git a/www/analytics/plugins/Morpheus/images/logo.png b/www/analytics/plugins/Morpheus/images/logo.png new file mode 100644 index 00000000..98ff9377 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/logo.png differ diff --git a/www/analytics/plugins/Morpheus/images/logo.svg b/www/analytics/plugins/Morpheus/images/logo.svg new file mode 100644 index 00000000..d288e207 --- /dev/null +++ b/www/analytics/plugins/Morpheus/images/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/www/analytics/plugins/Morpheus/images/maximise.png b/www/analytics/plugins/Morpheus/images/maximise.png new file mode 100644 index 00000000..426d8864 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/maximise.png differ diff --git a/www/analytics/plugins/Morpheus/images/minimise.png b/www/analytics/plugins/Morpheus/images/minimise.png new file mode 100644 index 00000000..8cf70667 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/minimise.png differ diff --git a/www/analytics/plugins/Morpheus/images/pause.gif b/www/analytics/plugins/Morpheus/images/pause.gif new file mode 100644 index 00000000..23162c35 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/pause.gif differ diff --git a/www/analytics/plugins/Morpheus/images/pause_disabled.gif b/www/analytics/plugins/Morpheus/images/pause_disabled.gif new file mode 100644 index 00000000..dc62608e Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/pause_disabled.gif differ diff --git a/www/analytics/plugins/Morpheus/images/play.gif b/www/analytics/plugins/Morpheus/images/play.gif new file mode 100644 index 00000000..c49479e7 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/play.gif differ diff --git a/www/analytics/plugins/Morpheus/images/play_disabled.gif b/www/analytics/plugins/Morpheus/images/play_disabled.gif new file mode 100644 index 00000000..325147c2 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/play_disabled.gif differ diff --git a/www/analytics/plugins/Morpheus/images/refresh.png b/www/analytics/plugins/Morpheus/images/refresh.png new file mode 100644 index 00000000..c7c98017 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/refresh.png differ diff --git a/www/analytics/plugins/Morpheus/images/regions.png b/www/analytics/plugins/Morpheus/images/regions.png new file mode 100644 index 00000000..f1b3c32e Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/regions.png differ diff --git a/www/analytics/plugins/Morpheus/images/search_ico.png b/www/analytics/plugins/Morpheus/images/search_ico.png new file mode 100644 index 00000000..5abe31a4 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/search_ico.png differ diff --git a/www/analytics/plugins/Morpheus/images/segment-users.png b/www/analytics/plugins/Morpheus/images/segment-users.png new file mode 100644 index 00000000..6be046ce Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/segment-users.png differ diff --git a/www/analytics/plugins/Morpheus/images/sort_subtable_desc.png b/www/analytics/plugins/Morpheus/images/sort_subtable_desc.png new file mode 100644 index 00000000..939f0f7b Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/sort_subtable_desc.png differ diff --git a/www/analytics/plugins/Morpheus/images/sortasc.png b/www/analytics/plugins/Morpheus/images/sortasc.png new file mode 100644 index 00000000..a24d43e8 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/sortasc.png differ diff --git a/www/analytics/plugins/Morpheus/images/sortasc_dark.png b/www/analytics/plugins/Morpheus/images/sortasc_dark.png new file mode 100644 index 00000000..31fbd9b6 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/sortasc_dark.png differ diff --git a/www/analytics/plugins/Morpheus/images/sortdesc.png b/www/analytics/plugins/Morpheus/images/sortdesc.png new file mode 100644 index 00000000..3ba4201a Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/sortdesc.png differ diff --git a/www/analytics/plugins/Morpheus/images/sortdesc_dark.png b/www/analytics/plugins/Morpheus/images/sortdesc_dark.png new file mode 100644 index 00000000..bc0b68e2 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/sortdesc_dark.png differ diff --git a/www/analytics/plugins/Morpheus/images/table.png b/www/analytics/plugins/Morpheus/images/table.png new file mode 100644 index 00000000..83a78543 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/table.png differ diff --git a/www/analytics/plugins/Morpheus/images/table_more.png b/www/analytics/plugins/Morpheus/images/table_more.png new file mode 100644 index 00000000..e84b93ae Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/table_more.png differ diff --git a/www/analytics/plugins/Morpheus/images/tagcloud.png b/www/analytics/plugins/Morpheus/images/tagcloud.png new file mode 100644 index 00000000..d351c5c6 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/tagcloud.png differ diff --git a/www/analytics/plugins/Morpheus/images/zoom-out-disabled.png b/www/analytics/plugins/Morpheus/images/zoom-out-disabled.png new file mode 100644 index 00000000..2eb9fbdf Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/zoom-out-disabled.png differ diff --git a/www/analytics/plugins/Morpheus/images/zoom-out.png b/www/analytics/plugins/Morpheus/images/zoom-out.png new file mode 100644 index 00000000..be5b5689 Binary files /dev/null and b/www/analytics/plugins/Morpheus/images/zoom-out.png differ diff --git a/www/analytics/plugins/Morpheus/javascripts/jquery.icheck.min.js b/www/analytics/plugins/Morpheus/javascripts/jquery.icheck.min.js new file mode 100644 index 00000000..2ca9f12e --- /dev/null +++ b/www/analytics/plugins/Morpheus/javascripts/jquery.icheck.min.js @@ -0,0 +1,8 @@ +(function(f){function C(a,c,d){var b=a[0],e=/er/.test(d)?k:/bl/.test(d)?u:j;active=d==E?{checked:b[j],disabled:b[u],indeterminate:"true"==a.attr(k)||"false"==a.attr(v)}:b[e];if(/^(ch|di|in)/.test(d)&&!active)p(a,e);else if(/^(un|en|de)/.test(d)&&active)w(a,e);else if(d==E)for(var e in active)active[e]?p(a,e,!0):w(a,e,!0);else if(!c||"toggle"==d){if(!c)a[r]("ifClicked");active?b[l]!==x&&w(a,e):p(a,e)}}function p(a,c,d){var b=a[0],e=a.parent(),g=c==j,H=c==k,m=H?v:g?I:"enabled",r=h(b,m+y(b[l])),L=h(b, +c+y(b[l]));if(!0!==b[c]){if(!d&&c==j&&b[l]==x&&b.name){var p=a.closest("form"),s='input[name="'+b.name+'"]',s=p.length?p.find(s):f(s);s.each(function(){this!==b&&f.data(this,n)&&w(f(this),c)})}H?(b[c]=!0,b[j]&&w(a,j,"force")):(d||(b[c]=!0),g&&b[k]&&w(a,k,!1));J(a,g,c,d)}b[u]&&h(b,z,!0)&&e.find("."+F).css(z,"default");e[t](L||h(b,c));e[A](r||h(b,m)||"")}function w(a,c,d){var b=a[0],e=a.parent(),g=c==j,f=c==k,m=f?v:g?I:"enabled",n=h(b,m+y(b[l])),p=h(b,c+y(b[l]));if(!1!==b[c]){if(f||!d||"force"==d)b[c]= +!1;J(a,g,m,d)}!b[u]&&h(b,z,!0)&&e.find("."+F).css(z,"pointer");e[A](p||h(b,c)||"");e[t](n||h(b,m))}function K(a,c){if(f.data(a,n)){var d=f(a);d.parent().html(d.attr("style",f.data(a,n).s||"")[r](c||""));d.off(".i").unwrap();f(D+'[for="'+a.id+'"]').add(d.closest(D)).off(".i")}}function h(a,c,d){if(f.data(a,n))return f.data(a,n).o[c+(d?"":"Class")]}function y(a){return a.charAt(0).toUpperCase()+a.slice(1)}function J(a,c,d,b){if(!b){if(c)a[r]("ifToggled");a[r]("ifChanged")[r]("if"+y(d))}}var n="iCheck", +F=n+"-helper",x="radio",j="checked",I="un"+j,u="disabled",v="determinate",k="in"+v,E="update",l="type",t="addClass",A="removeClass",r="trigger",D="label",z="cursor",G=/ipad|iphone|ipod|android|blackberry|windows phone|opera mini/i.test(navigator.userAgent);f.fn[n]=function(a,c){var d=":checkbox, :"+x,b=f(),e=function(a){a.each(function(){var a=f(this);b=a.is(d)?b.add(a):b.add(a.find(d))})};if(/^(check|uncheck|toggle|indeterminate|determinate|disable|enable|update|destroy)$/i.test(a))return a=a.toLowerCase(), +e(this),b.each(function(){"destroy"==a?K(this,"ifDestroyed"):C(f(this),!0,a);f.isFunction(c)&&c()});if("object"==typeof a||!a){var g=f.extend({checkedClass:j,disabledClass:u,indeterminateClass:k,labelHover:!0},a),h=g.handle,m=g.hoverClass||"hover",y=g.focusClass||"focus",v=g.activeClass||"active",z=!!g.labelHover,s=g.labelHoverClass||"hover",B=(""+g.increaseArea).replace("%","")|0;if("checkbox"==h||h==x)d=":"+h;-50>B&&(B=-50);e(this);return b.each(function(){K(this);var a=f(this),b=this,c=b.id,d= +-B+"%",e=100+2*B+"%",e={position:"absolute",top:d,left:d,display:"block",width:e,height:e,margin:0,padding:0,background:"#fff",border:0,opacity:0},d=G?{position:"absolute",visibility:"hidden"}:B?e:{position:"absolute",opacity:0},h="checkbox"==b[l]?g.checkboxClass||"icheckbox":g.radioClass||"i"+x,k=f(D+'[for="'+c+'"]').add(a.closest(D)),q=a.wrap('
          ')[r]("ifCreated").parent().append(g.insert),e=f('').css(e).appendTo(q);a.data(n,{o:g,s:a.attr("style")}).css(d); +g.inheritClass&&q[t](b.className);g.inheritID&&c&&q.attr("id",n+"-"+c);"static"==q.css("position")&&q.css("position","relative");C(a,!0,E);if(k.length)k.on("click.i mouseenter.i mouseleave.i touchbegin.i touchend.i",function(c){var d=c[l],e=f(this);if(!b[u])if("click"==d?C(a,!1,!0):z&&(/ve|nd/.test(d)?(q[A](m),e[A](s)):(q[t](m),e[t](s))),G)c.stopPropagation();else return!1});a.on("click.i focus.i blur.i keyup.i keydown.i keypress.i",function(c){var d=c[l];c=c.keyCode;if("click"==d)return!1;if("keydown"== +d&&32==c)return b[l]==x&&b[j]||(b[j]?w(a,j):p(a,j)),!1;if("keyup"==d&&b[l]==x)!b[j]&&p(a,j);else if(/us|ur/.test(d))q["blur"==d?A:t](y)});e.on("click mousedown mouseup mouseover mouseout touchbegin.i touchend.i",function(d){var c=d[l],e=/wn|up/.test(c)?v:m;if(!b[u]){if("click"==c)C(a,!1,!0);else{if(/wn|er|in/.test(c))q[t](e);else q[A](e+" "+v);if(k.length&&z&&e==m)k[/ut|nd/.test(c)?A:t](s)}if(G)d.stopPropagation();else return!1}})})}return this}})(jQuery); diff --git a/www/analytics/plugins/Morpheus/javascripts/morpheus.js b/www/analytics/plugins/Morpheus/javascripts/morpheus.js new file mode 100644 index 00000000..734785be --- /dev/null +++ b/www/analytics/plugins/Morpheus/javascripts/morpheus.js @@ -0,0 +1,25 @@ +$(document).ready(function () { + // do not apply on the Login page + if($('#loginPage').length) { + return; + } + + function initICheck() + { + $('input').iCheck({ + checkboxClass: 'form-checkbox', + radioClass: 'form-radio', + checkedClass: 'checked', + hoverClass: 'form-hover' + }); + } + + initICheck(); + $(document).bind('ScheduledReport.edit', initICheck); + + $('body').on('ifClicked', 'input', function () { + $(this).trigger('click'); + }).on('ifChanged', 'input', function () { + $(this).trigger('change'); + }); +}); \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/plugin.json b/www/analytics/plugins/Morpheus/plugin.json new file mode 100644 index 00000000..2bb60951 --- /dev/null +++ b/www/analytics/plugins/Morpheus/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "Morpheus", + "description": "Morpheus is the default theme of Piwik 2 designed to help you focus on your analytics. In Greek mythology, Morpheus is the God of dreams. In the Matrix movie, Morpheus is the leader of the rebel forces who fight to awaken humans from a dreamlike reality called The Matrix. ", + "theme": true, + "stylesheet": "stylesheets/theme.less", + "javascript": ["javascripts/jquery.icheck.min.js", "javascripts/morpheus.js"] +} \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/stylesheets/admin.less b/www/analytics/plugins/Morpheus/stylesheets/admin.less new file mode 100644 index 00000000..397b8731 --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/admin.less @@ -0,0 +1,118 @@ +.Menu--admin { + .Menu-tabList { + .border-radius(0px); + border-color: @gray; + background-image: none; + padding-left: 0; + border-top: 0; + > li { + padding-bottom: 0px; + > span { + color: @brand-black; + .font-default(18px, 26px); + border-top: 1px solid @gray; + border-bottom: 1px solid @gray; + padding: 12px 15px; + } + ul { + li { + a { + color: @silver-40 !important; + padding: 0.6em 1.1em; + &:hover { + color: @brand-black; + text-decoration: none; + } + } + } + } + } + } +} + +.admin { + h2 { + border-bottom: 1px solid @gray; + margin-right:0; + } + h3 { + color: @brand-black; + .font-default(18px, 24px); + font-weight: normal; + } + a:hover { + text-decoration:underline; + } +} + +.ui-state-highlight { + border-color: @silver-80 !important; + background: @silver-95 !important; + .ui-icon { + background-image: url('plugins/Morpheus/images/info.png'); + background-position: 0 0; + } +} + +.adminTable { + td { + padding: 0; + } + label { + cursor: pointer; + min-height: 30px; + } + + .sites_autocomplete { + position: static !important; + } +} + +.sites_autocomplete { + vertical-align: middle; +} + +#loadingError { + color: @brand-red; + font-weight: normal; +} + +.sites_autocomplete .custom_select .custom_select_block .custom_select_container .custom_select_ul_list { + margin-top: 5px; + padding-bottom: 0; +} + +.form-description { + margin-left: 0; +} + +.adminTable a { + color: @brand-blue; +} + +.addRowSite, +.addrow { + cursor: pointer; +} + +.addrow { + margin-top: 10px; +} + +.addRowSite { + display: inline-block; + margin: 5px 0; + &:before { + content: url(plugins/Morpheus/images/add.png) !important; + } +} + +code { + border-color: @silver-80; + border-left: 5px solid @brand-red; +} + +#geoipdb-screen1>div>p { + line-height: 1.4em; + height: 6em; +} \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/stylesheets/charts.less b/www/analytics/plugins/Morpheus/stylesheets/charts.less new file mode 100644 index 00000000..654a1e52 --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/charts.less @@ -0,0 +1,158 @@ +// bar graph colors +.bar-graph-colors[data-name=grid-background] { + color: @theme-color-background-base; +} + +.bar-graph-colors[data-name=grid-border] { + color: #f00; +} + +.bar-graph-colors[data-name=grid-border] { + color: @theme-color-background-smallContrast; +} + +.bar-graph-colors[data-name=series1] { + color: @graph-colors-data-series1; +} + +.bar-graph-colors[data-name=series2] { + color: @graph-colors-data-series2; +} + +.bar-graph-colors[data-name=series3] { + color: @graph-colors-data-series3; +} + +.bar-graph-colors[data-name=series4] { + color: @graph-colors-data-series4; +} + +.bar-graph-colors[data-name=series5] { + color: @graph-colors-data-series5; +} + +.bar-graph-colors[data-name=series6] { + color: @graph-colors-data-series6; +} + +.bar-graph-colors[data-name=series7] { + color: @graph-colors-data-series7; +} + +.bar-graph-colors[data-name=series8] { + color: @graph-colors-data-series8; +} + +.bar-graph-colors[data-name=ticks] { + color: #ccc; +} + +.bar-graph-colors[data-name=single-metric-label] { + color: #666; +} + +// pie graph colors +.pie-graph-colors[data-name=grid-background] { + color: @theme-color-background-base; +} + +.pie-graph-colors[data-name=grid-border] { + color: @theme-color-background-smallContrast; +} + +.pie-graph-colors[data-name=series1] { + color: @graph-colors-data-series1; +} + +.pie-graph-colors[data-name=series2] { + color: @graph-colors-data-series2; +} + +.pie-graph-colors[data-name=series3] { + color: @graph-colors-data-series3; +} + +.pie-graph-colors[data-name=series4] { + color: @graph-colors-data-series4; +} + +.pie-graph-colors[data-name=series5] { + color: @graph-colors-data-series5; +} + +.pie-graph-colors[data-name=series6] { + color: @graph-colors-data-series6; +} + +.pie-graph-colors[data-name=series7] { + color: @graph-colors-data-series7; +} + +.pie-graph-colors[data-name=series8] { + color: @graph-colors-data-series8; +} + +.pie-graph-colors[data-name=ticks] { + color: #ccc; +} + +.pie-graph-colors[data-name=single-metric-label] { + color: #444; +} + +//line chart colors + +// evolution graph colors + +.evolution-graph-colors[data-name=series1] { + color: @graph-colors-data-series1; +} + +.evolution-graph-colors[data-name=series2] { + color: @graph-colors-data-series2; +} + +.evolution-graph-colors[data-name=series3] { + color: @graph-colors-data-series3; +} + +.evolution-graph-colors[data-name=series4] { + color: @graph-colors-data-series4; +} + +.evolution-graph-colors[data-name=series5] { + color: @graph-colors-data-series5; +} + +.evolution-graph-colors[data-name=series6] { + color: @graph-colors-data-series6; +} + +.evolution-graph-colors[data-name=series7] { + color: @graph-colors-data-series7; +} + +.evolution-graph-colors[data-name=series8] { + color: @graph-colors-data-series8; +} + +.evolution-graph-colors[data-name=ticks] { + color: #ccc; +} + +.evolution-graph-colors[data-name=grid-background] { + color: #fff; +} + +.evolution-graph-colors[data-name=grid-border] { + color: #f00; +} + +.evolution-graph-colors[data-name=ticks] { + color: #ccc; +} + +.evolution-graph-colors[data-name=single-metric-label] { + color: #666; +} + diff --git a/www/analytics/plugins/Morpheus/stylesheets/colors.less b/www/analytics/plugins/Morpheus/stylesheets/colors.less new file mode 100644 index 00000000..b5d973fa --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/colors.less @@ -0,0 +1,58 @@ +@white: #fff; +@silver: #999; +@silverDark: #333; +@gray: #ccc; +@lightGray: #f0f0f0; +@hr: #eee; + +@articleHeader: #0c0c0c; +@quoteText: #999; +@tabActiveBackground: #f4f4f4; + +//new colors define +@black: #000; +@silver-14: lighten(@black, 14%); +@silver-20: lighten(@black, 20%); +@silver-20: lighten(@black, 30%); +@silver-40: lighten(@black, 40%); +@silver-50: lighten(@black, 50%); +@silver-60: lighten(@black, 60%); +@silver-70: lighten(@black, 70%); +@silver-80: lighten(@black, 80%); +@silver-85: lighten(@black, 85%); +@silver-90: lighten(@black, 90%); +@silver-95: lighten(@black, 95%); + +@brand-black: #0d0d0d; +@brand-blue: #1e93d1; +@brand-red: #d4291f; +@brand-social-green: #009874; +@brand-social-blue: #3b5998; +@brand-social-blue2: #00aced; +@brand-social-lightblue: #1c87bd; +@brand-orange: #ff9600; + +//charts +@theme-color-background-base: #fff; +@theme-color-background-contrast: #5F5A60; +@theme-color-background-smallContrast: #202020; +@theme-color-background-lighter: #888; +@theme-color-base-series: #ee3024; + +@graph-colors-data-series1: #d4291f; +@graph-colors-data-series2: #1f78b4; +@graph-colors-data-series3: #ff7f00; +@graph-colors-data-series4: #33a02c; +@graph-colors-data-series5: #6a3d9a; +@graph-colors-data-series6: #b15928; +@graph-colors-data-series7: #fdbf6f; +@graph-colors-data-series8: #cab2d6; +/* +Qualitative data color series inspired from colorbrewer2.org/ +next ones could be: #cab2d6 #ffff99 # #b2df8a +*/ + +.color-red { + color: @defaultRed; +} + diff --git a/www/analytics/plugins/Morpheus/stylesheets/components.less b/www/analytics/plugins/Morpheus/stylesheets/components.less new file mode 100644 index 00000000..9b035b7a --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/components.less @@ -0,0 +1,362 @@ +//colors calendar +@calendarHeaderBackground: #fff; +@calendarHeaderColor: #999; +@calendarCurrentStateHover: #f5f5f5; +@calendarBorder: #ccc; +.ui-datepicker { + + th, th.ui-datepicker-week-end { + background: @calendarHeaderBackground !important; + color: @calendarHeaderColor !important; + } + + .ui-state-default { + border-color: @calendarBorder !important; + background: @brad-black !important; + } + + .ui-datepicker-header { + background: @calendarHeaderBackground !important; + border-color: @gray; + border-bottom-width: 0px; + } + + .ui-datepicker-calendar { + border: 1px solid @gray; + thead { + border-bottom: 1px solid @gray; + } + } + + .ui-datepicker-title select { + font-size: 10px; + } +} + +.ui-datepicker td.ui-datepicker-current-period a.ui-state-default, td .ui-state-active, .ui-datepicker td.ui-datepicker-current-period a.ui-state-active, .ui-datepicker td.ui-datepicker-week-end .ui-state-active, .ui-datepicker td.ui-datepicker-other-month.ui-datepicker-current-period { + background: @brand-black !important; +} + +.ui-datepicker td.ui-datepicker-current-period a.ui-state-default, td .ui-state-active, .ui-datepicker td.ui-datepicker-current-period a.ui-state-active, .ui-datepicker td.ui-datepicker-week-end .ui-state-active, .ui-datepicker td.ui-datepicker-other-month.ui-datepicker-current-period { + background: @calendarCurrentStateHover; +} + +//add new segment +.add_new_segment, +#calendarRangeApply { + font-size: 12px !important; + padding: 0 10px !important; + margin-left: 0px !important; +} + +.segment-element { + background: @white; + border-color: @silver-80; + + .segment-add-row { + .border-radius(5px); + } + + .segment-nav { + h4.visits { + background: url('plugins/Morpheus/images/segment-users.png') no-repeat 3px 0px; + } + } + + .custom_select_search { + input { + margin-top: -10px; + } + } + + .segment-content { + h3 { + font-weight: normal; + .font-default(13px, 15px); + color: @brand-black; + } + + .segment-add-row > div a span, + .segment-add-or > div a span { + color: @brand-red; + text-shadow: none; + } + + .segment-input { + select, input { + .font-default(12px, 14px); + color: @brand-black; + font-weight: 500; + margin: 0px; + min-height: auto; + } + } + } + .segment-nav { + div > ul > li { + padding: 5px 0; + a { + font-weight: normal; + color: #333333; + text-shadow: none; + } + li { + padding: 3px 0 ; + &:hover { + background: #f2f2f2; + border: 0; + padding: 4px 0 3px; + + a { + border: 0; + background-color: #f2f2f2; + padding-right: 15px; + + } + } + } + + } + } + + .segment-top { + .font-default(10px, 12px); + color: #444; + text-transform: uppercase; + + h4 { + color: @silver-30; + text-transform: uppercase; + .font-default(10px, 12px); + a.dropdown { + color: @silver-30; + text-transform: uppercase; + .font-default(10px, 12px); + } + } + + a.dropdown { + color: @brand-black; + background: url('plugins/Zeitgeist/images/sort_subtable_desc.png') 100% -2px no-repeat; + &.ui-autocomplete-input { + background-position: 100% -2px; + } + } + } + .segment-footer { + background: @white; + button { + height: auto; + font-weight: normal; + min-width: 100px; + } + + a.delete { + color: @brand-red; + } + .segmentFooterNote, .segment-element .segment-footer .segmentFooterNote a { + color: #444; + } + } +} + +.available_segments a.dropdown { + color: @brand-black !important; + text-transform: uppercase; + .font-default(10px, 12px); +} + + +#periodString { + border: 1px solid @silver-80; + .border-radius(0px); + background: #fff; + &:hover { + background: #fff; + border-color: @silver-80; + } + + select { + min-height: 0; + } + + .calendar-icon { + width: 17px; + height: 17px; + } + + label.selected-period-label { + text-decoration: none !important; + } + + h6 { + .font-default(13px, 16px); + font-weight: normal; + color: @brand-black; + } + + #periodMore { + .period-range { + .ui-datepicker-header { + background: red; + } + } + } + + #date { + .border-radius(0px); + background-color: #fff; + .box-shadow(~"inset 1px 1px 3px #d8d8d8"); + padding: 8px 10px; + color: @silver-20; + text-transform: uppercase; + .font-default(10px, 12px); + + strong { + color: @brand-black; + + } + } +} + +#header_message { + border: 1px solid @silver-80; + padding: 8px 10px 8px 10px; + height: auto; + background: #fff; + .border-radius(0px); + .header_short { + .font-default(10px, 12px); + text-transform: uppercase; + } + + .header_full { + .font-default(12px, 18px); + } +} + +.ui-menu { + .ui-menu-item { + a { + color: @silver-20; + text-transform: uppercase; + .font-default(10px, 18px); + padding: 2px; + } + } +} + +.loadingPiwikBelow, +.loadingPiwik { + .font-default(10px, 12px); + color: @silver-60; + font-weight: normal; +} + +.annotations { + table { + td { + .font-default(12px, 14px) !important; + color: @brand-black; + padding: 6px 5px; + } + } +} + +//reports box +.reports { + border: 1px solid @gray; + .border-radius(6px); + + h2 { + background: #f2f2f2; + border-bottom: 1px solid @gray; + padding: 11px 15px 10px; + } +} + +.jqplot-seriespicker-popover { + .box-shadow-1(none); +} + +// transition box +#Transitions_Container { + #Transitions_CenterBox { + border: 1px solid @gray; + .box-shadow-1(none); + .border-radius(6px); + margin: 27px 0 0 319px; + width: 258px; + height: 390px; + background: #fff; + h2 { + color: #1e93d1; + border-bottom: 1px solid @gray; + font-weight: normal; + padding: 15px; + background: #f5f5f5; + .border-radius(6px 6px 0 0); + } + + .Transitions_CenterBoxMetrics { + padding: 0; + p { + font-family: Verdana, sans-serif; + } + p.Transitions_Margin { + text-align: left; + .font-default(15px, 20px); + border-bottom: 1px solid @gray; + padding: 13px; + .Transitions_Metric { + font-weight: normal; + } + } + .Transitions_IncomingTraffic { + padding: 0 15px; + + h3 { + font-weight: normal; + color: #000; + .font-default(15px, 20px); + margin-bottom: 10px; + } + } + .Transitions_OutgoingTraffic { + padding: 0 15px; + h3 { + font-weight: normal; + color: #000; + .font-default(15px, 20px); + margin-bottom: 10px; + } + } + } + } + + .Transitions_TitleOfOpenGroup { + color: #000; + .font-default(15px, 20px); + font-weight: normal; + margin-top: -4px; + } +} + + +table.dataTable tr td .dataTableRowActions { + a.rightmost, a { + background-color: #f2f2f2 !important; + margin: 6px 0px 6px 0; + padding: 0px 4px 0px 0px; + } +} + +table.dataTable tr td.labeleven .dataTableRowActions { + a.rightmost, a { + background-color: #fff !important; + } +} + +table.dataTable th .columnDocumentation { + color: @silver-90; +} diff --git a/www/analytics/plugins/Morpheus/stylesheets/forms.less b/www/analytics/plugins/Morpheus/stylesheets/forms.less new file mode 100644 index 00000000..5c5562e3 --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/forms.less @@ -0,0 +1,300 @@ +input, select, textarea { + color: @brand-black; + .border-radius(0px); + margin-left: 0; + padding: 8px 10px; + min-height: 30px; + .box-sizing(border-box); + background: #fff; +} +button, +.add-trusted-host, +input[type="submit"], +button[type="button"], +.submit { + .border-radius(3px) !important; + background: none !important; + background-color: #d3291f !important; + .box-shadow(~"0 1px 1px rgba(13,13,13,.3), inset 0 -1px 0 rgba(13,13,13,.1)"); + #gradient > .vertical(rgba(255,255,255,.15), rgba(255,255,255,0)) !important; + .font-default(12px, 16px) !important; + color: #fff !important;; + font-weight: normal; + padding: 5px 15px !important; + text-align: center; + cursor: pointer; + border: 0px !important; + &:hover { + background: #ff9600 !important; + background-color: #ff9600 !important; + } + + em { + font-style: normal; + } + &.ui-dialog-titlebar-close { + &:hover { + background: none !important; + background-color: none !important; + } + } +} + +.sites_autocomplete { + input { + min-height: 0; + } + .custom_select { + border-color: @silver-80; + .border-radius(0px); + background: #fff; + .box-shadow(~"inset 1px 1px 3px #d8d8d8"); + padding: 9px 5px 7px; + color: @silver-20; + text-transform: uppercase; + .font-default(10px, 12px); + min-height: 30px; + .box-sizing(border-box); + a { + color: @silver-20; + } + .custom_select_block { + .custom_select_container{ + .custom_select_ul_list { + margin-top: 20px; + } + } + } + } +} + +.ajaxError { + background: @brand-red; + color: #fff; + border:0px; + .border-radius(6px); + padding: 20px 25px; + text-align: center; + font-weight: normal; +} + + +.sites_autocomplete--dropdown { + .custom_select_main_link:not(.loading):before { + color: @brand-red; + font-size: 0.7em; + top: 2px; + right: 5px; + content: ''; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid @brand-red; + } +} + + +.limitSelection { + > ul { + position: relative; + top: -6px; + > li { + width: 25px; + } + } + > div { + .border-radius(2px); + background-color: #fff; + .font-default(10px, 12px); + background: none; + padding: 2px 14px 2px 1px; + span { + position: relative; + background: none; + color: @brand-black; + display: block; + padding-right: 3px; + &:after { + content: ''; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid @brand-red; + position: absolute; + top: 7px; + right: -9px; + } + } + } + + span { + color: @brand-black; + font-weight: normal; + } + + &.visible { + > div { + background-image: none; + > span:after { + content: ''; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid #d4291f; + border-top-color: transparent; + position: absolute; + top: 1px; + right: -9px; + } + } + } +} + +//checkboxes and radio +.form-radio, .form-checkbox { + display: inline-block; + padding: 0; + margin: 0; +} +.form-radio, .form-checkbox { + height: 15px; + width: 15px; + float: left; + cursor: pointer; + background: url('plugins/Morpheus/images/forms-sprite.png'); + margin: 2px 5px 2px 0; +} + +.form-radio.disabled { + background-position:15px -16px; +} + +.form-checkbox.disabled { + background-position:15px 0px; +} + +.form-radio + label, .form-checkbox + label { + float:left; + display:inline-block; + + + .form-description { + margin-left:1em; + } + + + br, + .form-description + br { + clear:both; + } + + + .form-radio, + .form-checkbox { + margin-left:32px; + } +} + +fieldset > .form-radio + label { // assumes
          after the label + display: inline-block; + margin-bottom: -.5em; +} + +label { + &.hover, + &:hover { + border: 0px; + } +} + +.form-radio { + background-position: 0 -16px; + + &.form-hover { + background-position: -60px -16px; + } + + &.checked { + background-position: -30px -16px; + &.form-hover { + background-position: -90px -16px; + } + } +} + +.form-checkbox { + background-position: 0 0; + + &.form-hover { + background-position: -60px 0; + } + + &.checked { + background-position: -30px 0; + &.form-hover { + background-position: -90px 0; + } + } +} + +.prettycheckbox a:focus, .prettyradio a:focus { + outline: 0 none; +} +.prettycheckbox label, .prettyradio label { + display: block; + float: left; + margin: 2px; + cursor: pointer; +} +.prettycheckbox a.disabled, .prettycheckbox label.disabled, .prettyradio a.disabled, .prettyradio label.disabled { + cursor: not-allowed; +} +.prettycheckbox a { + background-position: 0 0; +} +.prettycheckbox a:focus { + background-position: -15px 0; +} +.prettycheckbox a.checked { + background-position: -38px 0; +} +.prettycheckbox a.checked:focus { + background-position: -53px 0; +} +.prettycheckbox a.checked.disabled { + background-position: -99px 0; +} +.prettycheckbox a.disabled { + background-position: -76px 0; +} +.prettyradio a { + background-position: -129px 0; +} +.prettyradio a:focus { + background-position: -144px 0; +} +.prettyradio a.checked { + background-position: -167px 0; +} +.prettyradio a.checked:focus { + background-position: -182px 0; +} +.prettyradio a.checked.disabled { + background-position: -228px 0; +} +.prettyradio a.disabled { + background-position: -205px 0; +} + +// specific form control overrides (for iCheck) +.indented-radio-button { + margin:0; +} + +.listReports > li:after { + content:""; + display:table; + clear:both; +} + +.listReports { + .form-radio + label, .form-checkbox + label { + width: 250px; + } +} + +.small-form-description { // for tracking code generator + clear:both; +} \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/stylesheets/map.less b/www/analytics/plugins/Morpheus/stylesheets/map.less new file mode 100644 index 00000000..acf68f62 --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/map.less @@ -0,0 +1,71 @@ +.RealTimeMap-overlay, +.RealTimeMap-tooltip { + display: block; + position: absolute; + z-index: 1000; +} + +.RealTimeMap-overlay .content, +.RealTimeMap-tooltip .content { + padding: 5px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.9); +} + +.RealTimeMap-title { + top: 5px; + left: 5px; +} + +.RealTimeMap-legend { + right: 5px; + font-size: 9px; + bottom: 40px; +} + +.RealTimeMap-info { + left: 5px; + font-size: 11px; + bottom: 60px; + max-width: 42%; +} + +.RealTimeMap-info-btn { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAA3NCSVQICAjb4U/gAAAAOVBMVEX///8AAAAAAABXV1dSUlKsrKzExMTd3d3V1dXp6end3d3p6enz8/P7+/v39/f///+vqZ6oopWUjH2LPulWAAAAE3RSTlMAESIzM2Z3mZmqqrvd7u7/////UUgTXgAAAAlwSFlzAAALEgAACxIB0t1+/AAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNXG14zYAAAAYdEVYdENyZWF0aW9uIFRpbWUAMDMuMDEuMjAxM8rVeD8AAABnSURBVBiVhY/LFoAgCEQZ0p4W6f9/bIJ4slV3oTIeBoaICGADIAO8ibEwWn2IcwVovev7znqmCYRon9kEWUFvg3IysXyIXSil3fOvELupC9XUx7pQx/piDV1sVFLwMNF80sw97hj/AXRPCjtYdmhtAAAAAElFTkSuQmCC); + width: 16px; + height: 16px; + cursor: pointer; + left: 5px; + bottom: 40px; + position: absolute; + z-index: 1000; + opacity: 0.9; + +} + +.realTimeMap_overlay { + position: absolute; + left: 10px; + bottom: 6px; + font-size: 12px; + z-index: 10; + text-shadow: 1px 1px 1px #FFFFFF, -1px 1px 1px #FFFFFF, 1px -1px 1px #FFFFFF, -1px -1px 1px #FFFFFF, 1px 1px 1px #FFFFFF, -1px 1px 1px #FFFFFF, 1px -1px 1px #FFFFFF, -1px -1px 1px #FFFFFF; +} + +.realTimeMap_datetime { + bottom: 24px; + color: #887; + font-size: 14px; +} + +.realtime-map[data-name=white-fill] { + color: #f2f2f2 !important; +} + +.realtime-map[data-name=visit-stroke] { + color: #fff !important; +} + +.realtime-map[data-name=white-bg] { + color: #808080 !important; +} \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/stylesheets/mixins.less b/www/analytics/plugins/Morpheus/stylesheets/mixins.less new file mode 100644 index 00000000..ceeec230 --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/mixins.less @@ -0,0 +1,80 @@ +.clearfix { + *zoom: 1; + &:after { + content: ""; + display: table; + clear: both; + } + &:before { + content: ""; + display: table; + } +} + +.font-default(@size: 13px, @line: 16px) { + font-family: Verdana, sans-serif; + font-size: @size; + line-height: @line; +} + +.border-radius (@radius: 6px) { + -webkit-border-radius: @radius; + -moz-border-radius: @radius; + border-radius: @radius; + + -moz-background-clip: padding; + -webkit-background-clip: padding-box; + background-clip: padding-box; +} + +.box-shadow (@string, @string1) { + -webkit-box-shadow: @string @string1; + -moz-box-shadow: @string @string1; + box-shadow: @string @string1; +} + +.box-shadow-1 (@string) { + -webkit-box-shadow: @string; + -moz-box-shadow: @string; + box-shadow: @string; +} + +.no-box-shadow () { + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} + +#gradient { + + .horizontal(@start-color: #555, @end-color: #333, @start-percent: 0%, @end-percent: 100%) { + background-image: -webkit-gradient(linear, @start-percent top, @end-percent top, from(@start-color), to(@end-color)); // Safari 4+, Chrome 2+ + background-image: -webkit-linear-gradient(left, color-stop(@start-color @start-percent), color-stop(@end-color @end-percent)); // Safari 5.1+, Chrome 10+ + background-image: -moz-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // FF 3.6+ + background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10 + background-repeat: repeat-x; + filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)",argb(@start-color),argb(@end-color))); // IE9 and down + } + + .vertical(@start-color: #555, @end-color: #333, @start-percent: 0%, @end-percent: 100%) { + background-image: -webkit-gradient(linear, left @start-percent, left @end-percent, from(@start-color), to(@end-color)); // Safari 4+, Chrome 2+ + background-image: -webkit-linear-gradient(top, @start-color, @start-percent, @end-color, @end-percent); // Safari 5.1+, Chrome 10+ + background-image: -moz-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // FF 3.6+ + background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10 + background-repeat: repeat-x; + filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)",argb(@start-color),argb(@end-color))); // IE9 and down + } +} + +.box-sizing(@boxmodel) { + -webkit-box-sizing: @boxmodel; + -moz-box-sizing: @boxmodel; + box-sizing: @boxmodel; +} + +.opacity(@opacity) { + opacity: @opacity; + // IE8 filter + @opacity-ie: (@opacity * 100); + filter: ~"alpha(opacity=@{opacity-ie})"; +} \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/stylesheets/popups.less b/www/analytics/plugins/Morpheus/stylesheets/popups.less new file mode 100644 index 00000000..c7b9352a --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/popups.less @@ -0,0 +1,46 @@ +.ui-dialog-title { + color: @brand-black; + font-weight: normal; +} + +.ui-dialog .ui-widget-header { + color: @brand-black; + .font-default(18px, 24px); + font-weight: bold; +} + +#feedback-sent, +#feedback-faq { + a { + color: @brand-blue; + } +} + +.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { + border: 0px !important; + .ui-icon { + .opacity(0.5); + } + &:hover { + .opacity(1); + background: none !important; + } +} + +button.ui-state-default, .ui-widget-content button.ui-state-default, .ui-widget-header button.ui-state-default { + &:hover { + background: @brand-red !important; + } +} + +.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { + border: 0px !important; +} + +.ui-menu .ui-menu-item a.ui-state-focus { + background: @silver-90; +} + +button:hover, .add-trusted-host:hover, input[type="submit"]:hover, button[type="button"]:hover, .submit:hover { + background-color: @brand-red !important; +} \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/stylesheets/theme.less b/www/analytics/plugins/Morpheus/stylesheets/theme.less new file mode 100644 index 00000000..b02b0fb0 --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/theme.less @@ -0,0 +1,910 @@ +@import "colors.less"; +@import "mixins.less"; +@import "typography.less"; +@import "forms.less"; +@import "components.less"; +@import "popups.less"; +@import "admin.less"; +@import "tooltip.less"; +@import "charts.less"; +@import "map.less"; + +@root: "/plugins/Morpheus"; + +body { + font-family: Verdana, sans-serif; +} + +.pageWrap { + padding-left: 10px; + padding-right: 10px; + border: 0px; +} + +#content { + p { + margin-left: 0px; + margin-right: 0px; + .font-default(13px, 18px); + } +} + +#leftcolumn { + width: 49%; + margin-right: 1%; +} + +#rightcolumn { + float: left; + width: 50%; +} + +#languageSelection .ui-autocomplete-input { + position: relative; + &:after { + content: ''; + position: absolute; + top: 5px; + right: -13px; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid @brand-blue; + } +} + +#languageSelect { + border: 1px solid @silver-80 !important; + li { + a { + color: @brand-black !important; + &:hover { + background: @silver-80 !important; + color: @brand-black !important; + } + } + } +} + +.entityTable { + .border-radius(0px)!important; + border:1px solid @silver-80!important; + tr { + &.first { + th { + border-left: 0px !important; + } + } + } +} + +table.entityTable tr td a:hover { + text-decoration: underline !important; +} + +#root { + margin: 0; + padding: 0; + + #header { + margin: 0 15px; + #topBars { + a { + color: @brand-blue; + text-decoration: none; + } + } + + .topBarElem { + padding: 0 3px; + color: @silver-40; + strong { + font-weight: normal; + color: @brand-black; + } + } + } + + .top_bar_sites_selector { + margin-right: 10px; + label { + padding-top: 9px; + .font-default(13px, 15px); + } + } + + .Menu--dashboard { + border-top: 1px solid @silver-80; + border-bottom: 1px solid @silver-80; + > .Menu-tabList { + margin: 0; + a { + .font-default(18px, 22px); + padding: 14px 28px 11px; + } + > li { + .border-radius(0px); + border: 0; + background: none; + border-right: 1px solid @silver-80; + margin-bottom: -1px; + border-bottom: 1px solid #ccc; + + > ul { + li { + a { + color: @silver-40; + .font-default(15px, 18px); + padding: 14px 22px 11px; + } + + &.sfHover { + a { + color: @brand-black; + font-weight: normal; + } + + } + } + } + + &.sfActive { + > a { + color: @brand-black; + text-decoration: none; + position: relative; + &:after { + content: ''; + position: absolute; + bottom: -4px; + left: 0; + width: 100%; + height: 4px; + background: @brand-red; + } + } + + } + + &.sfHover { + > a { + border: 0; + text-decoration: none; + color: @brand-black; + } + } + } + } + } + + .nav_sep { + background: @silver-95; + min-height: 57px; + .border-radius(0px); + border-color: @silver-80 !important; + border-top: 0px !important; + border-right: 0px !important; + border-left: 0px !important; + .box-shadow-1(~"inset 0 2px 4px #d8d8d8"); + } + + .widgetize { + width: auto; + } +} + +.dashboardSettings { + border: 1px solid @silver-80; + background: #fff; + z-index: 10; + padding: 8px 10px 8px 10px; + .border-radius(0px); + .font-default(10px, 12px); + > span { + position: relative; + background: none; + text-transform: uppercase; + &:after { + content: ''; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid @brand-red; + position: absolute; + top: 3px; + right: 0; + } + } + + ul.submenu { + margin-left: 0; + li { + list-style-type: none; + > div { + .font-default(11px, 14px); + color: @brand-black; + } + text-transform: none; + color: @silver-20; + &.widgetpreview-choosen { + color: @brand-black; + font-weight: normal; + background: @silver-95; + position: relative; + &:after { + position: absolute; + content: ''; + top: 6px; + right: 10px; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid @brand-red; + } + } + } + } +} + +.segmentEditorPanel { + border: 1px solid @silver-80; + background: #fff; + padding: 8px 10px 8px 10px; + .border-radius(0px); + .segmentationTitle { + background: none; + text-transform: uppercase; + .font-default(10px, 12px); + position: relative; + &:after { + content: ''; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid @brand-red; + position: absolute; + top: 3px; + right: 0; + } + } + .dropdown-body { + background:#fff; + padding:0 10px; + .border-radius(0px); + border: 1px solid @silver-80; + border-top-width: 0px; + } + &:hover .dropdown-body { + background:#fff; + border-color: @silver-80; + } + .segmentationContainer { + > span > strong { + color: @brand-red; + } + .submenu { + li { + font-weight: normal; + .font-default(12px, 14px) !important; + color: @silver-30; + } + + ul li:hover { + color: @brand-black; + } + + } + + .add_new_segment { + .submit(); + .font-default(12px, 16px) !important; + width: auto; + padding: 7px 10px !important; + } + } + + .segmentListContainer { + .segmentationContainer { + .submenu { + li { + .font-default(13px, 16px); + color: #444; + &:hover { + border-color: @silver-80 !important; + background: #fff !important; + .border-radius(0px); + } + } + } + } + } +} + +.segmentEditorPanel:hover, .dashboardSettings:hover { + background: #fff; + border: 1px solid @silver-80; +} + +/* Iframed Embed dashboard style */ +#standalone { + + #Dashboard:hover ul { + background: #f1f0eb; + border-color: #a9a399; + } + + #Dashboard { + ul { + + background: #fff; + border: 1px solid @silver-80; + padding: 8px 10px 8px 10px; + color: #444; + height: 12px; + line-height:0.5em; + .border-radius(0px); + } + > ul > li:hover, + > ul > li:hover a, + > ul > li.sfHover, + > ul > li.sfHover a { + color: @brand-red; + } + > ul > li.sfHover, + > ul > li.sfHover a { + font-weight: normal; + } + } +} + +.rss-title { + color: @brand-blue !important; + font-weight: normal; + .font-default(15px, 18px); + text-decoration: none; + display: block; + width: 100%; +} + +.rss-date { + display: block; + color: @silver-60; + .font-default(11px, 14px); + margin-top: 15px; +} + +.rss-description { + p { + margin: 0; + color: @silver-40; + .font-default(13px, 18px); + } +} + +table.dataTable { + thead { + tr { + th { + background: #fff; + text-transform: uppercase; + color: @brand-black; + .font-default(10px, 12px); + padding-top: 12px; + padding-bottom: 12px; + border-bottom: 1px solid @silver-85; + &.columnSorted { + background: @silver-95 !important; + .sortIcon { + margin-top: -1px; + } + } + } + } + } + tr { + td { + border-color: @silver-85 !important; + color: @brand-black; + background: @silver-95; + + &:not(.value) { + .font-default(13px, 16px); + } + + .label { + line-height: normal; + } + + .value { + .font-default(13px, 16px); + } + + &:first-child { + border-left: 0px; + } + + &.labelodd, + &.columnodd { + background-color: @silver-95 !important; + } + + a { + text-decoration: none !important; + color: @brand-blue; + width: inherit; + } + + div.label, + a.label, + span.label { + word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + width: inherit; + display: inline-block; + } + } + & td.columneven, + & td.labeleven, + &:hover > td.labeleven, + &:hover > td.columneven, + &.subDataTable:hover > td.labeleven, + &.subDataTable:hover > td.columneven, + td.cellSubDataTable { + background-color: #fff !important; + } + } + + &.entityTable tr { + td { + background-color: #fff !important; + } + &.inactive-plugin td, + &:hover td { + background-color: @silver-95 !important; + } + } + + &.dataTableVizVisitorLog { + + } +} + +table.dataTable tr.subDataTable:hover > td, table.dataTable tr.subDataTable:hover > td .dataTableRowActions { + background-color: #d9d9d9; +} + +table.dataTable .dataTableRowActions { + margin-top: -8px; +} + +.visitsLiveFooter { + padding-left: 10px; + a.rightLink { + .font-default(13px, 16px); + margin-top: 10px; + margin-bottom: 10px; + padding-right: 10px; + } +} + +.dataTableFooterIcons { + padding: 6px 8px; + border-top: 1px solid @silver-80; +} + +#dashboard .dataTableFooterIcons { + padding: 6px 8px 0px; +} + +div.sparkline { + display: block; + width: 100%; + border-bottom: 0; + margin-bottom: 10px; + &:hover { + border-bottom: 0; + } +} + +.widgetpreview-base li.widgetpreview-choosen { + background: @silver-95; + position: relative; + color: @brand-black; + font-weight: normal; + text-transform: none; + &:after { + position: absolute; + content: ''; + top: 6px; + right: 10px; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 5px solid @brand-red; + } +} + +.dataTableNext, .dataTablePrevious { + color: @brand-blue; + text-transform: uppercase; + .font-default(10px, 12px); +} + +.UserCountryMap-info-btn { + z-index: 1; +} + + +.widget { + border: 1px solid @silver-80; + .border-radius(); + .font-default(13px, 18px); + .box-shadow-1(0 1px 1px rgba(204,204,204,.5)); + + h2 { + font-weight: normal; + } + + &.default { + margin-left: 0; + margin-right: 0; + .widgetTop { + cursor: default !important; + } + } + + .widgetTop { + background: @silver-95; + border-bottom: 1px solid @silver-80; + .widgetName { + .font-default(15px, 18px); + color: @brand-black; + text-shadow: none; + padding: 15px 15px 10px; + } + } + + .widgetText { + padding: 10px; + } + + .pk-emptyDataTable { + .font-default(12px, 16px); + text-transform: none; + } + + .dataTableFooterIcons { + border-top: 1px solid @silver-80; + } +} + +.dataTableSearchPattern { + background: none; + height: auto; + input { + color: @brand-black !important; + .border-radius(0px); + border: 0px; + margin-left: 0; + padding: 8px 10px; + min-height: 30px; + .box-sizing(border-box); + border: 1px solid @silver-80; + .opacity(1); + + &[type="text"] { + width: 40% !important; + padding-right: 4%; + &:focus { + border-color: @silver-60; + } + } + + &[type="submit"] { + margin: 0; + float: none; + .font-default(10px, 12px) !important; + padding: 0px !important; + position: relative; + right: 33px; + background: no-repeat 0px 7px url('plugins/Zeitgeist/images/search_ico.png') !important; + text-indent: 999999px; + width: 17px !important; + } + } +} + +.dataTable .searchReset>img { + left: -45px; +} + +.visitor-profile a { + color: @brand-blue; + font-weight: normal; +} + +.visitor-profile-info { + .no-box-shadow() !important; +} + +.visitor-profile-latest-visit-loc { + display: inline-block; + position: absolute; + right: 4px; + top: -19px; +} + +.visitor-profile-date { + padding-top: 3px; +} + +.visitor-profile-more-info > a { + color: @brand-blue; + .font-default(12px, 16px); +} + +.visitor-profile-more-info { + margin-top: 15px; +} + +.visitor-profile-visits-container { + padding: 0; + margin: 0; + li { + margin: 0 !important; + background: @silver-95; + padding: 5px 10px !important; + border-bottom: 1px solid @silver-80; + > div { + border: 0px !important; + margin: 0 !important; + } + + &:first-child { + border-top: 1px solid @silver-80; + } + } + + ol.visitor-profile-visits { + > li { + padding: 0 0 0 !important; + span { + color: @silver-40; + .font-default(13px, 18px); + font-weight: normal; + margin-right: 20px; + } + + h2 { + margin-left: 20px; + margin-bottom: 12px; + } + + + ol { + background: #fff !important; + border-top: 0 !important; + li { + background: #fff !important; + .font-default(11px, 19px); + font-weight: normal; + color: @silver-40; + &:last-child { + border-bottom: 0px !important; + } + } + } + } + } +} + +.visitor-profile-visit-title-row { + padding-top:10px; + + &:hover { + background-color: @silver-90; + } +} + +.widget .visitor-profile .visitor-profile-info > div > div { + min-width: 100% !important; +} + + +.visitor-profile { + background: none; + .box-shadow(none); + border: 0; + .border-radius(0px); + .visitor-profile-info { + .border-radius(0px); + border: 0px; + .box-shadow(none) !important; + + > div { + border: 0px !important; + } + + .visitor-profile-image-frame { + background: none !important; + } + .visitor-profile-avatar { + > div > img { + display: none; + } + } + } + + h1 { + .font-default(15px, 20px); + font-weight: normal; + color: @brand-black; + } + + h2 { + .font-default(13px, 18px); + color: @brand-black; + } + + p { + margin-left: 0; + margin-right: 0; + .font-default(13px, 18px); + color: @silver-60; + strong { + .font-default(13px, 18px); + color: @brand-black; + font-weight: normal; + } + span { + .font-default(13px, 18px); + } + } + + + .visitor-profile-avatar { + ul { + width: 100% !important; + li { + display: block; + border: 0px; + &:first-child { + border: 0px !important; + } + span { + color: @silver-40; + .font-default(13px, 18px); + padding-left: 0; + } + + strong { + color: @brand-black; + .font-default(13px, 18px); + font-weight: normal; + } + + .visitor-profile-os, + .visitor-profile-browser { + margin-left: 0; + img { + margin-right: 5px; + } + } + } + } + } + + .visitor-profile-avatar > div:nth-child(2n) { + width: 376px; + } + + .visitor-profile-latest-visit { + .visitor-profile-latest-visit-column { + display: inline-block; + margin-left: 0; + } + + .visitor-profile-latest-visit-column:first-child { + width: 50%; + margin-right:-4px; + } + + .visitor-profile-latest-visit-column:last-child { + width: 50%; + margin-right:-4px; + } + } +} + +.widgetTop .button { + margin: 16px 8px 0 0; +} + +.widget .widgetTop:hover .button { + visibility: visible; +} + +.widget .widgetTop .button { + opacity: 0.8; + visibility: hidden; +} + +.annotationView { + .font-default(10px, 12px); + text-transform: uppercase; + color: @brand-black; +} + +.datatableFooterMessage { + .font-default(13px, 18px); + color: @silver; + font-weight: normal; +} +.multisites_asc, +.multisites_desc { + background-repeat: no-repeat; + height: 6px; +} + +.dataTableFooterIcons, .dataTableFooterIcons a { + color: @brand-blue; +} + +#visitsLive .datetime { + background: @silver-95; + border-top: 0px; +} + +.metricValueBlock input { + padding: 5px !important; +} + + +#piwik-promo-share { + border: 0px; + background: #f2f2f2; + .font-default(10px, 16px); +} + +#token_auth { + background: @silver-95; +} + +tr:hover #token_auth { + background: #FFFFF7; +} + +table#users { + .deleteuser:hover > span, + .edituser:hover > span { + text-decoration:underline; + } +} + +table#editSites { + .editSite:hover > span, + .deleteSite:hover > span { + text-decoration:underline; + } +} + +// previous style overrides +#header_message a { + text-decoration:underline; +} + +#header_message a:hover { + text-decoration:none; +} + + +#multisites table.dataTable { + thead tr th { + background: @theme-color-background-base; + } + td { + background: white; + } + tr:hover td { + background: #f2f2f2; + } + tfoot tr:hover td { + background: white; + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/stylesheets/tooltip.less b/www/analytics/plugins/Morpheus/stylesheets/tooltip.less new file mode 100644 index 00000000..770f5541 --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/tooltip.less @@ -0,0 +1,30 @@ +.ui-tooltip, +.ui-tooltip.Transitions_Tooltip_Small { + border: 0px !important; + background: rgba(0,0,0,0.9) !important; + .box-shadow-1(none) !important; + .border-radius(3px); + .ui-tooltip-content { + background: none; + color: @silver-90; + padding: 5px; + } + h1, h2, h3, h4, h5 { + font-weight: normal; + color: @white; + } +} + +.columnDocumentation { + border: 0px !important; + background: rgba(0,0,0,0.9) !important; + color: @silver-60; + .font-default(12px, 16px); + padding: 7px 10px 8px 10px; + text-transform: none !important; + .columnDocumentationTitle { + color: #fff; + font-weight: normal !important; + margin-bottom: 2px; + } +} \ No newline at end of file diff --git a/www/analytics/plugins/Morpheus/stylesheets/typography.less b/www/analytics/plugins/Morpheus/stylesheets/typography.less new file mode 100644 index 00000000..68558633 --- /dev/null +++ b/www/analytics/plugins/Morpheus/stylesheets/typography.less @@ -0,0 +1,124 @@ +h1, h2, h3, h4, h5 { + font-family: Verdana, sans-serif; +} + +a { + color: @brand-blue; + text-decoration: none; +} + +h2 { + color: @brand-black; +} + +.datatableRelatedReports { + color: @silver-40; +} + +.tableIcon { + background: none; + padding: 2px 4px 4px 4px; + &.activeIcon { + background: @silver-80; + } +} + +.exportToFormatItems { + background: @silver-80; + padding-top: 5px; + padding-bottom: 4px; + color: #000; + + a { + color: @brand-red !important; + } +} + +.dataTableFooterActiveItem { + display: none; +} + +#topApiRef { + color: @brand-black; +} + +.tableConfiguration div.configItem span.action { + color: @brand-blue; +} + +.dataTablePages { + color: @brand-black; + font-weight: normal; + .font-default(10px, 12px); +} + +.datatableRelatedReports { + color: #808080; + span { + color: @brand-black; + font-weight: normal; + } +} +.dataTableSearchPattern { + img { + top: 7px !important + } +} + +.tagCloud span, .tagCloud span a { + color: @brand-blue !important; +} + +ul.widgetpreview-widgetlist, ul.widgetpreview-categorylist { + color: #4d4d4d; +} + + +.dataTableRowActions { + background: none !important; +} + +a#Overlay_Title { + color: #4d4d4d !important; + .font-default(12px, 14px) !important; + margin-left: 15px !important; +} + +a { + color: @brand-blue; +} + +a.rowevolution-startmulti { + color: @brand-blue !important; +} + +.Piwik_Popover_Loading_Subject { + color: @brand-blue !important; +} + +.segmentFooterNote { + margin-top: -3px; + font-family: Verdana, sans-serif !important; + a { + color: @brand-blue !important; + } +} + +body > a.ddmetric { + background-color: @lightGray !important; + border-color: @gray !important; + color: #000 !important; + font-family: Verdana, sans-serif !important; +} + +.segment-element .segment-content .segment-or, +.segment-element .segment-content .segment-add-or, +.segment-element .segment-content .segment-row { + background-color: @lightGray !important; +} + +.segmentEditorPanel *, +.segment-element .segment-content .segment-input select, +.segment-element .segment-content .segment-input input { + font-family: Verdana, sans-serif !important; +} diff --git a/www/analytics/plugins/MultiSites/API.php b/www/analytics/plugins/MultiSites/API.php new file mode 100755 index 00000000..102529e4 --- /dev/null +++ b/www/analytics/plugins/MultiSites/API.php @@ -0,0 +1,496 @@ + array( + self::METRIC_TRANSLATION_KEY => 'General_ColumnNbVisits', + self::METRIC_EVOLUTION_COL_NAME_KEY => 'visits_evolution', + self::METRIC_RECORD_NAME_KEY => self::NB_VISITS_METRIC, + self::METRIC_IS_ECOMMERCE_KEY => false, + ), + self::NB_ACTIONS_METRIC => array( + self::METRIC_TRANSLATION_KEY => 'General_ColumnNbActions', + self::METRIC_EVOLUTION_COL_NAME_KEY => 'actions_evolution', + self::METRIC_RECORD_NAME_KEY => self::NB_ACTIONS_METRIC, + self::METRIC_IS_ECOMMERCE_KEY => false, + ) + ); + + /** + * Returns a report displaying the total visits, actions and revenue, as + * well as the evolution of these values, of all existing sites over a + * specified period of time. + * + * If the specified period is not a 'range', this function will calculcate + * evolution metrics. Evolution metrics are metrics that display the + * percent increase/decrease of another metric since the last period. + * + * This function will merge the result of the archive query so each + * row in the result DataTable will correspond to the metrics of a single + * site. If a date range is specified, the result will be a + * DataTable\Map, but it will still be merged. + * + * @param string $period The period type to get data for. + * @param string $date The date(s) to get data for. + * @param bool|string $segment The segments to get data for. + * @param bool|string $_restrictSitesToLogin Hack used to enforce we restrict the returned data to the specified username + * Only used when a scheduled task is running + * @param bool|string $enhanced When true, return additional goal & ecommerce metrics + * @param bool|string $pattern If specified, only the website which names (or site ID) match the pattern will be returned using SitesManager.getPatternMatchSites + * @return DataTable + */ + public function getAll($period, $date, $segment = false, $_restrictSitesToLogin = false, $enhanced = false, $pattern = false) + { + Piwik::checkUserHasSomeViewAccess(); + + $idSites = $this->getSitesIdFromPattern($pattern); + + if (empty($idSites)) { + return new DataTable(); + } + return $this->buildDataTable( + $idSites, + $period, + $date, + $segment, + $_restrictSitesToLogin, + $enhanced, + $multipleWebsitesRequested = true + ); + } + + /** + * Fetches the list of sites which names match the string pattern + * + * @param $pattern + * @return array|string + */ + private function getSitesIdFromPattern($pattern) + { + $idSites = 'all'; + if (empty($pattern)) { + return $idSites; + } + $idSites = array(); + $sites = Request::processRequest('SitesManager.getPatternMatchSites', + array('pattern' => $pattern, + // added because caller could overwrite these + 'serialize' => 0, + 'format' => 'original')); + if (!empty($sites)) { + foreach ($sites as $site) { + $idSites[] = $site['idsite']; + } + } + return $idSites; + } + + /** + * Same as getAll but for a unique Piwik site + * @see Piwik\Plugins\MultiSites\API::getAll() + * + * @param int $idSite Id of the Piwik site + * @param string $period The period type to get data for. + * @param string $date The date(s) to get data for. + * @param bool|string $segment The segments to get data for. + * @param bool|string $_restrictSitesToLogin Hack used to enforce we restrict the returned data to the specified username + * Only used when a scheduled task is running + * @param bool|string $enhanced When true, return additional goal & ecommerce metrics + * @return DataTable + */ + public function getOne($idSite, $period, $date, $segment = false, $_restrictSitesToLogin = false, $enhanced = false) + { + Piwik::checkUserHasViewAccess($idSite); + return $this->buildDataTable( + $idSite, + $period, + $date, + $segment, + $_restrictSitesToLogin, + $enhanced, + $multipleWebsitesRequested = false + ); + } + + private function buildDataTable($idSitesOrIdSite, $period, $date, $segment, $_restrictSitesToLogin, $enhanced, $multipleWebsitesRequested) + { + $allWebsitesRequested = ($idSitesOrIdSite == 'all'); + if ($allWebsitesRequested) { + // First clear cache + Site::clearCache(); + // Then, warm the cache with only the data we should have access to + if (Piwik::hasUserSuperUserAccess() + // Hack: when this API function is called as a Scheduled Task, Super User status is enforced. + // This means this function would return ALL websites in all cases. + // Instead, we make sure that only the right set of data is returned + && !TaskScheduler::isTaskBeingExecuted() + ) { + APISitesManager::getInstance()->getAllSites(); + } else { + APISitesManager::getInstance()->getSitesWithAtLeastViewAccess($limit = false, $_restrictSitesToLogin); + } + // Both calls above have called Site::setSitesFromArray. We now get these sites: + $sitesToProblablyAdd = Site::getSites(); + } else { + $sitesToProblablyAdd = array(APISitesManager::getInstance()->getSiteFromId($idSitesOrIdSite)); + } + + // build the archive type used to query archive data + $archive = Archive::build( + $idSitesOrIdSite, + $period, + $date, + $segment, + $_restrictSitesToLogin + ); + + // determine what data will be displayed + $fieldsToGet = array(); + $columnNameRewrites = array(); + $apiECommerceMetrics = array(); + $apiMetrics = API::getApiMetrics($enhanced); + foreach ($apiMetrics as $metricName => $metricSettings) { + $fieldsToGet[] = $metricSettings[self::METRIC_RECORD_NAME_KEY]; + $columnNameRewrites[$metricSettings[self::METRIC_RECORD_NAME_KEY]] = $metricName; + + if ($metricSettings[self::METRIC_IS_ECOMMERCE_KEY]) { + $apiECommerceMetrics[$metricName] = $metricSettings; + } + } + + // get the data + // $dataTable instanceOf Set + $dataTable = $archive->getDataTableFromNumeric($fieldsToGet); + + $dataTable = $this->mergeDataTableMapAndPopulateLabel($idSitesOrIdSite, $multipleWebsitesRequested, $dataTable); + + if ($dataTable instanceof DataTable\Map) { + foreach ($dataTable->getDataTables() as $table) { + $this->addMissingWebsites($table, $fieldsToGet, $sitesToProblablyAdd); + } + } else { + $this->addMissingWebsites($dataTable, $fieldsToGet, $sitesToProblablyAdd); + } + + // calculate total visits/actions/revenue + $this->setMetricsTotalsMetadata($dataTable, $apiMetrics); + + // if the period isn't a range & a lastN/previousN date isn't used, we get the same + // data for the last period to show the evolution of visits/actions/revenue + list($strLastDate, $lastPeriod) = Range::getLastDate($date, $period); + + if ($strLastDate !== false) { + + if ($lastPeriod !== false) { + // NOTE: no easy way to set last period date metadata when range of dates is requested. + // will be easier if DataTable\Map::metadata is removed, and metadata that is + // put there is put directly in DataTable::metadata. + $dataTable->setMetadata(self::getLastPeriodMetadataName('date'), $lastPeriod); + } + + $pastArchive = Archive::build($idSitesOrIdSite, $period, $strLastDate, $segment, $_restrictSitesToLogin); + + $pastData = $pastArchive->getDataTableFromNumeric($fieldsToGet); + + $pastData = $this->mergeDataTableMapAndPopulateLabel($idSitesOrIdSite, $multipleWebsitesRequested, $pastData); + + // use past data to calculate evolution percentages + $this->calculateEvolutionPercentages($dataTable, $pastData, $apiMetrics); + Common::destroy($pastData); + } + + // remove eCommerce related metrics on non eCommerce Piwik sites + // note: this is not optimal in terms of performance: those metrics should not be retrieved in the first place + if ($enhanced) { + if ($dataTable instanceof DataTable\Map) { + foreach ($dataTable->getDataTables() as $table) { + $this->removeEcommerceRelatedMetricsOnNonEcommercePiwikSites($table, $apiECommerceMetrics); + } + } else { + $this->removeEcommerceRelatedMetricsOnNonEcommercePiwikSites($dataTable, $apiECommerceMetrics); + } + } + + // move the site id to a metadata column + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'group', array('\Piwik\Site', 'getGroupFor'), array())); + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'main_url', array('\Piwik\Site', 'getMainUrlFor'), array())); + $dataTable->filter('ColumnCallbackAddMetadata', array('label', 'idsite')); + + // set the label of each row to the site name + if ($multipleWebsitesRequested) { + $dataTable->filter('ColumnCallbackReplace', array('label', '\Piwik\Site::getNameFor')); + } else { + $dataTable->filter('ColumnDelete', array('label')); + } + + Site::clearCache(); + + // replace record names with user friendly metric names + $dataTable->filter('ReplaceColumnNames', array($columnNameRewrites)); + + // Ensures data set sorted, for Metadata output + $dataTable->filter('Sort', array(self::NB_VISITS_METRIC, 'desc', $naturalSort = false)); + + // filter rows without visits + // note: if only one website is queried and there are no visits, we can not remove the row otherwise + // ResponseBuilder throws 'Call to a member function getColumns() on a non-object' + if ($multipleWebsitesRequested + // We don't delete the 0 visits row, if "Enhanced" mode is on. + && !$enhanced + ) { + $dataTable->filter( + 'ColumnCallbackDeleteRow', + array( + self::NB_VISITS_METRIC, + function ($value) { + return $value == 0; + } + ) + ); + } + + return $dataTable; + } + + /** + * Performs a binary filter of two + * DataTables in order to correctly calculate evolution metrics. + * + * @param DataTable|DataTable\Map $currentData + * @param DataTable|DataTable\Map $pastData + * @param array $apiMetrics The array of string fields to calculate evolution + * metrics for. + * @throws Exception + */ + private function calculateEvolutionPercentages($currentData, $pastData, $apiMetrics) + { + if (get_class($currentData) != get_class($pastData)) { // sanity check for regressions + throw new Exception("Expected \$pastData to be of type " . get_class($currentData) . " - got " + . get_class($pastData) . "."); + } + + if ($currentData instanceof DataTable\Map) { + $pastArray = $pastData->getDataTables(); + foreach ($currentData->getDataTables() as $subTable) { + $this->calculateEvolutionPercentages($subTable, current($pastArray), $apiMetrics); + next($pastArray); + } + } else { + foreach ($apiMetrics as $metricSettings) { + $currentData->filter( + 'CalculateEvolutionFilter', + array( + $pastData, + $metricSettings[self::METRIC_EVOLUTION_COL_NAME_KEY], + $metricSettings[self::METRIC_RECORD_NAME_KEY], + $quotientPrecision = 1) + ); + } + } + } + + /** + * @ignore + */ + public static function getApiMetrics($enhanced) + { + $metrics = self::$baseMetrics; + + if(Common::isActionsPluginEnabled()) { + $metrics[self::NB_PAGEVIEWS_LABEL] = array( + self::METRIC_TRANSLATION_KEY => 'General_ColumnPageviews', + self::METRIC_EVOLUTION_COL_NAME_KEY => 'pageviews_evolution', + self::METRIC_RECORD_NAME_KEY => self::NB_PAGEVIEWS_METRIC, + self::METRIC_IS_ECOMMERCE_KEY => false, + ); + } + + if (Common::isGoalPluginEnabled()) { + // goal revenue metric + $metrics[self::GOAL_REVENUE_METRIC] = array( + self::METRIC_TRANSLATION_KEY => 'General_ColumnRevenue', + self::METRIC_EVOLUTION_COL_NAME_KEY => self::GOAL_REVENUE_METRIC . '_evolution', + self::METRIC_RECORD_NAME_KEY => Archiver::getRecordName(self::GOAL_REVENUE_METRIC), + self::METRIC_IS_ECOMMERCE_KEY => false, + ); + + if ($enhanced) { + // number of goal conversions metric + $metrics[self::GOAL_CONVERSION_METRIC] = array( + self::METRIC_TRANSLATION_KEY => 'Goals_ColumnConversions', + self::METRIC_EVOLUTION_COL_NAME_KEY => self::GOAL_CONVERSION_METRIC . '_evolution', + self::METRIC_RECORD_NAME_KEY => Archiver::getRecordName(self::GOAL_CONVERSION_METRIC), + self::METRIC_IS_ECOMMERCE_KEY => false, + ); + + // number of orders + $metrics[self::ECOMMERCE_ORDERS_METRIC] = array( + self::METRIC_TRANSLATION_KEY => 'General_EcommerceOrders', + self::METRIC_EVOLUTION_COL_NAME_KEY => self::ECOMMERCE_ORDERS_METRIC . '_evolution', + self::METRIC_RECORD_NAME_KEY => Archiver::getRecordName(self::GOAL_CONVERSION_METRIC, 0), + self::METRIC_IS_ECOMMERCE_KEY => true, + ); + + // eCommerce revenue + $metrics[self::ECOMMERCE_REVENUE_METRIC] = array( + self::METRIC_TRANSLATION_KEY => 'General_ProductRevenue', + self::METRIC_EVOLUTION_COL_NAME_KEY => self::ECOMMERCE_REVENUE_METRIC . '_evolution', + self::METRIC_RECORD_NAME_KEY => Archiver::getRecordName(self::GOAL_REVENUE_METRIC, 0), + self::METRIC_IS_ECOMMERCE_KEY => true, + ); + } + } + + return $metrics; + } + + /** + * Sets the total visits, actions & revenue for a DataTable returned by + * $this->buildDataTable. + * + * @param DataTable $dataTable + * @param array $apiMetrics Metrics info. + * @return array Array of three values: total visits, total actions, total revenue + */ + private function setMetricsTotalsMetadata($dataTable, $apiMetrics) + { + if ($dataTable instanceof DataTable\Map) { + foreach ($dataTable->getDataTables() as $table) { + $this->setMetricsTotalsMetadata($table, $apiMetrics); + } + } else { + $revenueMetric = ''; + if (Common::isGoalPluginEnabled()) { + $revenueMetric = Archiver::getRecordName(self::GOAL_REVENUE_METRIC); + } + + $totals = array(); + foreach ($apiMetrics as $label => $metricInfo) { + $totalMetadataName = self::getTotalMetadataName($label); + $totals[$totalMetadataName] = 0; + } + + foreach ($dataTable->getRows() as $row) { + foreach ($apiMetrics as $label => $metricInfo) { + $totalMetadataName = self::getTotalMetadataName($label); + $totals[$totalMetadataName] += $row->getColumn($metricInfo[self::METRIC_RECORD_NAME_KEY]); + } + } + + foreach ($totals as $name => $value) { + $dataTable->setMetadata($name, $value); + } + } + } + + private static function getTotalMetadataName($name) + { + return 'total_' . $name; + } + + private static function getLastPeriodMetadataName($name) + { + return 'last_period_' . $name; + } + + /** + * @param DataTable|DataTable\Map $dataTable + * @param $fieldsToGet + * @param $sitesToProblablyAdd + */ + private function addMissingWebsites($dataTable, $fieldsToGet, $sitesToProblablyAdd) + { + $siteIdsInDataTable = array(); + foreach ($dataTable->getRows() as $row) { + /** @var DataTable\Row $row */ + $siteIdsInDataTable[] = $row->getColumn('label'); + } + + foreach ($sitesToProblablyAdd as $site) { + if (!in_array($site['idsite'], $siteIdsInDataTable)) { + $siteRow = array_combine($fieldsToGet, array_pad(array(), count($fieldsToGet), 0)); + $siteRow['label'] = (int) $site['idsite']; + $dataTable->addRowFromSimpleArray($siteRow); + } + } + } + + private function removeEcommerceRelatedMetricsOnNonEcommercePiwikSites($dataTable, $apiECommerceMetrics) + { + // $dataTableRows instanceOf Row[] + $dataTableRows = $dataTable->getRows(); + + foreach ($dataTableRows as $dataTableRow) { + $siteId = $dataTableRow->getColumn('label'); + if (!Site::isEcommerceEnabledFor($siteId)) { + foreach ($apiECommerceMetrics as $metricSettings) { + $dataTableRow->deleteColumn($metricSettings[self::METRIC_RECORD_NAME_KEY]); + $dataTableRow->deleteColumn($metricSettings[self::METRIC_EVOLUTION_COL_NAME_KEY]); + } + } + } + } + + private function mergeDataTableMapAndPopulateLabel($idSitesOrIdSite, $multipleWebsitesRequested, $dataTable) + { + // get rid of the DataTable\Map that is created by the IndexedBySite archive type + if ($dataTable instanceof DataTable\Map && $multipleWebsitesRequested) { + + return $dataTable->mergeChildren(); + + } else { + + if (!$dataTable instanceof DataTable\Map && $dataTable->getRowsCount() > 0) { + + $firstSite = is_array($idSitesOrIdSite) ? reset($idSitesOrIdSite) : $idSitesOrIdSite; + + $firstDataTableRow = $dataTable->getFirstRow(); + + $firstDataTableRow->setColumn('label', $firstSite); + } + } + + return $dataTable; + } +} + diff --git a/www/analytics/plugins/MultiSites/Controller.php b/www/analytics/plugins/MultiSites/Controller.php new file mode 100644 index 00000000..babbe716 --- /dev/null +++ b/www/analytics/plugins/MultiSites/Controller.php @@ -0,0 +1,87 @@ +getSitesInfo($isWidgetized = false); + } + + public function standalone() + { + return $this->getSitesInfo($isWidgetized = true); + } + + public function getSitesInfo($isWidgetized = false) + { + Piwik::checkUserHasSomeViewAccess(); + + $date = Common::getRequestVar('date', 'today'); + $period = Common::getRequestVar('period', 'day'); + + $view = new View("@MultiSites/getSitesInfo"); + + $view->isWidgetized = $isWidgetized; + $view->displayRevenueColumn = Common::isGoalPluginEnabled(); + $view->limit = Config::getInstance()->General['all_websites_website_per_page']; + $view->show_sparklines = Config::getInstance()->General['show_multisites_sparklines']; + + $view->autoRefreshTodayReport = 0; + // if the current date is today, or yesterday, + // in case the website is set to UTC-12), or today in UTC+14, we refresh the page every 5min + if (in_array($date, array('today', date('Y-m-d'), + 'yesterday', Date::factory('yesterday')->toString('Y-m-d'), + Date::factory('now', 'UTC+14')->toString('Y-m-d'))) + ) { + $view->autoRefreshTodayReport = Config::getInstance()->General['multisites_refresh_after_seconds']; + } + + $params = $this->getGraphParamsModified(); + $view->dateSparkline = $period == 'range' ? $date : $params['date']; + + $this->setGeneralVariablesView($view); + + return $view->render(); + } + + public function getEvolutionGraph($columns = false) + { + if (empty($columns)) { + $columns = Common::getRequestVar('columns'); + } + $api = "API.get"; + + if ($columns == 'revenue') { + $api = "Goals.get"; + } + $view = $this->getLastUnitGraph($this->pluginName, __FUNCTION__, $api); + return $this->renderView($view); + } +} diff --git a/www/analytics/plugins/MultiSites/MultiSites.php b/www/analytics/plugins/MultiSites/MultiSites.php new file mode 100644 index 00000000..284001d4 --- /dev/null +++ b/www/analytics/plugins/MultiSites/MultiSites.php @@ -0,0 +1,116 @@ + 'Piwik PRO', 'homepage' => 'http://piwik.pro')); + return $info; + } + + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'Menu.Top.addItems' => 'addTopMenu', + 'API.getReportMetadata' => 'getReportMetadata', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + ); + } + + public function getClientSideTranslationKeys(&$translations) + { + $translations[] = 'General_Website'; + $translations[] = 'General_ColumnNbVisits'; + $translations[] = 'General_ColumnPageviews'; + $translations[] = 'General_ColumnRevenue'; + $translations[] = 'General_TotalVisitsPageviewsRevenue'; + $translations[] = 'General_EvolutionSummaryGeneric'; + $translations[] = 'General_AllWebsitesDashboard'; + $translations[] = 'General_NVisits'; + $translations[] = 'MultiSites_Evolution'; + $translations[] = 'SitesManager_AddSite'; + $translations[] = 'General_Next'; + $translations[] = 'General_Previous'; + $translations[] = 'General_GoTo'; + $translations[] = 'Dashboard_DashboardOf'; + $translations[] = 'Actions_SubmenuSitesearch'; + $translations[] = 'MultiSites_LoadingWebsites'; + $translations[] = 'General_ErrorRequest'; + } + + public function getReportMetadata(&$reports) + { + $metadataMetrics = array(); + foreach (API::getApiMetrics($enhanced = true) as $metricName => $metricSettings) { + $metadataMetrics[$metricName] = + Piwik::translate($metricSettings[API::METRIC_TRANSLATION_KEY]); + $metadataMetrics[$metricSettings[API::METRIC_EVOLUTION_COL_NAME_KEY]] = + Piwik::translate($metricSettings[API::METRIC_TRANSLATION_KEY]) . " " . Piwik::translate('MultiSites_Evolution'); + } + + $reports[] = array( + 'category' => Piwik::translate('General_MultiSitesSummary'), + 'name' => Piwik::translate('General_AllWebsitesDashboard'), + 'module' => 'MultiSites', + 'action' => 'getAll', + 'dimension' => Piwik::translate('General_Website'), // re-using translation + 'metrics' => $metadataMetrics, + 'processedMetrics' => false, + 'constantRowsCount' => false, + 'order' => 4 + ); + + $reports[] = array( + 'category' => Piwik::translate('General_MultiSitesSummary'), + 'name' => Piwik::translate('General_SingleWebsitesDashboard'), + 'module' => 'MultiSites', + 'action' => 'getOne', + 'dimension' => Piwik::translate('General_Website'), // re-using translation + 'metrics' => $metadataMetrics, + 'processedMetrics' => false, + 'constantRowsCount' => false, + 'order' => 5 + ); + } + + public function addTopMenu() + { + $urlParams = array('module' => 'MultiSites', 'action' => 'index', 'segment' => false); + $tooltip = Piwik::translate('MultiSites_TopLinkTooltip'); + MenuTop::addEntry('General_MultiSitesSummary', $urlParams, true, 3, $isHTML = false, $tooltip); + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/MultiSites/angularjs/dashboard/dashboard-model.js"; + $jsFiles[] = "plugins/MultiSites/angularjs/dashboard/dashboard-controller.js"; + $jsFiles[] = "plugins/MultiSites/angularjs/dashboard/dashboard-filter.js"; + $jsFiles[] = "plugins/MultiSites/angularjs/dashboard/dashboard-directive.js"; + $jsFiles[] = "plugins/MultiSites/angularjs/site/site-directive.js"; + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/MultiSites/angularjs/dashboard/dashboard.less"; + } +} diff --git a/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-controller.js b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-controller.js new file mode 100644 index 00000000..470ab413 --- /dev/null +++ b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-controller.js @@ -0,0 +1,34 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +angular.module('piwikApp').controller('MultiSitesDashboardController', function($scope, piwik, multisitesDashboardModel){ + + $scope.model = multisitesDashboardModel; + + $scope.reverse = true; + $scope.predicate = 'nb_visits'; + $scope.evolutionSelector = 'visits_evolution'; + $scope.hasSuperUserAccess = piwik.hasSuperUserAccess; + $scope.date = piwik.broadcast.getValueFromUrl('date'); + $scope.url = piwik.piwik_url; + $scope.period = piwik.period; + + $scope.sortBy = function (metric) { + + var reverse = $scope.reverse; + if ($scope.predicate == metric) { + reverse = !reverse; + } + + $scope.predicate = metric; + $scope.reverse = reverse; + }; + + this.refresh = function (interval) { + multisitesDashboardModel.fetchAllSites(interval); + }; +}); diff --git a/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-directive.js b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-directive.js new file mode 100644 index 00000000..0576c934 --- /dev/null +++ b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-directive.js @@ -0,0 +1,40 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Renders the multisites dashboard + * Example usage: + * + *
          + */ +angular.module('piwikApp').directive('piwikMultisitesDashboard', function($document, piwik, $filter){ + + return { + restrict: 'A', + scope: { + displayRevenueColumn: '@', + showSparklines: '@', + dateSparkline: '@' + }, + templateUrl: 'plugins/MultiSites/angularjs/dashboard/dashboard.html?cb=' + piwik.cacheBuster, + controller: 'MultiSitesDashboardController', + link: function (scope, element, attrs, controller) { + + if (attrs.pageSize) { + scope.model.pageSize = attrs.pageSize; + } + + controller.refresh(attrs.autoRefreshTodayReport); + } + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-filter.js b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-filter.js new file mode 100644 index 00000000..b989415d --- /dev/null +++ b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-filter.js @@ -0,0 +1,63 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Filters a given list of websites and groups and makes sure only the websites within a given offset and limit are + * displayed. It also makes sure sites are displayed under the groups. That means it flattens a structure like this: + * + * - website1 + * - website2 + * - website3.sites // this is a group + * - website4 + * - website5 + * - website6 + * + * to the following structure + * - website1 + * - website2 + * - website3.sites // this is a group + * - website4 + * - website5 + * - website6 + */ +angular.module('piwikApp').filter('multiSitesGroupFilter', function() { + return function(websites, from, to) { + var offsetEnd = parseInt(from, 10) + parseInt(to, 10); + var groups = {}; + + var sites = []; + for (var index = 0; index < websites.length; index++) { + var website = websites[index]; + + sites.push(website); + if (website.sites && website.sites.length) { + groups[website.label] = website; + for (var innerIndex = 0; innerIndex < website.sites.length; innerIndex++) { + sites.push(website.sites[innerIndex]); + } + } + + if (sites.length >= offsetEnd) { + break; + } + } + + // if the first site is a website having a group, then try to find the related group and prepend it to the list + // of sites to make sure we always display the name of the group that belongs to a website. + var filteredSites = sites.slice(from, offsetEnd); + + if (filteredSites.length && filteredSites[0] && filteredSites[0].group) { + var groupName = filteredSites[0].group; + if (groups[groupName]) { + filteredSites.unshift(groups[groupName]); + } + } + + return filteredSites; + }; +}); + diff --git a/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-model.js b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-model.js new file mode 100644 index 00000000..edba4e47 --- /dev/null +++ b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard-model.js @@ -0,0 +1,273 @@ +/** + * Model for Multisites Dashboard aka All Websites Dashboard. + * + */ +angular.module('piwikApp').factory('multisitesDashboardModel', function (piwikApi, $filter, $timeout) { + /** + * + * this is the list of all available sites. For performance reason this list is different to model.sites. ngRepeat + * won't operate on the whole list this way. The allSites array contains websites and groups in the following + * structure + * + * - website1 + * - website2 + * - website3.sites = [ // this is a group + * - website4 + * - website5 + * ] + * - website6 + * + * This structure allows us to display the sites within a group directly under the group without big logic and also + * allows us to calculate the summary for each group easily + */ + var allSitesByGroup = []; + + var model = {}; + // those sites are going to be displayed + model.sites = []; + model.isLoading = false; + model.pageSize = 5; + model.currentPage = 0; + model.totalVisits = '?'; + model.totalActions = '?'; + model.totalRevenue = '?'; + model.searchTerm = ''; + model.lastVisits = '?'; + model.lastVisitsDate = '?'; + + fetchPreviousSummary(); + + // create a new group object which has similar structure than a website + function createGroup(name){ + return { + label: name, + sites: [], + nb_visits: 0, + nb_pageviews: 0, + revenue: 0, + isGroup: true + }; + } + + // create a new group with empty site to make sure we do not change the original group in $allSites + function copyGroup(group) + { + return { + label: group.label, + sites: [], + nb_visits: group.nb_visits, + nb_pageviews: group.nb_pageviews, + revenue: group.revenue, + isGroup: true + }; + } + + function onError () { + model.errorLoadingSites = true; + model.sites = []; + allSitesByGroup = []; + } + + function calculateMetricsForEachGroup(groups) + { + angular.forEach(groups, function (group) { + angular.forEach(group.sites, function (site) { + var revenue = (site.revenue+'').match(/(\d+\.?\d*)/); // convert $ 0.00 to 0.00 or 5€ to 5 + group.nb_visits += parseInt(site.nb_visits, 10); + group.nb_pageviews += parseInt(site.nb_pageviews, 10); + if (revenue.length) { + group.revenue += parseInt(revenue[0], 10); + } + }); + }); + } + + function createGroupsAndMoveSitesIntoRelatedGroup(allSitesUnordered, reportMetadata) + { + var sitesByGroup = []; + var groups = {}; + + // we do 3 things (complete site information, create groups, move sites into group) in one step for + // performance reason, there can be > 20k sites + angular.forEach(allSitesUnordered, function (site, index) { + site.idsite = reportMetadata[index].idsite; + site.group = reportMetadata[index].group; + site.main_url = reportMetadata[index].main_url; + + if (site.group) { + + if (!groups[site.group]) { + var group = createGroup(site.group); + + groups[site.group] = group; + sitesByGroup.push(group); + } + + groups[site.group].sites.push(site); + + } else { + sitesByGroup.push(site); + } + }); + + calculateMetricsForEachGroup(groups); + + return sitesByGroup; + } + + model.updateWebsitesList = function (processedReport) { + if (!processedReport) { + onError(); + return; + } + + var allSitesUnordered = processedReport.reportData; + model.totalVisits = processedReport.reportTotal.nb_visits; + model.totalActions = processedReport.reportTotal.nb_actions; + model.totalRevenue = processedReport.reportTotal.revenue; + + allSitesByGroup = createGroupsAndMoveSitesIntoRelatedGroup(allSitesUnordered, processedReport.reportMetadata); + + if (!allSitesByGroup.length) { + return; + } + + if (model.searchTerm) { + model.searchSite(model.searchTerm); + } else { + model.sites = allSitesByGroup; + } + }; + + model.getNumberOfFilteredSites = function () { + var numSites = model.sites.length; + + var groupNames = {}; + + for (var index = 0; index < model.sites.length; index++) { + var site = model.sites[index]; + if (site && site.isGroup) { + numSites += site.sites.length; + } + } + + return numSites; + }; + + model.getNumberOfPages = function () { + return Math.ceil(model.getNumberOfFilteredSites() / model.pageSize - 1); + }; + + model.getCurrentPagingOffsetStart = function() { + return Math.ceil(model.currentPage * model.pageSize); + }; + + model.getCurrentPagingOffsetEnd = function() { + var end = model.getCurrentPagingOffsetStart() + parseInt(model.pageSize, 10); + var max = model.getNumberOfFilteredSites(); + if (end > max) { + end = max; + } + return parseInt(end, 10); + }; + + model.previousPage = function () { + model.currentPage = model.currentPage - 1; + }; + + model.nextPage = function () { + model.currentPage = model.currentPage + 1; + }; + + function nestedSearch(sitesByGroup, term) + { + var filteredSites = []; + + for (var index in sitesByGroup) { + var site = sitesByGroup[index]; + if (site.isGroup) { + var matchingSites = nestedSearch(site.sites, term); + if (matchingSites.length || (''+site.label).toLowerCase().indexOf(term) > -1) { + var clonedGroup = copyGroup(site); + clonedGroup.sites = matchingSites; + filteredSites.push(clonedGroup); + } + } else if ((''+site.label).toLowerCase().indexOf(term) > -1) { + filteredSites.push(site); + } else if (site.group && (''+site.group).toLowerCase().indexOf(term) > -1) { + filteredSites.push(site); + } + } + + return filteredSites; + } + + model.searchSite = function (term) { + model.searchTerm = term; + model.currentPage = 0; + model.sites = nestedSearch(allSitesByGroup, term); + }; + + function fetchPreviousSummary () { + piwikApi.fetch({ + method: 'API.getLastDate' + }).then(function (response) { + if (response && response.value) { + return response.value; + } + }).then(function (lastDate) { + if (!lastDate) { + return; + } + + model.lastVisitsDate = lastDate; + + return piwikApi.fetch({ + method: 'API.getProcessedReport', + apiModule: 'MultiSites', + apiAction: 'getAll', + hideMetricsDoc: '1', + filter_limit: '0', + showColumns: 'label,nb_visits', + enhanced: 1, + date: lastDate + }); + }).then(function (response) { + if (response && response.reportTotal) { + model.lastVisits = response.reportTotal.nb_visits; + } + }); + } + + model.fetchAllSites = function (refreshInterval) { + + if (model.isLoading) { + piwikApi.abort(); + } + + model.isLoading = true; + model.errorLoadingSites = false; + + return piwikApi.fetch({ + method: 'API.getProcessedReport', + apiModule: 'MultiSites', + apiAction: 'getAll', + hideMetricsDoc: '1', + filter_limit: '-1', + showColumns: 'label,nb_visits,nb_pageviews,visits_evolution,pageviews_evolution,revenue_evolution,nb_actions,revenue', + enhanced: 1 + }).then(function (response) { + model.updateWebsitesList(response); + }, onError)['finally'](function () { + model.isLoading = false; + + if (refreshInterval && refreshInterval > 0) { + $timeout(function () { + model.fetchAllSites(refreshInterval); + }, refreshInterval * 1000); + } + }); + }; + + return model; +}); diff --git a/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard.html b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard.html new file mode 100644 index 00000000..dd7149d3 --- /dev/null +++ b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard.html @@ -0,0 +1,128 @@ +
          +

          + {{ 'General_AllWebsitesDashboard'|translate }} + + +

          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          + {{ 'General_Website'|translate }} + + + {{ 'General_ColumnNbVisits'|translate }} + + + {{ 'General_ColumnPageviews'|translate }} + + + {{ 'General_ColumnRevenue'|translate }} + + + + {{ 'MultiSites_Evolution'|translate }} + +
          + {{ 'MultiSites_LoadingWebsites' | translate }} + +
          +
          + {{ 'General_ErrorRequest'|translate }} +
          +
          + + {{ 'SitesManager_AddSite'|translate }} + +
          +
          +
          + + « {{ 'General_Previous'|translate }} + + + + {{ model.getCurrentPagingOffsetStart() }} - {{ model.getCurrentPagingOffsetEnd() }} of {{ model.getNumberOfFilteredSites() }} + + + + {{ 'General_Next'|translate }} » + +
          +
          \ No newline at end of file diff --git a/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard.less b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard.less new file mode 100644 index 00000000..0638e131 --- /dev/null +++ b/www/analytics/plugins/MultiSites/angularjs/dashboard/dashboard.less @@ -0,0 +1,185 @@ + +.smallTitle { + font-size: 15px; +} + +#multisites { + border: 0; + padding: 0 15px; + font-size: 14px; + + .notification-error { + margin-top: 15px; + } + + .add_new_site, + .clean { + border: 0 !important; + text-align: right; padding-top: 15px;padding-right:10px; + } + + .add_new_site { + img { + margin: 0px; + } + } + + .site_search { + padding: 0px; + text-align: center; + border: 0 !important; + } + + td, tr, .sparkline { + text-align: center; + vertical-align: middle; + padding: 1px; + margin: 0; + } + + .indicator { + background: url(plugins/MultiSites/images/loading-blue.gif) no-repeat center; + height: 20px; + width: 60px; + margin: auto; + border: 0 !important; + } + + .paging { + padding: 20px; + + .previous { + padding-right: 20px; + } + .next { + padding-left: 20px; + } + } + + .top_controls { + height: 10px; + } + + th { + cursor: pointer; + } + + .site_search input { + margin-right: 0px; + margin-left: 25px; + } + + .search_ico { + position: relative; + left: -25px; + margin-right: 0px; + } + .reset { + position: relative; + left: -25px; + cursor: pointer; + margin-right: 0px; + } + + tr.columnodd:hover td, tr.columnodd td { + background: #F2F2F2 !important; + } + + tr:hover td { + background: #FFF !important; + } + + tr.group { + font-weight: bold; + height: 30px; + } + tr.groupedWebsite .label { + padding-left: 50px; + } + td.multisites-label { + padding-left: 15px; + text-align: left; + width: 250px; + } + td.multisites-label a:hover { + text-decoration: underline; + } + + td.multisites-column, + th.multisites-column { + width: 70px; + white-space: nowrap; + } + + td.multisites-column-evolution, + th.multisites-column-evolution { + width: 70px; + } + + th#evolution { + width:350px; + } + + th#visits { + width: 100px; + } + + th#pageviews { + width: 110px; + } + + th#revenue { + width: 110px; + } + + .evolution { + cursor:pointer; + } + + .allWebsitesLoading { + padding:20px + } + + .allWebsitesLoadingIndicator { + background: url(plugins/Zeitgeist/images/loading-blue.gif) no-repeat right 3px; + display: inline-block; + width: 16px; + height: 16px; + } + + .heading { + display: inline-block; + margin-top: 4px; + } + .multisites_desc { + width: 16px; + height: 13px; + display: inline-block; + background-image: url(plugins/Zeitgeist/images/sortdesc.png); + } + .multisites_asc { + width: 16px; + height: 13px; + display: inline-block; + background-image: url(plugins/Zeitgeist/images/sortasc.png); + } + div.sparkline { + float:none; + } + + tfoot td { + border-bottom: 0px; + } +} + +#mt thead { + line-height: 2.5em; +} + +#mt thead *:first-child { + border-top-left-radius: 7px +} + +#mt thead *:last-child { + border-top-right-radius: 7px; +} \ No newline at end of file diff --git a/www/analytics/plugins/MultiSites/angularjs/site/site-directive.js b/www/analytics/plugins/MultiSites/angularjs/site/site-directive.js new file mode 100644 index 00000000..7cbaff54 --- /dev/null +++ b/www/analytics/plugins/MultiSites/angularjs/site/site-directive.js @@ -0,0 +1,55 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +/** + * Renders a single website row, for instance to be used within the MultiSites Dashboard. + * + * Usage: + *
          + * website="{label: 'Name', main_url: 'http://...', idsite: '...'}" + * evolution-metric="visits_evolution" + * show-sparklines="true" + * date-sparkline="2014-01-01,2014-02-02" + * display-revenue-column="true" + *
          + */ +angular.module('piwikApp').directive('piwikMultisitesSite', function($document, piwik, $filter){ + + return { + restrict: 'AC', + replace: true, + scope: { + website: '=', + evolutionMetric: '=', + showSparklines: '=', + dateSparkline: '=', + displayRevenueColumn: '=', + metric: '=' + }, + templateUrl: 'plugins/MultiSites/angularjs/site/site.html?cb=' + piwik.cacheBuster, + controller: function ($scope) { + + $scope.period = piwik.period; + $scope.date = piwik.broadcast.getValueFromUrl('date'); + $scope.parseInt = parseInt; + + this.getWebsite = function () { + return $scope.website; + }; + + $scope.sparklineImage = function(website){ + var append = ''; + var token_auth = piwik.broadcast.getValueFromUrl('token_auth'); + if (token_auth.length) { + append = '&token_auth=' + token_auth; + } + + return piwik.piwik_url + '?module=MultiSites&action=getEvolutionGraph&period=' + $scope.period + '&date=' + $scope.dateSparkline + '&evolutionBy=' +$scope.metric + '&columns=' + $scope.metric + '&idSite=' + website.idsite + '&idsite=' + website.idsite + '&viewDataTable=sparkline' + append + '&colors=' + encodeURIComponent(JSON.stringify(piwik.getSparklineColors())); + }; + } + }; +}); \ No newline at end of file diff --git a/www/analytics/plugins/MultiSites/angularjs/site/site.html b/www/analytics/plugins/MultiSites/angularjs/site/site.html new file mode 100644 index 00000000..2c6ab30c --- /dev/null +++ b/www/analytics/plugins/MultiSites/angularjs/site/site.html @@ -0,0 +1,39 @@ + + + {{ website.label }} + + + + + + + + {{ website.label }} + + + {{ website.nb_visits }} + + + {{ website.nb_pageviews }} + + + {{ website.revenue }} + + + +
          + {{ website[evolutionMetric] }}  + {{ website[evolutionMetric] }} + {{ website[evolutionMetric] }}  +
          + + + +
          + + + +
          + + \ No newline at end of file diff --git a/www/analytics/plugins/MultiSites/images/arrow_asc.gif b/www/analytics/plugins/MultiSites/images/arrow_asc.gif new file mode 100644 index 00000000..df463fa7 Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/arrow_asc.gif differ diff --git a/www/analytics/plugins/MultiSites/images/arrow_desc.gif b/www/analytics/plugins/MultiSites/images/arrow_desc.gif new file mode 100644 index 00000000..e8234178 Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/arrow_desc.gif differ diff --git a/www/analytics/plugins/MultiSites/images/arrow_down.png b/www/analytics/plugins/MultiSites/images/arrow_down.png new file mode 100644 index 00000000..5f1a3020 Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/arrow_down.png differ diff --git a/www/analytics/plugins/MultiSites/images/arrow_down_green.png b/www/analytics/plugins/MultiSites/images/arrow_down_green.png new file mode 100644 index 00000000..d77adb23 Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/arrow_down_green.png differ diff --git a/www/analytics/plugins/MultiSites/images/arrow_up.png b/www/analytics/plugins/MultiSites/images/arrow_up.png new file mode 100644 index 00000000..eaf8672c Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/arrow_up.png differ diff --git a/www/analytics/plugins/MultiSites/images/arrow_up_red.png b/www/analytics/plugins/MultiSites/images/arrow_up_red.png new file mode 100644 index 00000000..c03dad9a Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/arrow_up_red.png differ diff --git a/www/analytics/plugins/MultiSites/images/door_in.png b/www/analytics/plugins/MultiSites/images/door_in.png new file mode 100644 index 00000000..41676a0a Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/door_in.png differ diff --git a/www/analytics/plugins/MultiSites/images/link.gif b/www/analytics/plugins/MultiSites/images/link.gif new file mode 100644 index 00000000..e160f23b Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/link.gif differ diff --git a/www/analytics/plugins/MultiSites/images/loading-blue.gif b/www/analytics/plugins/MultiSites/images/loading-blue.gif new file mode 100644 index 00000000..8a3f8c01 Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/loading-blue.gif differ diff --git a/www/analytics/plugins/MultiSites/images/stop.png b/www/analytics/plugins/MultiSites/images/stop.png new file mode 100644 index 00000000..53d958c8 Binary files /dev/null and b/www/analytics/plugins/MultiSites/images/stop.png differ diff --git a/www/analytics/plugins/MultiSites/templates/getSitesInfo.twig b/www/analytics/plugins/MultiSites/templates/getSitesInfo.twig new file mode 100644 index 00000000..dde3f989 --- /dev/null +++ b/www/analytics/plugins/MultiSites/templates/getSitesInfo.twig @@ -0,0 +1,23 @@ +{% extends isWidgetized ? 'empty.twig' : 'dashboard.twig' %} + +{% block content %} +{% if not isWidgetized %} +
          + {% include "@CoreHome/_periodSelect.twig" %} + {% include "@CoreHome/_headerMessage.twig" %} +
          +{% endif %} + +
          +
          +
          +
          +
          +
          +{% endblock %} diff --git a/www/analytics/plugins/Overlay/API.php b/www/analytics/plugins/Overlay/API.php new file mode 100644 index 00000000..0862160a --- /dev/null +++ b/www/analytics/plugins/Overlay/API.php @@ -0,0 +1,136 @@ +authenticate($idSite); + + $translations = array( + 'oneClick' => 'Overlay_OneClick', + 'clicks' => 'Overlay_Clicks', + 'clicksFromXLinks' => 'Overlay_ClicksFromXLinks', + 'link' => 'Overlay_Link' + ); + + return array_map(array('\\Piwik\\Piwik','translate'), $translations); + } + + /** + * Get excluded query parameters for a site. + * This information is used for client side url normalization. + */ + public function getExcludedQueryParameters($idSite) + { + $this->authenticate($idSite); + + $sitesManager = APISitesManager::getInstance(); + $site = $sitesManager->getSiteFromId($idSite); + + try { + return SitesManager::getTrackerExcludedQueryParameters($site); + } catch (Exception $e) { + // an exception is thrown when the user has no view access. + // do not throw the exception to the outside. + return array(); + } + } + + /** + * Get following pages of a url. + * This is done on the logs - not the archives! + * + * Note: if you use this method via the regular API, the number of results will be limited. + * Make sure, you set filter_limit=-1 in the request. + */ + public function getFollowingPages($url, $idSite, $period, $date, $segment = false) + { + $this->authenticate($idSite); + + $url = PageUrl::excludeQueryParametersFromUrl($url, $idSite); + // we don't unsanitize $url here. it will be done in the Transitions plugin. + + $resultDataTable = new DataTable; + + try { + $limitBeforeGrouping = Config::getInstance()->General['overlay_following_pages_limit']; + $transitionsReport = APITransitions::getInstance()->getTransitionsForAction( + $url, $type = 'url', $idSite, $period, $date, $segment, $limitBeforeGrouping, + $part = 'followingActions', $returnNormalizedUrls = true); + } catch (Exception $e) { + return $resultDataTable; + } + + $reports = array('followingPages', 'outlinks', 'downloads'); + foreach ($reports as $reportName) { + if (!isset($transitionsReport[$reportName])) { + continue; + } + foreach ($transitionsReport[$reportName]->getRows() as $row) { + // don't touch the row at all for performance reasons + $resultDataTable->addRow($row); + } + } + + return $resultDataTable; + } + + /** Do cookie authentication. This way, the token can remain secret. */ + private function authenticate($idSite) + { + /** + * Triggered immediately before the user is authenticated. + * + * This event can be used by plugins that provide their own authentication mechanism + * to make that mechanism available. Subscribers should set the `'auth'` object in + * the {@link Piwik\Registry} to an object that implements the {@link Piwik\Auth} interface. + * + * **Example** + * + * use Piwik\Registry; + * + * public function initAuthenticationObject($activateCookieAuth) + * { + * Registry::set('auth', new LDAPAuth($activateCookieAuth)); + * } + * + * @param bool $activateCookieAuth Whether authentication based on `$_COOKIE` values should + * be allowed. + */ + Piwik::postEvent('Request.initAuthenticationObject', array($activateCookieAuth = true)); + + $auth = \Piwik\Registry::get('auth'); + $success = Access::getInstance()->reloadAccess($auth); + + if (!$success) { + throw new Exception('Authentication failed'); + } + + Piwik::checkUserHasViewAccess($idSite); + } +} diff --git a/www/analytics/plugins/Overlay/Controller.php b/www/analytics/plugins/Overlay/Controller.php new file mode 100644 index 00000000..89eb3606 --- /dev/null +++ b/www/analytics/plugins/Overlay/Controller.php @@ -0,0 +1,236 @@ +idSite); + + $template = '@Overlay/index'; + if (Config::getInstance()->General['overlay_disable_framed_mode']) { + $template = '@Overlay/index_noframe'; + } + + $view = new View($template); + + $this->setGeneralVariablesView($view); + + $view->idSite = $this->idSite; + $view->date = Common::getRequestVar('date', 'today'); + $view->period = Common::getRequestVar('period', 'day'); + + $view->ssl = ProxyHttp::isHttps(); + + return $view->render(); + } + + /** Render the area left of the iframe */ + public function renderSidebar() + { + $idSite = Common::getRequestVar('idSite'); + $period = Common::getRequestVar('period'); + $date = Common::getRequestVar('date'); + $currentUrl = Common::getRequestVar('currentUrl'); + $currentUrl = Common::unsanitizeInputValue($currentUrl); + + $normalizedCurrentUrl = PageUrl::excludeQueryParametersFromUrl($currentUrl, $idSite); + $normalizedCurrentUrl = Common::unsanitizeInputValue($normalizedCurrentUrl); + + // load the appropriate row of the page urls report using the label filter + ArchivingHelper::reloadConfig(); + $path = ArchivingHelper::getActionExplodedNames($normalizedCurrentUrl, Action::TYPE_PAGE_URL); + $path = array_map('urlencode', $path); + $label = implode('>', $path); + $request = new Request( + 'method=Actions.getPageUrls' + . '&idSite=' . urlencode($idSite) + . '&date=' . urlencode($date) + . '&period=' . urlencode($period) + . '&label=' . urlencode($label) + . '&format=original' + ); + $dataTable = $request->process(); + + $data = array(); + if ($dataTable->getRowsCount() > 0) { + $row = $dataTable->getFirstRow(); + + $translations = Metrics::getDefaultMetricTranslations(); + $showMetrics = array('nb_hits', 'nb_visits', 'nb_uniq_visitors', + 'bounce_rate', 'exit_rate', 'avg_time_on_page'); + + foreach ($showMetrics as $metric) { + $value = $row->getColumn($metric); + if ($value === false) { + // skip unique visitors for period != day + continue; + } + if ($metric == 'avg_time_on_page') { + $value = MetricsFormatter::getPrettyTimeFromSeconds($value); + } + $data[] = array( + 'name' => $translations[$metric], + 'value' => $value + ); + } + } + + // generate page url string + foreach ($path as &$part) { + $part = preg_replace(';^/;', '', urldecode($part)); + } + $page = '/' . implode('/', $path); + $page = preg_replace(';/index$;', '/', $page); + if ($page == '/') { + $page = '/index'; + } + + // render template + $view = new View('@Overlay/renderSidebar'); + $view->data = $data; + $view->location = $page; + $view->normalizedUrl = $normalizedCurrentUrl; + $view->label = $label; + $view->idSite = $idSite; + $view->period = $period; + $view->date = $date; + return $view->render(); + } + + /** + * Start an Overlay session: Redirect to the tracked website. The Piwik + * tracker will recognize this referrer and start the session. + */ + public function startOverlaySession() + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + Piwik::checkUserHasViewAccess($idSite); + + $sitesManager = APISitesManager::getInstance(); + $site = $sitesManager->getSiteFromId($idSite); + $urls = $sitesManager->getSiteUrlsFromId($idSite); + + @header('Content-Type: text/html; charset=UTF-8'); + return ' + + + + '; + } + + /** + * This method is called when the JS from startOverlaySession() detects that the target domain + * is not configured for the current site. + */ + public function showErrorWrongDomain() + { + $idSite = Common::getRequestVar('idSite', 0, 'int'); + Piwik::checkUserHasViewAccess($idSite); + + $url = Common::getRequestVar('url', ''); + $url = Common::unsanitizeInputValue($url); + + $message = Piwik::translate('Overlay_RedirectUrlError', array($url, "\n")); + $message = nl2br(htmlentities($message)); + + $view = new View('@Overlay/showErrorWrongDomain'); + $view->message = $message; + + if (Piwik::isUserHasAdminAccess($idSite)) { + // TODO use $idSite to link to the correct row. This is tricky because the #rowX ids don't match + // the site ids when sites have been deleted. + $url = 'index.php?module=SitesManager&action=index'; + $troubleshoot = htmlentities(Piwik::translate('Overlay_RedirectUrlErrorAdmin')); + $troubleshoot = sprintf($troubleshoot, '', ''); + $view->troubleshoot = $troubleshoot; + } else { + $view->troubleshoot = htmlentities(Piwik::translate('Overlay_RedirectUrlErrorUser')); + } + + return $view->render(); + } + + /** + * This method is used to pass information from the iframe back to Piwik. + * Due to the same origin policy, we can't do that directly, so we inject + * an additional iframe in the Overlay session that calls this controller + * method. + * The rendered iframe is from the same origin as the Piwik window so we + * can bypass the same origin policy and call the parent. + */ + public function notifyParentIframe() + { + $view = new View('@Overlay/notifyParentIframe'); + return $view->render(); + } +} diff --git a/www/analytics/plugins/Overlay/Overlay.php b/www/analytics/plugins/Overlay/Overlay.php new file mode 100644 index 00000000..c4a4c055 --- /dev/null +++ b/www/analytics/plugins/Overlay/Overlay.php @@ -0,0 +1,49 @@ + 'getJsFiles', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys' + ); + } + + /** + * Returns required Js Files + * @param $jsFiles + */ + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = 'plugins/Overlay/javascripts/rowaction.js'; + $jsFiles[] = 'plugins/Overlay/javascripts/Overlay_Helper.js'; + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = 'General_OverlayRowActionTooltipTitle'; + $translationKeys[] = 'General_OverlayRowActionTooltip'; + } +} diff --git a/www/analytics/plugins/Overlay/client/client.css b/www/analytics/plugins/Overlay/client/client.css new file mode 100644 index 00000000..80e526a2 --- /dev/null +++ b/www/analytics/plugins/Overlay/client/client.css @@ -0,0 +1,138 @@ +/** + * Reset styles + */ + +#PIS_StatusBar, +#PIS_StatusBar .PIS_Item, +#PIS_StatusBar .PIS_Loading, +.PIS_LinkTag, +.PIS_LinkHighlightBoxTop, +.PIS_LinkHighlightBoxRight, +.PIS_LinkHighlightBoxLeft, +.PIS_LinkHighlightBoxText { + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-weight: normal; + font-style: normal; + font-size: 11px; + font-family: Arial, Helvetica, sans-serif; + vertical-align: baseline; + line-height: 1.4em; + text-indent: 0; + text-decoration: none; + text-transform: none; + cursor: default; + text-align: left; + float: none; + color: #333; +} + +/** + * Link Tags + */ + +.PIS_LinkTag { + position: absolute; + z-index: 9999; + width: 36px; + height: 21px; + text-align: left; + background: url(./linktags_lessshadow.png) no-repeat 0 -21px; + overflow: hidden; +} + +.PIS_LinkTag span { + position: absolute; + width: 31px; + height: 14px; + font-size: 10px; + text-align: center; + font-weight: bold; + line-height: 14px; + margin-left: 1px; +} + +.PIS_LinkTag.PIS_Highlighted { + z-index: 10002; +} + +.PIS_LinkTag.PIS_Highlighted span { + color: #E87500; +} + +.PIS_LinkTag.PIS_Right { + background-position: -36px -21px; +} + +.PIS_LinkTag.PIS_Right span, +.PIS_LinkTag.PIS_BottomRight span { + margin-left: 5px; +} + +.PIS_LinkTag.PIS_Bottom { + background-position: 0 0; +} + +.PIS_LinkTag.PIS_Bottom span, +.PIS_LinkTag.PIS_BottomRight span { + margin-top: 4px; +} + +.PIS_LinkTag.PIS_BottomRight { + background-position: -36px 0; +} + +/** + * Link Highlights + */ + +.PIS_LinkHighlightBoxTop, +.PIS_LinkHighlightBoxRight, +.PIS_LinkHighlightBoxText, +.PIS_LinkHighlightBoxLeft { + position: absolute; + z-index: 10001; + overflow: hidden; + width: 2px; + height: 2px; + background: #E87500; +} + +.PIS_LinkHighlightBoxText { + line-height: 20px; + height: 20px; + font-size: 11px; + color: white; + width: auto; +} + +/** + * Status bar + */ + +#PIS_StatusBar { + padding: 10px 0; + position: fixed; + z-index: 10020; + bottom: 0; + right: 0; + border-top: 1px solid #ccc; + border-left: 1px solid #ccc; + background: #fbfbfb; + border-radius: 6px 0 0; +} + +#PIS_StatusBar .PIS_Item { + text-align: right; + padding: 3px 5px 0 0; + margin: 0 15px 0 20px; + + font-weight: bold; +} + +#PIS_StatusBar .PIS_Loading { + background: url(./loading.gif) no-repeat right center; + padding-right: 30px; +} \ No newline at end of file diff --git a/www/analytics/plugins/Overlay/client/client.js b/www/analytics/plugins/Overlay/client/client.js new file mode 100644 index 00000000..f791f8be --- /dev/null +++ b/www/analytics/plugins/Overlay/client/client.js @@ -0,0 +1,261 @@ +var Piwik_Overlay_Client = (function () { + + /** jQuery */ + var $; + + /** Url of the Piwik root */ + var piwikRoot; + + /** Piwik idsite */ + var idSite; + + /** The current period and date */ + var period, date; + + /** Reference to the status bar DOM element */ + var statusBar; + + /** Load the client CSS */ + function loadCss() { + var css = c('link').attr({ + rel: 'stylesheet', + type: 'text/css', + href: piwikRoot + 'plugins/Overlay/client/client.css' + }); + $('head').append(css); + } + + /** + * This method loads jQuery, if it is not there yet. + * The callback is triggered after jQuery is loaded. + */ + function loadJQuery(callback) { + if (typeof jQuery != 'undefined') { + $ = jQuery; + callback(); + } + else { + Piwik_Overlay_Client.loadScript('libs/jquery/jquery.js', function () { + $ = jQuery; + jQuery.noConflict(); + callback(); + }); + } + } + + /** + * Notify Piwik of the current iframe location. + * This way, we can display additional metrics on the side of the iframe. + */ + function notifyPiwikOfLocation() { + // check whether the session has been opened in a new tab (instead of an iframe) + if (window != window.top) { + var iframe = c('iframe', false, { + src: piwikRoot + 'index.php?module=Overlay&action=notifyParentIframe#' + window.location.href + }).css({width: 0, height: 0, border: 0}); + + // in some cases, calling append right away doesn't work in IE8 + $(document).ready(function () { + $('body').append(iframe); + }); + } + } + + /** Create a jqueryfied DOM element */ + function c(tagName, className, attributes) { + var el = $(document.createElement(tagName)); + + if (className) { + if (className.substring(0, 1) == '#') { + var id = className.substring(1, className.length); + id = 'PIS_' + id; + el.attr('id', id); + } + else { + className = 'PIS_' + className; + el.addClass(className); + } + } + + if (attributes) { + el.attr(attributes); + } + + return el; + } + + /** Special treatment for some internet explorers */ + var ieStatusBarEventsBound = false; + + function handleIEStatusBar() { + if (navigator.appVersion.indexOf("MSIE 7.") == -1 + && navigator.appVersion.indexOf("MSIE 8.") == -1) { + // this is not IE8 or lower + return; + } + + // IE7/8 can't handle position:fixed so we need to do it by hand + statusBar.css({ + position: 'absolute', + right: 'auto', + bottom: 'auto', + left: 0, + top: 0 + }); + + var position = function () { + var scrollY = document.body.parentElement.scrollTop; + var scrollX = document.body.parentElement.scrollLeft; + statusBar.css({ + top: (scrollY + $(window).height() - statusBar.outerHeight()) + 'px', + left: (scrollX + $(window).width() - statusBar.outerWidth()) + 'px' + }); + }; + + position(); + + statusBar.css({width: 'auto'}); + if (statusBar.width() < 350) { + statusBar.width(350); + } else { + statusBar.width(statusBar.width()); + } + + if (!ieStatusBarEventsBound) { + ieStatusBarEventsBound = true; + $(window).resize(position); + $(window).scroll(position); + } + } + + return { + + /** Initialize in-site analytics */ + initialize: function (pPiwikRoot, pIdSite, pPeriod, pDate) { + piwikRoot = pPiwikRoot; + idSite = pIdSite; + period = pPeriod; + date = pDate; + + var load = this.loadScript; + var loading = this.loadingNotification; + + loadJQuery(function () { + notifyPiwikOfLocation(); + loadCss(); + + // translations + load('plugins/Overlay/client/translations.js', function () { + Piwik_Overlay_Translations.initialize(function () { + // following pages + var finishPages = loading('Loading following pages'); + load('plugins/Overlay/client/followingpages.js', function () { + Piwik_Overlay_FollowingPages.initialize(finishPages); + }); + + }); + }); + }); + }, + + /** Create a jqueryfied DOM element */ + createElement: function (tagName, className, attributes) { + return c(tagName, className, attributes); + }, + + /** Load a script and wait for it to be loaded */ + loadScript: function (relativePath, callback) { + var loaded = false; + var onLoad = function () { + if (!loaded) { + loaded = true; + callback(); + } + }; + + var head = document.getElementsByTagName('head')[0]; + var script = document.createElement('script'); + script.type = 'text/javascript'; + + script.onreadystatechange = function () { + if (this.readyState == 'loaded' || this.readyState == 'complete') { + onLoad(); + } + }; + script.onload = onLoad; + + script.src = piwikRoot + relativePath + '?v=1'; + head.appendChild(script); + }, + + /** Piwik Overlay API Request */ + api: function (method, callback, additionalParams) { + var url = piwikRoot + 'index.php?module=API&method=Overlay.' + method + + '&idSite=' + idSite + '&period=' + period + '&date=' + date + '&format=JSON&filter_limit=-1'; + + if (additionalParams) { + url += '&' + additionalParams; + } + + $.getJSON(url + "&jsoncallback=?", function (data) { + if (typeof data.result != 'undefined' && data.result == 'error') { + alert('Error: ' + data.message); + } + else { + callback(data); + } + }); + }, + + /** + * Initialize a notification + * To hide the notification use the returned callback + */ + notification: function (message, addClass) { + if (!statusBar) { + statusBar = c('div', '#StatusBar').css('opacity', .8); + $('body').prepend(statusBar); + } + + var item = c('div', 'Item').html(message); + + if (addClass) { + item.addClass('PIS_' + addClass); + } + + statusBar.show().append(item); + + handleIEStatusBar(); + window.setTimeout(handleIEStatusBar, 100); + + return function () { + item.remove(); + if (statusBar.children().size() == 0) { + statusBar.hide(); + } else { + handleIEStatusBar(); + } + }; + }, + + /** Hide all notifications with a certain class */ + hideNotifications: function (className) { + statusBar.find('.PIS_' + className).remove(); + if (statusBar.children().size() == 0) { + statusBar.hide(); + } else { + handleIEStatusBar(); + } + }, + + /** + * Initialize a loading notification + * To hide the notification use the returned callback + */ + loadingNotification: function (message) { + return Piwik_Overlay_Client.notification(message, 'Loading'); + } + + }; + +})(); diff --git a/www/analytics/plugins/Overlay/client/close.png b/www/analytics/plugins/Overlay/client/close.png new file mode 100644 index 00000000..1514d51a Binary files /dev/null and b/www/analytics/plugins/Overlay/client/close.png differ diff --git a/www/analytics/plugins/Overlay/client/followingpages.js b/www/analytics/plugins/Overlay/client/followingpages.js new file mode 100644 index 00000000..015c79ea --- /dev/null +++ b/www/analytics/plugins/Overlay/client/followingpages.js @@ -0,0 +1,544 @@ +var Piwik_Overlay_FollowingPages = (function () { + + /** jQuery */ + var $ = jQuery; + + /** Info about the following pages */ + var followingPages = []; + + /** List of excluded get parameters */ + var excludedParams = []; + + /** Index of the links on the page */ + var linksOnPage = {}; + + /** Reference to create element function */ + var c; + + /** Load the following pages */ + function load(callback) { + // normalize current location + var location = window.location.href; + location = Piwik_Overlay_UrlNormalizer.normalize(location); + location = (("https:" == document.location.protocol) ? 'https' : 'http') + '://' + location; + + var excludedParamsLoaded = false; + var followingPagesLoaded = false; + + // load excluded params + Piwik_Overlay_Client.api('getExcludedQueryParameters', function (data) { + for (var i = 0; i < data.length; i++) { + if (typeof data[i] == 'object') { + data[i] = data[i][0]; + } + } + excludedParams = data; + + excludedParamsLoaded = true; + if (followingPagesLoaded) { + callback(); + } + }); + + // load following pages + Piwik_Overlay_Client.api('getFollowingPages', function (data) { + followingPages = data; + processFollowingPages(); + + followingPagesLoaded = true; + if (excludedParamsLoaded) { + callback(); + } + }, 'url=' + encodeURIComponent(location)); + } + + /** Normalize the URLs of following pages and aggregate some stats */ + function processFollowingPages() { + var totalClicks = 0; + for (var i = 0; i < followingPages.length; i++) { + var page = followingPages[i]; + // though the following pages are returned without the prefix, downloads + // and outlinks still have it. + page.label = Piwik_Overlay_UrlNormalizer.removeUrlPrefix(page.label); + totalClicks += followingPages[i].referrals; + } + for (i = 0; i < followingPages.length; i++) { + followingPages[i].clickRate = followingPages[i].referrals / totalClicks * 100; + } + } + + /** + * Build an index of links on the page. + * This function is passed to $('a').each() + */ + var processLinkDelta = false; + + function processLink() { + var a = $(this); + a[0].piwikDiscovered = true; + + var href = a.attr('href'); + href = Piwik_Overlay_UrlNormalizer.normalize(href); + + if (href) { + if (typeof linksOnPage[href] == 'undefined') { + linksOnPage[href] = [a]; + } + else { + linksOnPage[href].push(a); + } + } + + if (href && processLinkDelta !== false) { + if (typeof processLinkDelta[href] == 'undefined') { + processLinkDelta[href] = [a]; + } + else { + processLinkDelta[href].push(a); + } + } + } + + var repositionTimeout = false; + var resizeTimeout = false; + + function build(callback) { + // build an index of all links on the page + $('a').each(processLink); + + // add tags to known following pages + createLinkTags(linksOnPage); + + // position the tags + positionLinkTags(); + + callback(); + + // check on a regular basis whether new links have appeared. + // we use a timeout instead of an interval to make sure one call is done before + // the next one is triggered + var repositionAfterTimeout; + repositionAfterTimeout = function () { + repositionTimeout = window.setTimeout(function () { + findNewLinks(); + positionLinkTags(repositionAfterTimeout); + }, 1800); + }; + repositionAfterTimeout(); + + // reposition link tags on window resize + $(window).resize(function () { + if (repositionTimeout) { + window.clearTimeout(repositionTimeout); + } + if (resizeTimeout) { + window.clearTimeout(resizeTimeout); + } + resizeTimeout = window.setTimeout(function () { + positionLinkTags(); + repositionAfterTimeout(); + }, 70); + }); + } + + /** Create a batch of link tags */ + function createLinkTags(links) { + var body = $('body'); + for (var i = 0; i < followingPages.length; i++) { + var url = followingPages[i].label; + if (typeof links[url] != 'undefined') { + for (var j = 0; j < links[url].length; j++) { + createLinkTag(links[url][j], url, followingPages[i], body); + } + } + } + } + + /** Create the link tag element */ + function createLinkTag(linkTag, linkUrl, data, body) { + if (typeof linkTag[0].piwikTagElement != 'undefined' && linkTag[0].piwikTagElement !== null) { + // this link tag already has a tag element. happens in rare cases. + return; + } + + linkTag[0].piwikTagElement = true; + + var rate = data.clickRate; + if (rate < 10) { + rate = Math.round(rate * 10) / 10; + } else { + rate = Math.round(rate); + } + + var span = c('span').html(rate + '%'); + var tagElement = c('div', 'LinkTag').append(span).hide(); + body.prepend(tagElement); + + linkTag.add(tagElement).hover(function () { + highlightLink(linkTag, linkUrl, data); + }, function () { + unHighlightLink(linkTag, linkUrl); + }); + + // attach the tag element to the link element. we can't use .data() because jquery + // would remove it when removing the link from the dom. but we still need to find + // the tag element to remove it as well. + linkTag[0].piwikTagElement = tagElement; + } + + /** Position the link tags next to the links */ + function positionLinkTags(callback) { + var url, linkTag, tagElement, offset, top, left, isRight, hasOneChild, inlineChild; + var tagWidth = 36, tagHeight = 21; + var tagsToRemove = []; + + for (var i = 0; i < followingPages.length; i++) { + url = followingPages[i].label; + if (typeof linksOnPage[url] != 'undefined') { + for (var j = 0; j < linksOnPage[url].length; j++) { + linkTag = linksOnPage[url][j]; + tagElement = linkTag[0].piwikTagElement; + + if (linkTag.closest('html').length == 0 || !tagElement) { + // the link has been removed from the dom + if (tagElement) { + tagElement.hide(); + } + // mark for deletion. don't delete it now because we + // are iterating of the array it's in. it will be deleted + // below this for loop. + tagsToRemove.push({ + index1: url, + index2: j + }); + continue; + } + + hasOneChild = checkHasOneChild(linkTag); + inlineChild = false; + if (hasOneChild && linkTag.css('display') != 'block') { + inlineChild = linkTag.children().eq(0); + } + + if (getVisibility(linkTag) == 'hidden' || ( + // in case of hasOneChild: jquery always returns linkTag.is(':visible')=false + !linkTag.is(':visible') && !(hasOneChild && inlineChild && inlineChild.is(':visible')) + )) { + // link is not visible + tagElement.hide(); + continue; + } + + tagElement.attr('class', 'PIS_LinkTag'); // reset class + if (tagElement[0].piwikHighlighted) { + tagElement.addClass('PIS_Highlighted'); + } + + // see comment in highlightLink() + if (hasOneChild && linkTag.find('> img').size() == 1) { + offset = linkTag.find('> img').offset(); + if (offset.left == 0 && offset.top == 0) { + offset = linkTag.offset(); + } + } else if (inlineChild !== false) { + offset = inlineChild.offset(); + } else { + offset = linkTag.offset(); + } + + top = offset.top - tagHeight + 6; + left = offset.left - tagWidth + 10; + + if (isRight = (left < 2)) { + tagElement.addClass('PIS_Right'); + left = offset.left + linkTag.outerWidth() - 10; + } + + if (top < 2) { + tagElement.addClass(isRight ? 'PIS_BottomRight' : 'PIS_Bottom'); + top = offset.top + linkTag.outerHeight() - 6; + } + + tagElement.css({ + top: top + 'px', + left: left + 'px' + }).show(); + + } + } + } + + // walk tagsToRemove from back to front because it contains the indexes in ascending + // order. removing something from the front will impact the indexes that come after- + // wards. this can be avoided by starting in the back. + for (var k = tagsToRemove.length - 1; k >= 0; k--) { + var tagToRemove = tagsToRemove[k]; + linkTag = linksOnPage[tagToRemove.index1][tagToRemove.index2]; + // remove the tag element from the dom + if (linkTag && linkTag[0] && linkTag[0].piwikTagElement) { + tagElement = linkTag[0].piwikTagElement; + if (tagElement[0].piwikHighlighted) { + unHighlightLink(linkTag, tagToRemove.index1); + } + tagElement.remove(); + linkTag[0].piwikTagElement = null; + } + // remove the link from the index + linksOnPage[tagToRemove.index1].splice(tagToRemove.index2, 1); + if (linksOnPage[tagToRemove.index1].length == 0) { + delete linksOnPage[tagToRemove.index1]; + } + } + + if (typeof callback == 'function') { + callback(); + } + } + + /** Get the visibility of an element */ + function getVisibility(el) { + var visibility = el.css('visibility'); + if (visibility == 'inherit') { + el = el.parent(); + if (el.size() > 0) { + return getVisibility(el); + } + } + return visibility; + } + + /** + * Find out whether a link has only one child. Using .children().size() == 1 doesn't work + * because it doesn't take additional text nodes into account. + */ + function checkHasOneChild(linkTag) { + var hasOneChild = (linkTag.children().size() == 1); + if (hasOneChild) { + // if the element contains one tag and some text, hasOneChild is set incorrectly + var contents = linkTag.contents(); + if (contents.size() > 1) { + // find non-empty text nodes + contents = contents.filter(function () { + return this.nodeType == 3 && // text node + $.trim(this.data).length > 0; // contains more than whitespaces + }); + if (contents.size() > 0) { + hasOneChild = false; + } + } + } + return hasOneChild; + } + + /** Check whether new links have been added to the dom */ + function findNewLinks() { + var newLinks = $('a').filter(function () { + return typeof this.piwikDiscovered == 'undefined' || this.piwikDiscovered === null; + }); + + if (newLinks.size() == 0) { + return; + } + + processLinkDelta = {}; + newLinks.each(processLink); + createLinkTags(processLinkDelta); + processLinkDelta = false; + } + + /** Dom elements used for drawing a box around the link */ + var highlightElements = []; + + /** Highlight a link on hover */ + function highlightLink(linkTag, linkUrl, data) { + if (highlightElements.length == 0) { + highlightElements.push(c('div', 'LinkHighlightBoxTop')); + highlightElements.push(c('div', 'LinkHighlightBoxRight')); + highlightElements.push(c('div', 'LinkHighlightBoxLeft')); + + highlightElements.push(c('div', 'LinkHighlightBoxText')); + + var body = $('body'); + for (var i = 0; i < highlightElements.length; i++) { + body.prepend(highlightElements[i].css({display: 'none'})); + } + } + + var width = linkTag.outerWidth(); + + var offset, height; + var hasOneChild = checkHasOneChild(linkTag); + if (hasOneChild && linkTag.find('img').size() == 1) { + // if the tag contains only an , the offset and height methods don't work properly. + // as a result, the box around the image link would be wrong. we use the image to derive + // the offset and height instead of the link to get correct values. + var img = linkTag.find('img'); + offset = img.offset(); + height = img.outerHeight(); + } + if (hasOneChild && linkTag.css('display') != 'block') { + // if the tag is not displayed as block and has only one child, using the child to + // derive the offset and dimensions is more robust. + var child = linkTag.children().eq(0); + offset = child.offset(); + height = child.outerHeight(); + width = child.outerWidth(); + } else { + offset = linkTag.offset(); + height = linkTag.outerHeight(); + } + + var numLinks = linksOnPage[linkUrl].length; + + putBoxAroundLink(offset, width, height, numLinks, data.referrals); + + // highlight tags + for (var j = 0; j < numLinks; j++) { + var tag = linksOnPage[linkUrl][j][0].piwikTagElement; + tag.addClass('PIS_Highlighted'); + tag[0].piwikHighlighted = true; + } + + // Sometimes it fails to remove the notification when the hovered element is removed. + // To make sure we don't display more than one location at a time, we hide all before showing the new one. + Piwik_Overlay_Client.hideNotifications('LinkLocation'); + + // we don't use .data() because jquery would remove the callback when the link tag is removed + linkTag[0].piwikHideNotification = Piwik_Overlay_Client.notification( + Piwik_Overlay_Translations.get('link') + ': ' + linkUrl, 'LinkLocation'); + } + + function putBoxAroundLink(offset, width, height, numLinks, numReferrals) { + var borderWidth = 2; + var padding = 4; // the distance between the link and the border + + // top border + highlightElements[0] + .width(width + 2 * padding) + .css({ + top: offset.top - borderWidth - padding, + left: offset.left - padding + }).show(); + + // right border + highlightElements[1] + .height(height + 2 * borderWidth + 2 * padding) + .css({ + top: offset.top - borderWidth - padding, + left: offset.left + width + padding + }).show(); + + // left border + highlightElements[2] + .height(height + 2 * borderWidth + 2 * padding) + .css({ + top: offset.top - borderWidth - padding, + left: offset.left - borderWidth - padding + }).show(); + + // bottom box text + var text; + if (numLinks > 1) { + text = Piwik_Overlay_Translations.get('clicksFromXLinks') + .replace(/%1\$s/, numReferrals) + .replace(/%2\$s/, numLinks); + } else if (numReferrals == 1) { + text = Piwik_Overlay_Translations.get('oneClick'); + } else { + text = Piwik_Overlay_Translations.get('clicks') + .replace(/%s/, numReferrals); + } + + // bottom box position and dimension + var textPadding = '   '; + highlightElements[3].html(textPadding + text + textPadding).css({ + width: 'auto', + top: offset.top + height + padding, + left: offset.left - borderWidth - padding + }).show(); + + var minBoxWidth = width + 2 * borderWidth + 2 * padding; + if (highlightElements[3].width() < minBoxWidth) { + // we cannot use minWidth because of IE7 + highlightElements[3].width(minBoxWidth); + } + } + + /** Remove highlight from link */ + function unHighlightLink(linkTag, linkUrl) { + for (var i = 0; i < highlightElements.length; i++) { + highlightElements[i].hide(); + } + + var numLinks = linksOnPage[linkUrl].length; + for (var j = 0; j < numLinks; j++) { + var tag = linksOnPage[linkUrl][j][0].piwikTagElement; + if (tag) { + tag.removeClass('PIS_Highlighted'); + tag[0].piwikHighlighted = false; + } + } + + if ((typeof linkTag[0].piwikHideNotification) == 'function') { + linkTag[0].piwikHideNotification(); + linkTag[0].piwikHideNotification = null; + } + } + + + return { + + /** + * The main method + */ + initialize: function (finishCallback) { + c = Piwik_Overlay_Client.createElement; + Piwik_Overlay_Client.loadScript('plugins/Overlay/client/urlnormalizer.js', function () { + Piwik_Overlay_UrlNormalizer.initialize(); + load(function () { + Piwik_Overlay_UrlNormalizer.setExcludedParameters(excludedParams); + build(function () { + finishCallback(); + }) + }); + }); + }, + + /** + * Remove everything from the dom and terminate timeouts. + * This can be used from the console in order to load a new implementation for debugging afterwards. + * If you add `Piwik_Overlay_FollowingPages.remove();` to the beginning and + * `Piwik_Overlay_FollowingPages.initialize(function(){});` to the end of this file, you can just + * paste it into the console to inject the new implementation. + */ + remove: function () { + for (var i = 0; i < followingPages.length; i++) { + var url = followingPages[i].label; + if (typeof linksOnPage[url] != 'undefined') { + for (var j = 0; j < linksOnPage[url].length; j++) { + var linkTag = linksOnPage[url][j]; + var tagElement = linkTag[0].piwikTagElement; + if (tagElement) { + tagElement.remove(); + } + linkTag[0].piwikTagElement = null; + + $(linkTag).unbind('mouseenter').unbind('mouseleave'); + } + } + } + for (i = 0; i < highlightElements.length; i++) { + highlightElements[i].remove(); + } + if (repositionTimeout) { + window.clearTimeout(repositionTimeout); + } + if (resizeTimeout) { + window.clearTimeout(resizeTimeout); + } + $(window).unbind('resize'); + } + + }; + +})(); \ No newline at end of file diff --git a/www/analytics/plugins/Overlay/client/linktags.eps b/www/analytics/plugins/Overlay/client/linktags.eps new file mode 100644 index 00000000..b9279b8a --- /dev/null +++ b/www/analytics/plugins/Overlay/client/linktags.eps @@ -0,0 +1,5251 @@ +%!PS-Adobe-3.1 EPSF-3.0 +%ADO_DSC_Encoding: MacOS Roman +%%Title: bobbls.eps +%%Creator: Adobe Illustrator(R) 14.0 +%%For: Eva Besenreuther +%%CreationDate: 04.06.11 +%%BoundingBox: 0 0 100 50 +%%HiResBoundingBox: 0 0 99.6602 49.4995 +%%CropBox: 0 0 99.6602 49.4995 +%%LanguageLevel: 2 +%%DocumentData: Clean7Bit +%ADOBeginClientInjection: DocumentHeader "AI11EPS" +%%AI8_CreatorVersion: 14.0.0 %AI9_PrintingDataBegin %ADO_BuildNumber: Adobe Illustrator(R) 14.0.0 x367 R agm 4.4890 ct 5.1541 %ADO_ContainsXMP: MainFirst %AI7_Thumbnail: 128 64 8 %%BeginData: 7006 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD4BFFA8FD64FFA8FFA8A8A8FD16FFFD04A8FD60FFA8A8A8FFA8FD %17FFFD05A8FFA8FD58FFA8FFA8A8A8FFA8FFA8A8FD18FFA8FFFFFFFD05A8 %FD54FFA8A8A8FD05FFA8A8FD1AFFA8FD05FFA8A8A8FD50FFFD05A8FD06FF %A8A8FD1AFFA8A8FD07FFA8A8A8FFA8FD2CFFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFFD05A8FD09FFA8FD1CFFA8FD0AFF %A8A8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FD0CFFFD21A8FD0BFF7DFD1DFFA8A8FD0AFFFD23A8FD06FFA8FD2BFFA8 %FD1FFFA8FD2BFFA8A8FD05FFA8FD2CFFA8A8FD1EFFA8A8FD2BFFA8A8FFFF %FFA8FD2DFFA8FD20FFA8A8FD2BFFA8FFFFA8A8A8FFFFFFA8FFFFFFA8FFFF %FFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8 %FFFFFF7DFD21FFA8A8A8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8 %FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFA8FD2BFF %A8A8FD22FFA8FD2CFFA8FFA8A8FD2CFFA8FD21FFA8A8FD2CFFA8FFA8A8FD %2CFFA8A8FD21FFA8FD2CFFA8A87DFFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FD21FFA8A8FFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8A8A8FD2DFFA8A8FD21FF %A8FD2DFFA8A8FFFFFFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FD21FFA8A8FFFFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFFD04A8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8 %FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8A8FD %22FFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFF %A8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFCBFD05A8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FD21FFA8A8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFFFA8FFFFFF %AFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFF %FFFFAFFFFFFFAFFFFFFFA8FD23FFA8FFFFFFAFFFFFFFAFFFFFFFAFFFFFFF %AFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFAFFFFFFFA8A8 %FFFFA8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8A8A8FD22FFA8A8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8A8A8FFFFA8A8A8FFFFFFA8FFCBFFA8FFCBFFA8FFCBFFA8FFCB %FFA8FFCBFFA8FFCBFFA8FFCBFFA8FFCBFFA8FFFFFFA8FFA8FD24FFA8A8FF %FFFFA8FFCBFFA8FFCBFFA8FFCBFFA8FFCBFFA8FFCBFFA8FFCBFFA8FFCBFF %A8FFCBFFA8FFCBFFA8FFA8FD04FFA8A8A8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8A87DFD25FF %A8A8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8A87DFD06FFA8A8A8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8A8A8FD27FF %A8A8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8A8A8FD08FFA8FFFD23A8FFA8FD29FFA8FFFD25A8FD %FCFFFDFCFFFDFCFFFDFCFFFDFCFFFD9FFFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FD2DFFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FD0CFFFD27A8FD29FFFD27A8FD06FFA8A8A8FD23FFA8FFA8A8A8FD26FFA8 %7DFFA8FD21FFA8FFA8A87DFD05FFA8A8FD28FFA8A8FD25FFA8FD29FFA8FF %FFFFA8A8FD29FFA8A8A8FD23FFA8FD2BFFA8FFFFA8FD2BFFA8FD23FFA8FD %2CFFA8FFA8A8FD04FFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8 %FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FD22FFA8A8FFFFFFA8FF %FFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFF %A8FFFFFFA8FFFFFFA8FFA8A8FD2CFFA8FD22FFA8FD2DFFA8A8FD2DFFA8FD %21FFA8A8A8FD2BFFA8A8A8FD2DFFA8A8FD21FFA8FD2DFFA87DFFFFFFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FD21FFA8A8A8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %FD04A8FD2CFFA8A8FD21FFA8FD2DFFA8A8FFFFFFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FD21FFA8A8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFFD04A8FFFFFFA8FF %FFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFF %A8FFFFFFA8FFFFFFA8A8FD22FFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8 %FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FD05FFFD04A8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8A8A8FD22FFA8A8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8 %FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8A8A8FD21FFA8FFFFFFA8 %FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFF %FFA8FFFFFFA8FFFFFFA8A8FFFFA8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8A8 %FD20FFA8A8A8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8 %FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8FFA8A8A8FFFFFFA8A8FFFFFFA8FF %FFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFCAFF %A8FFCAFFA8FFFFFFA8FD1FFFA8A8A8FFA8FFCAFFA8FFA8FFA8FFFFFFA8FF %FFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8FFFFFFA8A8A8FD %05FFA87DFD04A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8 %A8FFA8A8A8FFA8FFA8FFA8FFA8FFA8A87DFD1DFFA8A8A8FFA8FFA8FFA8FF %A8FFA8FFA8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FF %FD07A8FD08FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FF %A8A8A8FFFD05A8FFA8FFA8FFA8FFA8FFA8FFA8FD1DFFA8FFA8FFA8FFA8FF %A8FFA8FFFD05A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFA8A8A8FFFD05 %A8FFA8FD28FFFD05A8FFA8FFA8FFA8FFA8A8A8FD1BFFA8A8A8FFA8FFA8FF %FD05A8FFA8FD4AFFFD05A8FFA8FFA8FFA8FD1BFFA8A8A8FFA8FFFD05A8FD %52FFFD05A8FFA8A87DFD19FFA8A8A8FFFD07A8FD56FFFD05A8FFA8FD18FF %A8A8FFA8A8A8FD5EFFFD05A8FD17FFFD05A8FD62FFA8A8FD16FFA8FFA8FD %66FFA8FD15FFA8FD34FFFF %%EndData +%ADOEndClientInjection: DocumentHeader "AI11EPS" +%%Pages: 1 +%%DocumentNeededResources: +%%DocumentSuppliedResources: procset Adobe_AGM_Image 1.0 0 +%%+ procset Adobe_CoolType_Utility_T42 1.0 0 +%%+ procset Adobe_CoolType_Utility_MAKEOCF 1.23 0 +%%+ procset Adobe_CoolType_Core 2.31 0 +%%+ procset Adobe_AGM_Core 2.0 0 +%%+ procset Adobe_AGM_Utils 1.0 0 +%%DocumentFonts: +%%DocumentNeededFonts: +%%DocumentNeededFeatures: +%%DocumentSuppliedFeatures: +%%DocumentProcessColors: Cyan Magenta Yellow Black +%%DocumentCustomColors: +%%CMYKCustomColor: +%%RGBCustomColor: +%%EndComments + + + + + + +%%BeginDefaults +%%ViewingOrientation: 1 0 0 1 +%%EndDefaults +%%BeginProlog +%%BeginResource: procset Adobe_AGM_Utils 1.0 0 +%%Version: 1.0 0 +%%Copyright: Copyright(C)2000-2006 Adobe Systems, Inc. All Rights Reserved. +systemdict/setpacking known +{currentpacking true setpacking}if +userdict/Adobe_AGM_Utils 75 dict dup begin put +/bdf +{bind def}bind def +/nd{null def}bdf +/xdf +{exch def}bdf +/ldf +{load def}bdf +/ddf +{put}bdf +/xddf +{3 -1 roll put}bdf +/xpt +{exch put}bdf +/ndf +{ + exch dup where{ + pop pop pop + }{ + xdf + }ifelse +}def +/cdndf +{ + exch dup currentdict exch known{ + pop pop + }{ + exch def + }ifelse +}def +/gx +{get exec}bdf +/ps_level + /languagelevel where{ + pop systemdict/languagelevel gx + }{ + 1 + }ifelse +def +/level2 + ps_level 2 ge +def +/level3 + ps_level 3 ge +def +/ps_version + {version cvr}stopped{-1}if +def +/set_gvm +{currentglobal exch setglobal}bdf +/reset_gvm +{setglobal}bdf +/makereadonlyarray +{ + /packedarray where{pop packedarray + }{ + array astore readonly}ifelse +}bdf +/map_reserved_ink_name +{ + dup type/stringtype eq{ + dup/Red eq{ + pop(_Red_) + }{ + dup/Green eq{ + pop(_Green_) + }{ + dup/Blue eq{ + pop(_Blue_) + }{ + dup()cvn eq{ + pop(Process) + }if + }ifelse + }ifelse + }ifelse + }if +}bdf +/AGMUTIL_GSTATE 22 dict def +/get_gstate +{ + AGMUTIL_GSTATE begin + /AGMUTIL_GSTATE_clr_spc currentcolorspace def + /AGMUTIL_GSTATE_clr_indx 0 def + /AGMUTIL_GSTATE_clr_comps 12 array def + mark currentcolor counttomark + {AGMUTIL_GSTATE_clr_comps AGMUTIL_GSTATE_clr_indx 3 -1 roll put + /AGMUTIL_GSTATE_clr_indx AGMUTIL_GSTATE_clr_indx 1 add def}repeat pop + /AGMUTIL_GSTATE_fnt rootfont def + /AGMUTIL_GSTATE_lw currentlinewidth def + /AGMUTIL_GSTATE_lc currentlinecap def + /AGMUTIL_GSTATE_lj currentlinejoin def + /AGMUTIL_GSTATE_ml currentmiterlimit def + currentdash/AGMUTIL_GSTATE_do xdf/AGMUTIL_GSTATE_da xdf + /AGMUTIL_GSTATE_sa currentstrokeadjust def + /AGMUTIL_GSTATE_clr_rnd currentcolorrendering def + /AGMUTIL_GSTATE_op currentoverprint def + /AGMUTIL_GSTATE_bg currentblackgeneration cvlit def + /AGMUTIL_GSTATE_ucr currentundercolorremoval cvlit def + currentcolortransfer cvlit/AGMUTIL_GSTATE_gy_xfer xdf cvlit/AGMUTIL_GSTATE_b_xfer xdf + cvlit/AGMUTIL_GSTATE_g_xfer xdf cvlit/AGMUTIL_GSTATE_r_xfer xdf + /AGMUTIL_GSTATE_ht currenthalftone def + /AGMUTIL_GSTATE_flt currentflat def + end +}def +/set_gstate +{ + AGMUTIL_GSTATE begin + AGMUTIL_GSTATE_clr_spc setcolorspace + AGMUTIL_GSTATE_clr_indx{AGMUTIL_GSTATE_clr_comps AGMUTIL_GSTATE_clr_indx 1 sub get + /AGMUTIL_GSTATE_clr_indx AGMUTIL_GSTATE_clr_indx 1 sub def}repeat setcolor + AGMUTIL_GSTATE_fnt setfont + AGMUTIL_GSTATE_lw setlinewidth + AGMUTIL_GSTATE_lc setlinecap + AGMUTIL_GSTATE_lj setlinejoin + AGMUTIL_GSTATE_ml setmiterlimit + AGMUTIL_GSTATE_da AGMUTIL_GSTATE_do setdash + AGMUTIL_GSTATE_sa setstrokeadjust + AGMUTIL_GSTATE_clr_rnd setcolorrendering + AGMUTIL_GSTATE_op setoverprint + AGMUTIL_GSTATE_bg cvx setblackgeneration + AGMUTIL_GSTATE_ucr cvx setundercolorremoval + AGMUTIL_GSTATE_r_xfer cvx AGMUTIL_GSTATE_g_xfer cvx AGMUTIL_GSTATE_b_xfer cvx + AGMUTIL_GSTATE_gy_xfer cvx setcolortransfer + AGMUTIL_GSTATE_ht/HalftoneType get dup 9 eq exch 100 eq or + { + currenthalftone/HalftoneType get AGMUTIL_GSTATE_ht/HalftoneType get ne + { + mark AGMUTIL_GSTATE_ht{sethalftone}stopped cleartomark + }if + }{ + AGMUTIL_GSTATE_ht sethalftone + }ifelse + AGMUTIL_GSTATE_flt setflat + end +}def +/get_gstate_and_matrix +{ + AGMUTIL_GSTATE begin + /AGMUTIL_GSTATE_ctm matrix currentmatrix def + end + get_gstate +}def +/set_gstate_and_matrix +{ + set_gstate + AGMUTIL_GSTATE begin + AGMUTIL_GSTATE_ctm setmatrix + end +}def +/AGMUTIL_str256 256 string def +/AGMUTIL_src256 256 string def +/AGMUTIL_dst64 64 string def +/AGMUTIL_srcLen nd +/AGMUTIL_ndx nd +/AGMUTIL_cpd nd +/capture_cpd{ + //Adobe_AGM_Utils/AGMUTIL_cpd currentpagedevice ddf +}def +/thold_halftone +{ + level3 + {sethalftone currenthalftone} + { + dup/HalftoneType get 3 eq + { + sethalftone currenthalftone + }{ + begin + Width Height mul{ + Thresholds read{pop}if + }repeat + end + currenthalftone + }ifelse + }ifelse +}def +/rdcmntline +{ + currentfile AGMUTIL_str256 readline pop + (%)anchorsearch{pop}if +}bdf +/filter_cmyk +{ + dup type/filetype ne{ + exch()/SubFileDecode filter + }{ + exch pop + } + ifelse + [ + exch + { + AGMUTIL_src256 readstring pop + dup length/AGMUTIL_srcLen exch def + /AGMUTIL_ndx 0 def + AGMCORE_plate_ndx 4 AGMUTIL_srcLen 1 sub{ + 1 index exch get + AGMUTIL_dst64 AGMUTIL_ndx 3 -1 roll put + /AGMUTIL_ndx AGMUTIL_ndx 1 add def + }for + pop + AGMUTIL_dst64 0 AGMUTIL_ndx getinterval + } + bind + /exec cvx + ]cvx +}bdf +/filter_indexed_devn +{ + cvi Names length mul names_index add Lookup exch get +}bdf +/filter_devn +{ + 4 dict begin + /srcStr xdf + /dstStr xdf + dup type/filetype ne{ + 0()/SubFileDecode filter + }if + [ + exch + [ + /devicen_colorspace_dict/AGMCORE_gget cvx/begin cvx + currentdict/srcStr get/readstring cvx/pop cvx + /dup cvx/length cvx 0/gt cvx[ + Adobe_AGM_Utils/AGMUTIL_ndx 0/ddf cvx + names_index Names length currentdict/srcStr get length 1 sub{ + 1/index cvx/exch cvx/get cvx + currentdict/dstStr get/AGMUTIL_ndx/load cvx 3 -1/roll cvx/put cvx + Adobe_AGM_Utils/AGMUTIL_ndx/AGMUTIL_ndx/load cvx 1/add cvx/ddf cvx + }for + currentdict/dstStr get 0/AGMUTIL_ndx/load cvx/getinterval cvx + ]cvx/if cvx + /end cvx + ]cvx + bind + /exec cvx + ]cvx + end +}bdf +/AGMUTIL_imagefile nd +/read_image_file +{ + AGMUTIL_imagefile 0 setfileposition + 10 dict begin + /imageDict xdf + /imbufLen Width BitsPerComponent mul 7 add 8 idiv def + /imbufIdx 0 def + /origDataSource imageDict/DataSource get def + /origMultipleDataSources imageDict/MultipleDataSources get def + /origDecode imageDict/Decode get def + /dstDataStr imageDict/Width get colorSpaceElemCnt mul string def + imageDict/MultipleDataSources known{MultipleDataSources}{false}ifelse + { + /imbufCnt imageDict/DataSource get length def + /imbufs imbufCnt array def + 0 1 imbufCnt 1 sub{ + /imbufIdx xdf + imbufs imbufIdx imbufLen string put + imageDict/DataSource get imbufIdx[AGMUTIL_imagefile imbufs imbufIdx get/readstring cvx/pop cvx]cvx put + }for + DeviceN_PS2{ + imageDict begin + /DataSource[DataSource/devn_sep_datasource cvx]cvx def + /MultipleDataSources false def + /Decode[0 1]def + end + }if + }{ + /imbuf imbufLen string def + Indexed_DeviceN level3 not and DeviceN_NoneName or{ + /srcDataStrs[imageDict begin + currentdict/MultipleDataSources known{MultipleDataSources{DataSource length}{1}ifelse}{1}ifelse + { + Width Decode length 2 div mul cvi string + }repeat + end]def + imageDict begin + /DataSource[AGMUTIL_imagefile Decode BitsPerComponent false 1/filter_indexed_devn load dstDataStr srcDataStrs devn_alt_datasource/exec cvx]cvx def + /Decode[0 1]def + end + }{ + imageDict/DataSource[1 string dup 0 AGMUTIL_imagefile Decode length 2 idiv string/readstring cvx/pop cvx names_index/get cvx/put cvx]cvx put + imageDict/Decode[0 1]put + }ifelse + }ifelse + imageDict exch + load exec + imageDict/DataSource origDataSource put + imageDict/MultipleDataSources origMultipleDataSources put + imageDict/Decode origDecode put + end +}bdf +/write_image_file +{ + begin + {(AGMUTIL_imagefile)(w+)file}stopped{ + false + }{ + Adobe_AGM_Utils/AGMUTIL_imagefile xddf + 2 dict begin + /imbufLen Width BitsPerComponent mul 7 add 8 idiv def + MultipleDataSources{DataSource 0 get}{DataSource}ifelse type/filetype eq{ + /imbuf imbufLen string def + }if + 1 1 Height MultipleDataSources not{Decode length 2 idiv mul}if{ + pop + MultipleDataSources{ + 0 1 DataSource length 1 sub{ + DataSource type dup + /arraytype eq{ + pop DataSource exch gx + }{ + /filetype eq{ + DataSource exch get imbuf readstring pop + }{ + DataSource exch get + }ifelse + }ifelse + AGMUTIL_imagefile exch writestring + }for + }{ + DataSource type dup + /arraytype eq{ + pop DataSource exec + }{ + /filetype eq{ + DataSource imbuf readstring pop + }{ + DataSource + }ifelse + }ifelse + AGMUTIL_imagefile exch writestring + }ifelse + }for + end + true + }ifelse + end +}bdf +/close_image_file +{ + AGMUTIL_imagefile closefile(AGMUTIL_imagefile)deletefile +}def +statusdict/product known userdict/AGMP_current_show known not and{ + /pstr statusdict/product get def + pstr(HP LaserJet 2200)eq + pstr(HP LaserJet 4000 Series)eq or + pstr(HP LaserJet 4050 Series )eq or + pstr(HP LaserJet 8000 Series)eq or + pstr(HP LaserJet 8100 Series)eq or + pstr(HP LaserJet 8150 Series)eq or + pstr(HP LaserJet 5000 Series)eq or + pstr(HP LaserJet 5100 Series)eq or + pstr(HP Color LaserJet 4500)eq or + pstr(HP Color LaserJet 4600)eq or + pstr(HP LaserJet 5Si)eq or + pstr(HP LaserJet 1200 Series)eq or + pstr(HP LaserJet 1300 Series)eq or + pstr(HP LaserJet 4100 Series)eq or + { + userdict/AGMP_current_show/show load put + userdict/show{ + currentcolorspace 0 get + /Pattern eq + {false charpath f} + {AGMP_current_show}ifelse + }put + }if + currentdict/pstr undef +}if +/consumeimagedata +{ + begin + AGMIMG_init_common + currentdict/MultipleDataSources known not + {/MultipleDataSources false def}if + MultipleDataSources + { + DataSource 0 get type + dup/filetype eq + { + 1 dict begin + /flushbuffer Width cvi string def + 1 1 Height cvi + { + pop + 0 1 DataSource length 1 sub + { + DataSource exch get + flushbuffer readstring pop pop + }for + }for + end + }if + dup/arraytype eq exch/packedarraytype eq or DataSource 0 get xcheck and + { + Width Height mul cvi + { + 0 1 DataSource length 1 sub + {dup DataSource exch gx length exch 0 ne{pop}if}for + dup 0 eq + {pop exit}if + sub dup 0 le + {exit}if + }loop + pop + }if + } + { + /DataSource load type + dup/filetype eq + { + 1 dict begin + /flushbuffer Width Decode length 2 idiv mul cvi string def + 1 1 Height{pop DataSource flushbuffer readstring pop pop}for + end + }if + dup/arraytype eq exch/packedarraytype eq or/DataSource load xcheck and + { + Height Width BitsPerComponent mul 8 BitsPerComponent sub add 8 idiv Decode length 2 idiv mul mul + { + DataSource length dup 0 eq + {pop exit}if + sub dup 0 le + {exit}if + }loop + pop + }if + }ifelse + end +}bdf +/addprocs +{ + 2{/exec load}repeat + 3 1 roll + [5 1 roll]bind cvx +}def +/modify_halftone_xfer +{ + currenthalftone dup length dict copy begin + currentdict 2 index known{ + 1 index load dup length dict copy begin + currentdict/TransferFunction known{ + /TransferFunction load + }{ + currenttransfer + }ifelse + addprocs/TransferFunction xdf + currentdict end def + currentdict end sethalftone + }{ + currentdict/TransferFunction known{ + /TransferFunction load + }{ + currenttransfer + }ifelse + addprocs/TransferFunction xdf + currentdict end sethalftone + pop + }ifelse +}def +/clonearray +{ + dup xcheck exch + dup length array exch + Adobe_AGM_Core/AGMCORE_tmp -1 ddf + { + Adobe_AGM_Core/AGMCORE_tmp 2 copy get 1 add ddf + dup type/dicttype eq + { + Adobe_AGM_Core/AGMCORE_tmp get + exch + clonedict + Adobe_AGM_Core/AGMCORE_tmp 4 -1 roll ddf + }if + dup type/arraytype eq + { + Adobe_AGM_Core/AGMCORE_tmp get exch + clonearray + Adobe_AGM_Core/AGMCORE_tmp 4 -1 roll ddf + }if + exch dup + Adobe_AGM_Core/AGMCORE_tmp get 4 -1 roll put + }forall + exch{cvx}if +}bdf +/clonedict +{ + dup length dict + begin + { + dup type/dicttype eq + {clonedict}if + dup type/arraytype eq + {clonearray}if + def + }forall + currentdict + end +}bdf +/DeviceN_PS2 +{ + /currentcolorspace AGMCORE_gget 0 get/DeviceN eq level3 not and +}bdf +/Indexed_DeviceN +{ + /indexed_colorspace_dict AGMCORE_gget dup null ne{ + dup/CSDBase known{ + /CSDBase get/CSD get_res/Names known + }{ + pop false + }ifelse + }{ + pop false + }ifelse +}bdf +/DeviceN_NoneName +{ + /Names where{ + pop + false Names + { + (None)eq or + }forall + }{ + false + }ifelse +}bdf +/DeviceN_PS2_inRip_seps +{ + /AGMCORE_in_rip_sep where + { + pop dup type dup/arraytype eq exch/packedarraytype eq or + { + dup 0 get/DeviceN eq level3 not and AGMCORE_in_rip_sep and + { + /currentcolorspace exch AGMCORE_gput + false + }{ + true + }ifelse + }{ + true + }ifelse + }{ + true + }ifelse +}bdf +/base_colorspace_type +{ + dup type/arraytype eq{0 get}if +}bdf +/currentdistillerparams where{pop currentdistillerparams/CoreDistVersion get 5000 lt}{true}ifelse +{ + /pdfmark_5{cleartomark}bind def +}{ + /pdfmark_5{pdfmark}bind def +}ifelse +/ReadBypdfmark_5 +{ + currentfile exch 0 exch/SubFileDecode filter + /currentdistillerparams where + {pop currentdistillerparams/CoreDistVersion get 5000 lt}{true}ifelse + {flushfile cleartomark} + {/PUT pdfmark}ifelse +}bdf +/ReadBypdfmark_5_string +{ + 2 dict begin + /makerString exch def string/tmpString exch def + { + currentfile tmpString readline not{pop exit}if + makerString anchorsearch + { + pop pop cleartomark exit + }{ + 3 copy/PUT pdfmark_5 pop 2 copy(\n)/PUT pdfmark_5 + }ifelse + }loop + end +}bdf +/xpdfm +{ + { + dup 0 get/Label eq + { + aload length[exch 1 add 1 roll/PAGELABEL + }{ + aload pop + [{ThisPage}<<5 -2 roll>>/PUT + }ifelse + pdfmark_5 + }forall +}bdf +/lmt{ + dup 2 index le{exch}if pop dup 2 index ge{exch}if pop +}bdf +/int{ + dup 2 index sub 3 index 5 index sub div 6 -2 roll sub mul exch pop add exch pop +}bdf +/ds{ + Adobe_AGM_Utils begin +}bdf +/dt{ + currentdict Adobe_AGM_Utils eq{ + end + }if +}bdf +systemdict/setpacking known +{setpacking}if +%%EndResource +%%BeginResource: procset Adobe_AGM_Core 2.0 0 +%%Version: 2.0 0 +%%Copyright: Copyright(C)1997-2007 Adobe Systems, Inc. All Rights Reserved. +systemdict/setpacking known +{ + currentpacking + true setpacking +}if +userdict/Adobe_AGM_Core 209 dict dup begin put +/Adobe_AGM_Core_Id/Adobe_AGM_Core_2.0_0 def +/AGMCORE_str256 256 string def +/AGMCORE_save nd +/AGMCORE_graphicsave nd +/AGMCORE_c 0 def +/AGMCORE_m 0 def +/AGMCORE_y 0 def +/AGMCORE_k 0 def +/AGMCORE_cmykbuf 4 array def +/AGMCORE_screen[currentscreen]cvx def +/AGMCORE_tmp 0 def +/AGMCORE_&setgray nd +/AGMCORE_&setcolor nd +/AGMCORE_&setcolorspace nd +/AGMCORE_&setcmykcolor nd +/AGMCORE_cyan_plate nd +/AGMCORE_magenta_plate nd +/AGMCORE_yellow_plate nd +/AGMCORE_black_plate nd +/AGMCORE_plate_ndx nd +/AGMCORE_get_ink_data nd +/AGMCORE_is_cmyk_sep nd +/AGMCORE_host_sep nd +/AGMCORE_avoid_L2_sep_space nd +/AGMCORE_distilling nd +/AGMCORE_composite_job nd +/AGMCORE_producing_seps nd +/AGMCORE_ps_level -1 def +/AGMCORE_ps_version -1 def +/AGMCORE_environ_ok nd +/AGMCORE_CSD_cache 0 dict def +/AGMCORE_currentoverprint false def +/AGMCORE_deltaX nd +/AGMCORE_deltaY nd +/AGMCORE_name nd +/AGMCORE_sep_special nd +/AGMCORE_err_strings 4 dict def +/AGMCORE_cur_err nd +/AGMCORE_current_spot_alias false def +/AGMCORE_inverting false def +/AGMCORE_feature_dictCount nd +/AGMCORE_feature_opCount nd +/AGMCORE_feature_ctm nd +/AGMCORE_ConvertToProcess false def +/AGMCORE_Default_CTM matrix def +/AGMCORE_Default_PageSize nd +/AGMCORE_Default_flatness nd +/AGMCORE_currentbg nd +/AGMCORE_currentucr nd +/AGMCORE_pattern_paint_type 0 def +/knockout_unitsq nd +currentglobal true setglobal +[/CSA/Gradient/Procedure] +{ + /Generic/Category findresource dup length dict copy/Category defineresource pop +}forall +setglobal +/AGMCORE_key_known +{ + where{ + /Adobe_AGM_Core_Id known + }{ + false + }ifelse +}ndf +/flushinput +{ + save + 2 dict begin + /CompareBuffer 3 -1 roll def + /readbuffer 256 string def + mark + { + currentfile readbuffer{readline}stopped + {cleartomark mark} + { + not + {pop exit} + if + CompareBuffer eq + {exit} + if + }ifelse + }loop + cleartomark + end + restore +}bdf +/getspotfunction +{ + AGMCORE_screen exch pop exch pop + dup type/dicttype eq{ + dup/HalftoneType get 1 eq{ + /SpotFunction get + }{ + dup/HalftoneType get 2 eq{ + /GraySpotFunction get + }{ + pop + { + abs exch abs 2 copy add 1 gt{ + 1 sub dup mul exch 1 sub dup mul add 1 sub + }{ + dup mul exch dup mul add 1 exch sub + }ifelse + }bind + }ifelse + }ifelse + }if +}def +/np +{newpath}bdf +/clp_npth +{clip np}def +/eoclp_npth +{eoclip np}def +/npth_clp +{np clip}def +/graphic_setup +{ + /AGMCORE_graphicsave save store + concat + 0 setgray + 0 setlinecap + 0 setlinejoin + 1 setlinewidth + []0 setdash + 10 setmiterlimit + np + false setoverprint + false setstrokeadjust + //Adobe_AGM_Core/spot_alias gx + /Adobe_AGM_Image where{ + pop + Adobe_AGM_Image/spot_alias 2 copy known{ + gx + }{ + pop pop + }ifelse + }if + /sep_colorspace_dict null AGMCORE_gput + 100 dict begin + /dictstackcount countdictstack def + /showpage{}def + mark +}def +/graphic_cleanup +{ + cleartomark + dictstackcount 1 countdictstack 1 sub{end}for + end + AGMCORE_graphicsave restore +}def +/compose_error_msg +{ + grestoreall initgraphics + /Helvetica findfont 10 scalefont setfont + /AGMCORE_deltaY 100 def + /AGMCORE_deltaX 310 def + clippath pathbbox np pop pop 36 add exch 36 add exch moveto + 0 AGMCORE_deltaY rlineto AGMCORE_deltaX 0 rlineto + 0 AGMCORE_deltaY neg rlineto AGMCORE_deltaX neg 0 rlineto closepath + 0 AGMCORE_&setgray + gsave 1 AGMCORE_&setgray fill grestore + 1 setlinewidth gsave stroke grestore + currentpoint AGMCORE_deltaY 15 sub add exch 8 add exch moveto + /AGMCORE_deltaY 12 def + /AGMCORE_tmp 0 def + AGMCORE_err_strings exch get + { + dup 32 eq + { + pop + AGMCORE_str256 0 AGMCORE_tmp getinterval + stringwidth pop currentpoint pop add AGMCORE_deltaX 28 add gt + { + currentpoint AGMCORE_deltaY sub exch pop + clippath pathbbox pop pop pop 44 add exch moveto + }if + AGMCORE_str256 0 AGMCORE_tmp getinterval show( )show + 0 1 AGMCORE_str256 length 1 sub + { + AGMCORE_str256 exch 0 put + }for + /AGMCORE_tmp 0 def + }{ + AGMCORE_str256 exch AGMCORE_tmp xpt + /AGMCORE_tmp AGMCORE_tmp 1 add def + }ifelse + }forall +}bdf +/AGMCORE_CMYKDeviceNColorspaces[ + [/Separation/None/DeviceCMYK{0 0 0}] + [/Separation(Black)/DeviceCMYK{0 0 0 4 -1 roll}bind] + [/Separation(Yellow)/DeviceCMYK{0 0 3 -1 roll 0}bind] + [/DeviceN[(Yellow)(Black)]/DeviceCMYK{0 0 4 2 roll}bind] + [/Separation(Magenta)/DeviceCMYK{0 exch 0 0}bind] + [/DeviceN[(Magenta)(Black)]/DeviceCMYK{0 3 1 roll 0 exch}bind] + [/DeviceN[(Magenta)(Yellow)]/DeviceCMYK{0 3 1 roll 0}bind] + [/DeviceN[(Magenta)(Yellow)(Black)]/DeviceCMYK{0 4 1 roll}bind] + [/Separation(Cyan)/DeviceCMYK{0 0 0}] + [/DeviceN[(Cyan)(Black)]/DeviceCMYK{0 0 3 -1 roll}bind] + [/DeviceN[(Cyan)(Yellow)]/DeviceCMYK{0 exch 0}bind] + [/DeviceN[(Cyan)(Yellow)(Black)]/DeviceCMYK{0 3 1 roll}bind] + [/DeviceN[(Cyan)(Magenta)]/DeviceCMYK{0 0}] + [/DeviceN[(Cyan)(Magenta)(Black)]/DeviceCMYK{0 exch}bind] + [/DeviceN[(Cyan)(Magenta)(Yellow)]/DeviceCMYK{0}] + [/DeviceCMYK] +]def +/ds{ + Adobe_AGM_Core begin + /currentdistillerparams where + { + pop currentdistillerparams/CoreDistVersion get 5000 lt + {<>setdistillerparams}if + }if + /AGMCORE_ps_version xdf + /AGMCORE_ps_level xdf + errordict/AGM_handleerror known not{ + errordict/AGM_handleerror errordict/handleerror get put + errordict/handleerror{ + Adobe_AGM_Core begin + $error/newerror get AGMCORE_cur_err null ne and{ + $error/newerror false put + AGMCORE_cur_err compose_error_msg + }if + $error/newerror true put + end + errordict/AGM_handleerror get exec + }bind put + }if + /AGMCORE_environ_ok + ps_level AGMCORE_ps_level ge + ps_version AGMCORE_ps_version ge and + AGMCORE_ps_level -1 eq or + def + AGMCORE_environ_ok not + {/AGMCORE_cur_err/AGMCORE_bad_environ def}if + /AGMCORE_&setgray systemdict/setgray get def + level2{ + /AGMCORE_&setcolor systemdict/setcolor get def + /AGMCORE_&setcolorspace systemdict/setcolorspace get def + }if + /AGMCORE_currentbg currentblackgeneration def + /AGMCORE_currentucr currentundercolorremoval def + /AGMCORE_Default_flatness currentflat def + /AGMCORE_distilling + /product where{ + pop systemdict/setdistillerparams known product(Adobe PostScript Parser)ne and + }{ + false + }ifelse + def + /AGMCORE_GSTATE AGMCORE_key_known not{ + /AGMCORE_GSTATE 21 dict def + /AGMCORE_tmpmatrix matrix def + /AGMCORE_gstack 32 array def + /AGMCORE_gstackptr 0 def + /AGMCORE_gstacksaveptr 0 def + /AGMCORE_gstackframekeys 14 def + /AGMCORE_&gsave/gsave ldf + /AGMCORE_&grestore/grestore ldf + /AGMCORE_&grestoreall/grestoreall ldf + /AGMCORE_&save/save ldf + /AGMCORE_&setoverprint/setoverprint ldf + /AGMCORE_gdictcopy{ + begin + {def}forall + end + }def + /AGMCORE_gput{ + AGMCORE_gstack AGMCORE_gstackptr get + 3 1 roll + put + }def + /AGMCORE_gget{ + AGMCORE_gstack AGMCORE_gstackptr get + exch + get + }def + /gsave{ + AGMCORE_&gsave + AGMCORE_gstack AGMCORE_gstackptr get + AGMCORE_gstackptr 1 add + dup 32 ge{limitcheck}if + /AGMCORE_gstackptr exch store + AGMCORE_gstack AGMCORE_gstackptr get + AGMCORE_gdictcopy + }def + /grestore{ + AGMCORE_&grestore + AGMCORE_gstackptr 1 sub + dup AGMCORE_gstacksaveptr lt{1 add}if + dup AGMCORE_gstack exch get dup/AGMCORE_currentoverprint known + {/AGMCORE_currentoverprint get setoverprint}{pop}ifelse + /AGMCORE_gstackptr exch store + }def + /grestoreall{ + AGMCORE_&grestoreall + /AGMCORE_gstackptr AGMCORE_gstacksaveptr store + }def + /save{ + AGMCORE_&save + AGMCORE_gstack AGMCORE_gstackptr get + AGMCORE_gstackptr 1 add + dup 32 ge{limitcheck}if + /AGMCORE_gstackptr exch store + /AGMCORE_gstacksaveptr AGMCORE_gstackptr store + AGMCORE_gstack AGMCORE_gstackptr get + AGMCORE_gdictcopy + }def + /setoverprint{ + dup/AGMCORE_currentoverprint exch AGMCORE_gput AGMCORE_&setoverprint + }def + 0 1 AGMCORE_gstack length 1 sub{ + AGMCORE_gstack exch AGMCORE_gstackframekeys dict put + }for + }if + level3/AGMCORE_&sysshfill AGMCORE_key_known not and + { + /AGMCORE_&sysshfill systemdict/shfill get def + /AGMCORE_&sysmakepattern systemdict/makepattern get def + /AGMCORE_&usrmakepattern/makepattern load def + }if + /currentcmykcolor[0 0 0 0]AGMCORE_gput + /currentstrokeadjust false AGMCORE_gput + /currentcolorspace[/DeviceGray]AGMCORE_gput + /sep_tint 0 AGMCORE_gput + /devicen_tints[0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]AGMCORE_gput + /sep_colorspace_dict null AGMCORE_gput + /devicen_colorspace_dict null AGMCORE_gput + /indexed_colorspace_dict null AGMCORE_gput + /currentcolor_intent()AGMCORE_gput + /customcolor_tint 1 AGMCORE_gput + /absolute_colorimetric_crd null AGMCORE_gput + /relative_colorimetric_crd null AGMCORE_gput + /saturation_crd null AGMCORE_gput + /perceptual_crd null AGMCORE_gput + currentcolortransfer cvlit/AGMCore_gray_xfer xdf cvlit/AGMCore_b_xfer xdf + cvlit/AGMCore_g_xfer xdf cvlit/AGMCore_r_xfer xdf + << + /MaxPatternItem currentsystemparams/MaxPatternCache get + >> + setuserparams + end +}def +/ps +{ + /setcmykcolor where{ + pop + Adobe_AGM_Core/AGMCORE_&setcmykcolor/setcmykcolor load put + }if + Adobe_AGM_Core begin + /setcmykcolor + { + 4 copy AGMCORE_cmykbuf astore/currentcmykcolor exch AGMCORE_gput + 1 sub 4 1 roll + 3{ + 3 index add neg dup 0 lt{ + pop 0 + }if + 3 1 roll + }repeat + setrgbcolor pop + }ndf + /currentcmykcolor + { + /currentcmykcolor AGMCORE_gget aload pop + }ndf + /setoverprint + {pop}ndf + /currentoverprint + {false}ndf + /AGMCORE_cyan_plate 1 0 0 0 test_cmyk_color_plate def + /AGMCORE_magenta_plate 0 1 0 0 test_cmyk_color_plate def + /AGMCORE_yellow_plate 0 0 1 0 test_cmyk_color_plate def + /AGMCORE_black_plate 0 0 0 1 test_cmyk_color_plate def + /AGMCORE_plate_ndx + AGMCORE_cyan_plate{ + 0 + }{ + AGMCORE_magenta_plate{ + 1 + }{ + AGMCORE_yellow_plate{ + 2 + }{ + AGMCORE_black_plate{ + 3 + }{ + 4 + }ifelse + }ifelse + }ifelse + }ifelse + def + /AGMCORE_have_reported_unsupported_color_space false def + /AGMCORE_report_unsupported_color_space + { + AGMCORE_have_reported_unsupported_color_space false eq + { + (Warning: Job contains content that cannot be separated with on-host methods. This content appears on the black plate, and knocks out all other plates.)== + Adobe_AGM_Core/AGMCORE_have_reported_unsupported_color_space true ddf + }if + }def + /AGMCORE_composite_job + AGMCORE_cyan_plate AGMCORE_magenta_plate and AGMCORE_yellow_plate and AGMCORE_black_plate and def + /AGMCORE_in_rip_sep + /AGMCORE_in_rip_sep where{ + pop AGMCORE_in_rip_sep + }{ + AGMCORE_distilling + { + false + }{ + userdict/Adobe_AGM_OnHost_Seps known{ + false + }{ + level2{ + currentpagedevice/Separations 2 copy known{ + get + }{ + pop pop false + }ifelse + }{ + false + }ifelse + }ifelse + }ifelse + }ifelse + def + /AGMCORE_producing_seps AGMCORE_composite_job not AGMCORE_in_rip_sep or def + /AGMCORE_host_sep AGMCORE_producing_seps AGMCORE_in_rip_sep not and def + /AGM_preserve_spots + /AGM_preserve_spots where{ + pop AGM_preserve_spots + }{ + AGMCORE_distilling AGMCORE_producing_seps or + }ifelse + def + /AGM_is_distiller_preserving_spotimages + { + currentdistillerparams/PreserveOverprintSettings known + { + currentdistillerparams/PreserveOverprintSettings get + { + currentdistillerparams/ColorConversionStrategy known + { + currentdistillerparams/ColorConversionStrategy get + /sRGB ne + }{ + true + }ifelse + }{ + false + }ifelse + }{ + false + }ifelse + }def + /convert_spot_to_process where{pop}{ + /convert_spot_to_process + { + //Adobe_AGM_Core begin + dup map_alias{ + /Name get exch pop + }if + dup dup(None)eq exch(All)eq or + { + pop false + }{ + AGMCORE_host_sep + { + gsave + 1 0 0 0 setcmykcolor currentgray 1 exch sub + 0 1 0 0 setcmykcolor currentgray 1 exch sub + 0 0 1 0 setcmykcolor currentgray 1 exch sub + 0 0 0 1 setcmykcolor currentgray 1 exch sub + add add add 0 eq + { + pop false + }{ + false setoverprint + current_spot_alias false set_spot_alias + 1 1 1 1 6 -1 roll findcmykcustomcolor 1 setcustomcolor + set_spot_alias + currentgray 1 ne + }ifelse + grestore + }{ + AGMCORE_distilling + { + pop AGM_is_distiller_preserving_spotimages not + }{ + //Adobe_AGM_Core/AGMCORE_name xddf + false + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 0 eq + AGMUTIL_cpd/OverrideSeparations known and + { + AGMUTIL_cpd/OverrideSeparations get + { + /HqnSpots/ProcSet resourcestatus + { + pop pop pop true + }if + }if + }if + { + AGMCORE_name/HqnSpots/ProcSet findresource/TestSpot gx not + }{ + gsave + [/Separation AGMCORE_name/DeviceGray{}]AGMCORE_&setcolorspace + false + AGMUTIL_cpd/SeparationColorNames 2 copy known + { + get + {AGMCORE_name eq or}forall + not + }{ + pop pop pop true + }ifelse + grestore + }ifelse + }ifelse + }ifelse + }ifelse + end + }def + }ifelse + /convert_to_process where{pop}{ + /convert_to_process + { + dup length 0 eq + { + pop false + }{ + AGMCORE_host_sep + { + dup true exch + { + dup(Cyan)eq exch + dup(Magenta)eq 3 -1 roll or exch + dup(Yellow)eq 3 -1 roll or exch + dup(Black)eq 3 -1 roll or + {pop} + {convert_spot_to_process and}ifelse + } + forall + { + true exch + { + dup(Cyan)eq exch + dup(Magenta)eq 3 -1 roll or exch + dup(Yellow)eq 3 -1 roll or exch + (Black)eq or and + }forall + not + }{pop false}ifelse + }{ + false exch + { + /PhotoshopDuotoneList where{pop false}{true}ifelse + { + dup(Cyan)eq exch + dup(Magenta)eq 3 -1 roll or exch + dup(Yellow)eq 3 -1 roll or exch + dup(Black)eq 3 -1 roll or + {pop} + {convert_spot_to_process or}ifelse + } + { + convert_spot_to_process or + } + ifelse + } + forall + }ifelse + }ifelse + }def + }ifelse + /AGMCORE_avoid_L2_sep_space + version cvr 2012 lt + level2 and + AGMCORE_producing_seps not and + def + /AGMCORE_is_cmyk_sep + AGMCORE_cyan_plate AGMCORE_magenta_plate or AGMCORE_yellow_plate or AGMCORE_black_plate or + def + /AGM_avoid_0_cmyk where{ + pop AGM_avoid_0_cmyk + }{ + AGM_preserve_spots + userdict/Adobe_AGM_OnHost_Seps known + userdict/Adobe_AGM_InRip_Seps known or + not and + }ifelse + { + /setcmykcolor[ + { + 4 copy add add add 0 eq currentoverprint and{ + pop 0.0005 + }if + }/exec cvx + /AGMCORE_&setcmykcolor load dup type/operatortype ne{ + /exec cvx + }if + ]cvx def + }if + /AGMCORE_IsSeparationAProcessColor + { + dup(Cyan)eq exch dup(Magenta)eq exch dup(Yellow)eq exch(Black)eq or or or + }def + AGMCORE_host_sep{ + /setcolortransfer + { + AGMCORE_cyan_plate{ + pop pop pop + }{ + AGMCORE_magenta_plate{ + 4 3 roll pop pop pop + }{ + AGMCORE_yellow_plate{ + 4 2 roll pop pop pop + }{ + 4 1 roll pop pop pop + }ifelse + }ifelse + }ifelse + settransfer + } + def + /AGMCORE_get_ink_data + AGMCORE_cyan_plate{ + {pop pop pop} + }{ + AGMCORE_magenta_plate{ + {4 3 roll pop pop pop} + }{ + AGMCORE_yellow_plate{ + {4 2 roll pop pop pop} + }{ + {4 1 roll pop pop pop} + }ifelse + }ifelse + }ifelse + def + /AGMCORE_RemoveProcessColorNames + { + 1 dict begin + /filtername + { + dup/Cyan eq 1 index(Cyan)eq or + {pop(_cyan_)}if + dup/Magenta eq 1 index(Magenta)eq or + {pop(_magenta_)}if + dup/Yellow eq 1 index(Yellow)eq or + {pop(_yellow_)}if + dup/Black eq 1 index(Black)eq or + {pop(_black_)}if + }def + dup type/arraytype eq + {[exch{filtername}forall]} + {filtername}ifelse + end + }def + level3{ + /AGMCORE_IsCurrentColor + { + dup AGMCORE_IsSeparationAProcessColor + { + AGMCORE_plate_ndx 0 eq + {dup(Cyan)eq exch/Cyan eq or}if + AGMCORE_plate_ndx 1 eq + {dup(Magenta)eq exch/Magenta eq or}if + AGMCORE_plate_ndx 2 eq + {dup(Yellow)eq exch/Yellow eq or}if + AGMCORE_plate_ndx 3 eq + {dup(Black)eq exch/Black eq or}if + AGMCORE_plate_ndx 4 eq + {pop false}if + }{ + gsave + false setoverprint + current_spot_alias false set_spot_alias + 1 1 1 1 6 -1 roll findcmykcustomcolor 1 setcustomcolor + set_spot_alias + currentgray 1 ne + grestore + }ifelse + }def + /AGMCORE_filter_functiondatasource + { + 5 dict begin + /data_in xdf + data_in type/stringtype eq + { + /ncomp xdf + /comp xdf + /string_out data_in length ncomp idiv string def + 0 ncomp data_in length 1 sub + { + string_out exch dup ncomp idiv exch data_in exch ncomp getinterval comp get 255 exch sub put + }for + string_out + }{ + string/string_in xdf + /string_out 1 string def + /component xdf + [ + data_in string_in/readstring cvx + [component/get cvx 255/exch cvx/sub cvx string_out/exch cvx 0/exch cvx/put cvx string_out]cvx + [/pop cvx()]cvx/ifelse cvx + ]cvx/ReusableStreamDecode filter + }ifelse + end + }def + /AGMCORE_separateShadingFunction + { + 2 dict begin + /paint? xdf + /channel xdf + dup type/dicttype eq + { + begin + FunctionType 0 eq + { + /DataSource channel Range length 2 idiv DataSource AGMCORE_filter_functiondatasource def + currentdict/Decode known + {/Decode Decode channel 2 mul 2 getinterval def}if + paint? not + {/Decode[1 1]def}if + }if + FunctionType 2 eq + { + paint? + { + /C0[C0 channel get 1 exch sub]def + /C1[C1 channel get 1 exch sub]def + }{ + /C0[1]def + /C1[1]def + }ifelse + }if + FunctionType 3 eq + { + /Functions[Functions{channel paint? AGMCORE_separateShadingFunction}forall]def + }if + currentdict/Range known + {/Range[0 1]def}if + currentdict + end}{ + channel get 0 paint? AGMCORE_separateShadingFunction + }ifelse + end + }def + /AGMCORE_separateShading + { + 3 -1 roll begin + currentdict/Function known + { + currentdict/Background known + {[1 index{Background 3 index get 1 exch sub}{1}ifelse]/Background xdf}if + Function 3 1 roll AGMCORE_separateShadingFunction/Function xdf + /ColorSpace[/DeviceGray]def + }{ + ColorSpace dup type/arraytype eq{0 get}if/DeviceCMYK eq + { + /ColorSpace[/DeviceN[/_cyan_/_magenta_/_yellow_/_black_]/DeviceCMYK{}]def + }{ + ColorSpace dup 1 get AGMCORE_RemoveProcessColorNames 1 exch put + }ifelse + ColorSpace 0 get/Separation eq + { + { + [1/exch cvx/sub cvx]cvx + }{ + [/pop cvx 1]cvx + }ifelse + ColorSpace 3 3 -1 roll put + pop + }{ + { + [exch ColorSpace 1 get length 1 sub exch sub/index cvx 1/exch cvx/sub cvx ColorSpace 1 get length 1 add 1/roll cvx ColorSpace 1 get length{/pop cvx}repeat]cvx + }{ + pop[ColorSpace 1 get length{/pop cvx}repeat cvx 1]cvx + }ifelse + ColorSpace 3 3 -1 roll bind put + }ifelse + ColorSpace 2/DeviceGray put + }ifelse + end + }def + /AGMCORE_separateShadingDict + { + dup/ColorSpace get + dup type/arraytype ne + {[exch]}if + dup 0 get/DeviceCMYK eq + { + exch begin + currentdict + AGMCORE_cyan_plate + {0 true}if + AGMCORE_magenta_plate + {1 true}if + AGMCORE_yellow_plate + {2 true}if + AGMCORE_black_plate + {3 true}if + AGMCORE_plate_ndx 4 eq + {0 false}if + dup not currentoverprint and + {/AGMCORE_ignoreshade true def}if + AGMCORE_separateShading + currentdict + end exch + }if + dup 0 get/Separation eq + { + exch begin + ColorSpace 1 get dup/None ne exch/All ne and + { + ColorSpace 1 get AGMCORE_IsCurrentColor AGMCORE_plate_ndx 4 lt and ColorSpace 1 get AGMCORE_IsSeparationAProcessColor not and + { + ColorSpace 2 get dup type/arraytype eq{0 get}if/DeviceCMYK eq + { + /ColorSpace + [ + /Separation + ColorSpace 1 get + /DeviceGray + [ + ColorSpace 3 get/exec cvx + 4 AGMCORE_plate_ndx sub -1/roll cvx + 4 1/roll cvx + 3[/pop cvx]cvx/repeat cvx + 1/exch cvx/sub cvx + ]cvx + ]def + }{ + AGMCORE_report_unsupported_color_space + AGMCORE_black_plate not + { + currentdict 0 false AGMCORE_separateShading + }if + }ifelse + }{ + currentdict ColorSpace 1 get AGMCORE_IsCurrentColor + 0 exch + dup not currentoverprint and + {/AGMCORE_ignoreshade true def}if + AGMCORE_separateShading + }ifelse + }if + currentdict + end exch + }if + dup 0 get/DeviceN eq + { + exch begin + ColorSpace 1 get convert_to_process + { + ColorSpace 2 get dup type/arraytype eq{0 get}if/DeviceCMYK eq + { + /ColorSpace + [ + /DeviceN + ColorSpace 1 get + /DeviceGray + [ + ColorSpace 3 get/exec cvx + 4 AGMCORE_plate_ndx sub -1/roll cvx + 4 1/roll cvx + 3[/pop cvx]cvx/repeat cvx + 1/exch cvx/sub cvx + ]cvx + ]def + }{ + AGMCORE_report_unsupported_color_space + AGMCORE_black_plate not + { + currentdict 0 false AGMCORE_separateShading + /ColorSpace[/DeviceGray]def + }if + }ifelse + }{ + currentdict + false -1 ColorSpace 1 get + { + AGMCORE_IsCurrentColor + { + 1 add + exch pop true exch exit + }if + 1 add + }forall + exch + dup not currentoverprint and + {/AGMCORE_ignoreshade true def}if + AGMCORE_separateShading + }ifelse + currentdict + end exch + }if + dup 0 get dup/DeviceCMYK eq exch dup/Separation eq exch/DeviceN eq or or not + { + exch begin + ColorSpace dup type/arraytype eq + {0 get}if + /DeviceGray ne + { + AGMCORE_report_unsupported_color_space + AGMCORE_black_plate not + { + ColorSpace 0 get/CIEBasedA eq + { + /ColorSpace[/Separation/_ciebaseda_/DeviceGray{}]def + }if + ColorSpace 0 get dup/CIEBasedABC eq exch dup/CIEBasedDEF eq exch/DeviceRGB eq or or + { + /ColorSpace[/DeviceN[/_red_/_green_/_blue_]/DeviceRGB{}]def + }if + ColorSpace 0 get/CIEBasedDEFG eq + { + /ColorSpace[/DeviceN[/_cyan_/_magenta_/_yellow_/_black_]/DeviceCMYK{}]def + }if + currentdict 0 false AGMCORE_separateShading + }if + }if + currentdict + end exch + }if + pop + dup/AGMCORE_ignoreshade known + { + begin + /ColorSpace[/Separation(None)/DeviceGray{}]def + currentdict end + }if + }def + /shfill + { + AGMCORE_separateShadingDict + dup/AGMCORE_ignoreshade known + {pop} + {AGMCORE_&sysshfill}ifelse + }def + /makepattern + { + exch + dup/PatternType get 2 eq + { + clonedict + begin + /Shading Shading AGMCORE_separateShadingDict def + Shading/AGMCORE_ignoreshade known + currentdict end exch + {pop<>}if + exch AGMCORE_&sysmakepattern + }{ + exch AGMCORE_&usrmakepattern + }ifelse + }def + }if + }if + AGMCORE_in_rip_sep{ + /setcustomcolor + { + exch aload pop + dup 7 1 roll inRip_spot_has_ink not { + 4{4 index mul 4 1 roll} + repeat + /DeviceCMYK setcolorspace + 6 -2 roll pop pop + }{ + //Adobe_AGM_Core begin + /AGMCORE_k xdf/AGMCORE_y xdf/AGMCORE_m xdf/AGMCORE_c xdf + end + [/Separation 4 -1 roll/DeviceCMYK + {dup AGMCORE_c mul exch dup AGMCORE_m mul exch dup AGMCORE_y mul exch AGMCORE_k mul} + ] + setcolorspace + }ifelse + setcolor + }ndf + /setseparationgray + { + [/Separation(All)/DeviceGray{}]setcolorspace_opt + 1 exch sub setcolor + }ndf + }{ + /setseparationgray + { + AGMCORE_&setgray + }ndf + }ifelse + /findcmykcustomcolor + { + 5 makereadonlyarray + }ndf + /setcustomcolor + { + exch aload pop pop + 4{4 index mul 4 1 roll}repeat + setcmykcolor pop + }ndf + /has_color + /colorimage where{ + AGMCORE_producing_seps{ + pop true + }{ + systemdict eq + }ifelse + }{ + false + }ifelse + def + /map_index + { + 1 index mul exch getinterval{255 div}forall + }bdf + /map_indexed_devn + { + Lookup Names length 3 -1 roll cvi map_index + }bdf + /n_color_components + { + base_colorspace_type + dup/DeviceGray eq{ + pop 1 + }{ + /DeviceCMYK eq{ + 4 + }{ + 3 + }ifelse + }ifelse + }bdf + level2{ + /mo/moveto ldf + /li/lineto ldf + /cv/curveto ldf + /knockout_unitsq + { + 1 setgray + 0 0 1 1 rectfill + }def + level2/setcolorspace AGMCORE_key_known not and{ + /AGMCORE_&&&setcolorspace/setcolorspace ldf + /AGMCORE_ReplaceMappedColor + { + dup type dup/arraytype eq exch/packedarraytype eq or + { + /AGMCORE_SpotAliasAry2 where{ + begin + dup 0 get dup/Separation eq + { + pop + dup length array copy + dup dup 1 get + current_spot_alias + { + dup map_alias + { + false set_spot_alias + dup 1 exch setsepcolorspace + true set_spot_alias + begin + /sep_colorspace_dict currentdict AGMCORE_gput + pop pop pop + [ + /Separation Name + CSA map_csa + MappedCSA + /sep_colorspace_proc load + ] + dup Name + end + }if + }if + map_reserved_ink_name 1 xpt + }{ + /DeviceN eq + { + dup length array copy + dup dup 1 get[ + exch{ + current_spot_alias{ + dup map_alias{ + /Name get exch pop + }if + }if + map_reserved_ink_name + }forall + ]1 xpt + }if + }ifelse + end + }if + }if + }def + /setcolorspace + { + dup type dup/arraytype eq exch/packedarraytype eq or + { + dup 0 get/Indexed eq + { + AGMCORE_distilling + { + /PhotoshopDuotoneList where + { + pop false + }{ + true + }ifelse + }{ + true + }ifelse + { + aload pop 3 -1 roll + AGMCORE_ReplaceMappedColor + 3 1 roll 4 array astore + }if + }{ + AGMCORE_ReplaceMappedColor + }ifelse + }if + DeviceN_PS2_inRip_seps{AGMCORE_&&&setcolorspace}if + }def + }if + }{ + /adj + { + currentstrokeadjust{ + transform + 0.25 sub round 0.25 add exch + 0.25 sub round 0.25 add exch + itransform + }if + }def + /mo{ + adj moveto + }def + /li{ + adj lineto + }def + /cv{ + 6 2 roll adj + 6 2 roll adj + 6 2 roll adj curveto + }def + /knockout_unitsq + { + 1 setgray + 8 8 1[8 0 0 8 0 0]{}image + }def + /currentstrokeadjust{ + /currentstrokeadjust AGMCORE_gget + }def + /setstrokeadjust{ + /currentstrokeadjust exch AGMCORE_gput + }def + /setcolorspace + { + /currentcolorspace exch AGMCORE_gput + }def + /currentcolorspace + { + /currentcolorspace AGMCORE_gget + }def + /setcolor_devicecolor + { + base_colorspace_type + dup/DeviceGray eq{ + pop setgray + }{ + /DeviceCMYK eq{ + setcmykcolor + }{ + setrgbcolor + }ifelse + }ifelse + }def + /setcolor + { + currentcolorspace 0 get + dup/DeviceGray ne{ + dup/DeviceCMYK ne{ + dup/DeviceRGB ne{ + dup/Separation eq{ + pop + currentcolorspace 3 gx + currentcolorspace 2 get + }{ + dup/Indexed eq{ + pop + currentcolorspace 3 get dup type/stringtype eq{ + currentcolorspace 1 get n_color_components + 3 -1 roll map_index + }{ + exec + }ifelse + currentcolorspace 1 get + }{ + /AGMCORE_cur_err/AGMCORE_invalid_color_space def + AGMCORE_invalid_color_space + }ifelse + }ifelse + }if + }if + }if + setcolor_devicecolor + }def + }ifelse + /sop/setoverprint ldf + /lw/setlinewidth ldf + /lc/setlinecap ldf + /lj/setlinejoin ldf + /ml/setmiterlimit ldf + /dsh/setdash ldf + /sadj/setstrokeadjust ldf + /gry/setgray ldf + /rgb/setrgbcolor ldf + /cmyk[ + /currentcolorspace[/DeviceCMYK]/AGMCORE_gput cvx + /setcmykcolor load dup type/operatortype ne{/exec cvx}if + ]cvx bdf + level3 AGMCORE_host_sep not and{ + /nzopmsc{ + 6 dict begin + /kk exch def + /yy exch def + /mm exch def + /cc exch def + /sum 0 def + cc 0 ne{/sum sum 2#1000 or def cc}if + mm 0 ne{/sum sum 2#0100 or def mm}if + yy 0 ne{/sum sum 2#0010 or def yy}if + kk 0 ne{/sum sum 2#0001 or def kk}if + AGMCORE_CMYKDeviceNColorspaces sum get setcolorspace + sum 0 eq{0}if + end + setcolor + }bdf + }{ + /nzopmsc/cmyk ldf + }ifelse + /sep/setsepcolor ldf + /devn/setdevicencolor ldf + /idx/setindexedcolor ldf + /colr/setcolor ldf + /csacrd/set_csa_crd ldf + /sepcs/setsepcolorspace ldf + /devncs/setdevicencolorspace ldf + /idxcs/setindexedcolorspace ldf + /cp/closepath ldf + /clp/clp_npth ldf + /eclp/eoclp_npth ldf + /f/fill ldf + /ef/eofill ldf + /@/stroke ldf + /nclp/npth_clp ldf + /gset/graphic_setup ldf + /gcln/graphic_cleanup ldf + /ct/concat ldf + /cf/currentfile ldf + /fl/filter ldf + /rs/readstring ldf + /AGMCORE_def_ht currenthalftone def + /clonedict Adobe_AGM_Utils begin/clonedict load end def + /clonearray Adobe_AGM_Utils begin/clonearray load end def + currentdict{ + dup xcheck 1 index type dup/arraytype eq exch/packedarraytype eq or and{ + bind + }if + def + }forall + /getrampcolor + { + /indx exch def + 0 1 NumComp 1 sub + { + dup + Samples exch get + dup type/stringtype eq{indx get}if + exch + Scaling exch get aload pop + 3 1 roll + mul add + }for + ColorSpaceFamily/Separation eq + {sep} + { + ColorSpaceFamily/DeviceN eq + {devn}{setcolor}ifelse + }ifelse + }bdf + /sssetbackground{ + aload pop + ColorSpaceFamily/Separation eq + {sep} + { + ColorSpaceFamily/DeviceN eq + {devn}{setcolor}ifelse + }ifelse + }bdf + /RadialShade + { + 40 dict begin + /ColorSpaceFamily xdf + /background xdf + /ext1 xdf + /ext0 xdf + /BBox xdf + /r2 xdf + /c2y xdf + /c2x xdf + /r1 xdf + /c1y xdf + /c1x xdf + /rampdict xdf + /setinkoverprint where{pop/setinkoverprint{pop}def}if + gsave + BBox length 0 gt + { + np + BBox 0 get BBox 1 get moveto + BBox 2 get BBox 0 get sub 0 rlineto + 0 BBox 3 get BBox 1 get sub rlineto + BBox 2 get BBox 0 get sub neg 0 rlineto + closepath + clip + np + }if + c1x c2x eq + { + c1y c2y lt{/theta 90 def}{/theta 270 def}ifelse + }{ + /slope c2y c1y sub c2x c1x sub div def + /theta slope 1 atan def + c2x c1x lt c2y c1y ge and{/theta theta 180 sub def}if + c2x c1x lt c2y c1y lt and{/theta theta 180 add def}if + }ifelse + gsave + clippath + c1x c1y translate + theta rotate + -90 rotate + {pathbbox}stopped + {0 0 0 0}if + /yMax xdf + /xMax xdf + /yMin xdf + /xMin xdf + grestore + xMax xMin eq yMax yMin eq or + { + grestore + end + }{ + /max{2 copy gt{pop}{exch pop}ifelse}bdf + /min{2 copy lt{pop}{exch pop}ifelse}bdf + rampdict begin + 40 dict begin + background length 0 gt{background sssetbackground gsave clippath fill grestore}if + gsave + c1x c1y translate + theta rotate + -90 rotate + /c2y c1x c2x sub dup mul c1y c2y sub dup mul add sqrt def + /c1y 0 def + /c1x 0 def + /c2x 0 def + ext0 + { + 0 getrampcolor + c2y r2 add r1 sub 0.0001 lt + { + c1x c1y r1 360 0 arcn + pathbbox + /aymax exch def + /axmax exch def + /aymin exch def + /axmin exch def + /bxMin xMin axmin min def + /byMin yMin aymin min def + /bxMax xMax axmax max def + /byMax yMax aymax max def + bxMin byMin moveto + bxMax byMin lineto + bxMax byMax lineto + bxMin byMax lineto + bxMin byMin lineto + eofill + }{ + c2y r1 add r2 le + { + c1x c1y r1 0 360 arc + fill + } + { + c2x c2y r2 0 360 arc fill + r1 r2 eq + { + /p1x r1 neg def + /p1y c1y def + /p2x r1 def + /p2y c1y def + p1x p1y moveto p2x p2y lineto p2x yMin lineto p1x yMin lineto + fill + }{ + /AA r2 r1 sub c2y div def + AA -1 eq + {/theta 89.99 def} + {/theta AA 1 AA dup mul sub sqrt div 1 atan def} + ifelse + /SS1 90 theta add dup sin exch cos div def + /p1x r1 SS1 SS1 mul SS1 SS1 mul 1 add div sqrt mul neg def + /p1y p1x SS1 div neg def + /SS2 90 theta sub dup sin exch cos div def + /p2x r1 SS2 SS2 mul SS2 SS2 mul 1 add div sqrt mul def + /p2y p2x SS2 div neg def + r1 r2 gt + { + /L1maxX p1x yMin p1y sub SS1 div add def + /L2maxX p2x yMin p2y sub SS2 div add def + }{ + /L1maxX 0 def + /L2maxX 0 def + }ifelse + p1x p1y moveto p2x p2y lineto L2maxX L2maxX p2x sub SS2 mul p2y add lineto + L1maxX L1maxX p1x sub SS1 mul p1y add lineto + fill + }ifelse + }ifelse + }ifelse + }if + c1x c2x sub dup mul + c1y c2y sub dup mul + add 0.5 exp + 0 dtransform + dup mul exch dup mul add 0.5 exp 72 div + 0 72 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt + 72 0 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt + 1 index 1 index lt{exch}if pop + /hires xdf + hires mul + /numpix xdf + /numsteps NumSamples def + /rampIndxInc 1 def + /subsampling false def + numpix 0 ne + { + NumSamples numpix div 0.5 gt + { + /numsteps numpix 2 div round cvi dup 1 le{pop 2}if def + /rampIndxInc NumSamples 1 sub numsteps div def + /subsampling true def + }if + }if + /xInc c2x c1x sub numsteps div def + /yInc c2y c1y sub numsteps div def + /rInc r2 r1 sub numsteps div def + /cx c1x def + /cy c1y def + /radius r1 def + np + xInc 0 eq yInc 0 eq rInc 0 eq and and + { + 0 getrampcolor + cx cy radius 0 360 arc + stroke + NumSamples 1 sub getrampcolor + cx cy radius 72 hires div add 0 360 arc + 0 setlinewidth + stroke + }{ + 0 + numsteps + { + dup + subsampling{round cvi}if + getrampcolor + cx cy radius 0 360 arc + /cx cx xInc add def + /cy cy yInc add def + /radius radius rInc add def + cx cy radius 360 0 arcn + eofill + rampIndxInc add + }repeat + pop + }ifelse + ext1 + { + c2y r2 add r1 lt + { + c2x c2y r2 0 360 arc + fill + }{ + c2y r1 add r2 sub 0.0001 le + { + c2x c2y r2 360 0 arcn + pathbbox + /aymax exch def + /axmax exch def + /aymin exch def + /axmin exch def + /bxMin xMin axmin min def + /byMin yMin aymin min def + /bxMax xMax axmax max def + /byMax yMax aymax max def + bxMin byMin moveto + bxMax byMin lineto + bxMax byMax lineto + bxMin byMax lineto + bxMin byMin lineto + eofill + }{ + c2x c2y r2 0 360 arc fill + r1 r2 eq + { + /p1x r2 neg def + /p1y c2y def + /p2x r2 def + /p2y c2y def + p1x p1y moveto p2x p2y lineto p2x yMax lineto p1x yMax lineto + fill + }{ + /AA r2 r1 sub c2y div def + AA -1 eq + {/theta 89.99 def} + {/theta AA 1 AA dup mul sub sqrt div 1 atan def} + ifelse + /SS1 90 theta add dup sin exch cos div def + /p1x r2 SS1 SS1 mul SS1 SS1 mul 1 add div sqrt mul neg def + /p1y c2y p1x SS1 div sub def + /SS2 90 theta sub dup sin exch cos div def + /p2x r2 SS2 SS2 mul SS2 SS2 mul 1 add div sqrt mul def + /p2y c2y p2x SS2 div sub def + r1 r2 lt + { + /L1maxX p1x yMax p1y sub SS1 div add def + /L2maxX p2x yMax p2y sub SS2 div add def + }{ + /L1maxX 0 def + /L2maxX 0 def + }ifelse + p1x p1y moveto p2x p2y lineto L2maxX L2maxX p2x sub SS2 mul p2y add lineto + L1maxX L1maxX p1x sub SS1 mul p1y add lineto + fill + }ifelse + }ifelse + }ifelse + }if + grestore + grestore + end + end + end + }ifelse + }bdf + /GenStrips + { + 40 dict begin + /ColorSpaceFamily xdf + /background xdf + /ext1 xdf + /ext0 xdf + /BBox xdf + /y2 xdf + /x2 xdf + /y1 xdf + /x1 xdf + /rampdict xdf + /setinkoverprint where{pop/setinkoverprint{pop}def}if + gsave + BBox length 0 gt + { + np + BBox 0 get BBox 1 get moveto + BBox 2 get BBox 0 get sub 0 rlineto + 0 BBox 3 get BBox 1 get sub rlineto + BBox 2 get BBox 0 get sub neg 0 rlineto + closepath + clip + np + }if + x1 x2 eq + { + y1 y2 lt{/theta 90 def}{/theta 270 def}ifelse + }{ + /slope y2 y1 sub x2 x1 sub div def + /theta slope 1 atan def + x2 x1 lt y2 y1 ge and{/theta theta 180 sub def}if + x2 x1 lt y2 y1 lt and{/theta theta 180 add def}if + } + ifelse + gsave + clippath + x1 y1 translate + theta rotate + {pathbbox}stopped + {0 0 0 0}if + /yMax exch def + /xMax exch def + /yMin exch def + /xMin exch def + grestore + xMax xMin eq yMax yMin eq or + { + grestore + end + }{ + rampdict begin + 20 dict begin + background length 0 gt{background sssetbackground gsave clippath fill grestore}if + gsave + x1 y1 translate + theta rotate + /xStart 0 def + /xEnd x2 x1 sub dup mul y2 y1 sub dup mul add 0.5 exp def + /ySpan yMax yMin sub def + /numsteps NumSamples def + /rampIndxInc 1 def + /subsampling false def + xStart 0 transform + xEnd 0 transform + 3 -1 roll + sub dup mul + 3 1 roll + sub dup mul + add 0.5 exp 72 div + 0 72 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt + 72 0 matrix defaultmatrix dtransform dup mul exch dup mul add sqrt + 1 index 1 index lt{exch}if pop + mul + /numpix xdf + numpix 0 ne + { + NumSamples numpix div 0.5 gt + { + /numsteps numpix 2 div round cvi dup 1 le{pop 2}if def + /rampIndxInc NumSamples 1 sub numsteps div def + /subsampling true def + }if + }if + ext0 + { + 0 getrampcolor + xMin xStart lt + { + xMin yMin xMin neg ySpan rectfill + }if + }if + /xInc xEnd xStart sub numsteps div def + /x xStart def + 0 + numsteps + { + dup + subsampling{round cvi}if + getrampcolor + x yMin xInc ySpan rectfill + /x x xInc add def + rampIndxInc add + }repeat + pop + ext1{ + xMax xEnd gt + { + xEnd yMin xMax xEnd sub ySpan rectfill + }if + }if + grestore + grestore + end + end + end + }ifelse + }bdf +}def +/pt +{ + end +}def +/dt{ +}def +/pgsv{ + //Adobe_AGM_Core/AGMCORE_save save put +}def +/pgrs{ + //Adobe_AGM_Core/AGMCORE_save get restore +}def +systemdict/findcolorrendering known{ + /findcolorrendering systemdict/findcolorrendering get def +}if +systemdict/setcolorrendering known{ + /setcolorrendering systemdict/setcolorrendering get def +}if +/test_cmyk_color_plate +{ + gsave + setcmykcolor currentgray 1 ne + grestore +}def +/inRip_spot_has_ink +{ + dup//Adobe_AGM_Core/AGMCORE_name xddf + convert_spot_to_process not +}def +/map255_to_range +{ + 1 index sub + 3 -1 roll 255 div mul add +}def +/set_csa_crd +{ + /sep_colorspace_dict null AGMCORE_gput + begin + CSA get_csa_by_name setcolorspace_opt + set_crd + end +} +def +/map_csa +{ + currentdict/MappedCSA known{MappedCSA null ne}{false}ifelse + {pop}{get_csa_by_name/MappedCSA xdf}ifelse +}def +/setsepcolor +{ + /sep_colorspace_dict AGMCORE_gget begin + dup/sep_tint exch AGMCORE_gput + TintProc + end +}def +/setdevicencolor +{ + /devicen_colorspace_dict AGMCORE_gget begin + Names length copy + Names length 1 sub -1 0 + { + /devicen_tints AGMCORE_gget 3 1 roll xpt + }for + TintProc + end +}def +/sep_colorspace_proc +{ + /AGMCORE_tmp exch store + /sep_colorspace_dict AGMCORE_gget begin + currentdict/Components known{ + Components aload pop + TintMethod/Lab eq{ + 2{AGMCORE_tmp mul NComponents 1 roll}repeat + LMax sub AGMCORE_tmp mul LMax add NComponents 1 roll + }{ + TintMethod/Subtractive eq{ + NComponents{ + AGMCORE_tmp mul NComponents 1 roll + }repeat + }{ + NComponents{ + 1 sub AGMCORE_tmp mul 1 add NComponents 1 roll + }repeat + }ifelse + }ifelse + }{ + ColorLookup AGMCORE_tmp ColorLookup length 1 sub mul round cvi get + aload pop + }ifelse + end +}def +/sep_colorspace_gray_proc +{ + /AGMCORE_tmp exch store + /sep_colorspace_dict AGMCORE_gget begin + GrayLookup AGMCORE_tmp GrayLookup length 1 sub mul round cvi get + end +}def +/sep_proc_name +{ + dup 0 get + dup/DeviceRGB eq exch/DeviceCMYK eq or level2 not and has_color not and{ + pop[/DeviceGray] + /sep_colorspace_gray_proc + }{ + /sep_colorspace_proc + }ifelse +}def +/setsepcolorspace +{ + current_spot_alias{ + dup begin + Name map_alias{ + exch pop + }if + end + }if + dup/sep_colorspace_dict exch AGMCORE_gput + begin + CSA map_csa + /AGMCORE_sep_special Name dup()eq exch(All)eq or store + AGMCORE_avoid_L2_sep_space{ + [/Indexed MappedCSA sep_proc_name 255 exch + {255 div}/exec cvx 3 -1 roll[4 1 roll load/exec cvx]cvx + ]setcolorspace_opt + /TintProc{ + 255 mul round cvi setcolor + }bdf + }{ + MappedCSA 0 get/DeviceCMYK eq + currentdict/Components known and + AGMCORE_sep_special not and{ + /TintProc[ + Components aload pop Name findcmykcustomcolor + /exch cvx/setcustomcolor cvx + ]cvx bdf + }{ + AGMCORE_host_sep Name(All)eq and{ + /TintProc{ + 1 exch sub setseparationgray + }bdf + }{ + AGMCORE_in_rip_sep MappedCSA 0 get/DeviceCMYK eq and + AGMCORE_host_sep or + Name()eq and{ + /TintProc[ + MappedCSA sep_proc_name exch 0 get/DeviceCMYK eq{ + cvx/setcmykcolor cvx + }{ + cvx/setgray cvx + }ifelse + ]cvx bdf + }{ + AGMCORE_producing_seps MappedCSA 0 get dup/DeviceCMYK eq exch/DeviceGray eq or and AGMCORE_sep_special not and{ + /TintProc[ + /dup cvx + MappedCSA sep_proc_name cvx exch + 0 get/DeviceGray eq{ + 1/exch cvx/sub cvx 0 0 0 4 -1/roll cvx + }if + /Name cvx/findcmykcustomcolor cvx/exch cvx + AGMCORE_host_sep{ + AGMCORE_is_cmyk_sep + /Name cvx + /AGMCORE_IsSeparationAProcessColor load/exec cvx + /not cvx/and cvx + }{ + Name inRip_spot_has_ink not + }ifelse + [ + /pop cvx 1 + ]cvx/if cvx + /setcustomcolor cvx + ]cvx bdf + }{ + /TintProc{setcolor}bdf + [/Separation Name MappedCSA sep_proc_name load]setcolorspace_opt + }ifelse + }ifelse + }ifelse + }ifelse + }ifelse + set_crd + setsepcolor + end +}def +/additive_blend +{ + 3 dict begin + /numarrays xdf + /numcolors xdf + 0 1 numcolors 1 sub + { + /c1 xdf + 1 + 0 1 numarrays 1 sub + { + 1 exch add/index cvx + c1/get cvx/mul cvx + }for + numarrays 1 add 1/roll cvx + }for + numarrays[/pop cvx]cvx/repeat cvx + end +}def +/subtractive_blend +{ + 3 dict begin + /numarrays xdf + /numcolors xdf + 0 1 numcolors 1 sub + { + /c1 xdf + 1 1 + 0 1 numarrays 1 sub + { + 1 3 3 -1 roll add/index cvx + c1/get cvx/sub cvx/mul cvx + }for + /sub cvx + numarrays 1 add 1/roll cvx + }for + numarrays[/pop cvx]cvx/repeat cvx + end +}def +/exec_tint_transform +{ + /TintProc[ + /TintTransform cvx/setcolor cvx + ]cvx bdf + MappedCSA setcolorspace_opt +}bdf +/devn_makecustomcolor +{ + 2 dict begin + /names_index xdf + /Names xdf + 1 1 1 1 Names names_index get findcmykcustomcolor + /devicen_tints AGMCORE_gget names_index get setcustomcolor + Names length{pop}repeat + end +}bdf +/setdevicencolorspace +{ + dup/AliasedColorants known{false}{true}ifelse + current_spot_alias and{ + 7 dict begin + /names_index 0 def + dup/names_len exch/Names get length def + /new_names names_len array def + /new_LookupTables names_len array def + /alias_cnt 0 def + dup/Names get + { + dup map_alias{ + exch pop + dup/ColorLookup known{ + dup begin + new_LookupTables names_index ColorLookup put + end + }{ + dup/Components known{ + dup begin + new_LookupTables names_index Components put + end + }{ + dup begin + new_LookupTables names_index[null null null null]put + end + }ifelse + }ifelse + new_names names_index 3 -1 roll/Name get put + /alias_cnt alias_cnt 1 add def + }{ + /name xdf + new_names names_index name put + dup/LookupTables known{ + dup begin + new_LookupTables names_index LookupTables names_index get put + end + }{ + dup begin + new_LookupTables names_index[null null null null]put + end + }ifelse + }ifelse + /names_index names_index 1 add def + }forall + alias_cnt 0 gt{ + /AliasedColorants true def + /lut_entry_len new_LookupTables 0 get dup length 256 ge{0 get length}{length}ifelse def + 0 1 names_len 1 sub{ + /names_index xdf + new_LookupTables names_index get dup length 256 ge{0 get length}{length}ifelse lut_entry_len ne{ + /AliasedColorants false def + exit + }{ + new_LookupTables names_index get 0 get null eq{ + dup/Names get names_index get/name xdf + name(Cyan)eq name(Magenta)eq name(Yellow)eq name(Black)eq + or or or not{ + /AliasedColorants false def + exit + }if + }if + }ifelse + }for + lut_entry_len 1 eq{ + /AliasedColorants false def + }if + AliasedColorants{ + dup begin + /Names new_names def + /LookupTables new_LookupTables def + /AliasedColorants true def + /NComponents lut_entry_len def + /TintMethod NComponents 4 eq{/Subtractive}{/Additive}ifelse def + /MappedCSA TintMethod/Additive eq{/DeviceRGB}{/DeviceCMYK}ifelse def + currentdict/TTTablesIdx known not{ + /TTTablesIdx -1 def + }if + end + }if + }if + end + }if + dup/devicen_colorspace_dict exch AGMCORE_gput + begin + currentdict/AliasedColorants known{ + AliasedColorants + }{ + false + }ifelse + dup not{ + CSA map_csa + }if + /TintTransform load type/nulltype eq or{ + /TintTransform[ + 0 1 Names length 1 sub + { + /TTTablesIdx TTTablesIdx 1 add def + dup LookupTables exch get dup 0 get null eq + { + 1 index + Names exch get + dup(Cyan)eq + { + pop exch + LookupTables length exch sub + /index cvx + 0 0 0 + } + { + dup(Magenta)eq + { + pop exch + LookupTables length exch sub + /index cvx + 0/exch cvx 0 0 + }{ + (Yellow)eq + { + exch + LookupTables length exch sub + /index cvx + 0 0 3 -1/roll cvx 0 + }{ + exch + LookupTables length exch sub + /index cvx + 0 0 0 4 -1/roll cvx + }ifelse + }ifelse + }ifelse + 5 -1/roll cvx/astore cvx + }{ + dup length 1 sub + LookupTables length 4 -1 roll sub 1 add + /index cvx/mul cvx/round cvx/cvi cvx/get cvx + }ifelse + Names length TTTablesIdx add 1 add 1/roll cvx + }for + Names length[/pop cvx]cvx/repeat cvx + NComponents Names length + TintMethod/Subtractive eq + { + subtractive_blend + }{ + additive_blend + }ifelse + ]cvx bdf + }if + AGMCORE_host_sep{ + Names convert_to_process{ + exec_tint_transform + } + { + currentdict/AliasedColorants known{ + AliasedColorants not + }{ + false + }ifelse + 5 dict begin + /AvoidAliasedColorants xdf + /painted? false def + /names_index 0 def + /names_len Names length def + AvoidAliasedColorants{ + /currentspotalias current_spot_alias def + false set_spot_alias + }if + Names{ + AGMCORE_is_cmyk_sep{ + dup(Cyan)eq AGMCORE_cyan_plate and exch + dup(Magenta)eq AGMCORE_magenta_plate and exch + dup(Yellow)eq AGMCORE_yellow_plate and exch + (Black)eq AGMCORE_black_plate and or or or{ + /devicen_colorspace_dict AGMCORE_gget/TintProc[ + Names names_index/devn_makecustomcolor cvx + ]cvx ddf + /painted? true def + }if + painted?{exit}if + }{ + 0 0 0 0 5 -1 roll findcmykcustomcolor 1 setcustomcolor currentgray 0 eq{ + /devicen_colorspace_dict AGMCORE_gget/TintProc[ + Names names_index/devn_makecustomcolor cvx + ]cvx ddf + /painted? true def + exit + }if + }ifelse + /names_index names_index 1 add def + }forall + AvoidAliasedColorants{ + currentspotalias set_spot_alias + }if + painted?{ + /devicen_colorspace_dict AGMCORE_gget/names_index names_index put + }{ + /devicen_colorspace_dict AGMCORE_gget/TintProc[ + names_len[/pop cvx]cvx/repeat cvx 1/setseparationgray cvx + 0 0 0 0/setcmykcolor cvx + ]cvx ddf + }ifelse + end + }ifelse + } + { + AGMCORE_in_rip_sep{ + Names convert_to_process not + }{ + level3 + }ifelse + { + [/DeviceN Names MappedCSA/TintTransform load]setcolorspace_opt + /TintProc level3 not AGMCORE_in_rip_sep and{ + [ + Names/length cvx[/pop cvx]cvx/repeat cvx + ]cvx bdf + }{ + {setcolor}bdf + }ifelse + }{ + exec_tint_transform + }ifelse + }ifelse + set_crd + /AliasedColorants false def + end +}def +/setindexedcolorspace +{ + dup/indexed_colorspace_dict exch AGMCORE_gput + begin + currentdict/CSDBase known{ + CSDBase/CSD get_res begin + currentdict/Names known{ + currentdict devncs + }{ + 1 currentdict sepcs + }ifelse + AGMCORE_host_sep{ + 4 dict begin + /compCnt/Names where{pop Names length}{1}ifelse def + /NewLookup HiVal 1 add string def + 0 1 HiVal{ + /tableIndex xdf + Lookup dup type/stringtype eq{ + compCnt tableIndex map_index + }{ + exec + }ifelse + /Names where{ + pop setdevicencolor + }{ + setsepcolor + }ifelse + currentgray + tableIndex exch + 255 mul cvi + NewLookup 3 1 roll put + }for + [/Indexed currentcolorspace HiVal NewLookup]setcolorspace_opt + end + }{ + level3 + { + currentdict/Names known{ + [/Indexed[/DeviceN Names MappedCSA/TintTransform load]HiVal Lookup]setcolorspace_opt + }{ + [/Indexed[/Separation Name MappedCSA sep_proc_name load]HiVal Lookup]setcolorspace_opt + }ifelse + }{ + [/Indexed MappedCSA HiVal + [ + currentdict/Names known{ + Lookup dup type/stringtype eq + {/exch cvx CSDBase/CSD get_res/Names get length dup/mul cvx exch/getinterval cvx{255 div}/forall cvx} + {/exec cvx}ifelse + /TintTransform load/exec cvx + }{ + Lookup dup type/stringtype eq + {/exch cvx/get cvx 255/div cvx} + {/exec cvx}ifelse + CSDBase/CSD get_res/MappedCSA get sep_proc_name exch pop/load cvx/exec cvx + }ifelse + ]cvx + ]setcolorspace_opt + }ifelse + }ifelse + end + set_crd + } + { + CSA map_csa + AGMCORE_host_sep level2 not and{ + 0 0 0 0 setcmykcolor + }{ + [/Indexed MappedCSA + level2 not has_color not and{ + dup 0 get dup/DeviceRGB eq exch/DeviceCMYK eq or{ + pop[/DeviceGray] + }if + HiVal GrayLookup + }{ + HiVal + currentdict/RangeArray known{ + { + /indexed_colorspace_dict AGMCORE_gget begin + Lookup exch + dup HiVal gt{ + pop HiVal + }if + NComponents mul NComponents getinterval{}forall + NComponents 1 sub -1 0{ + RangeArray exch 2 mul 2 getinterval aload pop map255_to_range + NComponents 1 roll + }for + end + }bind + }{ + Lookup + }ifelse + }ifelse + ]setcolorspace_opt + set_crd + }ifelse + }ifelse + end +}def +/setindexedcolor +{ + AGMCORE_host_sep{ + /indexed_colorspace_dict AGMCORE_gget + begin + currentdict/CSDBase known{ + CSDBase/CSD get_res begin + currentdict/Names known{ + map_indexed_devn + devn + } + { + Lookup 1 3 -1 roll map_index + sep + }ifelse + end + }{ + Lookup MappedCSA/DeviceCMYK eq{4}{1}ifelse 3 -1 roll + map_index + MappedCSA/DeviceCMYK eq{setcmykcolor}{setgray}ifelse + }ifelse + end + }{ + level3 not AGMCORE_in_rip_sep and/indexed_colorspace_dict AGMCORE_gget/CSDBase known and{ + /indexed_colorspace_dict AGMCORE_gget/CSDBase get/CSD get_res begin + map_indexed_devn + devn + end + } + { + setcolor + }ifelse + }ifelse +}def +/ignoreimagedata +{ + currentoverprint not{ + gsave + dup clonedict begin + 1 setgray + /Decode[0 1]def + /DataSourcedef + /MultipleDataSources false def + /BitsPerComponent 8 def + currentdict end + systemdict/image gx + grestore + }if + consumeimagedata +}def +/add_res +{ + dup/CSD eq{ + pop + //Adobe_AGM_Core begin + /AGMCORE_CSD_cache load 3 1 roll put + end + }{ + defineresource pop + }ifelse +}def +/del_res +{ + { + aload pop exch + dup/CSD eq{ + pop + {//Adobe_AGM_Core/AGMCORE_CSD_cache get exch undef}forall + }{ + exch + {1 index undefineresource}forall + pop + }ifelse + }forall +}def +/get_res +{ + dup/CSD eq{ + pop + dup type dup/nametype eq exch/stringtype eq or{ + AGMCORE_CSD_cache exch get + }if + }{ + findresource + }ifelse +}def +/get_csa_by_name +{ + dup type dup/nametype eq exch/stringtype eq or{ + /CSA get_res + }if +}def +/paintproc_buf_init +{ + /count get 0 0 put +}def +/paintproc_buf_next +{ + dup/count get dup 0 get + dup 3 1 roll + 1 add 0 xpt + get +}def +/cachepaintproc_compress +{ + 5 dict begin + currentfile exch 0 exch/SubFileDecode filter/ReadFilter exch def + /ppdict 20 dict def + /string_size 16000 def + /readbuffer string_size string def + currentglobal true setglobal + ppdict 1 array dup 0 1 put/count xpt + setglobal + /LZWFilter + { + exch + dup length 0 eq{ + pop + }{ + ppdict dup length 1 sub 3 -1 roll put + }ifelse + {string_size}{0}ifelse string + }/LZWEncode filter def + { + ReadFilter readbuffer readstring + exch LZWFilter exch writestring + not{exit}if + }loop + LZWFilter closefile + ppdict + end +}def +/cachepaintproc +{ + 2 dict begin + currentfile exch 0 exch/SubFileDecode filter/ReadFilter exch def + /ppdict 20 dict def + currentglobal true setglobal + ppdict 1 array dup 0 1 put/count xpt + setglobal + { + ReadFilter 16000 string readstring exch + ppdict dup length 1 sub 3 -1 roll put + not{exit}if + }loop + ppdict dup dup length 1 sub()put + end +}def +/make_pattern +{ + exch clonedict exch + dup matrix currentmatrix matrix concatmatrix 0 0 3 2 roll itransform + exch 3 index/XStep get 1 index exch 2 copy div cvi mul sub sub + exch 3 index/YStep get 1 index exch 2 copy div cvi mul sub sub + matrix translate exch matrix concatmatrix + 1 index begin + BBox 0 get XStep div cvi XStep mul/xshift exch neg def + BBox 1 get YStep div cvi YStep mul/yshift exch neg def + BBox 0 get xshift add + BBox 1 get yshift add + BBox 2 get xshift add + BBox 3 get yshift add + 4 array astore + /BBox exch def + [xshift yshift/translate load null/exec load]dup + 3/PaintProc load put cvx/PaintProc exch def + end + gsave 0 setgray + makepattern + grestore +}def +/set_pattern +{ + dup/PatternType get 1 eq{ + dup/PaintType get 1 eq{ + currentoverprint sop[/DeviceGray]setcolorspace 0 setgray + }if + }if + setpattern +}def +/setcolorspace_opt +{ + dup currentcolorspace eq{pop}{setcolorspace}ifelse +}def +/updatecolorrendering +{ + currentcolorrendering/RenderingIntent known{ + currentcolorrendering/RenderingIntent get + } + { + Intent/AbsoluteColorimetric eq + { + /absolute_colorimetric_crd AGMCORE_gget dup null eq + } + { + Intent/RelativeColorimetric eq + { + /relative_colorimetric_crd AGMCORE_gget dup null eq + } + { + Intent/Saturation eq + { + /saturation_crd AGMCORE_gget dup null eq + } + { + /perceptual_crd AGMCORE_gget dup null eq + }ifelse + }ifelse + }ifelse + { + pop null + } + { + /RenderingIntent known{null}{Intent}ifelse + }ifelse + }ifelse + Intent ne{ + Intent/ColorRendering{findresource}stopped + { + pop pop systemdict/findcolorrendering known + { + Intent findcolorrendering + { + /ColorRendering findresource true exch + } + { + /ColorRendering findresource + product(Xerox Phaser 5400)ne + exch + }ifelse + dup Intent/AbsoluteColorimetric eq + { + /absolute_colorimetric_crd exch AGMCORE_gput + } + { + Intent/RelativeColorimetric eq + { + /relative_colorimetric_crd exch AGMCORE_gput + } + { + Intent/Saturation eq + { + /saturation_crd exch AGMCORE_gput + } + { + Intent/Perceptual eq + { + /perceptual_crd exch AGMCORE_gput + } + { + pop + }ifelse + }ifelse + }ifelse + }ifelse + 1 index{exch}{pop}ifelse + } + {false}ifelse + } + {true}ifelse + { + dup begin + currentdict/TransformPQR known{ + currentdict/TransformPQR get aload pop + 3{{}eq 3 1 roll}repeat or or + } + {true}ifelse + currentdict/MatrixPQR known{ + currentdict/MatrixPQR get aload pop + 1.0 eq 9 1 roll 0.0 eq 9 1 roll 0.0 eq 9 1 roll + 0.0 eq 9 1 roll 1.0 eq 9 1 roll 0.0 eq 9 1 roll + 0.0 eq 9 1 roll 0.0 eq 9 1 roll 1.0 eq + and and and and and and and and + } + {true}ifelse + end + or + { + clonedict begin + /TransformPQR[ + {4 -1 roll 3 get dup 3 1 roll sub 5 -1 roll 3 get 3 -1 roll sub div + 3 -1 roll 3 get 3 -1 roll 3 get dup 4 1 roll sub mul add}bind + {4 -1 roll 4 get dup 3 1 roll sub 5 -1 roll 4 get 3 -1 roll sub div + 3 -1 roll 4 get 3 -1 roll 4 get dup 4 1 roll sub mul add}bind + {4 -1 roll 5 get dup 3 1 roll sub 5 -1 roll 5 get 3 -1 roll sub div + 3 -1 roll 5 get 3 -1 roll 5 get dup 4 1 roll sub mul add}bind + ]def + /MatrixPQR[0.8951 -0.7502 0.0389 0.2664 1.7135 -0.0685 -0.1614 0.0367 1.0296]def + /RangePQR[-0.3227950745 2.3229645538 -1.5003771057 3.5003465881 -0.1369979095 2.136967392]def + currentdict end + }if + setcolorrendering_opt + }if + }if +}def +/set_crd +{ + AGMCORE_host_sep not level2 and{ + currentdict/ColorRendering known{ + ColorRendering/ColorRendering{findresource}stopped not{setcolorrendering_opt}if + }{ + currentdict/Intent known{ + updatecolorrendering + }if + }ifelse + currentcolorspace dup type/arraytype eq + {0 get}if + /DeviceRGB eq + { + currentdict/UCR known + {/UCR}{/AGMCORE_currentucr}ifelse + load setundercolorremoval + currentdict/BG known + {/BG}{/AGMCORE_currentbg}ifelse + load setblackgeneration + }if + }if +}def +/set_ucrbg +{ + dup null eq{pop/AGMCORE_currentbg load}{/Procedure get_res}ifelse setblackgeneration + dup null eq{pop/AGMCORE_currentucr load}{/Procedure get_res}ifelse setundercolorremoval +}def +/setcolorrendering_opt +{ + dup currentcolorrendering eq{ + pop + }{ + product(HP Color LaserJet 2605)anchorsearch{ + pop pop pop + }{ + pop + clonedict + begin + /Intent Intent def + currentdict + end + setcolorrendering + }ifelse + }ifelse +}def +/cpaint_gcomp +{ + convert_to_process//Adobe_AGM_Core/AGMCORE_ConvertToProcess xddf + //Adobe_AGM_Core/AGMCORE_ConvertToProcess get not + { + (%end_cpaint_gcomp)flushinput + }if +}def +/cpaint_gsep +{ + //Adobe_AGM_Core/AGMCORE_ConvertToProcess get + { + (%end_cpaint_gsep)flushinput + }if +}def +/cpaint_gend +{np}def +/T1_path +{ + currentfile token pop currentfile token pop mo + { + currentfile token pop dup type/stringtype eq + {pop exit}if + 0 exch rlineto + currentfile token pop dup type/stringtype eq + {pop exit}if + 0 rlineto + }loop +}def +/T1_gsave + level3 + {/clipsave} + {/gsave}ifelse + load def +/T1_grestore + level3 + {/cliprestore} + {/grestore}ifelse + load def +/set_spot_alias_ary +{ + dup inherit_aliases + //Adobe_AGM_Core/AGMCORE_SpotAliasAry xddf +}def +/set_spot_normalization_ary +{ + dup inherit_aliases + dup length + /AGMCORE_SpotAliasAry where{pop AGMCORE_SpotAliasAry length add}if + array + //Adobe_AGM_Core/AGMCORE_SpotAliasAry2 xddf + /AGMCORE_SpotAliasAry where{ + pop + AGMCORE_SpotAliasAry2 0 AGMCORE_SpotAliasAry putinterval + AGMCORE_SpotAliasAry length + }{0}ifelse + AGMCORE_SpotAliasAry2 3 1 roll exch putinterval + true set_spot_alias +}def +/inherit_aliases +{ + {dup/Name get map_alias{/CSD put}{pop}ifelse}forall +}def +/set_spot_alias +{ + /AGMCORE_SpotAliasAry2 where{ + /AGMCORE_current_spot_alias 3 -1 roll put + }{ + pop + }ifelse +}def +/current_spot_alias +{ + /AGMCORE_SpotAliasAry2 where{ + /AGMCORE_current_spot_alias get + }{ + false + }ifelse +}def +/map_alias +{ + /AGMCORE_SpotAliasAry2 where{ + begin + /AGMCORE_name xdf + false + AGMCORE_SpotAliasAry2{ + dup/Name get AGMCORE_name eq{ + /CSD get/CSD get_res + exch pop true + exit + }{ + pop + }ifelse + }forall + end + }{ + pop false + }ifelse +}bdf +/spot_alias +{ + true set_spot_alias + /AGMCORE_&setcustomcolor AGMCORE_key_known not{ + //Adobe_AGM_Core/AGMCORE_&setcustomcolor/setcustomcolor load put + }if + /customcolor_tint 1 AGMCORE_gput + //Adobe_AGM_Core begin + /setcustomcolor + { + //Adobe_AGM_Core begin + dup/customcolor_tint exch AGMCORE_gput + 1 index aload pop pop 1 eq exch 1 eq and exch 1 eq and exch 1 eq and not + current_spot_alias and{1 index 4 get map_alias}{false}ifelse + { + false set_spot_alias + /sep_colorspace_dict AGMCORE_gget null ne + {/sep_colorspace_dict AGMCORE_gget/ForeignContent known not}{false}ifelse + 3 1 roll 2 index{ + exch pop/sep_tint AGMCORE_gget exch + }if + mark 3 1 roll + setsepcolorspace + counttomark 0 ne{ + setsepcolor + }if + pop + not{/sep_tint 1.0 AGMCORE_gput/sep_colorspace_dict AGMCORE_gget/ForeignContent true put}if + pop + true set_spot_alias + }{ + AGMCORE_&setcustomcolor + }ifelse + end + }bdf + end +}def +/begin_feature +{ + Adobe_AGM_Core/AGMCORE_feature_dictCount countdictstack put + count Adobe_AGM_Core/AGMCORE_feature_opCount 3 -1 roll put + {Adobe_AGM_Core/AGMCORE_feature_ctm matrix currentmatrix put}if +}def +/end_feature +{ + 2 dict begin + /spd/setpagedevice load def + /setpagedevice{get_gstate spd set_gstate}def + stopped{$error/newerror false put}if + end + count Adobe_AGM_Core/AGMCORE_feature_opCount get sub dup 0 gt{{pop}repeat}{pop}ifelse + countdictstack Adobe_AGM_Core/AGMCORE_feature_dictCount get sub dup 0 gt{{end}repeat}{pop}ifelse + {Adobe_AGM_Core/AGMCORE_feature_ctm get setmatrix}if +}def +/set_negative +{ + //Adobe_AGM_Core begin + /AGMCORE_inverting exch def + level2{ + currentpagedevice/NegativePrint known AGMCORE_distilling not and{ + currentpagedevice/NegativePrint get//Adobe_AGM_Core/AGMCORE_inverting get ne{ + true begin_feature true{ + <>setpagedevice + }end_feature + }if + /AGMCORE_inverting false def + }if + }if + AGMCORE_inverting{ + [{1 exch sub}/exec load dup currenttransfer exch]cvx bind settransfer + AGMCORE_distilling{ + erasepage + }{ + gsave np clippath 1/setseparationgray where{pop setseparationgray}{setgray}ifelse + /AGMIRS_&fill where{pop AGMIRS_&fill}{fill}ifelse grestore + }ifelse + }if + end +}def +/lw_save_restore_override{ + /md where{ + pop + md begin + initializepage + /initializepage{}def + /pmSVsetup{}def + /endp{}def + /pse{}def + /psb{}def + /orig_showpage where + {pop} + {/orig_showpage/showpage load def} + ifelse + /showpage{orig_showpage gR}def + end + }if +}def +/pscript_showpage_override{ + /NTPSOct95 where + { + begin + showpage + save + /showpage/restore load def + /restore{exch pop}def + end + }if +}def +/driver_media_override +{ + /md where{ + pop + md/initializepage known{ + md/initializepage{}put + }if + md/rC known{ + md/rC{4{pop}repeat}put + }if + }if + /mysetup where{ + /mysetup[1 0 0 1 0 0]put + }if + Adobe_AGM_Core/AGMCORE_Default_CTM matrix currentmatrix put + level2 + {Adobe_AGM_Core/AGMCORE_Default_PageSize currentpagedevice/PageSize get put}if +}def +/capture_mysetup +{ + /Pscript_Win_Data where{ + pop + Pscript_Win_Data/mysetup known{ + Adobe_AGM_Core/save_mysetup Pscript_Win_Data/mysetup get put + }if + }if +}def +/restore_mysetup +{ + /Pscript_Win_Data where{ + pop + Pscript_Win_Data/mysetup known{ + Adobe_AGM_Core/save_mysetup known{ + Pscript_Win_Data/mysetup Adobe_AGM_Core/save_mysetup get put + Adobe_AGM_Core/save_mysetup undef + }if + }if + }if +}def +/driver_check_media_override +{ + /PrepsDict where + {pop} + { + Adobe_AGM_Core/AGMCORE_Default_CTM get matrix currentmatrix ne + Adobe_AGM_Core/AGMCORE_Default_PageSize get type/arraytype eq + { + Adobe_AGM_Core/AGMCORE_Default_PageSize get 0 get currentpagedevice/PageSize get 0 get eq and + Adobe_AGM_Core/AGMCORE_Default_PageSize get 1 get currentpagedevice/PageSize get 1 get eq and + }if + { + Adobe_AGM_Core/AGMCORE_Default_CTM get setmatrix + }if + }ifelse +}def +AGMCORE_err_strings begin + /AGMCORE_bad_environ(Environment not satisfactory for this job. Ensure that the PPD is correct or that the PostScript level requested is supported by this printer. )def + /AGMCORE_color_space_onhost_seps(This job contains colors that will not separate with on-host methods. )def + /AGMCORE_invalid_color_space(This job contains an invalid color space. )def +end +/set_def_ht +{AGMCORE_def_ht sethalftone}def +/set_def_flat +{AGMCORE_Default_flatness setflat}def +end +systemdict/setpacking known +{setpacking}if +%%EndResource +%%BeginResource: procset Adobe_CoolType_Core 2.31 0 %%Copyright: Copyright 1997-2006 Adobe Systems Incorporated. All Rights Reserved. %%Version: 2.31 0 10 dict begin /Adobe_CoolType_Passthru currentdict def /Adobe_CoolType_Core_Defined userdict/Adobe_CoolType_Core known def Adobe_CoolType_Core_Defined {/Adobe_CoolType_Core userdict/Adobe_CoolType_Core get def} if userdict/Adobe_CoolType_Core 70 dict dup begin put /Adobe_CoolType_Version 2.31 def /Level2? systemdict/languagelevel known dup {pop systemdict/languagelevel get 2 ge} if def Level2? not { /currentglobal false def /setglobal/pop load def /gcheck{pop false}bind def /currentpacking false def /setpacking/pop load def /SharedFontDirectory 0 dict def } if currentpacking true setpacking currentglobal false setglobal userdict/Adobe_CoolType_Data 2 copy known not {2 copy 10 dict put} if get begin /@opStackCountByLevel 32 dict def /@opStackLevel 0 def /@dictStackCountByLevel 32 dict def /@dictStackLevel 0 def end setglobal currentglobal true setglobal userdict/Adobe_CoolType_GVMFonts known not {userdict/Adobe_CoolType_GVMFonts 10 dict put} if setglobal currentglobal false setglobal userdict/Adobe_CoolType_LVMFonts known not {userdict/Adobe_CoolType_LVMFonts 10 dict put} if setglobal /ct_VMDictPut { dup gcheck{Adobe_CoolType_GVMFonts}{Adobe_CoolType_LVMFonts}ifelse 3 1 roll put }bind def /ct_VMDictUndef { dup Adobe_CoolType_GVMFonts exch known {Adobe_CoolType_GVMFonts exch undef} { dup Adobe_CoolType_LVMFonts exch known {Adobe_CoolType_LVMFonts exch undef} {pop} ifelse }ifelse }bind def /ct_str1 1 string def /ct_xshow { /_ct_na exch def /_ct_i 0 def currentpoint /_ct_y exch def /_ct_x exch def { pop pop ct_str1 exch 0 exch put ct_str1 show {_ct_na _ct_i get}stopped {pop pop} { _ct_x _ct_y moveto 0 rmoveto } ifelse /_ct_i _ct_i 1 add def currentpoint /_ct_y exch def /_ct_x exch def } exch @cshow }bind def /ct_yshow { /_ct_na exch def /_ct_i 0 def currentpoint /_ct_y exch def /_ct_x exch def { pop pop ct_str1 exch 0 exch put ct_str1 show {_ct_na _ct_i get}stopped {pop pop} { _ct_x _ct_y moveto 0 exch rmoveto } ifelse /_ct_i _ct_i 1 add def currentpoint /_ct_y exch def /_ct_x exch def } exch @cshow }bind def /ct_xyshow { /_ct_na exch def /_ct_i 0 def currentpoint /_ct_y exch def /_ct_x exch def { pop pop ct_str1 exch 0 exch put ct_str1 show {_ct_na _ct_i get}stopped {pop pop} { {_ct_na _ct_i 1 add get}stopped {pop pop pop} { _ct_x _ct_y moveto rmoveto } ifelse } ifelse /_ct_i _ct_i 2 add def currentpoint /_ct_y exch def /_ct_x exch def } exch @cshow }bind def /xsh{{@xshow}stopped{Adobe_CoolType_Data begin ct_xshow end}if}bind def /ysh{{@yshow}stopped{Adobe_CoolType_Data begin ct_yshow end}if}bind def /xysh{{@xyshow}stopped{Adobe_CoolType_Data begin ct_xyshow end}if}bind def currentglobal true setglobal /ct_T3Defs { /BuildChar { 1 index/Encoding get exch get 1 index/BuildGlyph get exec }bind def /BuildGlyph { exch begin GlyphProcs exch get exec end }bind def }bind def setglobal /@_SaveStackLevels { Adobe_CoolType_Data begin /@vmState currentglobal def false setglobal @opStackCountByLevel @opStackLevel 2 copy known not { 2 copy 3 dict dup/args 7 index 5 add array put put get } { get dup/args get dup length 3 index lt { dup length 5 add array exch 1 index exch 0 exch putinterval 1 index exch/args exch put } {pop} ifelse } ifelse begin count 1 sub 1 index lt {pop count} if dup/argCount exch def dup 0 gt { args exch 0 exch getinterval astore pop } {pop} ifelse count /restCount exch def end /@opStackLevel @opStackLevel 1 add def countdictstack 1 sub @dictStackCountByLevel exch @dictStackLevel exch put /@dictStackLevel @dictStackLevel 1 add def @vmState setglobal end }bind def /@_RestoreStackLevels { Adobe_CoolType_Data begin /@opStackLevel @opStackLevel 1 sub def @opStackCountByLevel @opStackLevel get begin count restCount sub dup 0 gt {{pop}repeat} {pop} ifelse args 0 argCount getinterval{}forall end /@dictStackLevel @dictStackLevel 1 sub def @dictStackCountByLevel @dictStackLevel get end countdictstack exch sub dup 0 gt {{end}repeat} {pop} ifelse }bind def /@_PopStackLevels { Adobe_CoolType_Data begin /@opStackLevel @opStackLevel 1 sub def /@dictStackLevel @dictStackLevel 1 sub def end }bind def /@Raise { exch cvx exch errordict exch get exec stop }bind def /@ReRaise { cvx $error/errorname get errordict exch get exec stop }bind def /@Stopped { 0 @#Stopped }bind def /@#Stopped { @_SaveStackLevels stopped {@_RestoreStackLevels true} {@_PopStackLevels false} ifelse }bind def /@Arg { Adobe_CoolType_Data begin @opStackCountByLevel @opStackLevel 1 sub get begin args exch argCount 1 sub exch sub get end end }bind def currentglobal true setglobal /CTHasResourceForAllBug Level2? { 1 dict dup /@shouldNotDisappearDictValue true def Adobe_CoolType_Data exch/@shouldNotDisappearDict exch put begin count @_SaveStackLevels {(*){pop stop}128 string/Category resourceforall} stopped pop @_RestoreStackLevels currentdict Adobe_CoolType_Data/@shouldNotDisappearDict get dup 3 1 roll ne dup 3 1 roll { /@shouldNotDisappearDictValue known { { end currentdict 1 index eq {pop exit} if } loop } if } { pop end } ifelse } {false} ifelse def true setglobal /CTHasResourceStatusBug Level2? { mark {/steveamerige/Category resourcestatus} stopped {cleartomark true} {cleartomark currentglobal not} ifelse } {false} ifelse def setglobal /CTResourceStatus { mark 3 1 roll /Category findresource begin ({ResourceStatus}stopped)0()/SubFileDecode filter cvx exec {cleartomark false} {{3 2 roll pop true}{cleartomark false}ifelse} ifelse end }bind def /CTWorkAroundBugs { Level2? { /cid_PreLoad/ProcSet resourcestatus { pop pop currentglobal mark { (*) { dup/CMap CTHasResourceStatusBug {CTResourceStatus} {resourcestatus} ifelse { pop dup 0 eq exch 1 eq or { dup/CMap findresource gcheck setglobal /CMap undefineresource } { pop CTHasResourceForAllBug {exit} {stop} ifelse } ifelse } {pop} ifelse } 128 string/CMap resourceforall } stopped {cleartomark} stopped pop setglobal } if } if }bind def /ds { Adobe_CoolType_Core begin CTWorkAroundBugs /mo/moveto load def /nf/newencodedfont load def /msf{makefont setfont}bind def /uf{dup undefinefont ct_VMDictUndef}bind def /ur/undefineresource load def /chp/charpath load def /awsh/awidthshow load def /wsh/widthshow load def /ash/ashow load def /@xshow/xshow load def /@yshow/yshow load def /@xyshow/xyshow load def /@cshow/cshow load def /sh/show load def /rp/repeat load def /.n/.notdef def end currentglobal false setglobal userdict/Adobe_CoolType_Data 2 copy known not {2 copy 10 dict put} if get begin /AddWidths? false def /CC 0 def /charcode 2 string def /@opStackCountByLevel 32 dict def /@opStackLevel 0 def /@dictStackCountByLevel 32 dict def /@dictStackLevel 0 def /InVMFontsByCMap 10 dict def /InVMDeepCopiedFonts 10 dict def end setglobal }bind def /dt { currentdict Adobe_CoolType_Core eq {end} if }bind def /ps { Adobe_CoolType_Core begin Adobe_CoolType_GVMFonts begin Adobe_CoolType_LVMFonts begin SharedFontDirectory begin }bind def /pt { end end end end }bind def /unload { systemdict/languagelevel known { systemdict/languagelevel get 2 ge { userdict/Adobe_CoolType_Core 2 copy known {undef} {pop pop} ifelse } if } if }bind def /ndf { 1 index where {pop pop pop} {dup xcheck{bind}if def} ifelse }def /findfont systemdict begin userdict begin /globaldict where{/globaldict get begin}if dup where pop exch get /globaldict where{pop end}if end end Adobe_CoolType_Core_Defined {/systemfindfont exch def} { /findfont 1 index def /systemfindfont exch def } ifelse /undefinefont {pop}ndf /copyfont { currentglobal 3 1 roll 1 index gcheck setglobal dup null eq{0}{dup length}ifelse 2 index length add 1 add dict begin exch { 1 index/FID eq {pop pop} {def} ifelse } forall dup null eq {pop} {{def}forall} ifelse currentdict end exch setglobal }bind def /copyarray { currentglobal exch dup gcheck setglobal dup length array copy exch setglobal }bind def /newencodedfont { currentglobal { SharedFontDirectory 3 index known {SharedFontDirectory 3 index get/FontReferenced known} {false} ifelse } { FontDirectory 3 index known {FontDirectory 3 index get/FontReferenced known} { SharedFontDirectory 3 index known {SharedFontDirectory 3 index get/FontReferenced known} {false} ifelse } ifelse } ifelse dup { 3 index findfont/FontReferenced get 2 index dup type/nametype eq {findfont} if ne {pop false} if } if dup { 1 index dup type/nametype eq {findfont} if dup/CharStrings known { /CharStrings get length 4 index findfont/CharStrings get length ne { pop false } if } {pop} ifelse } if { pop 1 index findfont /Encoding get exch 0 1 255 {2 copy get 3 index 3 1 roll put} for pop pop pop } { currentglobal 4 1 roll dup type/nametype eq {findfont} if dup gcheck setglobal dup dup maxlength 2 add dict begin exch { 1 index/FID ne 2 index/Encoding ne and {def} {pop pop} ifelse } forall /FontReferenced exch def /Encoding exch dup length array copy def /FontName 1 index dup type/stringtype eq{cvn}if def dup currentdict end definefont ct_VMDictPut setglobal } ifelse }bind def /SetSubstituteStrategy { $SubstituteFont begin dup type/dicttype ne {0 dict} if currentdict/$Strategies known { exch $Strategies exch 2 copy known { get 2 copy maxlength exch maxlength add dict begin {def}forall {def}forall currentdict dup/$Init known {dup/$Init get exec} if end /$Strategy exch def } {pop pop pop} ifelse } {pop pop} ifelse end }bind def /scff { $SubstituteFont begin dup type/stringtype eq {dup length exch} {null} ifelse /$sname exch def /$slen exch def /$inVMIndex $sname null eq { 1 index $str cvs dup length $slen sub $slen getinterval cvn } {$sname} ifelse def end {findfont} @Stopped { dup length 8 add string exch 1 index 0(BadFont:)putinterval 1 index exch 8 exch dup length string cvs putinterval cvn {findfont} @Stopped {pop/Courier findfont} if } if $SubstituteFont begin /$sname null def /$slen 0 def /$inVMIndex null def end }bind def /isWidthsOnlyFont { dup/WidthsOnly known {pop pop true} { dup/FDepVector known {/FDepVector get{isWidthsOnlyFont dup{exit}if}forall} { dup/FDArray known {/FDArray get{isWidthsOnlyFont dup{exit}if}forall} {pop} ifelse } ifelse } ifelse }bind def /ct_StyleDicts 4 dict dup begin /Adobe-Japan1 4 dict dup begin Level2? { /Serif /HeiseiMin-W3-83pv-RKSJ-H/Font resourcestatus {pop pop/HeiseiMin-W3} { /CIDFont/Category resourcestatus { pop pop /HeiseiMin-W3/CIDFont resourcestatus {pop pop/HeiseiMin-W3} {/Ryumin-Light} ifelse } {/Ryumin-Light} ifelse } ifelse def /SansSerif /HeiseiKakuGo-W5-83pv-RKSJ-H/Font resourcestatus {pop pop/HeiseiKakuGo-W5} { /CIDFont/Category resourcestatus { pop pop /HeiseiKakuGo-W5/CIDFont resourcestatus {pop pop/HeiseiKakuGo-W5} {/GothicBBB-Medium} ifelse } {/GothicBBB-Medium} ifelse } ifelse def /HeiseiMaruGo-W4-83pv-RKSJ-H/Font resourcestatus {pop pop/HeiseiMaruGo-W4} { /CIDFont/Category resourcestatus { pop pop /HeiseiMaruGo-W4/CIDFont resourcestatus {pop pop/HeiseiMaruGo-W4} { /Jun101-Light-RKSJ-H/Font resourcestatus {pop pop/Jun101-Light} {SansSerif} ifelse } ifelse } { /Jun101-Light-RKSJ-H/Font resourcestatus {pop pop/Jun101-Light} {SansSerif} ifelse } ifelse } ifelse /RoundSansSerif exch def /Default Serif def } { /Serif/Ryumin-Light def /SansSerif/GothicBBB-Medium def { (fonts/Jun101-Light-83pv-RKSJ-H)status }stopped {pop}{ {pop pop pop pop/Jun101-Light} {SansSerif} ifelse /RoundSansSerif exch def }ifelse /Default Serif def } ifelse end def /Adobe-Korea1 4 dict dup begin /Serif/HYSMyeongJo-Medium def /SansSerif/HYGoThic-Medium def /RoundSansSerif SansSerif def /Default Serif def end def /Adobe-GB1 4 dict dup begin /Serif/STSong-Light def /SansSerif/STHeiti-Regular def /RoundSansSerif SansSerif def /Default Serif def end def /Adobe-CNS1 4 dict dup begin /Serif/MKai-Medium def /SansSerif/MHei-Medium def /RoundSansSerif SansSerif def /Default Serif def end def end def Level2?{currentglobal true setglobal}if /ct_BoldRomanWidthProc { stringwidth 1 index 0 ne{exch .03 add exch}if setcharwidth 0 0 }bind def /ct_Type0WidthProc { dup stringwidth 0 0 moveto 2 index true charpath pathbbox 0 -1 7 index 2 div .88 setcachedevice2 pop 0 0 }bind def /ct_Type0WMode1WidthProc { dup stringwidth pop 2 div neg -0.88 2 copy moveto 0 -1 5 -1 roll true charpath pathbbox setcachedevice }bind def /cHexEncoding [/c00/c01/c02/c03/c04/c05/c06/c07/c08/c09/c0A/c0B/c0C/c0D/c0E/c0F/c10/c11/c12 /c13/c14/c15/c16/c17/c18/c19/c1A/c1B/c1C/c1D/c1E/c1F/c20/c21/c22/c23/c24/c25 /c26/c27/c28/c29/c2A/c2B/c2C/c2D/c2E/c2F/c30/c31/c32/c33/c34/c35/c36/c37/c38 /c39/c3A/c3B/c3C/c3D/c3E/c3F/c40/c41/c42/c43/c44/c45/c46/c47/c48/c49/c4A/c4B /c4C/c4D/c4E/c4F/c50/c51/c52/c53/c54/c55/c56/c57/c58/c59/c5A/c5B/c5C/c5D/c5E /c5F/c60/c61/c62/c63/c64/c65/c66/c67/c68/c69/c6A/c6B/c6C/c6D/c6E/c6F/c70/c71 /c72/c73/c74/c75/c76/c77/c78/c79/c7A/c7B/c7C/c7D/c7E/c7F/c80/c81/c82/c83/c84 /c85/c86/c87/c88/c89/c8A/c8B/c8C/c8D/c8E/c8F/c90/c91/c92/c93/c94/c95/c96/c97 /c98/c99/c9A/c9B/c9C/c9D/c9E/c9F/cA0/cA1/cA2/cA3/cA4/cA5/cA6/cA7/cA8/cA9/cAA /cAB/cAC/cAD/cAE/cAF/cB0/cB1/cB2/cB3/cB4/cB5/cB6/cB7/cB8/cB9/cBA/cBB/cBC/cBD /cBE/cBF/cC0/cC1/cC2/cC3/cC4/cC5/cC6/cC7/cC8/cC9/cCA/cCB/cCC/cCD/cCE/cCF/cD0 /cD1/cD2/cD3/cD4/cD5/cD6/cD7/cD8/cD9/cDA/cDB/cDC/cDD/cDE/cDF/cE0/cE1/cE2/cE3 /cE4/cE5/cE6/cE7/cE8/cE9/cEA/cEB/cEC/cED/cEE/cEF/cF0/cF1/cF2/cF3/cF4/cF5/cF6 /cF7/cF8/cF9/cFA/cFB/cFC/cFD/cFE/cFF]def /ct_BoldBaseFont 11 dict begin /FontType 3 def /FontMatrix[1 0 0 1 0 0]def /FontBBox[0 0 1 1]def /Encoding cHexEncoding def /_setwidthProc/ct_BoldRomanWidthProc load def /_bcstr1 1 string def /BuildChar { exch begin _basefont setfont _bcstr1 dup 0 4 -1 roll put dup _setwidthProc 3 copy moveto show _basefonto setfont moveto show end }bind def currentdict end def systemdict/composefont known { /ct_DefineIdentity-H { /Identity-H/CMap resourcestatus { pop pop } { /CIDInit/ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo 3 dict dup begin /Registry(Adobe)def /Ordering(Identity)def /Supplement 0 def end def /CMapName/Identity-H def /CMapVersion 1.000 def /CMapType 1 def 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>0 endcidrange endcmap CMapName currentdict/CMap defineresource pop end end } ifelse } def /ct_BoldBaseCIDFont 11 dict begin /CIDFontType 1 def /CIDFontName/ct_BoldBaseCIDFont def /FontMatrix[1 0 0 1 0 0]def /FontBBox[0 0 1 1]def /_setwidthProc/ct_Type0WidthProc load def /_bcstr2 2 string def /BuildGlyph { exch begin _basefont setfont _bcstr2 1 2 index 256 mod put _bcstr2 0 3 -1 roll 256 idiv put _bcstr2 dup _setwidthProc 3 copy moveto show _basefonto setfont moveto show end }bind def currentdict end def }if Level2?{setglobal}if /ct_CopyFont{ { 1 index/FID ne 2 index/UniqueID ne and {def}{pop pop}ifelse }forall }bind def /ct_Type0CopyFont { exch dup length dict begin ct_CopyFont [ exch FDepVector { dup/FontType get 0 eq { 1 index ct_Type0CopyFont /_ctType0 exch definefont } { /_ctBaseFont exch 2 index exec } ifelse exch } forall pop ] /FDepVector exch def currentdict end }bind def /ct_MakeBoldFont { dup/ct_SyntheticBold known { dup length 3 add dict begin ct_CopyFont /ct_StrokeWidth .03 0 FontMatrix idtransform pop def /ct_SyntheticBold true def currentdict end definefont } { dup dup length 3 add dict begin ct_CopyFont /PaintType 2 def /StrokeWidth .03 0 FontMatrix idtransform pop def /dummybold currentdict end definefont dup/FontType get dup 9 ge exch 11 le and { ct_BoldBaseCIDFont dup length 3 add dict copy begin dup/CIDSystemInfo get/CIDSystemInfo exch def ct_DefineIdentity-H /_Type0Identity/Identity-H 3 -1 roll[exch]composefont /_basefont exch def /_Type0Identity/Identity-H 3 -1 roll[exch]composefont /_basefonto exch def currentdict end /CIDFont defineresource } { ct_BoldBaseFont dup length 3 add dict copy begin /_basefont exch def /_basefonto exch def currentdict end definefont } ifelse } ifelse }bind def /ct_MakeBold{ 1 index 1 index findfont currentglobal 5 1 roll dup gcheck setglobal dup /FontType get 0 eq { dup/WMode known{dup/WMode get 1 eq}{false}ifelse version length 4 ge and {version 0 4 getinterval cvi 2015 ge} {true} ifelse {/ct_Type0WidthProc} {/ct_Type0WMode1WidthProc} ifelse ct_BoldBaseFont/_setwidthProc 3 -1 roll load put {ct_MakeBoldFont}ct_Type0CopyFont definefont } { dup/_fauxfont known not 1 index/SubstMaster known not and { ct_BoldBaseFont/_setwidthProc /ct_BoldRomanWidthProc load put ct_MakeBoldFont } { 2 index 2 index eq {exch pop } { dup length dict begin ct_CopyFont currentdict end definefont } ifelse } ifelse } ifelse pop pop pop setglobal }bind def /?str1 256 string def /?set { $SubstituteFont begin /$substituteFound false def /$fontname 1 index def /$doSmartSub false def end dup findfont $SubstituteFont begin $substituteFound {false} { dup/FontName known { dup/FontName get $fontname eq 1 index/DistillerFauxFont known not and /currentdistillerparams where {pop false 2 index isWidthsOnlyFont not and} if } {false} ifelse } ifelse exch pop /$doSmartSub true def end { 5 1 roll pop pop pop pop findfont } { 1 index findfont dup/FontType get 3 eq { 6 1 roll pop pop pop pop pop false } {pop true} ifelse { $SubstituteFont begin pop pop /$styleArray 1 index def /$regOrdering 2 index def pop pop 0 1 $styleArray length 1 sub { $styleArray exch get ct_StyleDicts $regOrdering 2 copy known { get exch 2 copy known not {pop/Default} if get dup type/nametype eq { ?str1 cvs length dup 1 add exch ?str1 exch(-)putinterval exch dup length exch ?str1 exch 3 index exch putinterval add ?str1 exch 0 exch getinterval cvn } { pop pop/Unknown } ifelse } { pop pop pop pop/Unknown } ifelse } for end findfont }if } ifelse currentglobal false setglobal 3 1 roll null copyfont definefont pop setglobal }bind def setpacking userdict/$SubstituteFont 25 dict put 1 dict begin /SubstituteFont dup $error exch 2 copy known {get} {pop pop{pop/Courier}bind} ifelse def /currentdistillerparams where dup { pop pop currentdistillerparams/CannotEmbedFontPolicy 2 copy known {get/Error eq} {pop pop false} ifelse } if not { countdictstack array dictstack 0 get begin userdict begin $SubstituteFont begin /$str 128 string def /$fontpat 128 string def /$slen 0 def /$sname null def /$match false def /$fontname null def /$substituteFound false def /$inVMIndex null def /$doSmartSub true def /$depth 0 def /$fontname null def /$italicangle 26.5 def /$dstack null def /$Strategies 10 dict dup begin /$Type3Underprint { currentglobal exch false setglobal 11 dict begin /UseFont exch $WMode 0 ne { dup length dict copy dup/WMode $WMode put /UseFont exch definefont } if def /FontName $fontname dup type/stringtype eq{cvn}if def /FontType 3 def /FontMatrix[.001 0 0 .001 0 0]def /Encoding 256 array dup 0 1 255{/.notdef put dup}for pop def /FontBBox[0 0 0 0]def /CCInfo 7 dict dup begin /cc null def /x 0 def /y 0 def end def /BuildChar { exch begin CCInfo begin 1 string dup 0 3 index put exch pop /cc exch def UseFont 1000 scalefont setfont cc stringwidth/y exch def/x exch def x y setcharwidth $SubstituteFont/$Strategy get/$Underprint get exec 0 0 moveto cc show x y moveto end end }bind def currentdict end exch setglobal }bind def /$GetaTint 2 dict dup begin /$BuildFont { dup/WMode known {dup/WMode get} {0} ifelse /$WMode exch def $fontname exch dup/FontName known { dup/FontName get dup type/stringtype eq{cvn}if } {/unnamedfont} ifelse exch Adobe_CoolType_Data/InVMDeepCopiedFonts get 1 index/FontName get known { pop Adobe_CoolType_Data/InVMDeepCopiedFonts get 1 index get null copyfont } {$deepcopyfont} ifelse exch 1 index exch/FontBasedOn exch put dup/FontName $fontname dup type/stringtype eq{cvn}if put definefont Adobe_CoolType_Data/InVMDeepCopiedFonts get begin dup/FontBasedOn get 1 index def end }bind def /$Underprint { gsave x abs y abs gt {/y 1000 def} {/x -1000 def 500 120 translate} ifelse Level2? { [/Separation(All)/DeviceCMYK{0 0 0 1 pop}] setcolorspace } {0 setgray} ifelse 10 setlinewidth x .8 mul [7 3] { y mul 8 div 120 sub x 10 div exch moveto 0 y 4 div neg rlineto dup 0 rlineto 0 y 4 div rlineto closepath gsave Level2? {.2 setcolor} {.8 setgray} ifelse fill grestore stroke } forall pop grestore }bind def end def /$Oblique 1 dict dup begin /$BuildFont { currentglobal exch dup gcheck setglobal null copyfont begin /FontBasedOn currentdict/FontName known { FontName dup type/stringtype eq{cvn}if } {/unnamedfont} ifelse def /FontName $fontname dup type/stringtype eq{cvn}if def /currentdistillerparams where {pop} { /FontInfo currentdict/FontInfo known {FontInfo null copyfont} {2 dict} ifelse dup begin /ItalicAngle $italicangle def /FontMatrix FontMatrix [1 0 ItalicAngle dup sin exch cos div 1 0 0] matrix concatmatrix readonly end 4 2 roll def def } ifelse FontName currentdict end definefont exch setglobal }bind def end def /$None 1 dict dup begin /$BuildFont{}bind def end def end def /$Oblique SetSubstituteStrategy /$findfontByEnum { dup type/stringtype eq{cvn}if dup/$fontname exch def $sname null eq {$str cvs dup length $slen sub $slen getinterval} {pop $sname} ifelse $fontpat dup 0(fonts/*)putinterval exch 7 exch putinterval /$match false def $SubstituteFont/$dstack countdictstack array dictstack put mark { $fontpat 0 $slen 7 add getinterval {/$match exch def exit} $str filenameforall } stopped { cleardictstack currentdict true $SubstituteFont/$dstack get { exch { 1 index eq {pop false} {true} ifelse } {begin false} ifelse } forall pop } if cleartomark /$slen 0 def $match false ne {$match(fonts/)anchorsearch pop pop cvn} {/Courier} ifelse }bind def /$ROS 1 dict dup begin /Adobe 4 dict dup begin /Japan1 [/Ryumin-Light/HeiseiMin-W3 /GothicBBB-Medium/HeiseiKakuGo-W5 /HeiseiMaruGo-W4/Jun101-Light]def /Korea1 [/HYSMyeongJo-Medium/HYGoThic-Medium]def /GB1 [/STSong-Light/STHeiti-Regular]def /CNS1 [/MKai-Medium/MHei-Medium]def end def end def /$cmapname null def /$deepcopyfont { dup/FontType get 0 eq { 1 dict dup/FontName/copied put copyfont begin /FDepVector FDepVector copyarray 0 1 2 index length 1 sub { 2 copy get $deepcopyfont dup/FontName/copied put /copied exch definefont 3 copy put pop pop } for def currentdict end } {$Strategies/$Type3Underprint get exec} ifelse }bind def /$buildfontname { dup/CIDFont findresource/CIDSystemInfo get begin Registry length Ordering length Supplement 8 string cvs 3 copy length 2 add add add string dup 5 1 roll dup 0 Registry putinterval dup 4 index(-)putinterval dup 4 index 1 add Ordering putinterval 4 2 roll add 1 add 2 copy(-)putinterval end 1 add 2 copy 0 exch getinterval $cmapname $fontpat cvs exch anchorsearch {pop pop 3 2 roll putinterval cvn/$cmapname exch def} {pop pop pop pop pop} ifelse length $str 1 index(-)putinterval 1 add $str 1 index $cmapname $fontpat cvs putinterval $cmapname length add $str exch 0 exch getinterval cvn }bind def /$findfontByROS { /$fontname exch def $ROS Registry 2 copy known { get Ordering 2 copy known {get} {pop pop[]} ifelse } {pop pop[]} ifelse false exch { dup/CIDFont resourcestatus { pop pop save 1 index/CIDFont findresource dup/WidthsOnly known {dup/WidthsOnly get} {false} ifelse exch pop exch restore {pop} {exch pop true exit} ifelse } {pop} ifelse } forall {$str cvs $buildfontname} { false(*) { save exch dup/CIDFont findresource dup/WidthsOnly known {dup/WidthsOnly get not} {true} ifelse exch/CIDSystemInfo get dup/Registry get Registry eq exch/Ordering get Ordering eq and and {exch restore exch pop true exit} {pop restore} ifelse } $str/CIDFont resourceforall {$buildfontname} {$fontname $findfontByEnum} ifelse } ifelse }bind def end end currentdict/$error known currentdict/languagelevel known and dup {pop $error/SubstituteFont known} if dup {$error} {Adobe_CoolType_Core} ifelse begin { /SubstituteFont /CMap/Category resourcestatus { pop pop { $SubstituteFont begin /$substituteFound true def dup length $slen gt $sname null ne or $slen 0 gt and { $sname null eq {dup $str cvs dup length $slen sub $slen getinterval cvn} {$sname} ifelse Adobe_CoolType_Data/InVMFontsByCMap get 1 index 2 copy known { get false exch { pop currentglobal { GlobalFontDirectory 1 index known {exch pop true exit} {pop} ifelse } { FontDirectory 1 index known {exch pop true exit} { GlobalFontDirectory 1 index known {exch pop true exit} {pop} ifelse } ifelse } ifelse } forall } {pop pop false} ifelse { exch pop exch pop } { dup/CMap resourcestatus { pop pop dup/$cmapname exch def /CMap findresource/CIDSystemInfo get{def}forall $findfontByROS } { 128 string cvs dup(-)search { 3 1 roll search { 3 1 roll pop {dup cvi} stopped {pop pop pop pop pop $findfontByEnum} { 4 2 roll pop pop exch length exch 2 index length 2 index sub exch 1 sub -1 0 { $str cvs dup length 4 index 0 4 index 4 3 roll add getinterval exch 1 index exch 3 index exch putinterval dup/CMap resourcestatus { pop pop 4 1 roll pop pop pop dup/$cmapname exch def /CMap findresource/CIDSystemInfo get{def}forall $findfontByROS true exit } {pop} ifelse } for dup type/booleantype eq {pop} {pop pop pop $findfontByEnum} ifelse } ifelse } {pop pop pop $findfontByEnum} ifelse } {pop pop $findfontByEnum} ifelse } ifelse } ifelse } {//SubstituteFont exec} ifelse /$slen 0 def end } } { { $SubstituteFont begin /$substituteFound true def dup length $slen gt $sname null ne or $slen 0 gt and {$findfontByEnum} {//SubstituteFont exec} ifelse end } } ifelse bind readonly def Adobe_CoolType_Core/scfindfont/systemfindfont load put } { /scfindfont { $SubstituteFont begin dup systemfindfont dup/FontName known {dup/FontName get dup 3 index ne} {/noname true} ifelse dup { /$origfontnamefound 2 index def /$origfontname 4 index def/$substituteFound true def } if exch pop { $slen 0 gt $sname null ne 3 index length $slen gt or and { pop dup $findfontByEnum findfont dup maxlength 1 add dict begin {1 index/FID eq{pop pop}{def}ifelse} forall currentdict end definefont dup/FontName known{dup/FontName get}{null}ifelse $origfontnamefound ne { $origfontname $str cvs print ( substitution revised, using )print dup/FontName known {dup/FontName get}{(unspecified font)} ifelse $str cvs print(.\n)print } if } {exch pop} ifelse } {exch pop} ifelse end }bind def } ifelse end end Adobe_CoolType_Core_Defined not { Adobe_CoolType_Core/findfont { $SubstituteFont begin $depth 0 eq { /$fontname 1 index dup type/stringtype ne{$str cvs}if def /$substituteFound false def } if /$depth $depth 1 add def end scfindfont $SubstituteFont begin /$depth $depth 1 sub def $substituteFound $depth 0 eq and { $inVMIndex null ne {dup $inVMIndex $AddInVMFont} if $doSmartSub { currentdict/$Strategy known {$Strategy/$BuildFont get exec} if } if } if end }bind put } if } if end /$AddInVMFont { exch/FontName 2 copy known { get 1 dict dup begin exch 1 index gcheck def end exch Adobe_CoolType_Data/InVMFontsByCMap get exch $DictAdd } {pop pop pop} ifelse }bind def /$DictAdd { 2 copy known not {2 copy 4 index length dict put} if Level2? not { 2 copy get dup maxlength exch length 4 index length add lt 2 copy get dup length 4 index length add exch maxlength 1 index lt { 2 mul dict begin 2 copy get{forall}def 2 copy currentdict put end } {pop} ifelse } if get begin {def} forall end }bind def end end %%EndResource currentglobal true setglobal %%BeginResource: procset Adobe_CoolType_Utility_MAKEOCF 1.23 0 %%Copyright: Copyright 1987-2006 Adobe Systems Incorporated. %%Version: 1.23 0 systemdict/languagelevel known dup {currentglobal false setglobal} {false} ifelse exch userdict/Adobe_CoolType_Utility 2 copy known {2 copy get dup maxlength 27 add dict copy} {27 dict} ifelse put Adobe_CoolType_Utility begin /@eexecStartData def /@recognizeCIDFont null def /ct_Level2? exch def /ct_Clone? 1183615869 internaldict dup /CCRun known not exch/eCCRun known not ct_Level2? and or def ct_Level2? {globaldict begin currentglobal true setglobal} if /ct_AddStdCIDMap ct_Level2? {{ mark Adobe_CoolType_Utility/@recognizeCIDFont currentdict put { ((Hex)57 StartData 0615 1e27 2c39 1c60 d8a8 cc31 fe2b f6e0 7aa3 e541 e21c 60d8 a8c9 c3d0 6d9e 1c60 d8a8 c9c2 02d7 9a1c 60d8 a849 1c60 d8a8 cc36 74f4 1144 b13b 77)0()/SubFileDecode filter cvx exec } stopped { cleartomark Adobe_CoolType_Utility/@recognizeCIDFont get countdictstack dup array dictstack exch 1 sub -1 0 { 2 copy get 3 index eq {1 index length exch sub 1 sub{end}repeat exit} {pop} ifelse } for pop pop Adobe_CoolType_Utility/@eexecStartData get eexec } {cleartomark} ifelse }} {{ Adobe_CoolType_Utility/@eexecStartData get eexec }} ifelse bind def userdict/cid_extensions known dup{cid_extensions/cid_UpdateDB known and}if { cid_extensions begin /cid_GetCIDSystemInfo { 1 index type/stringtype eq {exch cvn exch} if cid_extensions begin dup load 2 index known { 2 copy cid_GetStatusInfo dup null ne { 1 index load 3 index get dup null eq {pop pop cid_UpdateDB} { exch 1 index/Created get eq {exch pop exch pop} {pop cid_UpdateDB} ifelse } ifelse } {pop cid_UpdateDB} ifelse } {cid_UpdateDB} ifelse end }bind def end } if ct_Level2? {end setglobal} if /ct_UseNativeCapability? systemdict/composefont known def /ct_MakeOCF 35 dict def /ct_Vars 25 dict def /ct_GlyphDirProcs 6 dict def /ct_BuildCharDict 15 dict dup begin /charcode 2 string def /dst_string 1500 string def /nullstring()def /usewidths? true def end def ct_Level2?{setglobal}{pop}ifelse ct_GlyphDirProcs begin /GetGlyphDirectory { systemdict/languagelevel known {pop/CIDFont findresource/GlyphDirectory get} { 1 index/CIDFont findresource/GlyphDirectory get dup type/dicttype eq { dup dup maxlength exch length sub 2 index lt { dup length 2 index add dict copy 2 index /CIDFont findresource/GlyphDirectory 2 index put } if } if exch pop exch pop } ifelse + }def /+ { systemdict/languagelevel known { currentglobal false setglobal 3 dict begin /vm exch def } {1 dict begin} ifelse /$ exch def systemdict/languagelevel known { vm setglobal /gvm currentglobal def $ gcheck setglobal } if ?{$ begin}if }def /?{$ type/dicttype eq}def /|{ userdict/Adobe_CoolType_Data known { Adobe_CoolType_Data/AddWidths? known { currentdict Adobe_CoolType_Data begin begin AddWidths? { Adobe_CoolType_Data/CC 3 index put ?{def}{$ 3 1 roll put}ifelse CC charcode exch 1 index 0 2 index 256 idiv put 1 index exch 1 exch 256 mod put stringwidth 2 array astore currentfont/Widths get exch CC exch put } {?{def}{$ 3 1 roll put}ifelse} ifelse end end } {?{def}{$ 3 1 roll put}ifelse} ifelse } {?{def}{$ 3 1 roll put}ifelse} ifelse }def /! { ?{end}if systemdict/languagelevel known {gvm setglobal} if end }def /:{string currentfile exch readstring pop}executeonly def end ct_MakeOCF begin /ct_cHexEncoding [/c00/c01/c02/c03/c04/c05/c06/c07/c08/c09/c0A/c0B/c0C/c0D/c0E/c0F/c10/c11/c12 /c13/c14/c15/c16/c17/c18/c19/c1A/c1B/c1C/c1D/c1E/c1F/c20/c21/c22/c23/c24/c25 /c26/c27/c28/c29/c2A/c2B/c2C/c2D/c2E/c2F/c30/c31/c32/c33/c34/c35/c36/c37/c38 /c39/c3A/c3B/c3C/c3D/c3E/c3F/c40/c41/c42/c43/c44/c45/c46/c47/c48/c49/c4A/c4B /c4C/c4D/c4E/c4F/c50/c51/c52/c53/c54/c55/c56/c57/c58/c59/c5A/c5B/c5C/c5D/c5E /c5F/c60/c61/c62/c63/c64/c65/c66/c67/c68/c69/c6A/c6B/c6C/c6D/c6E/c6F/c70/c71 /c72/c73/c74/c75/c76/c77/c78/c79/c7A/c7B/c7C/c7D/c7E/c7F/c80/c81/c82/c83/c84 /c85/c86/c87/c88/c89/c8A/c8B/c8C/c8D/c8E/c8F/c90/c91/c92/c93/c94/c95/c96/c97 /c98/c99/c9A/c9B/c9C/c9D/c9E/c9F/cA0/cA1/cA2/cA3/cA4/cA5/cA6/cA7/cA8/cA9/cAA /cAB/cAC/cAD/cAE/cAF/cB0/cB1/cB2/cB3/cB4/cB5/cB6/cB7/cB8/cB9/cBA/cBB/cBC/cBD /cBE/cBF/cC0/cC1/cC2/cC3/cC4/cC5/cC6/cC7/cC8/cC9/cCA/cCB/cCC/cCD/cCE/cCF/cD0 /cD1/cD2/cD3/cD4/cD5/cD6/cD7/cD8/cD9/cDA/cDB/cDC/cDD/cDE/cDF/cE0/cE1/cE2/cE3 /cE4/cE5/cE6/cE7/cE8/cE9/cEA/cEB/cEC/cED/cEE/cEF/cF0/cF1/cF2/cF3/cF4/cF5/cF6 /cF7/cF8/cF9/cFA/cFB/cFC/cFD/cFE/cFF]def /ct_CID_STR_SIZE 8000 def /ct_mkocfStr100 100 string def /ct_defaultFontMtx[.001 0 0 .001 0 0]def /ct_1000Mtx[1000 0 0 1000 0 0]def /ct_raise{exch cvx exch errordict exch get exec stop}bind def /ct_reraise {cvx $error/errorname get(Error: )print dup( )cvs print errordict exch get exec stop }bind def /ct_cvnsi { 1 index add 1 sub 1 exch 0 4 1 roll { 2 index exch get exch 8 bitshift add } for exch pop }bind def /ct_GetInterval { Adobe_CoolType_Utility/ct_BuildCharDict get begin /dst_index 0 def dup dst_string length gt {dup string/dst_string exch def} if 1 index ct_CID_STR_SIZE idiv /arrayIndex exch def 2 index arrayIndex get 2 index arrayIndex ct_CID_STR_SIZE mul sub { dup 3 index add 2 index length le { 2 index getinterval dst_string dst_index 2 index putinterval length dst_index add/dst_index exch def exit } { 1 index length 1 index sub dup 4 1 roll getinterval dst_string dst_index 2 index putinterval pop dup dst_index add/dst_index exch def sub /arrayIndex arrayIndex 1 add def 2 index dup length arrayIndex gt {arrayIndex get} { pop exit } ifelse 0 } ifelse } loop pop pop pop dst_string 0 dst_index getinterval end }bind def ct_Level2? { /ct_resourcestatus currentglobal mark true setglobal {/unknowninstancename/Category resourcestatus} stopped {cleartomark setglobal true} {cleartomark currentglobal not exch setglobal} ifelse { { mark 3 1 roll/Category findresource begin ct_Vars/vm currentglobal put ({ResourceStatus}stopped)0()/SubFileDecode filter cvx exec {cleartomark false} {{3 2 roll pop true}{cleartomark false}ifelse} ifelse ct_Vars/vm get setglobal end } } {{resourcestatus}} ifelse bind def /CIDFont/Category ct_resourcestatus {pop pop} { currentglobal true setglobal /Generic/Category findresource dup length dict copy dup/InstanceType/dicttype put /CIDFont exch/Category defineresource pop setglobal } ifelse ct_UseNativeCapability? { /CIDInit/ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo 3 dict dup begin /Registry(Adobe)def /Ordering(Identity)def /Supplement 0 def end def /CMapName/Identity-H def /CMapVersion 1.000 def /CMapType 1 def 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>0 endcidrange endcmap CMapName currentdict/CMap defineresource pop end end } if } { /ct_Category 2 dict begin /CIDFont 10 dict def /ProcSet 2 dict def currentdict end def /defineresource { ct_Category 1 index 2 copy known { get dup dup maxlength exch length eq { dup length 10 add dict copy ct_Category 2 index 2 index put } if 3 index 3 index put pop exch pop } {pop pop/defineresource/undefined ct_raise} ifelse }bind def /findresource { ct_Category 1 index 2 copy known { get 2 index 2 copy known {get 3 1 roll pop pop} {pop pop/findresource/undefinedresource ct_raise} ifelse } {pop pop/findresource/undefined ct_raise} ifelse }bind def /resourcestatus { ct_Category 1 index 2 copy known { get 2 index known exch pop exch pop { 0 -1 true } { false } ifelse } {pop pop/findresource/undefined ct_raise} ifelse }bind def /ct_resourcestatus/resourcestatus load def } ifelse /ct_CIDInit 2 dict begin /ct_cidfont_stream_init { { dup(Binary)eq { pop null currentfile ct_Level2? { {cid_BYTE_COUNT()/SubFileDecode filter} stopped {pop pop pop} if } if /readstring load exit } if dup(Hex)eq { pop currentfile ct_Level2? { {null exch/ASCIIHexDecode filter/readstring} stopped {pop exch pop(>)exch/readhexstring} if } {(>)exch/readhexstring} ifelse load exit } if /StartData/typecheck ct_raise } loop cid_BYTE_COUNT ct_CID_STR_SIZE le { 2 copy cid_BYTE_COUNT string exch exec pop 1 array dup 3 -1 roll 0 exch put } { cid_BYTE_COUNT ct_CID_STR_SIZE div ceiling cvi dup array exch 2 sub 0 exch 1 exch { 2 copy 5 index ct_CID_STR_SIZE string 6 index exec pop put pop } for 2 index cid_BYTE_COUNT ct_CID_STR_SIZE mod string 3 index exec pop 1 index exch 1 index length 1 sub exch put } ifelse cid_CIDFONT exch/GlyphData exch put 2 index null eq { pop pop pop } { pop/readstring load 1 string exch { 3 copy exec pop dup length 0 eq { pop pop pop pop pop true exit } if 4 index eq { pop pop pop pop false exit } if } loop pop } ifelse }bind def /StartData { mark { currentdict dup/FDArray get 0 get/FontMatrix get 0 get 0.001 eq { dup/CDevProc known not { /CDevProc 1183615869 internaldict/stdCDevProc 2 copy known {get} { pop pop {pop pop pop pop pop 0 -1000 7 index 2 div 880} } ifelse def } if } { /CDevProc { pop pop pop pop pop 0 1 cid_temp/cid_CIDFONT get /FDArray get 0 get /FontMatrix get 0 get div 7 index 2 div 1 index 0.88 mul }def } ifelse /cid_temp 15 dict def cid_temp begin /cid_CIDFONT exch def 3 copy pop dup/cid_BYTE_COUNT exch def 0 gt { ct_cidfont_stream_init FDArray { /Private get dup/SubrMapOffset known { begin /Subrs SubrCount array def Subrs SubrMapOffset SubrCount SDBytes ct_Level2? { currentdict dup/SubrMapOffset undef dup/SubrCount undef /SDBytes undef } if end /cid_SD_BYTES exch def /cid_SUBR_COUNT exch def /cid_SUBR_MAP_OFFSET exch def /cid_SUBRS exch def cid_SUBR_COUNT 0 gt { GlyphData cid_SUBR_MAP_OFFSET cid_SD_BYTES ct_GetInterval 0 cid_SD_BYTES ct_cvnsi 0 1 cid_SUBR_COUNT 1 sub { exch 1 index 1 add cid_SD_BYTES mul cid_SUBR_MAP_OFFSET add GlyphData exch cid_SD_BYTES ct_GetInterval 0 cid_SD_BYTES ct_cvnsi cid_SUBRS 4 2 roll GlyphData exch 4 index 1 index sub ct_GetInterval dup length string copy put } for pop } if } {pop} ifelse } forall } if cleartomark pop pop end CIDFontName currentdict/CIDFont defineresource pop end end } stopped {cleartomark/StartData ct_reraise} if }bind def currentdict end def /ct_saveCIDInit { /CIDInit/ProcSet ct_resourcestatus {true} {/CIDInitC/ProcSet ct_resourcestatus} ifelse { pop pop /CIDInit/ProcSet findresource ct_UseNativeCapability? {pop null} {/CIDInit ct_CIDInit/ProcSet defineresource pop} ifelse } {/CIDInit ct_CIDInit/ProcSet defineresource pop null} ifelse ct_Vars exch/ct_oldCIDInit exch put }bind def /ct_restoreCIDInit { ct_Vars/ct_oldCIDInit get dup null ne {/CIDInit exch/ProcSet defineresource pop} {pop} ifelse }bind def /ct_BuildCharSetUp { 1 index begin CIDFont begin Adobe_CoolType_Utility/ct_BuildCharDict get begin /ct_dfCharCode exch def /ct_dfDict exch def CIDFirstByte ct_dfCharCode add dup CIDCount ge {pop 0} if /cid exch def { GlyphDirectory cid 2 copy known {get} {pop pop nullstring} ifelse dup length FDBytes sub 0 gt { dup FDBytes 0 ne {0 FDBytes ct_cvnsi} {pop 0} ifelse /fdIndex exch def dup length FDBytes sub FDBytes exch getinterval /charstring exch def exit } { pop cid 0 eq {/charstring nullstring def exit} if /cid 0 def } ifelse } loop }def /ct_SetCacheDevice { 0 0 moveto dup stringwidth 3 -1 roll true charpath pathbbox 0 -1000 7 index 2 div 880 setcachedevice2 0 0 moveto }def /ct_CloneSetCacheProc { 1 eq { stringwidth pop -2 div -880 0 -1000 setcharwidth moveto } { usewidths? { currentfont/Widths get cid 2 copy known {get exch pop aload pop} {pop pop stringwidth} ifelse } {stringwidth} ifelse setcharwidth 0 0 moveto } ifelse }def /ct_Type3ShowCharString { ct_FDDict fdIndex 2 copy known {get} { currentglobal 3 1 roll 1 index gcheck setglobal ct_Type1FontTemplate dup maxlength dict copy begin FDArray fdIndex get dup/FontMatrix 2 copy known {get} {pop pop ct_defaultFontMtx} ifelse /FontMatrix exch dup length array copy def /Private get /Private exch def /Widths rootfont/Widths get def /CharStrings 1 dict dup/.notdef dup length string copy put def currentdict end /ct_Type1Font exch definefont dup 5 1 roll put setglobal } ifelse dup/CharStrings get 1 index/Encoding get ct_dfCharCode get charstring put rootfont/WMode 2 copy known {get} {pop pop 0} ifelse exch 1000 scalefont setfont ct_str1 0 ct_dfCharCode put ct_str1 exch ct_dfSetCacheProc ct_SyntheticBold { currentpoint ct_str1 show newpath moveto ct_str1 true charpath ct_StrokeWidth setlinewidth stroke } {ct_str1 show} ifelse }def /ct_Type4ShowCharString { ct_dfDict ct_dfCharCode charstring FDArray fdIndex get dup/FontMatrix get dup ct_defaultFontMtx ct_matrixeq not {ct_1000Mtx matrix concatmatrix concat} {pop} ifelse /Private get Adobe_CoolType_Utility/ct_Level2? get not { ct_dfDict/Private 3 -1 roll {put} 1183615869 internaldict/superexec get exec } if 1183615869 internaldict Adobe_CoolType_Utility/ct_Level2? get {1 index} {3 index/Private get mark 6 1 roll} ifelse dup/RunInt known {/RunInt get} {pop/CCRun} ifelse get exec Adobe_CoolType_Utility/ct_Level2? get not {cleartomark} if }bind def /ct_BuildCharIncremental { { Adobe_CoolType_Utility/ct_MakeOCF get begin ct_BuildCharSetUp ct_ShowCharString } stopped {stop} if end end end end }bind def /BaseFontNameStr(BF00)def /ct_Type1FontTemplate 14 dict begin /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0]def /FontBBox [-250 -250 1250 1250]def /Encoding ct_cHexEncoding def /PaintType 0 def currentdict end def /BaseFontTemplate 11 dict begin /FontMatrix [0.001 0 0 0.001 0 0]def /FontBBox [-250 -250 1250 1250]def /Encoding ct_cHexEncoding def /BuildChar/ct_BuildCharIncremental load def ct_Clone? { /FontType 3 def /ct_ShowCharString/ct_Type3ShowCharString load def /ct_dfSetCacheProc/ct_CloneSetCacheProc load def /ct_SyntheticBold false def /ct_StrokeWidth 1 def } { /FontType 4 def /Private 1 dict dup/lenIV 4 put def /CharStrings 1 dict dup/.notdefput def /PaintType 0 def /ct_ShowCharString/ct_Type4ShowCharString load def } ifelse /ct_str1 1 string def currentdict end def /BaseFontDictSize BaseFontTemplate length 5 add def /ct_matrixeq { true 0 1 5 { dup 4 index exch get exch 3 index exch get eq and dup not {exit} if } for exch pop exch pop }bind def /ct_makeocf { 15 dict begin exch/WMode exch def exch/FontName exch def /FontType 0 def /FMapType 2 def dup/FontMatrix known {dup/FontMatrix get/FontMatrix exch def} {/FontMatrix matrix def} ifelse /bfCount 1 index/CIDCount get 256 idiv 1 add dup 256 gt{pop 256}if def /Encoding 256 array 0 1 bfCount 1 sub{2 copy dup put pop}for bfCount 1 255{2 copy bfCount put pop}for def /FDepVector bfCount dup 256 lt{1 add}if array def BaseFontTemplate BaseFontDictSize dict copy begin /CIDFont exch def CIDFont/FontBBox known {CIDFont/FontBBox get/FontBBox exch def} if CIDFont/CDevProc known {CIDFont/CDevProc get/CDevProc exch def} if currentdict end BaseFontNameStr 3(0)putinterval 0 1 bfCount dup 256 eq{1 sub}if { FDepVector exch 2 index BaseFontDictSize dict copy begin dup/CIDFirstByte exch 256 mul def FontType 3 eq {/ct_FDDict 2 dict def} if currentdict end 1 index 16 BaseFontNameStr 2 2 getinterval cvrs pop BaseFontNameStr exch definefont put } for ct_Clone? {/Widths 1 index/CIDFont get/GlyphDirectory get length dict def} if FontName currentdict end definefont ct_Clone? { gsave dup 1000 scalefont setfont ct_BuildCharDict begin /usewidths? false def currentfont/Widths get begin exch/CIDFont get/GlyphDirectory get { pop dup charcode exch 1 index 0 2 index 256 idiv put 1 index exch 1 exch 256 mod put stringwidth 2 array astore def } forall end /usewidths? true def end grestore } {exch pop} ifelse }bind def currentglobal true setglobal /ct_ComposeFont { ct_UseNativeCapability? { 2 index/CMap ct_resourcestatus {pop pop exch pop} { /CIDInit/ProcSet findresource begin 12 dict begin begincmap /CMapName 3 index def /CMapVersion 1.000 def /CMapType 1 def exch/WMode exch def /CIDSystemInfo 3 dict dup begin /Registry(Adobe)def /Ordering CMapName ct_mkocfStr100 cvs (Adobe-)search { pop pop (-)search { dup length string copy exch pop exch pop } {pop(Identity)} ifelse } {pop (Identity)} ifelse def /Supplement 0 def end def 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>0 endcidrange endcmap CMapName currentdict/CMap defineresource pop end end } ifelse composefont } { 3 2 roll pop 0 get/CIDFont findresource ct_makeocf } ifelse }bind def setglobal /ct_MakeIdentity { ct_UseNativeCapability? { 1 index/CMap ct_resourcestatus {pop pop} { /CIDInit/ProcSet findresource begin 12 dict begin begincmap /CMapName 2 index def /CMapVersion 1.000 def /CMapType 1 def /CIDSystemInfo 3 dict dup begin /Registry(Adobe)def /Ordering CMapName ct_mkocfStr100 cvs (Adobe-)search { pop pop (-)search {dup length string copy exch pop exch pop} {pop(Identity)} ifelse } {pop(Identity)} ifelse def /Supplement 0 def end def 1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>0 endcidrange endcmap CMapName currentdict/CMap defineresource pop end end } ifelse composefont } { exch pop 0 get/CIDFont findresource ct_makeocf } ifelse }bind def currentdict readonly pop end end %%EndResource setglobal %%BeginResource: procset Adobe_CoolType_Utility_T42 1.0 0 %%Copyright: Copyright 1987-2004 Adobe Systems Incorporated. %%Version: 1.0 0 userdict/ct_T42Dict 15 dict put ct_T42Dict begin /Is2015? { version cvi 2015 ge }bind def /AllocGlyphStorage { Is2015? { pop } { {string}forall }ifelse }bind def /Type42DictBegin { 25 dict begin /FontName exch def /CharStrings 256 dict begin /.notdef 0 def currentdict end def /Encoding exch def /PaintType 0 def /FontType 42 def /FontMatrix[1 0 0 1 0 0]def 4 array astore cvx/FontBBox exch def /sfnts }bind def /Type42DictEnd { currentdict dup/FontName get exch definefont end ct_T42Dict exch dup/FontName get exch put }bind def /RD{string currentfile exch readstring pop}executeonly def /PrepFor2015 { Is2015? { /GlyphDirectory 16 dict def sfnts 0 get dup 2 index (glyx) putinterval 2 index (locx) putinterval pop pop } { pop pop }ifelse }bind def /AddT42Char { Is2015? { /GlyphDirectory get begin def end pop pop } { /sfnts get 4 index get 3 index 2 index putinterval pop pop pop pop }ifelse }bind def /T0AddT42Mtx2 { /CIDFont findresource/Metrics2 get begin def end }bind def end %%EndResource currentglobal true setglobal %%BeginFile: MMFauxFont.prc %%Copyright: Copyright 1987-2001 Adobe Systems Incorporated. %%All Rights Reserved. userdict /ct_EuroDict 10 dict put ct_EuroDict begin /ct_CopyFont { { 1 index /FID ne {def} {pop pop} ifelse} forall } def /ct_GetGlyphOutline { gsave initmatrix newpath exch findfont dup length 1 add dict begin ct_CopyFont /Encoding Encoding dup length array copy dup 4 -1 roll 0 exch put def currentdict end /ct_EuroFont exch definefont 1000 scalefont setfont 0 0 moveto [ <00> stringwidth <00> false charpath pathbbox [ {/m cvx} {/l cvx} {/c cvx} {/cp cvx} pathforall grestore counttomark 8 add } def /ct_MakeGlyphProc { ] cvx /ct_PSBuildGlyph cvx ] cvx } def /ct_PSBuildGlyph { gsave 8 -1 roll pop 7 1 roll 6 -2 roll ct_FontMatrix transform 6 2 roll 4 -2 roll ct_FontMatrix transform 4 2 roll ct_FontMatrix transform currentdict /PaintType 2 copy known {get 2 eq}{pop pop false} ifelse dup 9 1 roll { currentdict /StrokeWidth 2 copy known { get 2 div 0 ct_FontMatrix dtransform pop 5 1 roll 4 -1 roll 4 index sub 4 1 roll 3 -1 roll 4 index sub 3 1 roll exch 4 index add exch 4 index add 5 -1 roll pop } { pop pop } ifelse } if setcachedevice ct_FontMatrix concat ct_PSPathOps begin exec end { currentdict /StrokeWidth 2 copy known { get } { pop pop 0 } ifelse setlinewidth stroke } { fill } ifelse grestore } def /ct_PSPathOps 4 dict dup begin /m {moveto} def /l {lineto} def /c {curveto} def /cp {closepath} def end def /ct_matrix1000 [1000 0 0 1000 0 0] def /ct_AddGlyphProc { 2 index findfont dup length 4 add dict begin ct_CopyFont /CharStrings CharStrings dup length 1 add dict copy begin 3 1 roll def currentdict end def /ct_FontMatrix ct_matrix1000 FontMatrix matrix concatmatrix def /ct_PSBuildGlyph /ct_PSBuildGlyph load def /ct_PSPathOps /ct_PSPathOps load def currentdict end definefont pop } def systemdict /languagelevel known { /ct_AddGlyphToPrinterFont { 2 copy ct_GetGlyphOutline 3 add -1 roll restore ct_MakeGlyphProc ct_AddGlyphProc } def } { /ct_AddGlyphToPrinterFont { pop pop restore Adobe_CTFauxDict /$$$FONTNAME get /Euro Adobe_CTFauxDict /$$$SUBSTITUTEBASE get ct_EuroDict exch get ct_AddGlyphProc } def } ifelse /AdobeSansMM { 556 0 24 -19 541 703 { 541 628 m 510 669 442 703 354 703 c 201 703 117 607 101 444 c 50 444 l 25 372 l 97 372 l 97 301 l 49 301 l 24 229 l 103 229 l 124 67 209 -19 350 -19 c 435 -19 501 25 509 32 c 509 131 l 492 105 417 60 343 60 c 267 60 204 127 197 229 c 406 229 l 430 301 l 191 301 l 191 372 l 455 372 l 479 444 l 194 444 l 201 531 245 624 348 624 c 433 624 484 583 509 534 c cp 556 0 m } ct_PSBuildGlyph } def /AdobeSerifMM { 500 0 10 -12 484 692 { 347 298 m 171 298 l 170 310 170 322 170 335 c 170 362 l 362 362 l 374 403 l 172 403 l 184 580 244 642 308 642 c 380 642 434 574 457 457 c 481 462 l 474 691 l 449 691 l 433 670 429 657 410 657 c 394 657 360 692 299 692 c 204 692 94 604 73 403 c 22 403 l 10 362 l 70 362 l 69 352 69 341 69 330 c 69 319 69 308 70 298 c 22 298 l 10 257 l 73 257 l 97 57 216 -12 295 -12 c 364 -12 427 25 484 123 c 458 142 l 425 101 384 37 316 37 c 256 37 189 84 173 257 c 335 257 l cp 500 0 m } ct_PSBuildGlyph } def end %%EndFile setglobal Adobe_CoolType_Core begin /$Oblique SetSubstituteStrategy end %%BeginResource: procset Adobe_AGM_Image 1.0 0 +%%Version: 1.0 0 +%%Copyright: Copyright(C)2000-2006 Adobe Systems, Inc. All Rights Reserved. +systemdict/setpacking known +{ + currentpacking + true setpacking +}if +userdict/Adobe_AGM_Image 71 dict dup begin put +/Adobe_AGM_Image_Id/Adobe_AGM_Image_1.0_0 def +/nd{ + null def +}bind def +/AGMIMG_&image nd +/AGMIMG_&colorimage nd +/AGMIMG_&imagemask nd +/AGMIMG_mbuf()def +/AGMIMG_ybuf()def +/AGMIMG_kbuf()def +/AGMIMG_c 0 def +/AGMIMG_m 0 def +/AGMIMG_y 0 def +/AGMIMG_k 0 def +/AGMIMG_tmp nd +/AGMIMG_imagestring0 nd +/AGMIMG_imagestring1 nd +/AGMIMG_imagestring2 nd +/AGMIMG_imagestring3 nd +/AGMIMG_imagestring4 nd +/AGMIMG_imagestring5 nd +/AGMIMG_cnt nd +/AGMIMG_fsave nd +/AGMIMG_colorAry nd +/AGMIMG_override nd +/AGMIMG_name nd +/AGMIMG_maskSource nd +/AGMIMG_flushfilters nd +/invert_image_samples nd +/knockout_image_samples nd +/img nd +/sepimg nd +/devnimg nd +/idximg nd +/ds +{ + Adobe_AGM_Core begin + Adobe_AGM_Image begin + /AGMIMG_&image systemdict/image get def + /AGMIMG_&imagemask systemdict/imagemask get def + /colorimage where{ + pop + /AGMIMG_&colorimage/colorimage ldf + }if + end + end +}def +/ps +{ + Adobe_AGM_Image begin + /AGMIMG_ccimage_exists{/customcolorimage where + { + pop + /Adobe_AGM_OnHost_Seps where + { + pop false + }{ + /Adobe_AGM_InRip_Seps where + { + pop false + }{ + true + }ifelse + }ifelse + }{ + false + }ifelse + }bdf + level2{ + /invert_image_samples + { + Adobe_AGM_Image/AGMIMG_tmp Decode length ddf + /Decode[Decode 1 get Decode 0 get]def + }def + /knockout_image_samples + { + Operator/imagemask ne{ + /Decode[1 1]def + }if + }def + }{ + /invert_image_samples + { + {1 exch sub}currenttransfer addprocs settransfer + }def + /knockout_image_samples + { + {pop 1}currenttransfer addprocs settransfer + }def + }ifelse + /img/imageormask ldf + /sepimg/sep_imageormask ldf + /devnimg/devn_imageormask ldf + /idximg/indexed_imageormask ldf + /_ctype 7 def + currentdict{ + dup xcheck 1 index type dup/arraytype eq exch/packedarraytype eq or and{ + bind + }if + def + }forall +}def +/pt +{ + end +}def +/dt +{ +}def +/AGMIMG_flushfilters +{ + dup type/arraytype ne + {1 array astore}if + dup 0 get currentfile ne + {dup 0 get flushfile}if + { + dup type/filetype eq + { + dup status 1 index currentfile ne and + {closefile} + {pop} + ifelse + }{pop}ifelse + }forall +}def +/AGMIMG_init_common +{ + currentdict/T known{/ImageType/T ldf currentdict/T undef}if + currentdict/W known{/Width/W ldf currentdict/W undef}if + currentdict/H known{/Height/H ldf currentdict/H undef}if + currentdict/M known{/ImageMatrix/M ldf currentdict/M undef}if + currentdict/BC known{/BitsPerComponent/BC ldf currentdict/BC undef}if + currentdict/D known{/Decode/D ldf currentdict/D undef}if + currentdict/DS known{/DataSource/DS ldf currentdict/DS undef}if + currentdict/O known{ + /Operator/O load 1 eq{ + /imagemask + }{ + /O load 2 eq{ + /image + }{ + /colorimage + }ifelse + }ifelse + def + currentdict/O undef + }if + currentdict/HSCI known{/HostSepColorImage/HSCI ldf currentdict/HSCI undef}if + currentdict/MD known{/MultipleDataSources/MD ldf currentdict/MD undef}if + currentdict/I known{/Interpolate/I ldf currentdict/I undef}if + currentdict/SI known{/SkipImageProc/SI ldf currentdict/SI undef}if + /DataSource load xcheck not{ + DataSource type/arraytype eq{ + DataSource 0 get type/filetype eq{ + /_Filters DataSource def + currentdict/MultipleDataSources known not{ + /DataSource DataSource dup length 1 sub get def + }if + }if + }if + currentdict/MultipleDataSources known not{ + /MultipleDataSources DataSource type/arraytype eq{ + DataSource length 1 gt + } + {false}ifelse def + }if + }if + /NComponents Decode length 2 div def + currentdict/SkipImageProc known not{/SkipImageProc{false}def}if +}bdf +/imageormask_sys +{ + begin + AGMIMG_init_common + save mark + level2{ + currentdict + Operator/imagemask eq{ + AGMIMG_&imagemask + }{ + use_mask{ + process_mask AGMIMG_&image + }{ + AGMIMG_&image + }ifelse + }ifelse + }{ + Width Height + Operator/imagemask eq{ + Decode 0 get 1 eq Decode 1 get 0 eq and + ImageMatrix/DataSource load + AGMIMG_&imagemask + }{ + BitsPerComponent ImageMatrix/DataSource load + AGMIMG_&image + }ifelse + }ifelse + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + cleartomark restore + end +}def +/overprint_plate +{ + currentoverprint{ + 0 get dup type/nametype eq{ + dup/DeviceGray eq{ + pop AGMCORE_black_plate not + }{ + /DeviceCMYK eq{ + AGMCORE_is_cmyk_sep not + }if + }ifelse + }{ + false exch + { + AGMOHS_sepink eq or + }forall + not + }ifelse + }{ + pop false + }ifelse +}def +/process_mask +{ + level3{ + dup begin + /ImageType 1 def + end + 4 dict begin + /DataDict exch def + /ImageType 3 def + /InterleaveType 3 def + /MaskDict 9 dict begin + /ImageType 1 def + /Width DataDict dup/MaskWidth known{/MaskWidth}{/Width}ifelse get def + /Height DataDict dup/MaskHeight known{/MaskHeight}{/Height}ifelse get def + /ImageMatrix[Width 0 0 Height neg 0 Height]def + /NComponents 1 def + /BitsPerComponent 1 def + /Decode DataDict dup/MaskD known{/MaskD}{[1 0]}ifelse get def + /DataSource Adobe_AGM_Core/AGMIMG_maskSource get def + currentdict end def + currentdict end + }if +}def +/use_mask +{ + dup/Mask known {dup/Mask get}{false}ifelse +}def +/imageormask +{ + begin + AGMIMG_init_common + SkipImageProc{ + currentdict consumeimagedata + } + { + save mark + level2 AGMCORE_host_sep not and{ + currentdict + Operator/imagemask eq DeviceN_PS2 not and{ + imagemask + }{ + AGMCORE_in_rip_sep currentoverprint and currentcolorspace 0 get/DeviceGray eq and{ + [/Separation/Black/DeviceGray{}]setcolorspace + /Decode[Decode 1 get Decode 0 get]def + }if + use_mask{ + process_mask image + }{ + DeviceN_NoneName DeviceN_PS2 Indexed_DeviceN level3 not and or or AGMCORE_in_rip_sep and + { + Names convert_to_process not{ + 2 dict begin + /imageDict xdf + /names_index 0 def + gsave + imageDict write_image_file{ + Names{ + dup(None)ne{ + [/Separation 3 -1 roll/DeviceGray{1 exch sub}]setcolorspace + Operator imageDict read_image_file + names_index 0 eq{true setoverprint}if + /names_index names_index 1 add def + }{ + pop + }ifelse + }forall + close_image_file + }if + grestore + end + }{ + Operator/imagemask eq{ + imagemask + }{ + image + }ifelse + }ifelse + }{ + Operator/imagemask eq{ + imagemask + }{ + image + }ifelse + }ifelse + }ifelse + }ifelse + }{ + Width Height + Operator/imagemask eq{ + Decode 0 get 1 eq Decode 1 get 0 eq and + ImageMatrix/DataSource load + /Adobe_AGM_OnHost_Seps where{ + pop imagemask + }{ + currentgray 1 ne{ + currentdict imageormask_sys + }{ + currentoverprint not{ + 1 AGMCORE_&setgray + currentdict imageormask_sys + }{ + currentdict ignoreimagedata + }ifelse + }ifelse + }ifelse + }{ + BitsPerComponent ImageMatrix + MultipleDataSources{ + 0 1 NComponents 1 sub{ + DataSource exch get + }for + }{ + /DataSource load + }ifelse + Operator/colorimage eq{ + AGMCORE_host_sep{ + MultipleDataSources level2 or NComponents 4 eq and{ + AGMCORE_is_cmyk_sep{ + MultipleDataSources{ + /DataSource DataSource 0 get xcheck + { + [ + DataSource 0 get/exec cvx + DataSource 1 get/exec cvx + DataSource 2 get/exec cvx + DataSource 3 get/exec cvx + /AGMCORE_get_ink_data cvx + ]cvx + }{ + DataSource aload pop AGMCORE_get_ink_data + }ifelse def + }{ + /DataSource + Width BitsPerComponent mul 7 add 8 idiv Height mul 4 mul + /DataSource load + filter_cmyk 0()/SubFileDecode filter def + }ifelse + /Decode[Decode 0 get Decode 1 get]def + /MultipleDataSources false def + /NComponents 1 def + /Operator/image def + invert_image_samples + 1 AGMCORE_&setgray + currentdict imageormask_sys + }{ + currentoverprint not Operator/imagemask eq and{ + 1 AGMCORE_&setgray + currentdict imageormask_sys + }{ + currentdict ignoreimagedata + }ifelse + }ifelse + }{ + MultipleDataSources NComponents AGMIMG_&colorimage + }ifelse + }{ + true NComponents colorimage + }ifelse + }{ + Operator/image eq{ + AGMCORE_host_sep{ + /DoImage true def + currentdict/HostSepColorImage known{HostSepColorImage not}{false}ifelse + { + AGMCORE_black_plate not Operator/imagemask ne and{ + /DoImage false def + currentdict ignoreimagedata + }if + }if + 1 AGMCORE_&setgray + DoImage + {currentdict imageormask_sys}if + }{ + use_mask{ + process_mask image + }{ + image + }ifelse + }ifelse + }{ + Operator/knockout eq{ + pop pop pop pop pop + currentcolorspace overprint_plate not{ + knockout_unitsq + }if + }if + }ifelse + }ifelse + }ifelse + }ifelse + cleartomark restore + }ifelse + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + end +}def +/sep_imageormask +{ + /sep_colorspace_dict AGMCORE_gget begin + CSA map_csa + begin + AGMIMG_init_common + SkipImageProc{ + currentdict consumeimagedata + }{ + save mark + AGMCORE_avoid_L2_sep_space{ + /Decode[Decode 0 get 255 mul Decode 1 get 255 mul]def + }if + AGMIMG_ccimage_exists + MappedCSA 0 get/DeviceCMYK eq and + currentdict/Components known and + Name()ne and + Name(All)ne and + Operator/image eq and + AGMCORE_producing_seps not and + level2 not and + { + Width Height BitsPerComponent ImageMatrix + [ + /DataSource load/exec cvx + { + 0 1 2 index length 1 sub{ + 1 index exch + 2 copy get 255 xor put + }for + }/exec cvx + ]cvx bind + MappedCSA 0 get/DeviceCMYK eq{ + Components aload pop + }{ + 0 0 0 Components aload pop 1 exch sub + }ifelse + Name findcmykcustomcolor + customcolorimage + }{ + AGMCORE_producing_seps not{ + level2{ + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne AGMCORE_avoid_L2_sep_space not and currentcolorspace 0 get/Separation ne and{ + [/Separation Name MappedCSA sep_proc_name exch dup 0 get 15 string cvs(/Device)anchorsearch{pop pop 0 get}{pop}ifelse exch load]setcolorspace_opt + /sep_tint AGMCORE_gget setcolor + }if + currentdict imageormask + }{ + currentdict + Operator/imagemask eq{ + imageormask + }{ + sep_imageormask_lev1 + }ifelse + }ifelse + }{ + AGMCORE_host_sep{ + Operator/knockout eq{ + currentdict/ImageMatrix get concat + knockout_unitsq + }{ + currentgray 1 ne{ + AGMCORE_is_cmyk_sep Name(All)ne and{ + level2{ + Name AGMCORE_IsSeparationAProcessColor + { + Operator/imagemask eq{ + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne{ + /sep_tint AGMCORE_gget 1 exch sub AGMCORE_&setcolor + }if + }{ + invert_image_samples + }ifelse + }{ + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne{ + [/Separation Name[/DeviceGray] + { + sep_colorspace_proc AGMCORE_get_ink_data + 1 exch sub + }bind + ]AGMCORE_&setcolorspace + /sep_tint AGMCORE_gget AGMCORE_&setcolor + }if + }ifelse + currentdict imageormask_sys + }{ + currentdict + Operator/imagemask eq{ + imageormask_sys + }{ + sep_image_lev1_sep + }ifelse + }ifelse + }{ + Operator/imagemask ne{ + invert_image_samples + }if + currentdict imageormask_sys + }ifelse + }{ + currentoverprint not Name(All)eq or Operator/imagemask eq and{ + currentdict imageormask_sys + }{ + currentoverprint not + { + gsave + knockout_unitsq + grestore + }if + currentdict consumeimagedata + }ifelse + }ifelse + }ifelse + }{ + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne{ + currentcolorspace 0 get/Separation ne{ + [/Separation Name MappedCSA sep_proc_name exch 0 get exch load]setcolorspace_opt + /sep_tint AGMCORE_gget setcolor + }if + }if + currentoverprint + MappedCSA 0 get/DeviceCMYK eq and + Name AGMCORE_IsSeparationAProcessColor not and + //Adobe_AGM_Core/AGMCORE_pattern_paint_type get 2 ne{Name inRip_spot_has_ink not and}{false}ifelse + Name(All)ne and{ + imageormask_l2_overprint + }{ + currentdict imageormask + }ifelse + }ifelse + }ifelse + }ifelse + cleartomark restore + }ifelse + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + end + end +}def +/colorSpaceElemCnt +{ + mark currentcolor counttomark dup 2 add 1 roll cleartomark +}bdf +/devn_sep_datasource +{ + 1 dict begin + /dataSource xdf + [ + 0 1 dataSource length 1 sub{ + dup currentdict/dataSource get/exch cvx/get cvx/exec cvx + /exch cvx names_index/ne cvx[/pop cvx]cvx/if cvx + }for + ]cvx bind + end +}bdf +/devn_alt_datasource +{ + 11 dict begin + /convProc xdf + /origcolorSpaceElemCnt xdf + /origMultipleDataSources xdf + /origBitsPerComponent xdf + /origDecode xdf + /origDataSource xdf + /dsCnt origMultipleDataSources{origDataSource length}{1}ifelse def + /DataSource origMultipleDataSources + { + [ + BitsPerComponent 8 idiv origDecode length 2 idiv mul string + 0 1 origDecode length 2 idiv 1 sub + { + dup 7 mul 1 add index exch dup BitsPerComponent 8 idiv mul exch + origDataSource exch get 0()/SubFileDecode filter + BitsPerComponent 8 idiv string/readstring cvx/pop cvx/putinterval cvx + }for + ]bind cvx + }{origDataSource}ifelse 0()/SubFileDecode filter def + [ + origcolorSpaceElemCnt string + 0 2 origDecode length 2 sub + { + dup origDecode exch get dup 3 -1 roll 1 add origDecode exch get exch sub 2 BitsPerComponent exp 1 sub div + 1 BitsPerComponent 8 idiv{DataSource/read cvx/not cvx{0}/if cvx/mul cvx}repeat/mul cvx/add cvx + }for + /convProc load/exec cvx + origcolorSpaceElemCnt 1 sub -1 0 + { + /dup cvx 2/add cvx/index cvx + 3 1/roll cvx/exch cvx 255/mul cvx/cvi cvx/put cvx + }for + ]bind cvx 0()/SubFileDecode filter + end +}bdf +/devn_imageormask +{ + /devicen_colorspace_dict AGMCORE_gget begin + CSA map_csa + 2 dict begin + dup + /srcDataStrs[3 -1 roll begin + AGMIMG_init_common + currentdict/MultipleDataSources known{MultipleDataSources{DataSource length}{1}ifelse}{1}ifelse + { + Width Decode length 2 div mul cvi + { + dup 65535 gt{1 add 2 div cvi}{exit}ifelse + }loop + string + }repeat + end]def + /dstDataStr srcDataStrs 0 get length string def + begin + AGMIMG_init_common + SkipImageProc{ + currentdict consumeimagedata + }{ + save mark + AGMCORE_producing_seps not{ + level3 not{ + Operator/imagemask ne{ + /DataSource[[ + DataSource Decode BitsPerComponent currentdict/MultipleDataSources known{MultipleDataSources}{false}ifelse + colorSpaceElemCnt/devicen_colorspace_dict AGMCORE_gget/TintTransform get + devn_alt_datasource 1/string cvx/readstring cvx/pop cvx]cvx colorSpaceElemCnt 1 sub{dup}repeat]def + /MultipleDataSources true def + /Decode colorSpaceElemCnt[exch{0 1}repeat]def + }if + }if + currentdict imageormask + }{ + AGMCORE_host_sep{ + Names convert_to_process{ + CSA get_csa_by_name 0 get/DeviceCMYK eq{ + /DataSource + Width BitsPerComponent mul 7 add 8 idiv Height mul 4 mul + DataSource Decode BitsPerComponent currentdict/MultipleDataSources known{MultipleDataSources}{false}ifelse + 4/devicen_colorspace_dict AGMCORE_gget/TintTransform get + devn_alt_datasource + filter_cmyk 0()/SubFileDecode filter def + /MultipleDataSources false def + /Decode[1 0]def + /DeviceGray setcolorspace + currentdict imageormask_sys + }{ + AGMCORE_report_unsupported_color_space + AGMCORE_black_plate{ + /DataSource + DataSource Decode BitsPerComponent currentdict/MultipleDataSources known{MultipleDataSources}{false}ifelse + CSA get_csa_by_name 0 get/DeviceRGB eq{3}{1}ifelse/devicen_colorspace_dict AGMCORE_gget/TintTransform get + devn_alt_datasource + /MultipleDataSources false def + /Decode colorSpaceElemCnt[exch{0 1}repeat]def + currentdict imageormask_sys + }{ + gsave + knockout_unitsq + grestore + currentdict consumeimagedata + }ifelse + }ifelse + } + { + /devicen_colorspace_dict AGMCORE_gget/names_index known{ + Operator/imagemask ne{ + MultipleDataSources{ + /DataSource[DataSource devn_sep_datasource/exec cvx]cvx def + /MultipleDataSources false def + }{ + /DataSource/DataSource load dstDataStr srcDataStrs 0 get filter_devn def + }ifelse + invert_image_samples + }if + currentdict imageormask_sys + }{ + currentoverprint not Operator/imagemask eq and{ + currentdict imageormask_sys + }{ + currentoverprint not + { + gsave + knockout_unitsq + grestore + }if + currentdict consumeimagedata + }ifelse + }ifelse + }ifelse + }{ + currentdict imageormask + }ifelse + }ifelse + cleartomark restore + }ifelse + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + end + end + end +}def +/imageormask_l2_overprint +{ + currentdict + currentcmykcolor add add add 0 eq{ + currentdict consumeimagedata + }{ + level3{ + currentcmykcolor + /AGMIMG_k xdf + /AGMIMG_y xdf + /AGMIMG_m xdf + /AGMIMG_c xdf + Operator/imagemask eq{ + [/DeviceN[ + AGMIMG_c 0 ne{/Cyan}if + AGMIMG_m 0 ne{/Magenta}if + AGMIMG_y 0 ne{/Yellow}if + AGMIMG_k 0 ne{/Black}if + ]/DeviceCMYK{}]setcolorspace + AGMIMG_c 0 ne{AGMIMG_c}if + AGMIMG_m 0 ne{AGMIMG_m}if + AGMIMG_y 0 ne{AGMIMG_y}if + AGMIMG_k 0 ne{AGMIMG_k}if + setcolor + }{ + /Decode[Decode 0 get 255 mul Decode 1 get 255 mul]def + [/Indexed + [ + /DeviceN[ + AGMIMG_c 0 ne{/Cyan}if + AGMIMG_m 0 ne{/Magenta}if + AGMIMG_y 0 ne{/Yellow}if + AGMIMG_k 0 ne{/Black}if + ] + /DeviceCMYK{ + AGMIMG_k 0 eq{0}if + AGMIMG_y 0 eq{0 exch}if + AGMIMG_m 0 eq{0 3 1 roll}if + AGMIMG_c 0 eq{0 4 1 roll}if + } + ] + 255 + { + 255 div + mark exch + dup dup dup + AGMIMG_k 0 ne{ + /sep_tint AGMCORE_gget mul MappedCSA sep_proc_name exch pop load exec 4 1 roll pop pop pop + counttomark 1 roll + }{ + pop + }ifelse + AGMIMG_y 0 ne{ + /sep_tint AGMCORE_gget mul MappedCSA sep_proc_name exch pop load exec 4 2 roll pop pop pop + counttomark 1 roll + }{ + pop + }ifelse + AGMIMG_m 0 ne{ + /sep_tint AGMCORE_gget mul MappedCSA sep_proc_name exch pop load exec 4 3 roll pop pop pop + counttomark 1 roll + }{ + pop + }ifelse + AGMIMG_c 0 ne{ + /sep_tint AGMCORE_gget mul MappedCSA sep_proc_name exch pop load exec pop pop pop + counttomark 1 roll + }{ + pop + }ifelse + counttomark 1 add -1 roll pop + } + ]setcolorspace + }ifelse + imageormask_sys + }{ + write_image_file{ + currentcmykcolor + 0 ne{ + [/Separation/Black/DeviceGray{}]setcolorspace + gsave + /Black + [{1 exch sub/sep_tint AGMCORE_gget mul}/exec cvx MappedCSA sep_proc_name cvx exch pop{4 1 roll pop pop pop 1 exch sub}/exec cvx] + cvx modify_halftone_xfer + Operator currentdict read_image_file + grestore + }if + 0 ne{ + [/Separation/Yellow/DeviceGray{}]setcolorspace + gsave + /Yellow + [{1 exch sub/sep_tint AGMCORE_gget mul}/exec cvx MappedCSA sep_proc_name cvx exch pop{4 2 roll pop pop pop 1 exch sub}/exec cvx] + cvx modify_halftone_xfer + Operator currentdict read_image_file + grestore + }if + 0 ne{ + [/Separation/Magenta/DeviceGray{}]setcolorspace + gsave + /Magenta + [{1 exch sub/sep_tint AGMCORE_gget mul}/exec cvx MappedCSA sep_proc_name cvx exch pop{4 3 roll pop pop pop 1 exch sub}/exec cvx] + cvx modify_halftone_xfer + Operator currentdict read_image_file + grestore + }if + 0 ne{ + [/Separation/Cyan/DeviceGray{}]setcolorspace + gsave + /Cyan + [{1 exch sub/sep_tint AGMCORE_gget mul}/exec cvx MappedCSA sep_proc_name cvx exch pop{pop pop pop 1 exch sub}/exec cvx] + cvx modify_halftone_xfer + Operator currentdict read_image_file + grestore + }if + close_image_file + }{ + imageormask + }ifelse + }ifelse + }ifelse +}def +/indexed_imageormask +{ + begin + AGMIMG_init_common + save mark + currentdict + AGMCORE_host_sep{ + Operator/knockout eq{ + /indexed_colorspace_dict AGMCORE_gget dup/CSA known{ + /CSA get get_csa_by_name + }{ + /Names get + }ifelse + overprint_plate not{ + knockout_unitsq + }if + }{ + Indexed_DeviceN{ + /devicen_colorspace_dict AGMCORE_gget dup/names_index known exch/Names get convert_to_process or{ + indexed_image_lev2_sep + }{ + currentoverprint not{ + knockout_unitsq + }if + currentdict consumeimagedata + }ifelse + }{ + AGMCORE_is_cmyk_sep{ + Operator/imagemask eq{ + imageormask_sys + }{ + level2{ + indexed_image_lev2_sep + }{ + indexed_image_lev1_sep + }ifelse + }ifelse + }{ + currentoverprint not{ + knockout_unitsq + }if + currentdict consumeimagedata + }ifelse + }ifelse + }ifelse + }{ + level2{ + Indexed_DeviceN{ + /indexed_colorspace_dict AGMCORE_gget begin + }{ + /indexed_colorspace_dict AGMCORE_gget dup null ne + { + begin + currentdict/CSDBase known{CSDBase/CSD get_res/MappedCSA get}{CSA}ifelse + get_csa_by_name 0 get/DeviceCMYK eq ps_level 3 ge and ps_version 3015.007 lt and + AGMCORE_in_rip_sep and{ + [/Indexed[/DeviceN[/Cyan/Magenta/Yellow/Black]/DeviceCMYK{}]HiVal Lookup] + setcolorspace + }if + end + } + {pop}ifelse + }ifelse + imageormask + Indexed_DeviceN{ + end + }if + }{ + Operator/imagemask eq{ + imageormask + }{ + indexed_imageormask_lev1 + }ifelse + }ifelse + }ifelse + cleartomark restore + currentdict/_Filters known{_Filters AGMIMG_flushfilters}if + end +}def +/indexed_image_lev2_sep +{ + /indexed_colorspace_dict AGMCORE_gget begin + begin + Indexed_DeviceN not{ + currentcolorspace + dup 1/DeviceGray put + dup 3 + currentcolorspace 2 get 1 add string + 0 1 2 3 AGMCORE_get_ink_data 4 currentcolorspace 3 get length 1 sub + { + dup 4 idiv exch currentcolorspace 3 get exch get 255 exch sub 2 index 3 1 roll put + }for + put setcolorspace + }if + currentdict + Operator/imagemask eq{ + AGMIMG_&imagemask + }{ + use_mask{ + process_mask AGMIMG_&image + }{ + AGMIMG_&image + }ifelse + }ifelse + end end +}def + /OPIimage + { + dup type/dicttype ne{ + 10 dict begin + /DataSource xdf + /ImageMatrix xdf + /BitsPerComponent xdf + /Height xdf + /Width xdf + /ImageType 1 def + /Decode[0 1 def] + currentdict + end + }if + dup begin + /NComponents 1 cdndf + /MultipleDataSources false cdndf + /SkipImageProc{false}cdndf + /Decode[ + 0 + currentcolorspace 0 get/Indexed eq{ + 2 BitsPerComponent exp 1 sub + }{ + 1 + }ifelse + ]cdndf + /Operator/image cdndf + end + /sep_colorspace_dict AGMCORE_gget null eq{ + imageormask + }{ + gsave + dup begin invert_image_samples end + sep_imageormask + grestore + }ifelse + }def +/cachemask_level2 +{ + 3 dict begin + /LZWEncode filter/WriteFilter xdf + /readBuffer 256 string def + /ReadFilter + currentfile + 0(%EndMask)/SubFileDecode filter + /ASCII85Decode filter + /RunLengthDecode filter + def + { + ReadFilter readBuffer readstring exch + WriteFilter exch writestring + not{exit}if + }loop + WriteFilter closefile + end +}def +/spot_alias +{ + /mapto_sep_imageormask + { + dup type/dicttype ne{ + 12 dict begin + /ImageType 1 def + /DataSource xdf + /ImageMatrix xdf + /BitsPerComponent xdf + /Height xdf + /Width xdf + /MultipleDataSources false def + }{ + begin + }ifelse + /Decode[/customcolor_tint AGMCORE_gget 0]def + /Operator/image def + /SkipImageProc{false}def + currentdict + end + sep_imageormask + }bdf + /customcolorimage + { + Adobe_AGM_Image/AGMIMG_colorAry xddf + /customcolor_tint AGMCORE_gget + << + /Name AGMIMG_colorAry 4 get + /CSA[/DeviceCMYK] + /TintMethod/Subtractive + /TintProc null + /MappedCSA null + /NComponents 4 + /Components[AGMIMG_colorAry aload pop pop] + >> + setsepcolorspace + mapto_sep_imageormask + }ndf + Adobe_AGM_Image/AGMIMG_&customcolorimage/customcolorimage load put + /customcolorimage + { + Adobe_AGM_Image/AGMIMG_override false put + current_spot_alias{dup 4 get map_alias}{false}ifelse + { + false set_spot_alias + /customcolor_tint AGMCORE_gget exch setsepcolorspace + pop + mapto_sep_imageormask + true set_spot_alias + }{ + //Adobe_AGM_Image/AGMIMG_&customcolorimage get exec + }ifelse + }bdf +}def +/snap_to_device +{ + 6 dict begin + matrix currentmatrix + dup 0 get 0 eq 1 index 3 get 0 eq and + 1 index 1 get 0 eq 2 index 2 get 0 eq and or exch pop + { + 1 1 dtransform 0 gt exch 0 gt/AGMIMG_xSign? exch def/AGMIMG_ySign? exch def + 0 0 transform + AGMIMG_ySign?{floor 0.1 sub}{ceiling 0.1 add}ifelse exch + AGMIMG_xSign?{floor 0.1 sub}{ceiling 0.1 add}ifelse exch + itransform/AGMIMG_llY exch def/AGMIMG_llX exch def + 1 1 transform + AGMIMG_ySign?{ceiling 0.1 add}{floor 0.1 sub}ifelse exch + AGMIMG_xSign?{ceiling 0.1 add}{floor 0.1 sub}ifelse exch + itransform/AGMIMG_urY exch def/AGMIMG_urX exch def + [AGMIMG_urX AGMIMG_llX sub 0 0 AGMIMG_urY AGMIMG_llY sub AGMIMG_llX AGMIMG_llY]concat + }{ + }ifelse + end +}def +level2 not{ + /colorbuf + { + 0 1 2 index length 1 sub{ + dup 2 index exch get + 255 exch sub + 2 index + 3 1 roll + put + }for + }def + /tint_image_to_color + { + begin + Width Height BitsPerComponent ImageMatrix + /DataSource load + end + Adobe_AGM_Image begin + /AGMIMG_mbuf 0 string def + /AGMIMG_ybuf 0 string def + /AGMIMG_kbuf 0 string def + { + colorbuf dup length AGMIMG_mbuf length ne + { + dup length dup dup + /AGMIMG_mbuf exch string def + /AGMIMG_ybuf exch string def + /AGMIMG_kbuf exch string def + }if + dup AGMIMG_mbuf copy AGMIMG_ybuf copy AGMIMG_kbuf copy pop + } + addprocs + {AGMIMG_mbuf}{AGMIMG_ybuf}{AGMIMG_kbuf}true 4 colorimage + end + }def + /sep_imageormask_lev1 + { + begin + MappedCSA 0 get dup/DeviceRGB eq exch/DeviceCMYK eq or has_color not and{ + { + 255 mul round cvi GrayLookup exch get + }currenttransfer addprocs settransfer + currentdict imageormask + }{ + /sep_colorspace_dict AGMCORE_gget/Components known{ + MappedCSA 0 get/DeviceCMYK eq{ + Components aload pop + }{ + 0 0 0 Components aload pop 1 exch sub + }ifelse + Adobe_AGM_Image/AGMIMG_k xddf + Adobe_AGM_Image/AGMIMG_y xddf + Adobe_AGM_Image/AGMIMG_m xddf + Adobe_AGM_Image/AGMIMG_c xddf + AGMIMG_y 0.0 eq AGMIMG_m 0.0 eq and AGMIMG_c 0.0 eq and{ + {AGMIMG_k mul 1 exch sub}currenttransfer addprocs settransfer + currentdict imageormask + }{ + currentcolortransfer + {AGMIMG_k mul 1 exch sub}exch addprocs 4 1 roll + {AGMIMG_y mul 1 exch sub}exch addprocs 4 1 roll + {AGMIMG_m mul 1 exch sub}exch addprocs 4 1 roll + {AGMIMG_c mul 1 exch sub}exch addprocs 4 1 roll + setcolortransfer + currentdict tint_image_to_color + }ifelse + }{ + MappedCSA 0 get/DeviceGray eq{ + {255 mul round cvi ColorLookup exch get 0 get}currenttransfer addprocs settransfer + currentdict imageormask + }{ + MappedCSA 0 get/DeviceCMYK eq{ + currentcolortransfer + {255 mul round cvi ColorLookup exch get 3 get 1 exch sub}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 2 get 1 exch sub}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 1 get 1 exch sub}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 0 get 1 exch sub}exch addprocs 4 1 roll + setcolortransfer + currentdict tint_image_to_color + }{ + currentcolortransfer + {pop 1}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 2 get}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 1 get}exch addprocs 4 1 roll + {255 mul round cvi ColorLookup exch get 0 get}exch addprocs 4 1 roll + setcolortransfer + currentdict tint_image_to_color + }ifelse + }ifelse + }ifelse + }ifelse + end + }def + /sep_image_lev1_sep + { + begin + /sep_colorspace_dict AGMCORE_gget/Components known{ + Components aload pop + Adobe_AGM_Image/AGMIMG_k xddf + Adobe_AGM_Image/AGMIMG_y xddf + Adobe_AGM_Image/AGMIMG_m xddf + Adobe_AGM_Image/AGMIMG_c xddf + {AGMIMG_c mul 1 exch sub} + {AGMIMG_m mul 1 exch sub} + {AGMIMG_y mul 1 exch sub} + {AGMIMG_k mul 1 exch sub} + }{ + {255 mul round cvi ColorLookup exch get 0 get 1 exch sub} + {255 mul round cvi ColorLookup exch get 1 get 1 exch sub} + {255 mul round cvi ColorLookup exch get 2 get 1 exch sub} + {255 mul round cvi ColorLookup exch get 3 get 1 exch sub} + }ifelse + AGMCORE_get_ink_data currenttransfer addprocs settransfer + currentdict imageormask_sys + end + }def + /indexed_imageormask_lev1 + { + /indexed_colorspace_dict AGMCORE_gget begin + begin + currentdict + MappedCSA 0 get dup/DeviceRGB eq exch/DeviceCMYK eq or has_color not and{ + {HiVal mul round cvi GrayLookup exch get HiVal div}currenttransfer addprocs settransfer + imageormask + }{ + MappedCSA 0 get/DeviceGray eq{ + {HiVal mul round cvi Lookup exch get HiVal div}currenttransfer addprocs settransfer + imageormask + }{ + MappedCSA 0 get/DeviceCMYK eq{ + currentcolortransfer + {4 mul HiVal mul round cvi 3 add Lookup exch get HiVal div 1 exch sub}exch addprocs 4 1 roll + {4 mul HiVal mul round cvi 2 add Lookup exch get HiVal div 1 exch sub}exch addprocs 4 1 roll + {4 mul HiVal mul round cvi 1 add Lookup exch get HiVal div 1 exch sub}exch addprocs 4 1 roll + {4 mul HiVal mul round cvi Lookup exch get HiVal div 1 exch sub}exch addprocs 4 1 roll + setcolortransfer + tint_image_to_color + }{ + currentcolortransfer + {pop 1}exch addprocs 4 1 roll + {3 mul HiVal mul round cvi 2 add Lookup exch get HiVal div}exch addprocs 4 1 roll + {3 mul HiVal mul round cvi 1 add Lookup exch get HiVal div}exch addprocs 4 1 roll + {3 mul HiVal mul round cvi Lookup exch get HiVal div}exch addprocs 4 1 roll + setcolortransfer + tint_image_to_color + }ifelse + }ifelse + }ifelse + end end + }def + /indexed_image_lev1_sep + { + /indexed_colorspace_dict AGMCORE_gget begin + begin + {4 mul HiVal mul round cvi Lookup exch get HiVal div 1 exch sub} + {4 mul HiVal mul round cvi 1 add Lookup exch get HiVal div 1 exch sub} + {4 mul HiVal mul round cvi 2 add Lookup exch get HiVal div 1 exch sub} + {4 mul HiVal mul round cvi 3 add Lookup exch get HiVal div 1 exch sub} + AGMCORE_get_ink_data currenttransfer addprocs settransfer + currentdict imageormask_sys + end end + }def +}if +end +systemdict/setpacking known +{setpacking}if +%%EndResource +currentdict Adobe_AGM_Utils eq {end} if +%%EndProlog +%%BeginSetup +Adobe_AGM_Utils begin +2 2010 Adobe_AGM_Core/ds gx +Adobe_CoolType_Core/ds get exec Adobe_AGM_Image/ds gx +currentdict Adobe_AGM_Utils eq {end} if +%%EndSetup +%%Page: 1 1 +%%EndPageComments +%%BeginPageSetup +%ADOBeginClientInjection: PageSetup Start "AI11EPS" +%AI12_RMC_Transparency: Balance=75 RasterRes=300 GradRes=150 Text=0 Stroke=1 Clip=1 OP=0 +%ADOEndClientInjection: PageSetup Start "AI11EPS" +Adobe_AGM_Utils begin +Adobe_AGM_Core/ps gx +Adobe_AGM_Utils/capture_cpd gx +Adobe_CoolType_Core/ps get exec Adobe_AGM_Image/ps gx +%ADOBeginClientInjection: PageSetup End "AI11EPS" +/currentdistillerparams where {pop currentdistillerparams /CoreDistVersion get 5000 lt} {true} ifelse { userdict /AI11_PDFMark5 /cleartomark load put userdict /AI11_ReadMetadata_PDFMark5 {flushfile cleartomark } bind put} { userdict /AI11_PDFMark5 /pdfmark load put userdict /AI11_ReadMetadata_PDFMark5 {/PUT pdfmark} bind put } ifelse [/NamespacePush AI11_PDFMark5 [/_objdef {ai_metadata_stream_123} /type /stream /OBJ AI11_PDFMark5 [{ai_metadata_stream_123} currentfile 0 (% &&end XMP packet marker&&) /SubFileDecode filter AI11_ReadMetadata_PDFMark5 + + + + + xmp.did:F77F1174072068119109CE6C3C89164E + xmp.iid:F77F1174072068119109CE6C3C89164E + xmp.did:F77F1174072068119109CE6C3C89164E + + + + saved + xmp.iid:F77F1174072068119109CE6C3C89164E + 2011-06-04T12:16:32+02:00 + Adobe Illustrator CS4 + / + + + + + + + EmbedByReference + + /Users/Eva/Desktop/Bildschirmfoto 2011-06-04 um 11.57.44.png + + + + + + + application/postscript + + + Adobe Illustrator CS4 + 2011-06-04T12:16:30+02:00 + + + + 256 + 128 + JPEG + /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAgAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7 FXYq7FVskkcal5GCKOrMQB95xVLpvMFmrenbK91L2WMEj7/6YqgNRv8AXQiEqtr6rcYol3kav3/w xVOrC2a3tUjdi8nWRyakseu5xVEYq7FXYq7FUJqlrLPat6LFLiP4omU0NR2qPHFUq0/Utaa39YRr eRqSrqPhkUjsf9o4qjoNfsJG9OYtbSjYpKKb/P8AriqYqyuoZSGU9CDUYq3irsVdirsVdirsVdir sVdirsVaZlVSzEKo3JOwGKpbL5i02Nyis0zD/fa1/E0xVb/iK3/5Zrj/AIAf81Yq7/EVv/yzXH/A D/mrFXf4it/+Wa4/4Af81Yq7/EVv/wAs1x/wA/5qxV3+Irf/AJZrj/gB/wA1Yq7/ABFb/wDLNcf8 AP8AmrFXf4it/wDlmuP+AH/NWKrfrOu3f9xAtpGf92S7t91P4Yquj0CN2El9PJdP4MSFHyA3/HFU xigt4E4xIsaDrxAH34qlVgDqOpyX7D/R4P3dsD3PdsVTnFXYq7FXYq7FXYqk0v8AuN1cS9LS9NJP BZPH/P3xVNJ7W2uF4zxrIO3IVp8jiqXNoPpMXsLh7Zv5K8kP0HFWvrutWm11bC5jH+7YevzK/wBg xVd/iK3/AOWa4/4Af81Yq7/EVv8A8s1x/wAAP+asVd/iK3/5Zrj/AIAf81Yq7/EVv/yzXH/AD/mr FXf4it/+Wa4/4Af81Yq7/EVv/wAs1x/wA/5qxV3+Irb/AJZrj/gB/wA1YqqQ+YNMlfgXMTHtIOP4 7jFUxBBAINQehxVJbrlqepNacitlbUM/Hbk/hiqawQ28CBIUWNR2UUxVU5DFXchiruQxV3IYq7kM VdyGKu5DFXchiruQxVLNbunMaWNuf9Iujx+SdziqPtLaO2t44I/soKV8T3P04qq4q7FXYq7FXYq7 FUPqFml5aPA3VhVG8GHQ4qhtFvWmtjFNtc259OUHrtsDiqYchiruQxV3IYq7kMVdyGKu5DFXchir uQxV3IYqpXNta3KFJ41dfcbj5HqMVS3TmksdQbTnYvBIC9qx6inVfwxVZoL1t5pj9qaVmJxVM/Ux V3qYq71MVd6mKu9TFXepirvUxV3qYq71MVd6mKpbetx1bT5R1LNGfkdv+NjiqdYq7FXYq7FXYq7F XYq7FUl0huU99P3knIr7L0/XiqZepirvUxV3qYq71MVd6mKu9TFXepirvUxV3qYq71MVSzVmpdWE w+0swX6GIr+rFVHRWKW0kR6xyMpH3YqmHqYq71MVd6mKu9TFXepirvUxV3qYq71MVd6mKu9TFUFc t6mqafGOocufkKH+GKp9irsVdirsVdirsVdirsVY/pjenLeQ/wAk7bfPb+GKo/1MVd6mKu9TFXep irvUxV3qYq71MVd6mKu9TFXepiqA1Fi9xYxjq06kfQR/XFVt6h0/UpJGFLW6PIN2D964qriUEVBq D0OKu9TFXepirvUxV3qYq71MVd6mKu9TFXepirTzqilnbio6k4q7RYnur19QcERIPTgr38TiqeYq 7FXYq7FXYq7FXYq7FUg1RGsdRN3T/RrkASEfsuMVXrMGAZTUHoRirfqYq71MVd6mKu9TFXepirvU xV3qYq71MVaaYKCzGgHUnFVmlRte6j9bofq9uCsZP7TnFU8mghnjMcqB0bqpxVKn8tQhq29xJCD+ zXkP4Yqt/wAOy/8ALdJ939uKu/w7L/y3Sfd/birv8Oy/8t0n3f24q7/Dsv8Ay3Sfd/birv8ADsv/ AC3Sfd/birv8Oy/8t0n3f24q7/Dsv/LdJ939uKu/w7L/AMt0n3f24qqQ+W7UOHuJZLgjorGi/d1/ HFU1VVRQqgKqigUbADFW8VdirsVdirsVdirsVdiq2SOOVDHIodG2ZTuDiqVSeW7fkTbTSQV/ZBqP 6/jiqz/Dsv8Ay3Sfd/birv8ADsv/AC3Sfd/birv8Oy/8t0n3f24q7/Dsv/LdJ939uKu/w7L/AMt0 n3f24q7/AA7L/wAt0n3f24q7/Dsv/LdJ939uKu/w7L/y3Sfd/biq+Py3b8g1xNJPT9kmg/r+OKpr HHHGgjjUIi7Ko2AxVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirs VdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsV dirsVdirsVdirsVdirsVdirsVUbu8t7SEyztxUbDxJ8AMVS4arqlwOVpYH0z9l5WC1HjQ0/Xiq76 x5i/5ZYf+C/5uxV31jzF/wAssP8AwX/N2Ku+seYv+WWH/gv+bsVd9Y8xf8ssP/Bf83Yq76x5i/5Z Yf8Agv8Am7FXfWPMX/LLD/wX/N2Ku+seYv8Allh/4L/m7FXfWfMX/LJD/wAF/wA3Yq0dZu7cj6/Z NFH3lQ81Hzp/XFUzhmimiWWJg8bCqsMVX4q7FXYq7FXYq7FXYq7FVk00UMTSysEjUVZjiqWDWbu4 J+oWTSx9pXPBT8q/1xVv6z5i/wCWSH/gv+bsVd9Y8xf8ssP/AAX/ADdirvrHmL/llh/4L/m7FXfW PMX/ACyw/wDBf83Yq76x5i/5ZYf+C/5uxV31jzF/yyw/8F/zdirvrHmL/llh/wCC/wCbsVd9Y8xf 8ssP/Bf83YqtOq6pbjld2B9MfaeJg1B40Ff14qmNpeW93CJYG5Kdj4g+BGKpZAi6hq800vxW9mfT hQ9C3c4qnNRirqjFXVGKuqMVdUYq6oxV1RirqjFXVGKtNxYFWFVOxB3BGKpRZD6hq72S/wC81ypk hX+Vh1H4YqnGKuxV2KuxV2KuxV2KuxVJ70fX9XSyb/ea2USTL/Mx6D8cVTdeKgKooo2AGwAxVuox V1RirqjFXVGKuqMVdUYq6oxV1RirqjFUmnRdP1eGaL4be8PpzIOgbscVW+X3/wBCZ+8kjMfwGKpn 6uKu9XFXerirvVxV3q4q71cVd6uKu9XFXerirvVxVLdQb/clpzjr6hX6DTFU6xV2KuxV2KuxV2Ku xV2KpLp7f7ktRc9fUC/QK4qmXq4q71cVd6uKu9XFXerirvVxV3q4q71cVd6uKu9XFUs8wP8A6Er9 45FYfiMVUdJb0kntz9qGVhT26fwxVH+pirvUxV3qYq71MVd6mKu9TFXepirvUxV3qYq71MVQczer q9hF/KWkP0Co/wCI4qn2KuxV2KuxV2KuxV2KuxVIYW9LV7+L+YrIPpFT/wASxVGepirvUxV3qYq7 1MVd6mKu9TFXepirvUxV3qYq71MVQGrt6qQW43aaVQB7dP44qqatazWt2b+BC8MgpcIOoI/axVTj v7eQVWQfImh+44qv+sx/zr94xV31mP8AnX7xirvrMf8AOv3jFXfWY/51+8Yq76zH/Ov3jFXfWY/5 1+8Yq76zH/Ov3jFV3qYq71MVQ18rsizRGk0B5oR7dRiqfWF4l3axzrtyHxDwYdRiqvirsVdirsVd irsVUL+8S0tZJ234j4R4segxVIbFXVGmlNZpzzcn37YqifUxV3qYqt+sx/zr94xV31mP+dfvGKu+ sx/zr94xV31mP+dfvGKu+sx/zr94xV31mP8AnX7xirvrMf8AOv3jFVkl/bxirSD5A1P3DFVTSbWa 6uxfzoUhjFLdD1JP7WKp7iqDm0fTJm5SW61PUrVf+IkYqpf4e0f/AJZ/+Hf/AJqxV3+HtH/5Z/8A h3/5qxV3+HtH/wCWf/h3/wCasVd/h7R/+Wf/AId/+asVd/h7R/8Aln/4d/8AmrFXf4e0f/ln/wCH f/mrFXHy9pBFPQp783/riqEl8vTxb2VyQO0Uu4+8f0xVBStf2v8AvXbsqj/difEv4Yq6O7ik+wwJ 8O+Kq2j3QtL827GkFyap4B/7en3YqyPFXYq7FXYq7FXYqxzWLoXd+LdTWC2NX8C/9nT78VUZLuKP 7bAHw74q6Jr+6/3kt2ZT/ux/hX8cVRsXl6eXe9uSR/vqLYfef6Yqix5e0gCnoV9+b/1xV3+HtH/5 Z/8Ah3/5qxV3+HtH/wCWf/h3/wCasVd/h7R/+Wf/AId/+asVd/h7R/8Aln/4d/8AmrFXf4e0f/ln /wCHf/mrFXf4e0f/AJZ/+Hf/AJqxVVh0fTIW5R261HQtVv8AiROKozFXYq7FXYq7FXYq7FXYq7FX Yq7FXYqgbrRNOualogjn9uP4T+G34YqlV35bvVFbef1VU1VX+FhTwP8AtYqndhLPJaobhDHOvwyA juO49jiqIxV2KuxV2Koe/lnjtXNuhknb4YwB3Pc+wxVJLTy3esK3E/pKxqyp8TGvif8AbxVNbXRN OtqFYg7j9uT4j+O34YqjsVdirsVdirsVdirsVdirsVdirsVf/9k= + + + + 2011-06-04T12:16:32+02:00 + 2011-06-04T12:16:32+02:00 + + + + 612.000000 + 792.000000 + Points + + 1 + False + False + + + Cyan + Magenta + Yellow + Black + + + + + + Default Swatch Group + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + % &&end XMP packet marker&& [{ai_metadata_stream_123} <> /PUT AI11_PDFMark5 [/Document 1 dict begin /Metadata {ai_metadata_stream_123} def currentdict end /BDC AI11_PDFMark5 +%ADOEndClientInjection: PageSetup End "AI11EPS" +%%EndPageSetup +1 -1 scale 0 -49.4995 translate +pgsv +[1 0 0 1 0 0 ]ct +gsave +np +gsave +0 0 mo +0 49.4995 li +99.6602 49.4995 li +99.6602 0 li +cp +clp +[1 0 0 1 0 0 ]ct +40.6597 .91748 mo +27.6455 5.57764 li +6.55859 5.57764 li +2.66016 5.57764 .5 7.70215 .5 12.6362 cv +.5 17.6396 2.66016 19.6953 6.55859 19.6953 cv +30.3516 19.6953 li +34.2505 19.6953 36.4106 17.5708 36.4106 12.6362 cv +36.4106 11.3696 36.2607 10.2886 35.9756 9.37549 cv +40.6597 .91748 li +cp +false sop +/0 +[/DeviceCMYK] /CSA add_res +level3{ +gsave +clp +[-5.12966e-06 117.353 -117.353 -5.12966e-06 20.5796 7.771 ]ct +/0 +<< +/ShadingType 2 +/ColorSpace /0 /CSA get_res +/Coords [0 0 1 0 ] +/Domain [0 1 ] +/Extend[ true true] +/Function +<< +/Domain[0 1 ] +/FunctionType 3 +/Functions [ +<< +/Domain[0 1 ] +/Range[0 1 0 1 0 1 0 1 ] +/FunctionType 0 +/Order 1 +/DataSource <~z!W`<%"Tni,#6b82$3pe9%LNOC&e,9M'bCoV)&!Y`*#9:i*uPpr,9.['-QaE1.j?/;0-qqF1FXaQ2_?T +]4"r>g5;P(q6T6q(7li[290GE<:I.8H;aj(S=%Gj^>YRll@8Kc$AQ2V0Bie@;Cg1'FDdH]PEa`>[G%>( +fH"^dsHu!C)J8K'5JoGT@Kl_2LLj!hYMg9IfNdQ'rOFMU*PCe37Q%a`DR"p8PRYlb]S;i:jSr\_!T95% +-Tp1R;UR$sGUm[?SVONc`Vk')lWLfE#Wh>`0X.l& +/BitsPerSample 8 +/Encode [0 63 ] +/Decode [0 1 0 1 0 1 0 1 ] +/Size [64 ] +>> +<< +/Domain[0 1 ] +/Range[0 1 0 1 0 1 0 1 ] +/FunctionType 0 +/Order 1 +/DataSource <~ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7 +ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7 +ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7 +ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7 +~> +/BitsPerSample 8 +/Encode [0 63 ] +/Decode [0 1 0 1 0 1 0 1 ] +/Size [64 ] +>> +] +/Bounds [.860068 ] +/Encode [0 1 0 1 ] +>> +>>/Gradient add_res /0 /Gradient get_res clonedict shfill grestore +}if +level3 not{ +gsave +[-5.12966e-06 117.353 -117.353 -5.12966e-06 20.5796 7.771 ]ct +clp +/0 { +/0 /CSA get_res setcolorspace +<< +/NumSamples 256 +/NumComp 4 +/Scaling[[.00392157 0 ][.00392157 0 ][.00392157 0 ][.00392157 0 ]] +/Samples[ +<~!!!$#!WrQ/"U5/9#RLhG$k3[W&/#Th'GVB")&X>3*$$(B+<_pR,UFfd.4Qi!/MAe41,CaE2`NfY4$5Yj +5X@_(6q'U:8P2WL9i"S_;H$Op='/X1>[CcG@Uiq[AnYmmC27X&DJsH4EH6,CG'8(SH$XgbI=?ZrJ:W<) +K7nr5LPUeDMMmFQNfK0]OHG]iPE_;sQC!u+R@0M4S"-">SXuFET:_dLTq\9VUSFT[V5:&dVP^8iW2Zbq +WN)tuX/i;%XK8M*Xf\_.Y-+t3YHP17Yd(I +<~z! +<~z![:WA?XR;O@q0%\AnG[hBk_EF`qqPG^4R\H[C-gIXZcsJ:W<(K7ei2L51P?Ll$tGMi +<~zzzzzzzzzzzzzzzzzzzzzzz!!!!"!`k'c%T&)B0Y:*Z +lLK,:"T`.4Qi"/hf"91c73P3]oSj5XIk-7nH?J:Jk"h<`iO1?=.,NAS5^mD/XE9G'J=]It<6+LP^tMOH +YrrR@KkAUSXlfXKJe5['mKU]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni~> +] +>> +0 0 1 0 []true true [] +/DeviceCMYK +GenStrips +} /Gradient add_res /0 /Gradient get_res exec grestore +}if +np +1 lw +0 lc +0 lj +4 ml +[] 0 dsh +true sadj +40.6597 .91748 mo +27.6455 5.57764 li +6.55859 5.57764 li +2.66016 5.57764 .5 7.70215 .5 12.6362 cv +.5 17.6396 2.66016 19.6953 6.55859 19.6953 cv +30.3516 19.6953 li +34.2505 19.6953 36.4106 17.5708 36.4106 12.6362 cv +36.4106 11.3696 36.2607 10.2886 35.9756 9.37549 cv +40.6597 .91748 li +cp +0 0 0 .200012 cmyk +@ +58.751 .91748 mo +71.7646 5.57764 li +92.8521 5.57764 li +96.7505 5.57764 98.9111 7.70215 98.9111 12.6362 cv +98.9111 17.6396 96.7505 19.6953 92.8521 19.6953 cv +69.0596 19.6953 li +65.1597 19.6953 63.0005 17.5708 63.0005 12.6362 cv +63.0005 11.3696 63.1494 10.2886 63.4346 9.37549 cv +58.751 .91748 li +cp +level3{ +gsave +clp +[1.7482e-06 117.353 39.9942 -5.12966e-06 78.8311 7.771 ]ct +/0 /Gradient get_res clonedict shfill grestore +}if +level3 not{ +gsave +[1.7482e-06 117.353 39.9942 -5.12966e-06 78.8311 7.771 ]ct +clp +/0 /Gradient get_res exec grestore +}if +np +58.751 .91748 mo +71.7646 5.57764 li +92.8521 5.57764 li +96.7505 5.57764 98.9111 7.70215 98.9111 12.6362 cv +98.9111 17.6396 96.7505 19.6953 92.8521 19.6953 cv +69.0596 19.6953 li +65.1597 19.6953 63.0005 17.5708 63.0005 12.6362 cv +63.0005 11.3696 63.1494 10.2886 63.4346 9.37549 cv +58.751 .91748 li +cp +0 0 0 .200012 cmyk +@ +40.6597 48.5815 mo +27.6455 43.9214 li +6.55859 43.9214 li +2.66016 43.9214 .5 41.7969 .5 36.8628 cv +.5 31.8594 2.66016 29.8037 6.55859 29.8037 cv +30.3516 29.8037 li +34.2505 29.8037 36.4106 31.9277 36.4106 36.8628 cv +36.4106 38.1299 36.2607 39.2104 35.9756 40.1235 cv +40.6597 48.5815 li +cp +level3{ +gsave +clp +[-6.39794e-06 -114.618 -146.368 5.0101e-06 20.5796 147.394 ]ct +/1 +<< +/ShadingType 2 +/ColorSpace /0 /CSA get_res +/Coords [0 0 1 0 ] +/Domain [0 1 ] +/Extend[ true true] +/Function +<< +/Domain[0 1 ] +/FunctionType 3 +/Functions [ +<< +/Domain[0 1 ] +/Range[0 1 0 1 0 1 0 1 ] +/FunctionType 0 +/Order 1 +/DataSource <~ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7 +ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7 +ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7 +ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7ZE0h7 +~> +/BitsPerSample 8 +/Encode [0 63 ] +/Decode [0 1 0 1 0 1 0 1 ] +/Size [64 ] +>> +<< +/Domain[0 1 ] +/Range[0 1 0 1 0 1 0 1 ] +/FunctionType 0 +/Order 1 +/DataSource <~ZE0h7Z)aV-Yc=D#YGe+lY,@k`XehSTXJDAIX.l&`0WLfE#Vk')lVONc`Um[?SUR$sGTp1R;T95%- +Sr\_!S;i:jRYlb]R"p8PQ%a`DPCe37OFMU*NdQ'rMg9IfLj!hYKl_2LJoGT@J8K'5Hu!C)H"^dsG%>(f +Ea`>[DdH]PCg1'FBie@;AQ2V0@8Kc$>YRll=%Gj^;aj(S:I.8H90GE<7li[26T6q(5;P(q4"r>g2_?T] +1FXaQ0-qqF.j?/;-QaE1,9.['*uPpr*#9:i)&!Y`'bCoV&e,9M%LNOC$3pe9#6b82"Tni,!W`<%!!!!! +~> +/BitsPerSample 8 +/Encode [0 63 ] +/Decode [0 1 0 1 0 1 0 1 ] +/Size [64 ] +>> +] +/Bounds [.159435 ] +/Encode [0 1 0 1 ] +>> +>>/Gradient add_res /1 /Gradient get_res clonedict shfill grestore +}if +level3 not{ +gsave +[-6.39794e-06 -114.618 -146.368 5.0101e-06 20.5796 147.394 ]ct +clp +/1 { +/0 /CSA get_res setcolorspace +<< +/NumSamples 256 +/NumComp 4 +/Scaling[[.00392157 0 ][.00392157 0 ][.00392157 0 ][.00392157 0 ]] +/Samples[ +<~Z*CR=Z*CR=Z*CR=Z*CR=Z*CR=Z*CR=Z*CR=Z*CR=Z*CR=Z*CR=Z*CR=Z*:I:YHP.5Y-+q0Xf\\+XK8J' +X/`2"Wi;tsW2QVkVP^5eV50l]USFQWTqJ'NT:VXGSXl:?S!oe6R@'>,Q'IStPEM&iO,o<]NJrdQM2@%D +L4t;5JqAQ(J:E#rH[C'aG^"=RFE;JBE,TW2Chmg$BkV-jA7K(W?X?uA=]ea*<)Z[l:ej_Y91h`F7R]^4 +5sRXu4Zkbc3&``Q1GU[=0.ne+.Ocbn,pX]Z+WhaG*?6":)&O/*'bh;o&J,H^$k!CK#R:P<"U,#2!W`<% +~> +<~X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/i;%X/`2"WMuhpVl-GiVPU)aUnjc\ +U7n9RTV%gISXl=AS=?":R[KP0Q^3o$P`q8mOcYWbNfK*XN/NRMLkg_>KnY24Jq8H%IXZ]mH[9s^GB\4Q +FE;JBE,]`5D/=$(C2.HrB4YU`@UW\Q?X@#C>?Y03=&r=#;c6Ih:JOVX8kDQD7R]^469mb!4Zkee3B/rV +2)I*E0eb76/hJV).4?Pj,pX]Z+WqjK*ZZ4>)AsA.(D[`"'+tlg&.]6[$k!CK#m^b?"pG,4!s8T*!<<*" +~> +<~U8"BVU8"BVU8"BVU8"BVU8"BVU8"BVU8"BVU8"BVU8"BVU8"BVU8"BVU7n9STV.sNT:VXFSXl:@S=H(< +R[KS2R$X,)Q'IW!P`q;oP*(ifO,o<]NfB!UMi*@JLkph@L4t;5K7\Z*J:N,uI=-EhH?sj]GB\4QFEDSE +EH#i7DJj<-CMIQsB4kggARo:[@:3JM?$5!1=&r=#;c6Ih:JFMU8kDQC779L05sRXu4Zkee3B/rV +2)I*E0eb76/hJV).4?Pk-7'l\+WqjK*ZZ4>)AsA.(D[`"'+tlg&.]6[$k!CK#m^b?"pG,4!s8T*!<<*" +~> +<~]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(ni]Y(hc[^ +] +>> +0 0 1 0 []true true [] +/DeviceCMYK +GenStrips +} /Gradient add_res /1 /Gradient get_res exec grestore +}if +np +40.6597 48.5815 mo +27.6455 43.9214 li +6.55859 43.9214 li +2.66016 43.9214 .5 41.7969 .5 36.8628 cv +.5 31.8594 2.66016 29.8037 6.55859 29.8037 cv +30.3516 29.8037 li +34.2505 29.8037 36.4106 31.9277 36.4106 36.8628 cv +36.4106 38.1299 36.2607 39.2104 35.9756 40.1235 cv +40.6597 48.5815 li +cp +0 0 0 .200012 cmyk +@ +59 48.5815 mo +72.0146 43.9214 li +93.1016 43.9214 li +97 43.9214 99.1602 41.7969 99.1602 36.8628 cv +99.1602 31.8594 97 29.8037 93.1016 29.8037 cv +69.3086 29.8037 li +65.4092 29.8037 63.249 31.9277 63.249 36.8628 cv +63.249 38.1299 63.3984 39.2104 63.6846 40.1235 cv +59 48.5815 li +cp +level3{ +gsave +clp +[6.71881e-06 -114.618 153.709 5.0101e-06 79.0791 147.394 ]ct +/1 /Gradient get_res clonedict shfill grestore +}if +level3 not{ +gsave +[6.71881e-06 -114.618 153.709 5.0101e-06 79.0791 147.394 ]ct +clp +/1 /Gradient get_res exec grestore +}if +np +59 48.5815 mo +72.0146 43.9214 li +93.1016 43.9214 li +97 43.9214 99.1602 41.7969 99.1602 36.8628 cv +99.1602 31.8594 97 29.8037 93.1016 29.8037 cv +69.3086 29.8037 li +65.4092 29.8037 63.249 31.9277 63.249 36.8628 cv +63.249 38.1299 63.3984 39.2104 63.6846 40.1235 cv +59 48.5815 li +cp +0 0 0 .200012 cmyk +@ +%ADOBeginClientInjection: EndPageContent "AI11EPS" +userdict /annotatepage 2 copy known {get exec}{pop pop} ifelse +%ADOEndClientInjection: EndPageContent "AI11EPS" +grestore +grestore +pgrs +%%PageTrailer +%ADOBeginClientInjection: PageTrailer Start "AI11EPS" +[/EMC AI11_PDFMark5 [/NamespacePop AI11_PDFMark5 +%ADOEndClientInjection: PageTrailer Start "AI11EPS" +[ +[/CSA [/0 ]] +[/Gradient [/0 /1 ]] +] del_res +Adobe_AGM_Image/pt gx +Adobe_CoolType_Core/pt get exec Adobe_AGM_Core/pt gx +currentdict Adobe_AGM_Utils eq {end} if +%%Trailer +Adobe_AGM_Image/dt get exec +Adobe_CoolType_Core/dt get exec Adobe_AGM_Core/dt get exec +%%EOF +%AI9_PrintingDataEnd userdict /AI9_read_buffer 256 string put userdict begin /ai9_skip_data { mark { currentfile AI9_read_buffer { readline } stopped { } { not { exit } if (%AI9_PrivateDataEnd) eq { exit } if } ifelse } loop cleartomark } def end userdict /ai9_skip_data get exec %AI9_PrivateDataBegin %!PS-Adobe-3.0 EPSF-3.0 %%Creator: Adobe Illustrator(R) 14.0 %%AI8_CreatorVersion: 14.0.0 %%For: (Eva Besenreuther) () %%Title: (bobbls.eps) %%CreationDate: 04.06.11 12:17 %%Canvassize: 16383 %AI9_DataStream %Gb"-6BlmV1Pq_LlXnDP(n,$.fId=+S]slSi<>s!u8ufH[=)"g7o5)O3-+R!<8lAeiPuNC@dj/eo:/&5\rXss9+V4oN*!/kPg'R.S %44oS\&0N\1':X6&!Sc7W+bl?pe'6Y1S3,?9[r)YnR''_0GSZgZ<+4L%k--5)m@`&?.[c'1S2ZWORMb,Md//K11_ %/M[l4M";HgAf&a+0UX@'jE;AV:)A5pCp9C6G'M:KRnbPhUWL5pCc\(WG[lZ:GbRt!h&M;e]0gY$'%2ep(DgR?Bg%BJklm'lh/3T) %,)bDF/A;fjC/'qD68qX$TI1IWSEWTs9nhVM>)V3fDHh_&aIra$"i[3OEk=Iq]pP5ppHbM3RJ%pt:7q_[&75s&P=asD[)<*,oQH:= %nf(]/V$mhQAN%A3]>J3(>4;],2Oi[B7@ELCD<9D8GV5.M`^@j=/36Ws2^TSM?4+XfTO8*jG*?(pE1';ORdebr@ON,6fX"CtXi4ja %rmEg"gB>!/K2$-e"!VT$iGc=u3etrK`^om%3l'Zq,asZg(?0MJaJ7RrRIDE? %#5rpW0f2Dp!Xdl"8VT*rpfocb/.l)[U&NYXSP0JXTWZZ(E1Eu%G<$XK'-"qC[[IUO]0g?m>N)dAVR:)3()q@XZ>**/>XPX8Uc$il %Cs=/0P7kJ@e`8i%kEhWK+Dc@@C+R(aT,8gg:+$S]sXlE.80R*99'QQ[mOVIQO)oh+emDDN_m8-/e$ieU%i%lP#qC!jhUY,H'Y=TNJ[a6N$.*S+&;nhVj)J %Hu*0F37Yn<(AA&4#[tsli8SH?^(3d(GZ6NR*R? %!J]$=^B8RBF8/BVQBm.!@GtWkVM%(sFu%j9X$4U`288,aVe4Li9)n]_F!FI?f"E-8^J)i4EsR%-pVP)FK#(XQkGA0@QA\[<%2Z,Is^m0Y8lT!^3@jmdG=h2Jc(@!]gtoB=.c)YUY24RHNm %/X)"AXM&CmZ<)u*k@/DUaqjiFTItfG`r9FG)7tSOJ[.&AebmZY$hC),PJW=J>lI?26'8RpfQ*M,ia_l1BeS':($.=CTB9?tKp/J[ %SlDC\o<"LMWCbaC)6LCMofEE%+ksTIRJ=k-W7XfJ91Ff+LZ.?;QH`?-\6SUT[PB%Pu9."tcYF"[dO!9lV:/A4GPb%?lFRZ-akD`=3Bm'*C %r&f6W>_3`#XP3#mP-L@9p@YZWZh_.pH2^F.SWSrba2sQ2<]9GQg(q"sM:BoJUGHT> %NEga=95(.Y_5t`N`FFh23KhBgj-7B!1:9g,(@gj$:CM86U1hA=dt:Sh_7ml&,dkX%WTEj;CeRdlSr$0q[=Q:n4KKicdm\0cD^q73O0Em*&7M4`tZ %]qNEaaBUj74!T6@c8n8Yu)n?KO*n?KLI/sD36JIS8lqVgIUHCZ,Th[Ok%:'Z15:7K_t %[(s`KKPie!8&4)-p`*Uq#FaY(/.RHk@<5Nk(5LROE_uH0I/FnpcgJ2flu&cIBm84sG,sG.2qq&MYaK;kS(Hf-\SP$1*NrXMD&/Vo %B!MWBI]Lf[[[T3s;$7##@n9JmJX2afYZVObos`3^bR@qb$1lM8BJitKa,Bn1Y] %THHTh2bQM"VBMZXU*E<&L6`6baPanPLSb]X'1XKF.itZ$Yo%L9"LDcTRYe"5kf+#]-`.iW*1qC$4L>/Vib<*&0be.f)53arVP/dB %+\+s?91AF1dF*rPR11amP14RbC22reBOXK*OI!P!aAUKF73S?OTd<'#TVi!T7R'U?O(qjEHl`f[aPjCL/5UQk,cV)=MB0^0)9VFL %oisa:.hILb4YqKh1/uSoEgIo^o>BBdJtb6J4(&?SJQ7"Um742;a+AS+WM)3pLE!gBHH?T&jcgmL5*\Oa43Krn:_I0 %!03HR$5g#N2/65eQt^qeT:A01gLCC=&CV8rA-Hf&JV0JC4?&q[$_6<;#GB*a5ck4"c3B)J!$f(*+[(@'+-7UQ#ln^6^p4_`>oSmG %!CUsBJ6!`LC`dV@!:XV&.U%@g2P?uNkir67H@X!h.C00Xa!MWa()T@r(ebDV<%]"o*Z/QS(iTMKKLl9@ciTui#34-l[b&;SFi0=6 %_Vk)K![*:n^f_EihDQ57%N+ah)#Qq6qLJ;A+hYV^u=[=Wk8K:YMTQ4((&#Ls-Hu.7#f[u5;QnU7eKd@R< %?5@7"/Bfe&a2WKrAsc`Fc:$>nFb=q-9KYGi[e4([p*70cgV>/[ib!!KP=_V\1V4[RDk*3V?'t$8lo`@aTdN:Q7$a7nP%cP?nri5j %4V\/HA#[?1IM4VL/=b:;*GnlAXJUGLm!-hOq4=>J>*s.&YVD(AIL<9pqks:>CXl0k],Ia:=F30Z5T)P1H5qf$FJ`*_6+FblZ9opngI^IM/.0j4sBGe1B %<#pW@3QXU4O`2N(6V^u"%Wki9)IP`nAipI`3MIdH83m6Z=g;RX"+MK^1'(2(J=0ZX6p>!#'/5FU(rd#$iWfZB"&Cm55ZJ>nHO>RH %"X:R/Y!+s]XY<580G]'ZAPHQ\fYI&sJ^r_sZj[(&nY86A*g"5k?loVE*?,h$(,-Fr0\m.fiSXe?_?7*t"YAd9*k#M3hZAth"Ok`u %fH*5\5s-:=>oMWh#8egH0N'H0XI.@@,VWPP@)R:T(q@A[EI`SGJWXFCA&e^nf`95hHsd,'5\.BTT,fUjCg\PlOE4BR]qTu0&*gu; %2(/*ofru9PnXoW^5K6&P1``s8N-8cALCR#hr+INN8Y8p[ %=A!lf%84eEck2C;UPb)3W(;`C.0pZ6mP'WH)D[>ts!4rc;cBH3<2ugmVEeXSV5>T/&P8qCT2G.@6Vb7EU?kX3R1b\b1F:m_95=Gp %AY)Uf/D[p/$4;SaDI*`!1rdgSm%u31TrldCNqpPuU"!OlU.T4fJXMh47UmEs/1iN0R4OT2=$mje1:-#)94#TB=BAi4!J-M?(UG7pSpNhhM#Yg)h\'YGoWFP6Asf515m[32l4*jifJ@XRuo@2U-=_'t?XJ0W#6)'i(L!smMU@H@&PG"'6%6T8l7A^YT6Li=]pIKL0:M_@/R96YpgpFF* %L#gra1)Z-HV39e*'sJlmFiWO9^J5@E.%ciAH9SVo'+[T)*jaGM%[^['d8t?E>)c"NQ)g[:i5SSU;%KF@&5&#/$+"LX/Z+oJk^GiF %+7b\YCii;_JmUGbG+u6T=9W.LA((hE))[diEn4LQISE9#gI+^bq?G.u(f'e_:1Wc75Bh\"T-ksZMO75js%$g<0s$-"1K*m,&+o*c %+rOV]XG6S2,fT%,WpUkLWh,J?4$K`=KTL2,F<1iF[I![VNsfXEkGTh:KMe2*^$0U>NN_q-81[6-Dff,elYtjm9,RuOTg]+dX!7!) %QG"W7eNhe:CSos)3q2!=:WU&P>NiiL[!H#SD9g*#=FE3K]pub.'Z$T3LE)nZQ(^(*Hei,h5OpY3tP"0_stg7 %_P65Lm4RdHBGoNrLb,A;Br[_N.u\^IAl,KkUF1ptu3 %NJH\I'$M"'`V*'r8/tR9G%5bX)2i:.6AqQ$1u2BJc"=6&P#,rB:9d0Ahg1KP)&_-6%#%M5$lQt88"l;q+W?hVJF7188EPP'Wa-B8SR+B_9)q*HQ]`+a?2O(N[Fg>ONL*65kK.L4qjMZQ[r=M:Fmqc %ju;.u"^0AK&"#Z^J>)%EX^O_9L3f59)`a\0\03bA!,Nhp(TSBm[Uh]3nA&:2-lLL>+c-b6>ZB"6=GSM(_*D;);J]N!$o0.A7X^)n %X-V]V)JbW&$;C@Co)u78odh>]'pam4'FLqi,4(2p!_j"*8SHQKqZ\@&A`@bSc6=aV`7Ic3(aj4/OXQNWYsa8=RZ-Ool"o-M"[/Er %KO,p[]sJn'i!X"L:"4S87#]#O!.4JB=RRf%LAk7cGLRI+k@=4fikkpt4Wof`2R3bC1N2gdFnr2kNM;+JI_W57Scd0,6*&g:4$cWX6 %0RH`b1DurFNm6NZc5GeW]M?VFSoFW0AT.5j8W#U_E>RhoY"Pf5H;6t@ng9"-#jL%Z3DLK#`8B8k))(;hWmKb@=HjAKYo %3@i2b(C^bOgs_1$d#h0:^I^7r;.qH%abo=3oG2e-]^Zk,/<5I.-q!ZhE>IW^Hn>7CRQY2%@?&&U97htu)C1>bRs[OofW %/Jqkn$.cAmnUho+",N_LRV..]a[hSk^VO`MjEd%*L,9O]4E$R&dud?F`1'+[]:HRSJRr#mKDLN.A5'%#cO)Hr,rn'bMqd %nq;[RgjChbQd(PO1_@*!7:s!Y.e<.6XWf^=eP[StfZ-r8$oY^&rDXWQZdGRWAoTgp)iH0Tp>pr.=^`F='$>$Q0S%)0e(#jWjlu-= %S=>fRENqdG2M"iQ3AmN=o$NUNR:^\9k9H14q%CM=:'aA&V#%p\Qmm>*+^aj5P(@U$9J:6`P*]Fn=E6SF=$Sb/.3`48p]C$5Wh5TV %/nolg<$Nb]S5ut$0"r.5&r\3"5Vq,nc!"I:Z4XKF;8U0@S>i6UU]hr::.9pAj$"KG-ki2sX.CLAg*WflOL:?FS]'\HWuT3eOlu.p %cBXb,/V]\!1?MM3?R4+kiq-gd#/tkbUBr626Cq^uOma?SQ:)%d@kC%h/?L[AR5cEgV4('\R"-HFGnR)*VH4OI_6jR?mmT[m2AJMV %#fS@Bn6Ce@MTUfS0^tgZGns+Fi.dUG9+rA'g\K2i36q+P9'-aYA;?CH"_(?e:6ZE_@%Aahi7RV-Jr,%7,S[hM1X)X/kICpE(`la" %^dM;&U=6+F!mVgD7N(s6[)WI8as;[,VK6!G3Fis`B\/!:@)^Ot\/mQkc<]@5mMUHYGNohDkdLVA%Yb"*ECh0OM@I+!VLbgqOJ %3X_G(cX,R`H;!juUrPF[C0bL')e]ZUAuX0)2V"mI*3dbU<*@on959G$!ubI%^1j:7UoJ'uX/'>k!_JT^L(\p7DQoq6Hc/u`96H^Q %A)h5KY234=Xm3ABb_^4NS8Z6tgcO2"nMi*qq`=W&\q\q\5g6La6HsJK<*M'Ce`c#_VT('/c#QTM9;a;fjD8U6CC%l0=bltGnTC(7 %?!qbKT[DRTD5qb@U3%uQnp\9][X%r=la"]VQZU;Vl#N$k`mQVb-0"Ik`#>SMVO]JG#>$kaQ6i&PjKNoXCgf1YZeC(JmcB=c:JclP %.,10<2mnsQ`Z<*AnLYT:$"p%D0>73%?d!Z0CS/RkXV9g#b[B6hg %(%"qS&P3+dD[>%\9)C(f7-M&.-PpE7V8/!fIN_EUJWhJ**Bg@TWcY6Z`iP0U?9M8&NMWo.qM'N:V7+A"dNhmCa2t.2:ji4Vh69SL %JSBJ<_JYL;1uY%1aAppu(lOB5`h`Lc:g09cE3,'m<>nY-RWMPpc!qDbPu7XL^PT!*C(X@OHuM&O03tQ"FFjinZjfS"Zo!lCf.@hp %#Gip#kc!($<0'^[m:@$k\X$QiSsC5RM<2Hl<,$InHM[fpc&S*QDQ)'iXO2n%g?%g_dZojDtCo';TD&j[s8s/2DNd7V_M@la3JkN_u %;M0$h1JZ%aLV$57fh.$'NNJ,K;N4GGYFX24ZDC!edcRRgokPr2P*N1eSuS*V'P&u!2AgN`f?,4\ge(<5Y,RSe[&8+c_hbLi>-cS5 %UnJl3MY'Pf?^LW]X%25#%8an?Pm6JFHX!N0A+#"O<%6=KX7^D0[EB]FfiGDeL[$(KL.[Z3_ElWj:TYYDU.Y)mK;Q,Z^1r(2.0u\h %Ap%Tggt0[5JTA?M"]r*&e#]FXHYg2iF%'#=S$5dX4>GGbL+9=e.YV&>!RYI,))&<$T[K,oROB/:\O`RAQ2V[6H;!%Eh?m0F7'`s_ %JDR:LI=5=0#Lih*)Uh.6OK$gY[<_pS)G#]8NCTGk+Sq1>Uc\,oX/Kd@@J2$6^:P`#ju]7/fdOW[mR"S %2!H!(:IU'23_P*IWlGE$H$$!#]L+G;'e3pjg;ikXRFI*@;^g$p-C"oP#h5+&4QSbk.OANWVP86(T\P4Ke(CP)D17mMAQ#MT\>+8B %bpH%oWSa"83_`?7*7@WlY:aPR?0gsG8PRc[[Wdr^Uf0@S$&u)\YIC?=dd=lBQDRW89PWa,?_(]:<\i4YZd-L&;KIg#`k&*hQBnrC %WV4k0TeV%\9PQrVYd\%XM^Xk0=I5q3D0\8'Oa4O?-Kqk@)4++cFpBo^<`uGu;gS^6,B$*UrHan(OUXsLK[D?7]!N*N>mNNg;b&53 %liWqbnuMu6-E>*gmS86m\2):L6]'=>Lq0dt`Y";;m^49hj2=)1Q6q;VbJo %.]MqIX=^Ch?4ZYMi67XQb-@r\]';ZS:N&gAB+SmQOTbu8XJmq_!3I,T_=_e*)nVZg$CHj6V!qn"c]VlZfP8TgOiF@MYjb9`0\nGj %j^u0T>g_2*92B`R@sf$H<+\GU^LN*oG,ECe(AN3J?`g_N>e\Y7_"q6f8u[?]hCDO(]aSLE;+l>!X&`']k^%qT>>&f'Q+"-@8(gKd %aL^;0lYJ(UZ)>U08m<*c#bKsUC)nkgUUS!LJ62!ZlIBHNV,0h`i_U.eXU)b!gQnZd[>p9i='?N0UBI>i==p++aA`MQ8XMS5a]07B %XV=puX]1j-?a3$D>:HjJL?-]c_c9=1$XmDs=,X"lWW?<+=-'Ou6M.'c<9h]'sf9 %V;;kYoFUGG.ke4.g+d%Rbd&J-[nf %Ec]Xb*b`lecW';b&3uX7U^0`^2)Y2P]J#A*S@qWYZ?Vf7cDIC+!**+NCo!F-#/St;H?kO^Bd#[^BU%AYmP#r\QH8O/!V":phPCE-^X%(Z`19(Wdbp-^PDS71ua^n+@eXhoC/4:9_>CMVYLD? %LCfNCpCLM4?`f8G5R@\utI79`f[Qc$Ftj5dj-5F#'b]1**0,1/2M',?EH %s1!9@?NJo.U$0nf1/_VqhrLD#['E,&U_285\XGG!,Y+\\]JlhB*\?&]d+9,ll'd>g5FOl^8r7E\;->4"2+r4CB3/,>'5B7RlLD1( %W*W3e-UN,9iL2AGhe!F]2hmi469=p3B7d]1We"L`a;*IBNggudVLXL.?u))ZBU"F7:3D9`(k+Th?]BiRq-f^3$6U. %MV/ %W^I_-W.2gG9lcLKkXr!@.SMn%Dn\oJ8M0Se=%bh^PE$Ea_,Q,_bqG_t-Bh1frSjoYZ7qf=:LQ$o+hr'01ci$uAX[fk[7d_NNXM'2 %q-]D9Fd&[.Z)NFX,!"V('4e"hF/tJkSd84AeJ%V'O`Y9N",M6c?EE:,p?Pm$WS^F(DMNW[(T/ %B-_08DGErm1g(X(?k:@i*F:!5i4HEJIECh*OE#$BP82s*/B!=f!.eidWGIbT\uZg8UB"\IN&TfUZkl755lXkt45ZCl?ZTM&[kc)` %.Zs-lEjgQLItkb%kPP,"'8_!5@@.B+j=&@15kKu?Y>]=6^7h*d23j3%i(m'Z$Mop#LUc]XLRoe8AD-N=ehUTC9K"4d"!#E;;b'I! %X?3b4WC&e`jZFe\^'([(bEZ9VCVQcm81`kFaAJ%5;QR_]gVMS3$n#]V-@m?je[d]$jHin6*GK7K#PZ59#8KubXOZNFHkL0Ue<*Yf %UXNcCdTO^u.os'6O[TY\,oFtJ#9r?7!URo'CP85%PFDVLK@#iP>E^M0.GZF2V1m"BkYrDJ1_&aHbAtNA@ulcGaKJ0HZ@iCk>OE3* %6&T\7QB`YqlS$%uknNI!*;I1L^BCHtou^OQd?`oN/FNtk['r`)"9Sl8E&XrGX623>P+%bj-)8C7b4M+--uM=kbRA(X@0+TOD%#79/JX> %$&jiF8@6=r4h+q0e"48+H*,[([fIjfZ#-KVkTjlA)BL'k9\MTZ=K/tKCHiT-(4Z]@T,ZdgQK %=?RrlK,-!Y9%tbqD+Q[_![b]WeY5cDC,\>TfQ/o*/3Atp^Ea?NPS1:'#*9bpE %R#L-_%CrQD"+=V#aa4Bu/k+2$9OE(k=H<=#OYt6,XG]+A_p[JAJL![\pp_7D>8)'>;UAPcd9*6YDmTeJ@Q7p0+G %Qru[_H2CjZ5bW.Br`X,Tp+@pg/[dPZqd*)+XUFaK@V_h,H`9n"\r>aLdY>oUOHD5jdVa+ig/+RQd?a8?:)D?dV!WTPBfcsmrJ6[j %jE)\to+\YC6e3$'G-.+%NXAU>8#t-/.@B'O-$;21mZ=WSV.*>^:`hGN%PU+[TG"+<_Eh"Y^T#+;hrisDY=rAc] %GrqS,8^`6UE3bA[,uRD@$+2T>X-R;pag-e<.\SOfPM*Jn5MmOtn"l@bXF]]3-TgarMcco,@m2A6`2-Xg9o6'g\bF'deZ1 %3J&k8@OT863opW.14.81L/'a\:+9@#Ri"3J#WcD$@.#akaq8_6?"%4+PU#B[d=OC!cm_K3Blo&cTi6uE_n.u;&4<@c%nAi!+GNWO %Jr];Rb9r$>3J*:"a"k]^#ra-Q8!kl$=keg\i8l27>#qqDP+8GZDfn1k`pLdF8epko4KC-]S[EmH1[GBIn*(!^h[9YO0V@7Bi_>k(eh-MM'?3b;^e^on,?`'2KRrLqYh?fN>L,9(G4c[DN]D.@g4lZ6Bfhbk)adDbFgkeHD6.Yo@ZSk>IN_0KhA_/e.e",<;P;1]a\.^VZ`uq-ao(J#O:^J52Apgdjc(tPhV)GJl=k;?RcBk %mo>I/!ujl_RhOJnnL;5a5kR=QR9%u]`r%u$SC:(o1p&4[g`b,l"]JUI!tUBShZM2f:)3_4jdGhXB+`0#E.G5C^e1,`QCcoV:,qFqr083rm8QqkMMD:gbf+Cg5k`fmUM!VAd-P1?9)$:`?Te>Z?s8m`HuTC^-Z\7H`YRF*1m`$f)A=_.$YX,mW.8sW,> %ngdMg\"0J(,L$mY.>-!-CsNe19-T*)@ss_8T4g-#MmTGr@jri2a@SoCq1pFJ$KP`Y6'HD*h%m(T-B"d4V:sn.*+i9Gaj;HDGS74> %#[)\7YZ&LBR3L[%RjkIrSZX.b:8(8=GBYm53YEWtN2>=;S7,0j`ZrrKl7C20QWf.ONsrY?QXSUP %e<,e^7X3L7WO!fKY""!+CnTif4A7YW9#Vh`jg8bY-!Sl=-EO*/]&hU)i5kDSdP0EgCeJQKQ %SV;kkC`Y>\mhekWd3)c*cGDP`E7^n/c3cG7RP;+gWpD[dM=eu3VlHj,)8jK_>8Q@p"Z+-JA[e"'EeeD-gAQ??EHRe3;iDF9]KlZ* %f?(e'MU0E0[d$0'AJLqVlYs%*Gt/WIFIJ0WpFJ]p*8(jd_(D7&4BsN-+3Qhh:3m^uU#Y,&g7bN-*\P>?eH3_-@@SlTlnR;N1##`,^;Y5iTH.u6AT#e9E'"O##U&;go %OUtF$'/F&X8iqZ*YOJ9,7$HeM>/T'(\=@qVlq&Yjo4I5MnMTmqGHLN$.R2%KC6^b?a60$)dtEB-Y/:u1IQQ]oHDjHfq#0[Ft!OE0F6e"ts!-+hD?'de=I9W#:mnV^"d>9iF,?KB!j %pG/2c8eYrG7-`Jm7&5jH'JL^G$7f@LlM*sP,#V.3[R*BXL.]'nPY4jiOUC42,r^,>@]J97P-Hm-e#.",;UrMdRQi*ahUdMfLr)e` %Ze]/FhPa@S=d(`PlRF\%6ChJA%Y^+3@g*0C,SX#%X&/:^3'%#+b4uq= %:Gnk3o_rBT^aJ(FM:on.#uA!;4#>da8kc3eJtA3;PUY%TSC$SnM8LZ+5_Jm#Q\_@o8\gQ"hTc87\-6:q+M,-G5,0VR7cjXkYUXQ\R;lT&U)>5q+B&>g9R!< %6ac+<@^2qd&U'mV[m$:L=/dkV$m.@O<[nT&6O.-Sb!jrcWtZcE8cp5hCo(+(Z_:0l;KNtPcJ%4kC)%\^gaoEk6D8^jp=s<9 %E\"S+!Nid[i>RhS`-O4OUs>*dI&BSC-coraKhHdW=jGEV0o_49n/cT.^-9M' %j>-)f/'oG[ZMftIpJ'aUh2G(NGh.C[UoW-?.Wa&?Q;iA7H]TRAM>i<#)JP#n1J]=LUt8Ke^B%O(PP&V:9$mCejW?seL=Z %/C^qT0?FAGa@8/<#8dphkQh.72)-@O+/ %n3'k<9_.P2\W`@6*@fWqeK/e0&BUb$OA==f_3OaBdg+^jfqFE??nC3n()=mFWUVB[A:NBqX@H.HTK>$98m\?(m[2Y9D"[nQHA*op %6i&R;5%jP[X6%WY`E>%LorAFKc+HIO\lMn#G:_'\VlIEI^n9uM;<^?W%*')nk0HeK5CW\8kq_]*eYn?ZPi'EQ2C].Z`a4a$A=^$/ %5;=Od3FN<=aRj_O^*QoN-iIp2\b.!XY%Gc65R$[qW56@E9<]s\?O2.[0e:cMZ[bJ$R5ZK-mog %i@`#nY;PQ.XNW'IWI:g-g8%W.^b0rFLA^CAJKPKTlpU>+8N>]>TM3(9WNi.eq6jBjG_0EY8W0a6U3\X$'MAIN'@M[36?D(`enYbZPg<,U1d32N14m@H]SuM@;jOMq=%VQ`)_L7qE,s]^I'!(g#&)]^2++,i;a# %hU!ko&XHj/Uis_3?qbr;@u_%0<0X2"1)ClVD?!)F`SuSaPbS"blnI8EkW+VqD=)RY.`^$b/W]hcR/F8A:RnYpV %U:7#Q0riXseceOppt#9$/abOoRLZOA0^\OZN1FW$kMS+jG]\k\k0`$3d5c#1bDTk.nSfBe$;MO=39[N[Y)gn$Jp98'rH,5J&>!aQ %>1ZR`j3(tA^m9B7?_TA2A!F*[$EWdi93&">O`T1M;.ANW-^B_&X**q^'h4&!'6X-ScZ^Nrkfu4Ehc2[n,Qr8 %_%$'1]JJ_=@ %R!!,mQd/YcRucEAc."o5V5cq!.o*O"`bUP_#nf^RTC7@U8 %+;>`ZeQ*Nt^a:>I$FI3UbU:$-9'Cn-iCW=8&8sj55ru!/=iPjBD66\A!#*+m1A@`S9KP)qIB]aH.4/NL>kdseZIuOOWC]G-PSc'R %Ofj/"(A9++K`]EoM6e0T$N%268:/eZ`X9\M+dTQ]4^,`b02jop9:`RrmoR,KWSiEJmu8j=>N>X;q?;aG>44,[.M$W<4FdC`H@GtP %gB]X&>-cHE^",2iQGn2@rIoJ&/W'R.)_[Lseq+6mhkd0h5:5)@11cK&ULlli?-7Rr.q,=!WC"jlnGa8Z8;$=p,OkRJG&@eU_8s`U>hFW1.pg<@6a#i/M<:PTKj&d280tc[cF=SDU-LI7ed< %!Xe&&A14A>4]h3JJ9fO<&8+kf3*]ZAAf*F^oR:,#)-+%:(gX1==N87.Zb]b`o6o,4dTf>%=UL!?X))3YiPOj1+ZE_JM#pWq""<$R %SO7ppMQ%up4/=>_=QWSt6G+6ESa0@=8?RQX)_/UDrIeGi#<(\JgK*lM5@q$[7#Y,@kPC%F;KSpGY4%)XTBiEA %'p>,EB6gYP%#*nDrT)fEQ0l,2W]3$-F8_>IL@@<,H(F9!,*Ju,kPC<3/ie,.X'bE[f2&qml/u.1G-7J.WI?ju!$ZEcT5o^_GGo,' %Y+Hu-Pi$5I_mt6=43hBn"YIZQg@-??i$4iDQe/csb)-\4neC"e5NPiBZ?ub>m(%Sf;dGescrm",-ad`I44Q"QoCrY(o`ATCRR@Db#"XkP?V4c:UL#k&dbuoR[EkWh8KsL7HT_9A]ro5LkhJVj)7I$@T(LZ7sa`FB>X.qFRj=YU0L@-09WZTCR_P@74ku %kP>ME\'e%E97+ubNp$6f,EOpJ/`1XJ'Z\o=ioEFrl_hBVSAfc$H+_*`GTL5kE=^KqqILbn+apMcne/5*DVOO'oi&<[8^)P\kP?X" %l;s@OZqU?Q1qLPol_hDP/D`gWla_JeVu#Lp*a]\P1mH;GbPPj3m&>F;3KGiR>[#,$)q*(*bN:3:ObCjF>edXR$c/fkCrB/AkPDIM %-$cLt/l??9RlRc:QK>Z+8nQBD$I&5PEiramia+F5l_cB'2;`KRfMY\?NLnn8IbF,!OuV&HpL&7><'S4paS]&7XCIid:A%P1b?7m/ %qT6f5g'gO$@QC%GVnCD+c)6fd\UJSXERmf#;]kU1=/g(:k'aC>G4Wj]ch.qWY-_.+o%?1I5M&]!,;^m@]U@(<;`5';Q,Gf2ohu?V %jYV",kP?X"l6g#$j`IYtOqC)uQpR>/Vq-UEmp&(#-,leY.7Vp$ENYq>El\g2WVTjaA`Z0]8nb3POJX)1X9qZKH6I^WLZlELQ?(T/ %Om%4bCDX0`apr(GNVC;o7?JRredMgcDcR7#IH;+!jb3joHQWS('4=/"S:"tYGR"h<#V2koX%JZ,)CrB.Q7c6!PGp&EO.-gNP3TT& %^Tgg@\(\-rgZGrtoniKf@k8@pBF?n-e]hUclUjh(P>rps^jr\3FM5AjHuNqj:U^lUm&C?;0@sr\p;*nsTOVE-CT_5WZ=gm-4QphY %N45nt&"8eGB*[Vs,E"pk`pV>qYdLJd)"d*o2N6?JoIfh$lXuRWlWTHkh-ae_4DlT$.?0$k7NGJm?XL4DCO0+OdW\bNXctJ]Ts:eZ %qVq&-]"X?mM:V)-Z_r"E?U&CET`S27',=WU2F+[9`HnK6hOaOf4ORIR:X(gu,V!PTVGP<+kX`mo'u.K1fe\@R;M)gC.0$Lr(f/@u %PbCt-)jV1/1gP'JBi?5?>8M7&"WH3Ve(bOsj;buCccUbpZ5-0c_>0dFY`n*!;0u*\g7D'Q<*r3m;j2t,lIEJ.^2dlbU.V^h:t&_oG-NUI#'A'/NkkCHX2c %/]M>/:[bSa)*NFi>796PmV&"/>1;MLZVU;Y?+.c8J?+'*`]MRFeqSNdp5J;8A6@klQ)<-?Q0>5Gn#Oj4!gRWim3_l0\d"/>)[RBV %LcA93oYQKD16*p8RY,H523]i&;N#XE3SG'BMk1-,fbdqt1_5iiE3Y@<0<;mP+j+K)+U0l#A0r'kJQp28>^+5f8P/VppKXhl*aprP:h$) %h3\LE;^g[>5]?!1>d-KYRMq]2Y-QZaBN\hQC8%ZMSWnD>;!5$%s$05QanS'ZbU[2D%/uhZ`gJC=b:DqGFO$Hg.d7fIFGkfqJ(#kN3@bqIAsLE %Gd*\9Uk-\g,IV&]!(nG3fqhI\q:g^*P^9$@1>,4B.Z+5k;3hD^I %V1=et6':g_B[XhXc.W/!%37%?`,[Z"283%gY?+WGLed323PK![9RoA?<0;=.EfcS-=>oB29Z&A'1hmGT"Y/KoGQO][g$XmLqke5- %8pm!R5'IZh3i\$i$'c!(EQMl([DGf`,Rb1I7[D4YYTJV[A2&]6*">;(cOSKb8\/:prf#;7aaTod;bb6D&P7m1TnID3O_;4'cSg%M %)V#=7D";$a=,AhW/pJ.)UB+<MFda;fD;NA,!_Z19 %AL3=f:iif?"a(-1!mG/Mg(HS@1*V;+_23Hp;q9!aTP_PB\m\.J#@tIQccg!-8Pb`+dCVXg_43:Nq-^eC>E06XXYlp)k.,ptp8(k_ %Ym;C(64k[S&=t6Z7?Bm)ZRJ4GJ:G52=u79+?.=mj$?G9=ens@6;rY,o:>IUN7Rh0iL,Hu?M"jnUTq1067"LJ]Oq %P0]IMUK__S=(9JjbEk4(_<8tr,YTMV0DS&8jbQOQQ0]H4A;fBT6!OQ5Q1F!PY$6^FR5c[j=skqSa-X=jFsY1od$NOFi4)Yc&'W*; %@m@($Q7o3qhr#4G(7qHY?PUV/pk;FY^'RY#PrtqjX""jI&+Q#r&McJGQHuW,4I4`XY>QnH3$(^GK#_@]FbA"M3HdcMa!BD)GYHm" %'cOWY_Dg=I\&"S:0.`c",l)['=Y1u#3Nc?9#-gY#<[%@)gVDuEL*Is#Z2un`3K:*k\3seaVie$$e]Ken?]oI91b#@1`4>V2&ls(dT7C)BT$7;RTJJeB.I$0Pd4"Zs/`7h(BIIe,W"=pVc[lX"P"8[]'*`a3NBRlgj[i+f@6b^-&'LTND+r5Q:ioYedXJm$1127_,=T(k@P?Q9l.[mm.3W8gtGlThAq(>"eH`$2j.*+XLoGW/)SQ2?A"(fU7Q*1$l^l1\(Q+YnJ4kBd$-Q&M\VA<3T715DMr- %#soVAhjB,jPQC?u)To:b!_[tE9H$F535eKG[7gY\mH\M8`_aS<]")Ln:nYXCh8!6;+EQeuOoD_i-g!#tZn(F\V','d?4Q"I_6'^% %E;$o6Xaudqn/>[29OeCRfV9Z,TX0J3V&)e7(Do)ir33U=pM[]uK8R2joDp1U02-**s<8Pk;ql;bQ(_;Hp%V\d,q9 %m7s-(4<9mtCN2eO8I%=HoJE2h%P@ZPW*n_i*pIO&DqZ\B*@ %Wc#&:VL-6_;G2/%Ok,V!)oqp[MoTOOg=P#EehtrP#%EV#(2?=U%GTAljfVbSl"K='-(:?@%;QE(fK/?&5N.]JF$O-f(.%5+GCFWt6^Kp+)`4I.s@/m')"PqS,\ %0j2m`9)Hsu,=*lWCM=Pr7hn2Pg=RS&s$%jgL0Lb28CZ1omjh$O>O(FN.c$7V3&pKe!d=[aG^iO?XEJPoiThno")$As0isVZWgJL:G"bIj?aYsg)X(?Yce!s(=3Z'Sm=ATiU9i(c@nB?tZqa4#'5s5s %(A:6'N)JbOFLd_t7.Eb.:h-'J4g_eLdQ?p]>^!;j8(SB5l5?W,;jgrLRN@,9FVhpcLd1pWlS2=FI;8h %5kuPR'^.tuSop>WjKfp3R&O""1E^+fTFm)"&>#.XRW]gafnX*4>=hOho,7oi;_AhAK]>A4&nQ^,M`3th!]%V.+5*G9$3O&7$8^\A@d\P %9p-aD(KH]&m!;i9>@Has'VE*,d %:3rV3B#oZ%dV7c!ZbSEdU+"bq-eb'GC#Ud5HWKK81Lj_D;pG"I=Y&NMH:39L:@8_[!1Df5k%Zl-XO-MrgbLt0`J_B\I5*;k %mS\4nAlrN9a.0*f(Jtk9Mi,l.bFAIC'ragWW.e0npg.ZK;faIq*Yk^kX[d)IM:+XS`t^mo95R-q&L/NKDRP?l-#[!#AmN($!n5I[ %?=.JN>sF)+0tnR5DkMhK+<02A]f9gNG:+"h$eC4E$NuXpbk#-8\N$bF%adpGo_Z<@c.kVk>'QEr=UW)"AX6X1dcf13KJ\\>7+EoC %81fI%,F8#ZkMi\lo2%+V[nJM)^rpdU$8f=Dn%+q[$k`uV![Q]*GptD&$Xu_D %JlS'u/>?]EbkjPC,l&hSijSmO<)pM7:*rN)"VGD#u./bAL`ma!VF7-;@tcd %.RpnqfWM4)kjg;LIgK%P,?'8!Ni4*P7:T$"#XKEJ72T!c'/,eaR4Pa^?BZ1(T`R3>!fZ5AKLl=s\WMH`^:4G);hViC)d\BLekWCk %TH>@`#3URuFid,)pEaQ`815.?6sJa@ar9X"eLfG5H4sfhAC,92QYtZk'&6/ICEd`mZKt(2PaQ'lUrAoG27@Mg,nYTsnDc %^-:L&lp>X,##:K:KUp.!Cdc4K/_F/PVes:'oG28sD^jCn?0Elm"lIf]\HQ34PM1[qkUkG#]63rslp>X,`mdf=KUlfb[^jST_I(9- %X#-HB^P_V`iD>HYaBuo3X0@'KN;Q\%eltT(QqDj.i[[`lHArCd+pIqm)?']#7]1l#KAg?U).g#U4lNqBZn&LD*7n"j'E1i#a5PI8 %bYG:nAqM0`FJinH;i1*\9i]n"R"tm)mWd.rR8-4\d854s[LV+3l0]I)pM&s#,]=7benZe02,k--6W+Y&[0f5.K2AIMA($Cm";gik %WI8;5r9/Zh%<1V[X]=,X*Li_/1FV,\BmCi,(p;nA@N^n+68'gJ6]#bE@J"IH)1CJ^68GucoL'I-'%?H7psNt0):P$'PGskRf2>Xd %C.W1r)/Ke8eS83Hc;gR<>;bp.`lN7HT[feXl-YH(.>,h*E]S$4EQjJI/#BXX*81_$h.=u;SJLke]Sc(>2Y.4Gf;V3"'D,Mf"*oDt %\7<6L1/t//;3]UAV_fXcs6])%&,CUenbZYg<#qL]1$e/?mkX@ko)g@``-f;PUK8_(5'Z04YLe-Yd4F"P:thh>bO-1?#Xp..&O-6a %m^qrM>K#(8(XHC"Gk(?)RIjkAij2`L;j<=:Ac[$N8]SSQfSLgN`(f'3>>!_5[AD`VT*P=T%I!?T84S6eXS<>jC/_QN_H]X]QFPkREGG>">SppXdrb]1@p[*7k/s7:jqWgqP$FW*?GX!rJ %I%-]g`L7$RZ/]&3m^s'77^LG4bU^f`dOXGK:kU#,Ta4(868nf3tKJXuq1U>i5:g %5Qg#ij3M)k/hZ1#&++Y"bgjbCmbG@\+0#68F%kTI-InJH>9I/M[N/.,oam_?cgH/j.J[tRldP$Z[6`sPf1m@CZf@bV2U*G`@eRO2 %XEeb!jmV)J*p(.Y(ten:/Upa"[.HuJA%H$#+r$^V74:K'n/7Z"f?W6fH"Cl2jo/Q#cbD0.KkDKRj\no!?htK4#p/Z69p!bC4MC98 %^4:U#R+7%Kmb:)*oC"aQOrL<6/__!KWHMT>/k4&$/f09/se@+l!f:G)*>'fA5YQ(cgGo$J4T&,&8nmaa7TAP:)fJS7cP!1r" %FLX"%jG=Bm)hYU-qR/P0oYf]/aPF)l`-K!80YV,=oCremkXX%=^]18HkF^Y:M&8IYH#T?X51kA[.D07I^AegIWFT^WQ2[B,r?;rL %#Xp..&[Hp+`B!']*ctdEuO/GO3&=0SbF:]:Qd]?!t1$?1bBB?pC1=L5O[l2FYn_Bq#6YNVW4p;d5[7 %PdB,K6#l#ND9O#:E.C9iI$?%'a:gqa$_*`6qir0ulpJikE:*HA>F#C[ftS@64o)OKH_*r4kqaR!J5)gY$ANW %GS9Ksrp#cLf-IY-^2cqenJQpFA$/.>Y?Vgu&\%MgdA>5[Po?Mq?K9G*.YKH_,( %#R,bud6&gD&W0D<+r$^VT/F9.,J`tA;A"d_KH_,(#Xp..&W0D<+r$^V74:J7MGSsM'VPe$-peN':kU#,Ta4(864o)OKH_,(#Y"MT %_=L+*d9t:l4\n<@oT:@gIeh/o54cuSnFPSSK>7EYY?uP5KpQ+ll.&jBII4Go:7XELhnT3imq7PTKH_,(#XufUY.&[SKWG3U!jD_) %r9$:onA,?r0402"br8Fi4F-eqkr7IDj/ofIHM?6>Y?\X/iJ/MlpVi%,Y;^qGTa4(864n*+GbYO')B:_Ui:uj&?lT"s#2XXgS_%4u %rPF6rQS)Qis8D]lns@,,:&'J?f:0O4:kU$?5lg$of=M>XpoD:)eoaF&)@diQr7YqiIJDcV?itfSn]1-Iq=nOAs6\c/B3,sQ:/i\:Uo^=NM*O>Y%o&\%rY?hA?TkGJ]Ta4*.+!c%q?115tiNE9+g`.>(&<,JWrQt))g6P@ebeU?iDL;/8rL"!oC]Gq=HLnSc:5Z2m9K7(R#T*[4^AI=k0>4`&a,Y(eo:*5EE_?aLCtlC85Q0hB:RsQ>_$;'0 %o[-1t^]4<+k-#sCTa4(8_@6ee!H^KuigHmueX75_"l\-=Ntprrj1C'uBe[#%ep,Pks^6\c/bqWb,rqUMce %^$VCVp`Ah@,QDEORK">_;n;g>bL5cfo;SpCrTh5torl2\\:"+X,gige3?L[<64o)OK[2e`\o-C%dd\,t5L>+LYl!Q1Z;t$SZ,.Nq %K0LGBF7N":4%&:!EZ]#mY+'VU3jaD*mK[V=&d.+$h4.(RPV6)JQi#Fl>u0h@qqN'tnDS5pHV1G"J73ET^k%tmrGM;l&,u=N^&MYm %s8S?FZMN,gm/M1WrN2E7?[chYdbk)b5Q2m0#T#*u.%1B0-peN':kTfr(S`cS5chfJr$[8>\l1hFG,1BV.d%5m'4$R?+aES\W9ku,h]*UGVB.oR %;>?+U?ln*PpC>*n,Cd%Cmsj$[X2!K_S'%r5Y2NTgFDX5so%cN*I.*-6?[H7&C#_ANh"068Y8,<7+r$^V74:J4+>Mn?^71^sG6NW+ %U(&=>I?O-r]7WsA@CftR=WT$2EUhT>Y`&nmnp4RP`>WF0m^N=/XY$HCHHg.C*[ga"? %@^i#G)rEc&qWXnPlZW9:nU1Mo?_-*Ea8YsCoA0#lfFQllnAIDXJ+;]+%t=H,K-)c&G9@ZZ4oY6:7J!/X74:J7MN@qE>4TmYO);Mh %iVbDNgPbh%c##[5mdImmX&G.DIH1)C=AeNNc7*"cS!.Igi4eTGM?al/J#dpu]Dq?+^)+lf3En %I.WG+^3S?0o3ZqBc-H2]:HieOrprAS_nhE6ji)#mqVAP`Ie!-Ubfl,tr8[*S*^'K#o7)Ht4qEnoTa4(8_@6rpP4Q+0?Gnnf)""%> %hYtam3UEYn(iMC,fDUK6mEK#[FCFV=T!8g`p;HJW-XY[+fH&]gG\m,J1FaF[J4Ps1V]3uOg^D05`d@g3KE(64f>$)1 %a86BQfRDcVs4hspn,>+!4"oI_bgim;rqMM?YMJf*CZ#!U[>B`s7ilrOBHDP0i9e#25'6#8pV6b,gMak*m^qrep:%eb(PZj<\DaU` %[V\LS2q@itFl0h()pb$9pJqmn)6[rmXPPAp\YXa`#q[MbO^9%<%Pt0[g\7=fj/'f;YHGE8C6%.#Yj3.(c,Elcpb$m5@5.B'7rKW= %\))*0feDX(FdJO%GOms'oO\F %+5QpZl3B?Ja-[**"5L`V[_9`F5XpV$H0'G@O[n&`MSV=.$)mTVB-+Qso3hHHo=#t9i6;,ZDu\YMX8hhIa*7RBg4O$&\bjomj5]1Q %/hT\,ePIVg9k^gi%m6.FSPBIB7GF!K3-?HnETf[s)?r7j65B-i#Aj!E;i57#tZebq8)N+^[h;p %hVWh\mIocdD>i$A7C4S_%>!%K"[:8^0.`gY,j-+Z:*)bFTe0%]QbG*ZOSSZCk#DOsn<55"]jXM%TLt:qT2uLp5(;P+'ep@Eo:Y@< %XZnuA^nLAoJpHk/$iSsG1]*AoS+g1$mCA:mrT^ff:F#I=@Ue?%`u`FT4A\7c+L?Y\c=e8tYGb9_hRIOhd\Z?c[i[e]pk')@p^;h; %5.X?]gph@=n+l(5K.#P)0(1t&m]PH^IIk@k[$!LO+A1[o>_r:oOR%\DR,i;C$skBjpiB'(IZDqss3=._j,&&*,IPAfVm*qV1KSq3MU_jC\l%Sj1d5i4;An*Zqr %jm9gcX/`Y#e"b3Y.O*_H1L$Cn%Jc#qgV(Sjj.#2>QMZ`Kn6_j3.9fYl.=6g\r?*M3q15.s)q)#7s'/_ %7@qinqHn\kCA4cqGQ6Xm0E1It093gA%0!a'q`jsBrqkT%j82&nRumJbY?q5(Z$2gfq41#_]_F-E5H1Woo<#Ji9;lKQ\C,ol](K%Z %Gg6?WU2(P3n!uXep\fFlrH1t@33:7(O/:1iHgnirBAq.E<=R^RBC_SN1MfEjl;%5m@->h+3L^7oGm\F1]_q?DPEV1G+g@qA4Rn*g %_G'mTmFGs5DOo%T"LA+$TrY(Q[t_`gs`e.5Y!0BD67K[42Q,T5FCEE;K4k47BiR`/,/r]^lX2Hk-Udp[K'rqo3\a %+6FG+X#g!iq>U@tf>joFmc6Kq#f=Cn$T:LAhj`d?prFPcue19b?:L:\177H %U[S-U[r4M'laLbWlAtt_pDlMQ&B0I8Riae6l;Z^Wdg3:klEqbF'bQIQC"T5'IQj:D&eZ\iHqif._Frmm@^AR(G %O,T#;3cp2X?r#iM_`j[YfXD;@KZP#_^3f:%+&":OM"SoIHWPrXG9O9bEO^)XKa0[X-9"FUYesm\f2;0&RQAjY_8UisDJgR-1^r3P %mfXom44t1UpB[bXe@oc/*Eul_b!1+2_2b56gU7OLr0%X4Uh@7DJ%12+ICJ"BD6d[CO`Mr:I!,+Q#)(rT>iiF7B+P:Rq?4]=nNKGR'.B9\CX&4V>.4GQ$HkNeX7K&>6AgLI]Z2h0!?8:260OEq%StG6nR: %,$Qadoc9NG*Sa)r%RZ^-4S,g=D)j4Br[EqTB`"FYH07s.mXTV,SWpe_edmA5`*jQM_C8="'U`\S-#mG!5urG^ZAUBe8&d[oiYaQhE0AC.q)Ig@u7*bWYDV6(Zi[dW/B/W^?%6G\>*HRnjY %HK!cmOVP1%@lLhh2Rg./iCnbmWpb>u42:[Kn'7MQrPJ@K/J)=lk^A",+++3M)"2DSBSKes>4E07$ %T)R3gp]'a_%K90Php*6f*,qk6p?:q^5Q>At-oQ#"Ep&!:GG<8tO2(2$DQFlbm4\hnn(bD@LVsbZr8=_`pEKQbmgJo8"EOF;(a"`a/@QDKZ1^(XrSnUn#/96bgL^*C8!%\\5s5E7MQcQ6&Y'I)X[cTOfS*'LkD%3.MK?Sj1? %6#r&ZNl5b5s8&GD=08KP##jp;q.HD[\,Y_E7tn:aJg'd:B2<#pS@uT:7baV0lqN`-m-XIbEuu[`oZXk(c[?OW4E\u=a@4c?MYHg&DIA&%ueb9o\h(A<(Q_oF\8m#Ac,qYt3s3oJ^OZG'RAS9C7[0)C %Xikf2&AFks1e!nI:O$50"JadJ^---$e2@P[R&3cgf-ok^J>u> %P-`<3C7CY![]?IZQBY?Cm+shPX-Fi4JV7kV2;=h2o/5^!U?4EmUXgj?`q92MD]9kVVG&R#dAabVjs,RBR&19u4-/"MbWpWIOd0*_ %TCO%F5,:63`0lWL/):dG6<43Bo;q#V^c3X,O,cOAZKLT)3cmci*0FSuE\gnD/%6:rUY\QeRhFRb[Gg;PIU;\'iVgYsXG2ID5=B_X %nJQo'2eVO^1*+Df"uSP7jb,>k_*nEt!hg4&eL\-*'MWnV!Wp:_\m5p!r*[m(!s]<<=]nJ=%0/;&fFSf6G$>cR8.Ue"d+EPJ44AJ7 %>#2hF%5G=XLjZUDjOMYLMs(="S$h+4[3NZs9Xg#+R!>4DgQ'[C$!r(9[C$J-"u7tIC$gl7$BCm^DeXm09Eb'd$4dZNY-%u4WKrhc %NY\cW/LA&J>KIbD<84V5-`2KTRLEC%_7#(s+1RFuf]^L"GO3b;M[>'MTuH;&of8Z\G\+F@?E'Q9c9$q)OZ/;snh&;D/^iC7;qinQ %e\qUe"T-[Ca&:ZQUpM.7DDeuC=WPr[f!`Nen$eFq7=3Q!'-2lG,%bR>J?a1@D&ubeO^0TjnXbZ9U'Hm)4$_k5!b&',EIE7a,T(D# %.ApKR[uUPOT*/oKeSnECmh9k4U)fj%(#c1rUgC_fg@[tk,FRa*g0NrCK]k$aA&NdgfGYB18luag)ek2OENM`# %]B;&dm8:tho#%'!gM]8RSm"o-$>8bBRee(I4et-2`0lX7bnVT-@7NN4s#$d6OjVR&"RD15^=TRoW6tu+!S^T04$h]Fb2W=qn-j6j %]5dB#`U=HUqrWt:e_JEiB*:9QSB\,R(rq752*FLj;MmP').!5`iTA54UZiu#!kD)A64Y\k.O-n`L2hc6U]']"&%8pTR]lY0@`=3> %GM;ea4_1Ts90"K9CDi(7SrhQcRi$[U0Jh+[[PCA!JE]D.[11;(F.%iU+kKIV,\k/"FHJ"l%H1Mj2`la8N7?8A7&E^p6A4VS3)<6Z %U8"AsCMUb%MT=bbU;0`"2:PP-_SV[4[:W":T6b8Bq^8=MUHTXeE4aN?p,=<0N60drZ<`b.QL-LS&AC=DdA5IQ`/O1J[Lg)HpIB2B %T(C6FmuM7kbnAZ?GFltF[*K?djTjVW[l.B@P8PB/CAZs(^+lJK?.60[keTe#CNh:E:>PJ=GSZWODJ?56hnSY_b&e]!4#GTP0U28' %p#%^XhVR+?66Hh]!q;+B&@;#FX@b,?YDlJQ)&:-OG%Ngb^!JeJe7m3O,S1rb"G2LZ;"912]Gc?oXh(6qhq=FZprs&("bQTHDnm/) %;/P6LqGKA\$(NBpm2'_@4`Z*K!K,/a]ks@jlrMKn:)A2K`Ud9:;[2uM2r8uLWTTk$kmcD.qD/:!GFHT5YM8#NQW8b?b^_l>S2I/: %5i'4U` %+:oN]d37?L%S3TbLV(9n!$!KHk.)_ef]1;-B6mSF^.VQA^r3kq^bZR(46@nBiaj;LZnuB#^@lJLVtONHDSPF,2'YRLJo`-q8">=j %;OTM/S+7VB5,:63`#1d]SX!Tq9gT_*Mse1p)bkcU[[X[/C:"&)hQ21J/0Akj%mf$;2"c-Q858cZLJ9WZ:[k*8"#&,AeSFYEl]7OAN,1<1m*l4K`3_h3'WL7l %g@JJ69pM09b%@urCC9U,7\T?\m]=0LI(<8[(EI,WHO;mB>j+Df>j1tiFsB0?XTu4G=[C?N#dr_=u2:tf2 %09cn?Pn*UGLB>VQ0;?G8,6kg<[b1o]QO`ju+7q!"n7MKEYP\pZ->=4\Q)lm!#*r7kWbhdh7`quY-HFH(?FkEn&IVV,ClX5;V10^b&8eN0HaFi*$Jm*'l< %?#,_P9tL:8,6]-ebk%^#?ab#bjk+3mWdE+gOQq9-BE$9J9.>cgDEG:iO,g^Tpuc`Op8F5t+8PbXCoQQaEB@rFpOLG]b8P[V':b1< %(MuaEdCG;!D@JE,S/sdL_!$>*Po!RQUm).;);V1[*B)UL*l[]C3[g;l8F9D`DSKJ'E1_ePic;RgDVWT&nEr_>D!)rT"`,-+FeHas %B&4d6q:`YdmGkrsE(2[ibirAm)+iUuQ\\qN&m=KRQ(Bcd0bO$[GX;"t+^,(I%&(JF48F\$Z;&\/DZ@66rZ3`oRCZ-XnhTR\EU97P %G0fKMe3]B=:_[)Bl4os[f0eka3(;E?^n/V..&$nQXc`pT9@&q5*)5L3FLa0[qe[O1aS8FeFuRGu]D^#mGCu;lGG!/ALhu>uf`q%< %oUh!'\_FeY%daoNJ6Th67g`4/k-\"Yj](uIo-(e-VD\3u!q=-Np.mT8rG=,ak;_3mt< %i!'c>?%GFM4(J(6ZMN>oM"VsGLM.39a1*ftbRQeGK,:'jGe&WKoo&tB2'\$_$(-8>7B7=;EC]]^n"2=E_X#'@gX`oOSt+EmY\hl@7pmdni@ZNH39K80YG %H9gR]Q?7j:3\R5A#8U:$b;Hh;'boN!8F06oE,Uj[bQt[F%rN8kP]MCWR@26gdBl9nb_QZ5U"<&#*2$uKA]09*"G@os;cB5i-j4Ig %jQ,C9^-:oFE3FbDG6!IOnnWm#+ph-aBIOi@fg*"SLQqE3AU_DbC(D#&A)QFe/2oLHiS@&-n.9a>rSOXd$L[N5^U"$%dtXYC %5S&OA$t2D0f9U+LOn9\bFQGhkM^#;kH#!d,Z/=pAR6 %4bM]^[Du(8"3h(AE,.N(8#P,1s'C+ME1`3jSK2C6*]u8Uq];Q?6kjhL(C:0,!i$%(=_bE5JXf[L"g\q@m0Z)kDN,^m.GPS.rNO]P %&Xn%FqXs/T<`[taDr:8E!AkZN*&JaZ!oENJ_ZFq?#3=[;%qVB4a>TZ %R(km/!)nE\]Y(;+$p\;Q/!1QrpM,H[:j2_!n'qXH&,PKcF.dN!nBaE^kCS1L#:ZjoON]G9(L#okge/et2!9I?h6!J`_K#SX4QbI\ %)"%V@p@*5%rQqSU$6JEY^R]qifq'THk#?I;P!-C?biDFT=Th2) %g_3mEdQ7Fln@[J[P0DSsheeK#FXiN#=$_BW`ULSjKpQNjD9\?4H_F0?S(rOK)D)QUahlU %i4smG#EE!n-?3Kt,(aC*VS)7lq(K3,Yd#CJEEKBn.AXi(H9d0;cIY4)Z4(PcMJ;==-tXaeUl`a`C'R6S]3T\B4`KE):eC)t(@gG/ %o[-BfFp1&SN^WF?+HqYLe2O)N6(BoNPK%4FlD';h-587ue1(S'&mlhT1.+TdI1CaM=oiDt$OP'DKn9;NLKC'T'q)DgZHt?si`m]% %Y(OeXKl7&g%rgIBJHNrp"-Y]K@U!.uM2BU^!trI')"n5L %0LJVt44*[a\6.,:9bS@uE:tW#S\-F8F'\3hqAobshIG/**&qoq_!sYlL`;%G"h;BP8e[j#j%g;a?.lGa)1s0ggVc %^-;DFKJhVKi@[H6>Y*h,1-1&LPm`1dPdU[n4gm28B,MC[%jR\leapGsTX%NIHIi4.YF`(`P@!0G_0`Ea/A!ERi9dO.hVRAKC0 %&uqaM&00\.,h0!:l][mRg&^pI`O>\jWt''$Xg'J9X7_V"(eOMe@KXAP?ICn,03?)RnUlnDg. %?2amL#,3!^,>tdr=_MYGcS.s%,]"[eVkAhD0q`Q!cT=uu%oPDr$peip^o'G26tnMOm63t&SuY*h3_-Y0V@JM@CU]0'#EB$".OCG` %_Pk%J"iI*M@q85hrDgY;$nW(qXAGjW>tPr3^e,pD'e"+,:PJ4j+gcc<,8VjDT/^GQ:saY5Zo#Tn!0WFmUJLcc!L`MBF1"[=ld$@Sc-oi<4+ZTM^A#6ZM`i\l;i>dM),p:(%5I"$kVo4G@$352!=N]OBs&:9W$A(- %_%j47E2`Koq5CX^S#E1hBj=8MB.0OuT.\n=fm5J\jFfus"SCVMV_WjVdfURLE82@r(_)!CdK>b$V,G)R$itr7E=Y#0U]dtFT`hSf %%a0r.(fDQChhC4WY=]: %p(\TW\`PB0%K52n+f4"Y*S&pirO[L>@\5@^s2-:fg-1_$6J3.=aI]KQS*1`35tKGEef!,$Y"s]oKU,2!A@mMK#_"p`NME1$#F598 %EF>8eqBd,E=VteeC_N0T"$tg_f$"M:hh-#G<2hRa\18-8\R;#cj%Pg8Rqntq?Gr(BVEBO %<8.AGg4KuWYCn$d$plj[V%hUX;?DRqLXc!.EYZ.dk^XXN82M^0l+qHqI1@saZ$.Hb"M7kFUod!Zqpb7geM01IaL'-*#D\+i'k$oX %C?lT,WV$N7!5`#_+d:*>B<99\7+*NEElg[b'A8?i1\\=$E1nkg/`8.`o2:hZCiF`4YFViob"&Aa^m$F1)dSDYf`V`]Qt!IB-`JaJ %U?R`&ojT8VY0k&;!)l!*nKB'n'g%DC]figUR)6S>@@.ANoRP)B/epjAFp0\59Ocb5)'ZO6,\(,T1V-<@4)D5"4c8>eF\jVbOkQkKgjXb#"!A\4)bTuP %c/j-KKtVqdYIMGN]nNAuNK&q:id&iK/_sPloF8b/83s)Q5k8>O;4nn^TmdjD<=O-^Mn"2+ocej%(U%SXM`33TKVS^S8tf'(RW(eH %8)+ORB>?VF<%>gZ!QWHX/\u(7:q3h3EqYPJp.`'u.(37a.l^:@RnmS/mE022P>,>tTOhO!EFb15\-fdRHYW">9f\Kp%Vku47TM?r %%6oT-6[4$b2dKPDgK0pq_,YBV"2n._1AS\AB-AN$Dg8NTa%hbAp&ZFH:a^A1j8j-0;:61]e?q)uIj1r>SUkGb@X*_&i;PG`o[K6$ %VL&cas,76."=puG&+8DnXVDJh+4'@^j6uI:TM*Ud^4A6b2G"*aoAnk)Q3TA%=I@*] %q6B68qaI'C7j9&[J0$+/*"q_HXN:,bVU-AX`5MW'3WCpO!^V346'%9tm\"12_8I5)cE]44nQD?%-ZhAd1Y1Yi3/+>)F,q$Ib%KmP %c-n#I?f\H18(1Yn75gFgc70%:=-3o's#L#1(@ej92*IUQ,>1'b)4_R@hZF7daQ8lGY6kVT;'^<6A%9!C&+7r9=[?cQT[-f0lh>AJ %!Os[%(,j"BI!&#'-pIKeV55K*>gL.PrLn-P%NMq[bjA6M!C:R$BPq?db>63-L:[JcqrSJF8H,A=<5;UD:>Um5L@E>aKU3GchK\RI5CWF%^:J,O %gbZeY'\KCI"V*MhJa(DKn!&mLA=,P>\@$?4PODWsi(oh>bd9L@bcE1+V79*H:>I1r:FAEK,?(EtX#-:T9],['_ac9'qaRo-C/dbZWe(-n(:*-)' %kTBX20jX#H=1O?08T[,1M>M;N\1&!V!A`8._Fcfs%Df&Q\[\E>Wpi&$.47+mE.c4>-c8(XOe$]lpmPcK&m``kG8Yk8A(;s=%DVi6 %o/H;JJ+DE^*rksbcWVFFa(q]-='254n,V,^7uYa?>s<`*Ak8GE/W^"<:11Y%&P8T3>T5f-pQeKH.*@095Qr6",[DeVtQ3Ni\F[mSJk@i7EJ9r#M^HCD1AM=T(F/s.(?>A/Q\IO=m,7\STJ>Seb9$FrV!?HhjFG9Qfe5; %3Pq*j?hZX/>!CH:0ejf%&TE'ci8C3MGFT_u'\YP`r!PPVGo_WMN_-n1.S&K)$MgDV(]:1)kWJC*Lrp`3X*'4n/h>H.Gc`\L14SZi %#+Ejmj0aX85-Z;"-K_qCrS!VU+@W?\4JDlu9]^\m#u(uYeh[omAN,=!m$Tqj8]h&R5[Hrm %(fO6d+Q8b+1pYh>N0/&C`alWiY0IkEOA8p+5f6KH8-FBR?r,>!+&(+uYmm,&iA51UoVsi%="_b/SCh@_"S[cR2fK*FSrt-gBHrGZ %1iocS5kZm*r.Z?]ShVWPd'&D/_ruZIN`'=!2qmc3qMdP*^VP`;?uKWQk>&puF5tSR %]hD$([Ys]?''d'G@J:;1[XSnS\=OUJ)12@5e%VY:gO?B\oZb[RhuEYis7aA$K`A^YoZA8j@,trClQ'A(0#+B?&'+X&HS42qH8&9* %oYGX6.bJrO2U@U=]\Oe(B<%\M;F8Bs&5i\?m*aJ51cl?-m!!>0bAXR)U>UccSZIF+]uUad@8%4@jCU)\Kpf9,HaNo$jmK0 %TkE79L:Ts&FHecojd`&pk_3hLJ_2iYXlQM;Iam&dE=J>;iA55Aam9!!?_gc7H?E!q%5,0qkN0K\dT*[iWS`3&F$OlT&Ja_.%$$iR %/")=&E?Ij/1X`i[cnc(C';Ge"9]WNi[,L_#4d(2G*WW45&@'C@:>\`t!scsX'LT)u,\Xi^7!9mEr,p!AYC-1-Td\U++Mfik`:fAO %dG:k[1\pE4dZL/Zi_ILahrhM!Boq_:iY[I'e(_PHXU)DnETZ3E_a[@K[TA.7--bIsnus(2G6C52ArKRfHM-ceAK+S]C^n. %/C.o$=Y/^7AYctSI`i0<3bR$:p_]t'/HGG$i52sGH6!:XkKmM>1n%7!_43Q@W10RnjEhoh'bhc=kltZ)$_[g(p-;=_?jWSr%j;>` %#T9".BOKC&QSTaN?:<`@61;cG38AG9B=rP&=KHcD3uJ0fJ?=$QQ>\If>JpM+sli^[-7Tl+>g#r&rR.,>TnR*d,7Fp/'5rdjbek:KpGdA %NB:?I4;&OIKQ-^U08L^aX4-B"5b(3N=3%,rRHUX7J\J187Vg>lBr<!hE^MP+_Gt`1!EO %iu9aV_e7C_bJ1fTp$0:[++1(c*Pe$%i>XXQd=[9m<)XpLgXVtZB(S>PdVu)?*;Wp9iGu%VLes+R]0A@sNs^F8Q7@UmeO?J::AZ(* %/Dmf3h*4),E5Z^(nKl6RBKN[J^KYUSo(\M(c5ul5le'PO#ClBN=lX;(f&OQo$\t%rI1MX,/M>cr7G8S=meZU`R8I6]-5'?V,u^I& %OWHN"09-8<-p97lBgd1d$-`-4YUIV"S\\1LKS8Jn'N8;b4aI;Z#5VqOF_H_%,$)aOPTtq%A7Ib86\q=&6oNE`@u;OSPa*S8"64,_ %CndL%Q5H(QC4HItPcuV5#%Y!KcIEbiii)=$)N1L(D;Tu("WcGVJRJgsDaN'tX3rfUjaO/s5u?SM>SGYOldcC`g4N>I>dKM`Zm14%;C0rE8Cp53:TOMO>E$^eJu(SPiC-K@+?p,(:/?m66$$;M(C %J,o4l0iu,\0*-*W:ORKM)r\"##?\f;9/\3G.@j+tR->Se*Gq<3&(G2-^0"Q/ %/^ZI/TV&?TL't!Q\U">I(jaPgdfURG!$mneHJ30+PCpqo#CJ)S=Ddt\pQ$upqt9W`5dO*rGFu9F$-9o-/&P:4h`1*'rdrZmM_/:< %n,,(o\;KEmYaT$D4lB/>Vb-ID*PTSFHWb1LD5fc%/](&i5c"gc#<;-A@+k]r78Nm`(n[E"R[o.5Lql60^"6IX0C!>')]6t`'OTn-!'Wbc$uCihNFZo''pZg9f*Y`R!GZfGJ\FMP]TRcKp&^<8C$ %jZ-2mJ4'\t+WT4Bq!cl79X87re7+0CfZbk`6pE6^&bWClFFAKJiiC/"7t(*pF7i2ucoM`9#+7@7GLkq2Z'ke^WE0S0=r218qWW&, %P;R<,6UhtR^>Uk^T]KNf!q[Uf/qdAeYhk^c?4NV5iUNG[keY1qC+i3_oUq3f6@:=U-E(^S4s3:Z8K@E?7c`#karb=WlJ/G_Y[%c39__i4*?sZS)mrHC1_JT#*smZ@i=i66+[DLa6d\0h#%t+Kj8`dMsGsc]uU?&j7/CP$C:= %ZTF?sU)\+VL'5SD4b=P,CE$Y)oUoSY9cc9J5"3PLdmMPKl0%R3,W&^^#79KccndBIMp((8AnI9(dt:Up(dej>,k*5o)Gs\_;S,`P %Uus4hK[/!4MA_+:!g4H.rMRh[&+7#@^PG,TjZ=tf(OtAs.0@>#TXTc`(@B%/$^"J9?9p)K1F!aB](g4b`F.(Kj6s]/CUl2EV2#F? %IaW^DccfqidC"n0Lk_^iL_\;34n>j$XVKi8FWcCllNr`,YJrVgFE=ujQg]#RE-t#B)E>Ii1MWT%pKqKJgJD5,'qNdPijL>-:8hnX %VPFI.gSg,23;Y04p9Wi3+KgG4)5W@Jj1mBj.$_rKriX8hCeCGjEJJDgfPg`\d)rh`IlPJ_mggR:)l)55fGK*H^$_*?hnH\d^X'N1 %qR.rr]KXo,&UZ.IHiJAthm\+8O,]e0T@`KqI*=Jh10cTUX+jUVJ0$+8;t+Jk8(IA79KKp+?U`!O8hp[HTs!>C4ae7Z#4l\mn0A&EWE%08NA=./;Z/0R#p@PJ?5qAc8\E,`mkbZoDrAC)An[+3VN:R6o2V+d,^3"5+@g*`[hP:aWor;:9,XXI_BJOoE!_S"?i9`u5HQko1s<]J %Vek:%ASf8^f>,is%CX"DRb,Rf^GGKcHiOlGg/Ve#s!7:7c_dQE;a\B]_c-l>L;A!4G6#['[t+5p%/%&nk>7=h/R1u8=KU12rUin` %IhU%c5P)9hJ)RnG[*tPRQiQ!]hHH=rbW&%"KqltQST8Ar#gdKAT1Z6-pMci^An %Ee!%m#_:;)`n4E1doDGoUk&u2T*+6Q6n3s*LDh!(@Sn/r!&ZB:Hg4GV0\MhdB91OY0hLT9dhQpi_!"(dNg^RflGu6L5[kXpj:OHn %&.cX6Yo)i&c,@2dp,tt07$S+sMn9&EMQMp9Ql*kZ=fBHMa'/Hcb#q/O-r7H#ud-a\Aa@Ago>ZN.Bqi:@H>JgJ^bRBTB?8\Eq"g[L5+#k1m]4 %nW*LV=2N*hHuhWfTrr!025l+<&=S^fbormP(hdDO#,1&bPnl`s>9/@Vi<>NqBq4l^`$DD44R3k%l`11MNF[Y/dFSW?5)BAV,nWXF %3pYP,e=rH8,eQ\g)uBl6#9S7$kc2`p'GMm:i0b8+^it7k$?X'QN(6oN)O#2RU_6_W""Gt8<#Acl(!PksM/=VPLtmlLN/^\>,\(># %l\qScAn>Ns!Ye>5*Buj\&Ta=`5]21;,Zqn6mt0"tU=8#W+'`LQ;\VCe%E*hX%KX5B#,#C27;d@%c]LKP+83^HT32-h$GGVPq9Rsl %r;K-Ph*GG1a.I9R2'JdPb`0U,S-r;&A>tYg+q/(*S&T[)AG:EbbCf)Q=Ip_E=*_?)`q07)G_'+p"k`N1IY5_g4-d1o %K[>3H09itL,(3FhW4S&s*L5;)m3V>8&q,G9F`B7G>_5@4=7F8*0QkLk.I]ARZJY-&g:T*-Vi)Fr[A?5W&PESGUXRX%qlKaTL<@U801\PLVA=!E81bM=5KTD_CENZ?>!qEamSR7,=/kNK0U0);RgpSuSf3Kin#3XL_!sCSH_"$>L,%S%GfPLQc7h$$.klI%g=je21 %ke)`J*`u6-0%]053HD"*jMX6q!:#*[F&I4=d%VjmR %<'UtITaY?p@FpfYrfQgo7s*iZ=GnB+6TLoqNT2.mCRC[l\LATr#3*F<)aIIWLg(cjj!liIMS]o"R!j>Nf/ImOn'/7Ir,^oXGCP## %kqAYR**rF0R$38mB/XA3YG7ZE?@74jF=7,oDf,VBgV36OO=b=N$NKT %TF6]&\7V_r%L$lN3>B\<7("NF8[r>XJ--%:7i4i5$qZ6.\ZD%<%!R$]^5J<7RK)fsp*DApqr7)AIb">eUVHj,O#[^95"g&Sau[-p %iKQX._tn349naYCl)pLrmFUJ[(e#MCoC24@"?W&>h0d!E@+C(s<[jo=#:c!BZn%D6R7sLV0*&G?-$<*6ZuVGloDf,V&ZE)BaXs1p %KH>BeK^6Oc#El]9$fmmQk[K/GM42N*klD863,Sn-U9Bf<3ZE+^;I&dF9u=#^YVU`TbP+eNe,MZ0D;QEmph7^WYFXACbuN3[]^r`` %`*^BBrK:R[B=cpa4D/08g\*2]Xe?MGNu3>0&onJ%4?t,?@o`bt*_^.OeRemCTL+i&l,`[VdfURL7\H;n_7uXqPh0>7k*X'pX`>]T %V3!@!F2<>Q$(Z(o^k"iT`!RXSm:OgLe2(6V>Q'S0lF?L-Ma(Q1AFS^bbMUSerq5V!IIaZ)FPd1S!=.'/N;O2;#sRuGhsFd)mf+`l %IT)EiNkc<4*;?S9/%uA6L,"gm6rWD$O[%4B10fM77TBSQ9Ff\Y,aT]9%6:4-!2+p](4jp:*03!3HJkCIEfo2Zk^hpINg4/#cM2:8 %*2ukngq*n#jp9OY.!oL[;_2!r$%qhKi*c_*:pPg8e %qVsr(Xnj)c@m(pZ;Q`S=^SW%PjS`4*HpY,q_6/fe8i03<82r&H( %aZhs]nN7!`WaM5+8`C/R3Phr-L(KSPAdhXk%mdKBDFJ_[l@i,n9t\C?:lF5j?&[4EG2riJa:Hup-grPl3LK9I$ZH2;OBE1k@#E"e$YI.6DI8hRj=9KT^p"O" %!"%eBW6d4$[:>ph4jXu4.[:?m1mC8-5j7JEoF!]Kr-"#?5a#R*G7qMfHsIs,*+PX"/u3GQ)4a(7ap#UB9&"pH*a>)WZO@2>LQIc/ %9fP;OJHNqE5Uf4oJe*doCK]=fXne*U'ZW>N@lY>8Pt"oC``e^73a&*;:`+5/(1:.I\qj&>K=+%IUo=+GlZ/k,W58,5nRN&<>]@nE]Da74R!"=Jh.<9%IX\)-` %P,VCfA@:HR2F&IOURMfG*t["'>WV1E[P:!ZI`UEgVSA*"bPGWaN1sR;S,ua-j'/>s;,,uanRt6`&(7tPim(@iQTncc3O8.$2JjL`RbLn*B_iU+BJWeTgVs+W#ZWsm(1ha7]A_2?<[o_q\s55 %O`ok$X$^mPq2AU/:_.UiW8&L#L!VJS<=4oCO):]$.>rU2Uj(_HTURKEK7^#u'>g;>4BpE^B5N(hjsQ6 %K9N>ODY`q8Xe.ap]&`5\^OCRGn:r9pG?%4#.H;>!_73OeBP:fJM0`%AJTg^"radAkb$aigqKdJ!>$@A`RMr`l %(4i,a,jHEfkf8>j?j-fX5fF0XPH.9,%Qm__@"]@>ds@5naH:2N&iIo6;%?0+`IPJ'pj0SSq5)OT0fC>Ok&p4f[0_j.1%qt>F15G5 %h01I]4M:%MdasQZr0H[2Db10C4UrFo`!(:')Ofr0#_h)Gj].Q\[7uW#q%/2s!76#+6D9!qn;&,n`$#.d?.%Cm%DC\>]huP?`Z2BXqR8eS7:(M=%(kFa2!gOR).\r:7K,NLBnJsO@r>rrKTK4KeVb$2T?>dgJ!=g)aTKGLHa8hc+F)lMD$Ai9rWuWuqJ0FRU8s64f]i`Z?Q!kHY %'Ol3DD/Hpi@>IpVjbh6Al`ZIpqsDl,2q8GJ>TILgd<=9Jd6=I`"Itd4]"U@JGsujs;_,JO;d3N<+[.#`562%LH)+?m#dE%d&mdWS %aiTAHN-DVuh"k[QJM",Sp7L#JI),7'cA!6>5!3TM;2m%:<4MAtN^k/UK[,uJ^^VJ[\cRf$G/j)KAG*Jh&FDal&()HV`W[>A0OV2X %_'l\![ef6d+5_>'[HF-0HJVoDj,_H3k33%WqrO1Dp>LKb=L$jE-RU8[NJ?s(#_FB2rf?%^U'F;u>sUJS8IjqbkQA?s_U]kSR2/f? %_l^"pm#]poVf0LU=+se_-mu(TS-sq)@9/+sDHj8LfH5=sO74C;!tR$YgAU?RQ(M!fCn]_:%UO3hB*\B=;11fQ6[a#YBmBqj-l*=[ %>)OB.mLjS3)").A\JcKQiT#5mO3r[KX1bsC3kb44,AU=*L%lmN(UWFXQLBJn]eTWoN^jduH9gR]Q?7j:3\R5A#8U:$b;Hh;'boN! %8F06oE,g2Sj&$'DE%$n[='&DiftD1K#Oft^&/ID(q#'sZ#q4r/QofLEpft%2_)q+uqpWm7D!gT\C%U7GZ,W%@4k[bFpB*Yh0MG(L %<2bUB((Qu@enFi@cj,pJ+.J=dH3?5jd*[#"XX_0c7bjtX$F(r3L)F]pkBAK&G($[p`bbAiQ\gFt42:MDqT"fo;5VQ-f-\uqo2d,#q2b;LkHj%SK:('ZSLP&SD:f %3$(qqI1K3V4V3?'KE6d4>GCp`dX)t]Pm_"PMRX-B^9A1S%N&a_"6)KPo:p6:rot/Xc_!._0Sc3`XO]@(_*d27CU,QH`bV2[T%iD"\$iH&43Z(AUY=Ljf2VrISd&nZYgV3@7 %nRIsRNb!X;Su/fE*c>#%C-^."K]KX@As'=(NEoWAdj[Oe6W&"N=2AXT6j7m0b$eC0aK$qjP.e4#c7jj`8jg9/A<..K2k)s"6NRa< %KF77+:ha\d7UnAQPn=W6Ne\Qghh./ZLghX'Er]KR'YuY"m<0n>PKKS,[N__W!D!8e%raYj:ne=2P*6NmrFYRu$_E_M&)dPM5;j%6 %J]Ag]Ekt]#J565TY'-3hG"A^ULd3T;aQSU`h^s!-&/!)9:qh@K?=OIGtqdHq[ejo,;D4N1oYL %A9e4ba]1rU$q)f<=476X/FRGV0"Mt[>8$1%FXa7Gl?qBTj6)Glp9q25jU`=s!E+k49Yf:i8Xpj[\5e@;EIP&YU)DA8O2_r2]7Q.0 %kA"U@7WbK2-0ecF2N:BW;_Xs02Y9)oG(A#jUXR!PIY*r$9Gdje90A1@,-ldn4sC4XVbE9QR&]k==_ie9c3=6"%T6Vg %+dlgQ0=8la\iEd"]`JaVJ?l9*jV+:k9Prc15<.QZK<"6a.%N[F:+Qdc3UeMH_GX9,o %\t<^9\XghPlVd(.-95q>SD"b=IPgfI`U9$qE>QBh^,@2f\M4%u8R?;Pog2,'`P^-2UV7Gel3NC3=\u%.q$mGFd[qBtOEA-3 %RS[Vj?"q4$^kjj7*e&N=[YQ#67RLk3"'SK'A&P6LgfoJOANFVHjXW\NEE)1;\8FHQN_"2<]8ht$l %I()P)e>Yk(>m1CP'Gk_7JE)ar\kGj*`PtW824N^UN4;enA+gP4=H#hmKJ4A,Hi$_SG)ICmb5tF[XNKeERuD\C9[_J;JfahGZB-9=V(:b>h\!dQIg=R^@Al;ra"Mj %mpFl#g*b?pQ%G;&N)AZ/HM]2.0W9Jp819%$@mJ^HP-KRhI1cd$93?X.\f6`u2m'ke69'V34UpbZo0YKg4u`mY'D2o_&BEdL3;cpf %FfC!C?Z$4FGFYRRGiZ4b&*.P`m6$OdA7$n^We1/<'r3=L*oM9B0U_5I,aZ)%42(IdCC;2Q%tsrBEtO6>53J!rKZE5RbC!9un:Xhf %')d2m%cONk?a-ssmh#k*YY:aTWj<U&pFK?-0/DZ``FE',V;N#m(bF`-*FjlI:cGj=h>Spk-4-jVs9HiSPgj[lPt %+Y4`ISt:jV^[NN-)6f*d^Fp`-FY;X4(De(D]7\#?ljGSm7hJDfoO'q\;7qs1"80;GaC=6R/8@cJGK;afPj5h1hJ-&jj5t<9rMdUY %3]4X2;qEJZ%nQskjkg!NJUsNohSK3*TqNWdW2FE5grXC].E[[bSkCM'nLmH,X"/uVM"a[O3SRM.PP)?lq(9jN[:RqtrQh$SYrZ?D %B\Srm>LUlg9KBdJi6VgLo*r!c.`>YddMNcSKuVK!d]7m>>kU8lIXKbhFj%quW02]qFV>H[('k`dn2Q")D_9'5"IVUL(YeNROj2pr %JYlh]Ia8(Hjq'6(J&]'9CL#'$b&7$,3QFbn':=$<%m6l,pT%r8%6]^ANShg:eUlcTlJ>U4.\F2N^o0Rt:%,Te.-3T,mDF.L[1M4k %m9r7jl[6Xkpm6'd.TW4=O?#$',ZICp*OmP]IH'c?1*LE*X"@[Y),+Z'hh(-C8tG5 %9>5oDWRKi_BPnRSH3X47ir8fN\q,>#J4SJ8'm'cZfM1/t %`eM36@;cZX+mkR)F?oePANsNGjS#r=FRbgr\iS!$`Ad(_M[OUT'-kfV*]dd.o=8AsZ[Ikq\=:DJs7Ft@HY.p9i_cA5C;iD:_Qs/6 %+m17[ek,&+I1ND@](5_a5"@&c,:b@A85o[;1`Anf1Y+Jo*0'"0OFEr8aQ;p^m`FYn.0"Io=rQ%d3@(F:3@)2A7qZD.kiR.YAhe4# %q^UrM.6,6hV]A_bBcW(uE4HonP%Uo?!GBbC$4m;u2E5"0J8RRiQD>!J$36GJ7FS9P,[oC/(>'DN>UPsc?r7DSKYF)(oA'kSN=.2* %:e#(/Z>r78Nm`(n[E"R[o.5Lql60^"65**)E'/GC^EM*"&W7!1T76F)-g%Gr"cj6$00]+e?jM7-l3W+_r;A;Kq8_AWHfsM0$X`ki %-Z?3`HJpMa]PY7VLLZg?*F,dDh!pluRW"aX)Yomel?D*M;-Ye3gc([1&;I*AWH^WnM]T[/To:_nJHT$Ga]clj#23?CZTI4>#+DSi %Nf.n7qjqci6hJla^H@#G!]*8J&%5`dF:f6T.7gnTa*&ifI5j7 %SR]Q%W^\oh:geL]itCN8RrXO&]OaC@m#L6]3*A-j=80g3oFPoDmR%o(-*KC3^43"`gSo]kmfZP&O?5G@q0g,fmWt"^gfqVJo80Sh--.$0;KK8b)B4;k5AtZkp`afjWk%Cd!B7a;,+>sBdKJ_HU+I*V[&-NdUHjYNr %PZG-?mSbt,\I'UUF*,"q%uZ,$;1`h1qDfJg:X3F7I(8[j-OGUBi41k$(S@5tgk*S9Q:cbKJQam0/'^M]kI %mTtO,c&KFIGM[f%CJUDFri;a2P'H0U.3>lcZ-8pn6"cZ\4sZJUqAr=Y6^f<_RZ6jj+_O_"XsHPGN>D8VO8)1.eba$GkO*Gi %NTgr=P8oQ1p,g/tNcN>74.<28C0=7_c0`A#@965t;fd(M3Vu;%NU!P7ZI8UGqFXdG3A`!D/1m(O9Pe"0J[#6Q=%M=<&r[G'emNE7X@H+a/'\p[oH9,d.IIUUFSXC>Rj2-uVMgtkW/6$G>j>9XE,^,Rj&*[lFrL/eNP.". %EpGphnEh%T&RM%rpt)ins7,Hg^H>89p$[BUl1sN!IcZ:X?@)O]Im(,RksB0\YAC[!5&'r*f:f%o!='GRa)Wi%(53j5:3ESY4Bmp] %eA\W3Lk/%6h_aQ57!bN"(AE>#+;je:+/K2l.4c,>bL1Ga!-rFMmUVjSYi2n*VEjHaJI[Ih=EIXhlKW'Km1]4)8K/S;g=t@D"`:!Z %p?p57qARrXlc&,@/Zsan"(+B=Bb>ApZbS %(g5&gmS,TA]j!U4jD+!"lMKjd"gK"0<<%Akf,hDU>4k"2[,gK)E0GZqM92I8Ce23i1!]X*!UhC_[m?95B7YhdlYGlC*9"d*)`iVPo %H]ZO0.M[[SG6O^$(X1?TSEVfDC5B`,,RX(+9K4(:N)Al?klKFi`4bP&J+M("cP&Khq6:\7IIk(#^$tII*e3Lpa5?mN'CYr!YsltP %^3Q!Jn)lWXc'F'T1o/d+NHHtelG((Zlu6D&^*<-a'$Ba%7-D_Z+CtH,W!37#[G4Fbna(!A!u#7fn=W0!(D"\ML#"mT4eeOnc8KQIO.`Jd2D[-F,W%V<3U+uRW9im#Ur9e8ZkNk$P*)#T,pUUtZqF(dN5*\! %X(2#Yp22M-Lh.og?817XmrjBHB3XSL,4D%)MMoTqj2U=LcEOAdQ4LjM]a>o15K"ekj&h39&^nLT-ap3IeiDS3H8%+sUDse#.k"d2 %6Mr-km+4MHbD(c)m"UJ#/)KGOa8m),XqgU!\FAu1js6YK14Og/O=HJ%Fc;(fH2u8P4W)3A-EX*IJf]a+-B[qtK);I(%I]je*g/&R %l,#b&13?0dXrgfoK+a4tE$:-]ABdG=D=:3&&p,-k4.OH2cYaGLNAV;BPFWRAQ`G"&W@t+]Vr:_`8OObb=JB<'83GWk>r=dUGR^hdBPNS5tYmDTC9=K!>g??0)GG@S3(r?e^ZN41=F_7[+f:" %7r.\u4#RNCmPPX_g)?l$KjTIJLhuO=2"<&/U[7Sg=73aWe%Rl2J+\nB4,OKgqYee[>eWcW0?^W^RFT*Y`h5@5ha'lLt5?CsCKWnP,i<-k89) %5TG8oY'(kK),bboD$*g&,1ZV7XidP<#S-[iHsKtVbn&e+aY]_6Ph\juNHD:D;bRSW$,c>gl:snkR6WIIRI/?)[fpp""Y)12<(JGM %"uEhbj#_(_q`Thu6$6!T:QaA3]\JOr"!!@)1p:.A4[^\?BmqUFd`4h(EPkck#aiMVT@-iEK&q:gCFoDHF9&QT+` %euKE'f-S"Fb445YGOa%fOCJFp#AO/BncKBP,eK'L*!;5\0_^'rS4E;J:)j)9XjCjS;dP4 %LcDpgl,"lX"1,>1G-JGkE,ZB$@KAXU*>i[2jTAPFT9EF$.eiQ-\TmZ(V9915fibSH@cB"/"U+TH,[AX:cHNe]mdGAYU_V:nf#HS? %`9s`H&+23-,pIT,,<`t,bfN!5_TD]`(=rYE=WXI1::knu2b(;]_@cn)fm#CP>`sm('ed`qM_-;7s-fQFJ['Ls]n8ds!/Q2p.R6n2 %W&m.'HL9-lYDo[.g(3kdGYC[ffFf?9#2pis]B@gEqhCbsTRK %h:NnN$YYofJc[ISgP0/eHU+9G#&[UIB7P\k)sW+%R687o,"fEJMn4)OnkWfh\MFfa5n":O*oP$A*01B@o8a&"J2-*P-p7S!\NpJ, %D#rc<.7XinDYV^sbEmZi^Miu:j^/hL47^Gh+iKY4:9BPa>Es/RID"mC34E`J[VI+YN8n3Q:s\ON$aQQu3i65jVBlD^n?B?V^iX$0 %NOlKVeaLZ[F0)9+%td!"4EBXfN.mLL%L#FgfoLI.+t[EsGW=n"SHNES^@:1HC8V(7sSu %9dQ?$E'EK.@,6Yk/"9/qW*eOIUBg#PNU\QR,_Kt,FcPTS5(2b!=.am2Dfb'S3,dY\k7X@1&=t*X5(*$;_lqqOs2VT=2uIoF_[h3V %VUA3T%/"C82]/$<'Ug,*2EdEZf>O0*"%Z@f.C-5L]NrK7?*Okd/,XklkMC@I-r&/<+Yt^2/j5kJ,>1'b)4_R@hZF7daQ8lGY6kVT %;'^<6A%4K]_!sr\M]O:soO'LYc`kl\D98pcP&3,P(k#"2qoSlbchi3?CYKB'SD*m0KXKd(ZTCF_jD0*AnEAGLnX..'Dh&HOtM]ZLtN*;5h`?G&aBg5Ub^&o_%q!+bU^) %LkicYEsn>Tj'=cn %]t)!sk;No6[&#>g/fVm%MfR8el)I8-;^01=;*t$X%q`[Ws6`:rO&=jdC$fgsrb(c:+<&+L!K[-Za4rs0s).Yb@9k."_6]C>e4Z?* %Cn-^h&XdVk:m#!XP3`VQj:jEJaJ[M_4fih[ijBeP\1qC:.eY3GOVO%sUW\Wu?5:$a?m#>3\`N1A\SN86e@[WJF+5Xfq4P<)0aJb]_E*\!G4EQB*tMiiadG %@Itc#>VX2e/QhbHX7BP]RjXd!Pej?Hl$`<`+t+KTrWRO*>u=q0YGHRDk@=D?/Dda)@/$[t]@SUgZrh:,!;fB+L^d,S)Cn;3^'Q]D %).;qZ_Yg6s[Zn>.A8Pa1EH22N#a2lQ!%j&EaDLiFi;q"Z'7/qm&.*XW#V %F*KAds*aJ&k++i6'%-Jn8<8jg#TfY\kE&:SD9N<1;neB(,u\J-';R,GV1.B_`(nk+gJ"H)q5@OlZ[Q*BY>l %VaE%Bjp)c)-96EWX1$MjK&FSS1&cC>Vk/Uq#A5-Vc&t<$,/6KA_t\=q*94@s['B&1AA:+q=(-$DP)LJKO:\(55;1(^,`U_07gJn& %a^cTH8=h#gl7oDpV%otD)>X)^jeZO.0sTE"=V)W"-RO+bHA4mY"bqbCkN7#8\G:D9@%aKqr*''l9.dR'dW]U5qK\5q81Yi$X%sr# %L3gklBZ4+('tJ*kfdXiX4GYqFRUR_oQeCE)2tnTcUT%-AYp3++E/"_Lq*2MFs_j^*Z_3"cWO4oPPR(PcPJR %VUAt"rc\6(LBh09FR)5'X%,@4#?I5pf0.kq6k3pCOj$G=:5ostC*I7"&*Drf]-?fq[anujH/!4[%=6ciWn"U\20'E.%1=978+i105fcH@;;k4q"Sn\Epm$H_JoGOAf6U.6;N+=^> %n#NjPiW6]*E"iH1\mPm&>uqSN'5@4m9(.51JTR^eUB%u?I2-Hp6_,L^'Z:4&o*:&+g:Fk2lP_J%X\G1KlK[*/ZEhLH"pbV]_^#)- %rEd6fmI'B%md1`$&`Mean)NiqBOe$BY4=pUc84]Hl7*.k(iBmTP[!D%/u>g-I1u-p!eWrFaIHC[3e+_#Y_%Ce0reGK_*>/c>#4rL;aHR\Wb8?(h7*EZ@'1s1*^bfFgOO0ZMP`PO4I'$P%4eS`f(K:-PuH1C+#mh*Lo[UfIr;74"^F0K"H]bRT$po).7s8*sWSEKWEDX`;n\nM+m[ %IJOSg/aj^%H?q\ArDJO;_a7FiEdtbB&'^7;]i'?NRP9B"&H0UjQd3`eOP9G_GX*pNoVj]U\r.65Q((6sDp\hP(QfB^3&_RHXYWP_[W4CR4*W]m3$,+7\em\P7nU>/M/Pk*UWh(rQq)(G[ara6U="8?=#pBBUG?MXe(QU\[cu^8?f4kK:SR( %S?*.;#-PDc`]@9^08dMhr4qm:d6N1?%1^Tm,]5lR-KD+),N>7&;k13NS^\>1^IU80%cK^TiDVt##=d/)'s@N-?*;l %*gietrp&).+EU./KqMLZA!K?Boi;QN_%]V)m4>q,;:(#Tinm%5gf++?*rsS08M1cQ)oH7k^5mUT``l5k"W,RUAP: %;/tM^GB&:WU5`o^ajVXYW=UY\0OqXQ(GcUa!3*gar$3=JKt?*6(J[_#@^0$k$@7$p+@<$1Dp6JOnb;_Jo'hTuN)EMOQ$Xg\q*nZX %`H*,FQc-u7&0K6MZHj\BRLgQ0VVl.s#6D1?85VZ837=#K@2!(EQ0t^oB-rgW+e*09/6k^!=oQFqgSZ-a4.6fC!TM/6YI>-V)[0oA+JITJdHUJ$5XquQ_bCR9Pf=e_6(n-&Olnq`+;ed-Bhu7MM`q>s1h7_=h:^._$@,M'R6Mn9q %o%+C6?sJI$fQKY-btb3jD)r)\!ZK9MUj`EDc9FjK$P+R6h'?P@PdfN%mn"PAE%\\giU:.F9Xo)"DBKLHn`19KEiW75_*]&";!NI?^mDBA!V#1P%=J=9]LGdmXmd"=,!nFjG@;##tLjjAjn8t+#+U+ %D/F,[7i-_A8l*c,9ht'MYIfg3nMB7j.`6#=%C^VKTh-3p`?S&F2o89CBbc;M"XV9\R\5MPEcF*@A-/;kap/@&n %PIoeQ.,h:SfD=_8bmObNUb>=;Ej-fN#qd2l,9m,@%p@hC1rI(+IcFV+_O!*tLK"?G"+`k1Ur#kH17:OtES/[;J%c*G^IWem&)al( %DY\,_h3eg!(m^+g9?[FP]EE,g+BpUQ+/t/0!s"7BObj\Yk\m7hj+N+'r(NnpP@XXmKG58?0gIK"o*m$5G:nQV\E*?DB(qalpRQMD %],rf$P_.L?,[&Z[h)Zs-NH9=u-ia-3,*=#81HNrK1u-^=5;Y>\0o:O)$?i3s/(!FX^%tn17sj@CYEKXWUilQrV!$#WAkaJ#@Vh9R3%/!'it3j5>^+RF'i?p,;nO6,H<-#E/mUfUG[U %;+J65;M0&'$o73V7r-uh;7db?mW!c>@V][1)2EpQ3Jh'n)7D[q"3#naSfYBN;aj\j>=W:;G%2boYH,:@[&bB72^^[C0fC@?4Y8BD %4?YmZda[L.rB'`(('XNe1J&6:)?`d`[L<^3SegDTTdT00qpb)Z*Ij&QFtYI`8Y12II+5Y[(4A`r[l]P6Z_>T1>nFM:Qofp[G9/UN>i1'&Lc/%Ss5E5Ibgs`9t,hDksQ>#uSXjDhA;e\KRQG)7(&7*RZ3( %*iJ!eqR\IVFetXG%J\%RG`IT^#(d!Ma^<$GnP>G-`WHT6bQ=o5*%*7KQp62)K^g2.;!^0i`u2HVK-UK]3;cIc!dOl/*gFGl*lSR? %%&&5/IWkU3F.BD&MZgXW8gXEAmoi-N7&]l)N4`G,($Vd.Ts#-9^1k>"%3#lgXFEDK+^V?tQ\$p]l'K^mkilR9W)=)3s %MmRupX!d>4qLeL4n+7\C1OaAmioOn?QTs`jfr]O8T?dB[L=4s`^%fS*Q+3mCn4H%YR(u[m\XYlM)2:)m$0BN/S+'MiZ$+8akkZJW<+4S\U8YYmR+h>5B.^!<3<LL.SfG=Up3UX^#!^4 %,DCXqP"A%kCk[Tj66YB'A/n-k@fZB&qb'ORC?lf')k'u8gRF[:CFcG(C[4#Cg8ZVqo*CLf&OhVbTI,p6=Xtf,ZXK/INDkM7Gf,s^&?$##@NcRE+\pS7d?0QtfL>ppnn\>4F"$/?5+FuQim&0C3\7&u]AdTf'&J6IWh[C#(UY7j$qd&.( %Kt_=d>q_q-2)C)$V.@UN17Yo%4GO/_nm)4Co8AmXEI4NhD-i.4 %C#3f04)b&hR/W9k%%dLsma3\bcd1:W]&o]/2o+@.a.(gUQZbq@'Rb&_R%Erh[c/j"$L9GE(ZPN)#%S&NnOem48,<;i]HqH%fs5nZ %Wg+c4\#_f7oYA0;\)1q9BPFJLR%%W^-_m8hNuSVmI@^*-&,H@$IF)L*c79$;_kZo#WU9s.NbV"#l8_nN3S"PB(.pSQcFC\s!Y3Oq %N$Ar36l[(/#q(?jb^_("NfNl-p31`R2`3JEf%*o(VnZkLep[/ZP#km`UmXXhXKV'mXpqJ$63b\RRXrYnQJ]EhYq8bn7I?c_]DrO'""RKBlRfLP4q3!PZ"mO/9&Drdo*')F_7"5[P %NjqX^@bR7-?ljDPLi2h.mrQb>ZcO,InEH0s*dX;IYUkPK3rAYJk/nmmahM"M,l2t63^!MgZ^\hJd09INh&RMQd"'bV@%\#l3^P/* %La4hR)VX:I9_=RiHKdiPbPJl`m\"[U;<=m3qF/F`=.Q^:>,kl8;A+KZl;oe;S*s_?[T'nTVfF`?iao4!9Xs=S>D3Hr&&c-jhggoS %+OYW2ZZn0P#=cVJOb1\S\fO/Kpen(id%cr.rJ@:rG&1>.`66,rHhm3\8A09BMZ7gG^Ub:::o!`JAN**^'riRTJZ7:oQRAqcZes:qf %!ei` %.Xp4Q\ag@,r'F^(uHncd@OnU,"a`+4*kiI$rrCu3j';WaAk3='"]B<'npM=*>D5H_O@K*+pGkj=%3E_c;OJk0? %<`WgEAp/?@5$mZH="i*(PnS/t$t$pPOGm-CM9iPEQB#a5p\K\?^j[14kg"m!k#cRH6=m`l1B[dk.(!.(?[leBQr %`$Wq&+K!tmT\guDclmbo[Vt?0fF^9BC*J<9_>R@%[Q-I;PCKi2-Y#k@O$e.G0\Fk1e)&s/gtL?/gsQ3$!9!rH$^94_3/nl6b!n_4 %E)T+uWM)3^%SGUS;GO7T>h$a+FK`=@hQVELh>5ARCUK)_MO-HujTEbMh"6gOhnH*b-khBCn%7:4KdN!lRHn!LITAJJ?fc %HhLU)m5+!F+Bk4j8t_:oab3MK7G0QA(@BneC;&i*V#jmgge9iSE)St:!$7Z3/?AiQAH]M'Q7N[Dq<&t'^2D_heb3$:6?J&e5E]1@u7f;T=5XS=5lb*??^b02G>8 %84cQM9h@m7NJ\&Rda:s>VkX`A6@DQgL!6JCba8tK.:P`TQ9Zri+l\7^qcXpXbDKJloMm0Z2lUTa!?R>"St[rE!75*%jb3mC %]71RqR2PiBf`5o%5a[aH"Z%:+B;;el<.CQVhCFf1WO[Td>;Q2a\NlnIhiPC7'u"hM+l5b %J9)>038F::E`A]`oSpbXQgIm-\4N''brcb=!EOXT:]Z(UK`cjnM&El;R/nWM#J1T6k$7RAug+3jmYSQ0fdatN %l.9LbK$]f$7m^^l-tl2lcf"mSBH.hac[gT!A4BgOC33EaGZ[q,^SR(,L9cE3PAP/:.;D"r?3T:,R5@j2%l(MS&/ZP+qd4"JnVF&0NSF)A %]5NN2#SN55$p6d,*b;dV',?o=H"q*hi<(NNa3DfP"+?6R3.X0KmrXZ)*N?*t,5:$[d@JUDm'sK?Thbsqr1hO@ptK7_/OZB?,(=Ph1El\A`Li0KXZ3t%dIsDARtWl3/ER`,L:]rq+G0j.)Bgk$-is;p(Fp.P#Kqh-^N?ro %+9 %16:J8Kp0JYM'-MaK_-0J5+N`T5c$1DnmC_q7O%0VE!XtB0Jut$S7/T@B6#ZArah7`;MM#k]"L+t[i*%4!j&L9CBKDcJsMQf6/_t# %`V9#t<`YLs%.k/$;hG08eQ$1K)(G(^d0VOi"_m4H8SLV-);0j<)0)pdTtqeqp?F+7Rd*L6Kt5ZW5W=OmA,>!E-5R:c_BIlo0JK0M %"l].PK#0Eq65[/"&+P0DDmDt7Z6;sgN",MP>ebr.]32\SGp1un_?4%'\A;KX,"1a"XM0k"oQBg_LUG']ar]I.V(->[oQUB:#dFCp %*8DDPieP;[*KXk_,CL%7^/QM>RYUA8T`6k`HsUR3Q(JRRCiFdni]YIE2[_@B'*MA+",g:?cB8a*_R:uu%p0E=?tY4Di*qB^QRi]t %:f2oE8osPb"@"X3%F#"TO3i5+`-(hj&,6hK\E4;b$13/)4Dn=fEiC8gG#4o'W?FEGGVCNF@[U4?T$l*:LQCt(0-*V[ND#;8:O-\[E.;rF9D_2Geao[TFqr!kJ`B,RR-)->m`MQ(L8ND@WF!N2 %5QqIQVN3PH3*gT>Q:s@V5/ICmdri-.Npkf/EhNJd#J;UhWu!GB\9,iY`Uh[eE%YQ(k-G*$XN]JdD#BgYK'XKKR,Lei$eU#IWtm>L %BAU-lnBgkd)R#`_Zq7bg!O@JLNSP"*p]DBCA1Hr+-K*6P&k3frJeG1sP:[ip=ZfE`oJQ+i %W_e2(#P9bJ[ic8X?5VmDKm)%;/0%=`.h`R()ZX\\b&_mL&sGgu"4ck:R&WQeH/YqLD1(r6__VUKSQB#3E:Q;>^^g9_Ck"`?.]RG: %e.8)UBOD.+9(,@Ajb//I#'hO1OYk_<+FES"pKNrTgV*0UbmVLDJ.;Fl.W/Y-&DKg+ejURo4B>iR#7DU]',M=tc3,?m%h!4'63i/K %U.Z9pGBa!3X#h]d;WnR\'0@IB>Q>ib1ClRj6=)k'q1;fdcP,CAf$%W.0$#3OaUIRd\6)l.=d^)1!/oM2@4jfuO*N0- %PGeKQNjXctrc0tS0b!IfJWL1tOa"SHLA]K]KV=5,&dZRY;TF*AUKle0Su5^TN\u,"Bd=X-p9(SA%ih2A+jcsFl-=9>j\XpgIGEJn %fYA%9ESj"pKGGP2H0FREW1^?'F.g:[^Up6Ydo]XKR[9M7Q'n58OW5;V#n87p]4Y9BaD<=3@&[Dfej2%&n!ZHAGPFM^Q9u3\sqk$$YjlZ)M(\Xs*EBX=&;3(#O=]]PC)]/#sk@5=ejUgQ`?d-QuJ=BJfK8lLo %'nQSPEodE(DC1_ak&IXZ']_oIO8BiR4==K9k]dZjmT\?4Ol,V\`#^>HN>U`r1'%k^6&mh>4CY?-CgSjn7e'&?9-ou[%Xn1qN#WH8$%@.["@sj!i/e.2-P?;3kN$0M,f`DFKuM##"par-#5E:a[?68i %&=fTlOO=B?2'>78;eNBm5$I0R/i.dPkW)J$!":%dp2(@4[%ZI+hVS7+U(U1[2tQfh+cXgg_u`(/Z2i>9&#e>E.!p!2_TC@)2ima5 %54hm$iii;al%\so5p#Y:=MiD_>j<5F\.<,WA0Nh\J2[ARFPjkU_#sI3Vjj'+V#HC#4f,(.:2I[gAGsE<\"=C>ZpauI,Jq'81#E=( %/eL;8`S;G"SEu/Y\:h@O"Zb80_8F..cd'DN9TMaV(>tb0a/u2@p=0q+:;3%@@h5:W4;dS/+)oFEPk+:/Nuce?1)E&W.7(Ys\\uKJ %q)8[EY,Aa=FEEn#fZP]l)'IY&5bp_k!4S4:&E)V#.Mefi%qG/\P.\tIS-qY8U?pF#hEEjH %G`nC-!`B?OVKd2nZF-I:Ye1t)h9jp591IDaScIZNW>)qOq6sfDVO\)_,2Hb\\b>PotVXpV*Gr'pqQ,)A1GTM+WdYW\,7.9cu>")G$M6#KAjKlH;!S4!@jEC8e86W[K=A8kVp9 %O3(!da@qQeEH`;eksY(d.1!dfkS!u03/?q3J3T!E-tOZpOMYZJKN;7R\jBsf2:)mF.YSK'!>3`o'l7RPMMP%;#`UaM[(giS;Io#! %IPsrGUC@4pR/iYu7pK#r)+a+[5I'RkIM'P?q_TTp]tJpokdojWn--VI`X6MBf8jh0#J/ir9Q8laibTV2!"ZgA/#G%a6/Zg=dle!( %%Af5#O'FfQq9#D=B=9BS[pAdeEits+Gdf49=>%ZRT9DX(NJ//m*#<.(-=j?d6BA&S!?qXi/e47ufU/jJ'`U3%4ss]7kck@iXkj5# %6YFI+4pg\$g#sTM/fbHmEl_-S*0S]mSo$g+3:i.9 %D/(M5F;F/@a5MkR;cjZ]!dX&0d %E^D_h;V^2hR@Tp&s()6`-CtFn4+LDE]dc7p<\)SiH%PIt8G*2oAf>&a)i1%%#`/kunQ8LrPPjFJc4]m+ULM'u&J0^$.F!(c<`^cV,_-&9[R5I1TOFO8& %i&A5]e*@42Ba*]H"Ln<)K#7^pASZ418WB=EGVu-)_%,?B9Z2q^[[lAY?Q]%r4GP[iH6l; %"gA/eR>2Yf?d-Lgi6iKj?TUfqU6VJ#b8^,P9Z&7FNtNQE73!f*BSf3o<9>:W^@LQ/YqnJ.?J`Hfa=$,QdnG*XBMp\`\Zs49f2$38/e[ET\Y-kQ[a]+DZ*Epr/:$Jg">g$4\1j+14m^idus(Qh(R=X:!O6dj %\#`oAEf%TKq)h:cnSMScZ=V;?>oSIkDI0bdl$QeIQIR2+Dce,E3,rp7Z$fj&T07(bVbX)HdGf`(VnhP>?Z(6\:Qn+4l3PamNJ/g$ %G$9`Wcf0'gpaY1'$hjXVql0^L^l0JLI6X,*XZ$en/]0)JYkLP^Yn`X1N%6HJXJl7k#/TgaCk#f;@:&&(g=C>*A?ug?W<^L&7I!g6NA8ZdBgr&9[aj6,-#UEC3.LN#/'g=Kq*SohnEP^.0<_o8>^e&?i:0U40(*"ok.Ba<%W9t2<)1QcQLp_2+F$9Ad(c.,6\inn*)Z2OU7Snd+ES97scJXTsq4hKV+nM-HMAq'Kd/a`PU=E+pq"l6[Ec> %;J[VUXo^gJ8oM7uS.gV(fp3472X?ktLBk=?<@Z/L%Q8*Y7nQ@JRbLU-_BEDVDF6or/W%n(k^0SJXhcpqR(C,`.%0P7dONf,(]Bi/iIVdStL.5f.l57'4KAC3P;h(%Da0?s@9C %V'@TRhJDqKpO[/hJOQ$O$e3iF>DqnT47m=*=fM=S9&3MhV"ec1)?Y0Vp")+!7)juWLQpkd%X&9kpC0!feCA"XMb8&bU4Yg"I$3Ka %P2i:rUV[AD1[l'QBEO;uekn'+s*g!,`'Og!lr73#`ig'&q %VjdM/g_ARlE6"";@`oP1>kgTXnjcar.;QoW;C?Z.gFU!c)4rT%XH!J;3RIV[@d@&YOM%88-hC)/Vn9R);(NWj*\`Ice%eah07NAs %d$g\b,Dc]cL6KNg*=Z"BQ0u=eO?1-hjd,_X;H@0+1ZAsSAQm.`5_BU;O)RC;Kakm$AAE!]5lru$4AHqZn5h4=^H`"_!!%i=7Tj-; %64Aqc_D$9pJ(U*UN):0#O(a0=9M3BW[mIuhrg$$qpW)X^0/dX]fCroonTdS.K.=XQ<^1_F7D2NEiX>eMPGfF4-LOH#V=#7!K//B#@L %^rjn*Y]0[pd)!\K*5Ysr1*3;#a*4.>Y[)pCFnl*1AO>2<7-i8T^$>Shn2!mamgeJb`;R>oP+>43?<^ %+=quG^9ON;+tFlb/ArdKf-N8p1GU^>+X&$S1V'ToO?f=(N;(^(q'.0[#'_j#X\E/Ad#%FT:,R2'2>9mH[#`0@2MG\nL?:@= %V,t5RQUAYSY1Oo#bU9IE+g+Gf/k4MHlL?sf#9$X3`_PE.gNdE2G>=TI@?t)'-L,W\`U@.rn`8CQWH6+@DHjSggK$l"$LmRMI/*4% %ji^PGmKE^tA+9[/]i[+VT%:`nK0N;l!R+tS6U!%_R`XrmI7qXh%p$i0Pu(_YJ` %pdm@ZFuC[Z(.PDO:)6)^;>.2!r-u05IDc/n#pZu4#jpK)p24dG\1_oOKHq].L&o+K\2+5[1&uok(<6ICbS#^],^h")YT%lp]fjiR %(4&j>OCjjG?L,gAe!-HQc>?I1rUL7ng'&bn.^DDL`M.)W&*DrF0!fKGZNC^\SQo?a(E!sPQ_,<>'2i+7;HFD'%>OAF];L'nCh.3m %TX1u2X/g"Edf4P9\g?_,BS6"TE9eSkC1)Dr[N7]CipeHa7o+&9=3oOOfYG9&oSD@eF0fr)Jc_4u?Wrj8AYg`^2:^_67]5:qAnpVZ %"3Q*,&#Qs*i23_6:ERDs=sc4!%,O)J^cVU!_T'!DuU"T.$mto]9HRCmYahZ.X]Yo3&LSj5%G>J)(0,;*JBu %+NnupQCU#)>uteT=pb7PVU4O1i6-d?5Bd0G]Y2DD1;up160F**_X3)0"E:l#d,*[WkTqo/Vd838@J*qO@'L`Va<$`3_R(RR?piS` %mD2-DrTA+n?hdpBdf"WqO/;brVd8tD\!]aPRULf&I]8f4AQ3ndOAENu97joK6C;[r<+'0EA84SCAR,IjMFV.d);d9Gm_S'>LsI(9 %8uR7E[DJ>sd*6s&d+",WBHJnr^nk'BH7%6'`g#5sQ$_W"\7F*W7HV3p@k=CuS5UBO_b*WjbESPrFoCp^>n\+pnZ5F7[gplcNu..LM)S)B*Aq#(+A17u)j)]=YZu65UUZ)'0hfJcf]5&L%gJ<'l-..=m0@lKR^U)MW.#UY&.o'ZS26 %YuiQ!oBkr5$h4aVFT7r#OhSDi6>T&'EIM4eEt`_E!o6W"ZeK(ao_`=f_+`2ud[0Ho[O,14%_CaS@0T+"?mRT/h19h_$SumD*]($f %$.uosG]IPSSVkqr'sJ2fU4r&(3KJ'L%mB>e_7b7Gf>^7LCPB9YmFaNX4MXL?KD1j7l6#9C84))>E*MRm_Eo>sACHkgCA0hI*oTUm %_24$.(HN^p/n5p7^u/merML(6=5'o1E-rE8*-Ye-,bPg2H1;S!XZ[FUmElnAl!q!Bu&VL/c<#$n@9ZD\,o]"qa_c?1p3W)34IE_'k8%a@hi3b(0,5e?fda2C7/18 %QX&jHJ[('82$k_>Nc!/:0@BDB1lA5rd^6c.?VY"F;?:ZqPmcbl&eu-c]io76b4U[HKm>\P>ra8k(Vn/i6qtuh1!_Eudk`>o2)HaA %0rK(0L]=T#,\gib@s@u\Ha=Ltmb'lK%_!h^g%q/M:3G);%PZ.Hf$qJ/e,?%W%.=,YnFWgFX)$SWpFkr>^7#unU<(@VpK`=>J;FFU %K_;BIN"#FXfbOOR#t>g5ge(1LTuT2Rnp(EM53g<"GCLAe<"YI=.nS,P0!6B+nH9&F#CJJ-1;XLn0M$YNrlq(Vm+;E,^Ug1^nk`t1iZ:il1/Nh5j5okWV@f6XabjkH:C50;-%7.&W-f^`OR"[/S!g.&0(96FEM\Io_/!=N/Vn%pCaG^&e41H %kf;X)$lFTPa:n]DJfc!!DsM)/_W)t_;hK^T?!U`E+e]r0Y"tl9?2jNprEH`Fj^]Q%!jgA)EjP?kc0a.Tip7FO<-fhGD^IAk3!JTo %@/kj@\%(,LnF+WFk/gs*d8BS1l-jF'=3Qm7cg3l0F^)WN/1Z]:L]4SMiO,+1q&pLLThOL;@ %]W6Xo0j,u2/db>?qubLPHD2h$ %>VcOT@3CN;'I?qqQ:c@HgDF?rlLd'[8iFXT-Gf1[\FLs7kGN,G)uN %ke3HMe!Cs@,7-T^ViTm7+Yk2bDCZTB3@ %#e89e_?e_O]15B3]=[q0"Lqu\s1f0]4$M$K9r6e<\8`\n>>\]H*#+KsQLbN]paVZbk]jLO!rob8aYJ^fiu3@5*Y92.mkcs^o%;/84''t %^T^9Ah.06M"Na#jnSXa1Eq0"ShHJ?09HcsGJV+UnMIn#Y0"?@SU-_TWD!D*q0d/bgZsC(BT4'K*T5&?rb96PU]_EKLp %J_[:Nk&$!I[l&bUI4X:r'&`F5+AVS$(juGkb5JR"NthqTsK4ZAZdT4SG[Hh_gD0[$?^#L %A@O?9*"[ps*9la)Bksc1:C`A?$cjhlX/ma&Y:?]V/h8A&JFHrkW/j9bNstdWp2gc;&%#`5LBg>h7^,8Y@hYWIZ'"RjT&.cDQEd._ %LsU[>n6kLm[ndu(pf:Nd92XsLSU7N*eltRErd*SE(QY0#e^SVZEJoj;9hJ1,bUG(lYGoRLU5amFVam=9B(XTN)n@Q %AcmMG`c::Rc-)S?aq?@g0#&\ZGYer2/Bi9`diucSLB@,9k.2uUql1A-C#o./jS9a-HG"7JOu.K)c9UNTKuQJa]iW%Q(.0)O8Fo>, %^"2VD#hq^IR08O-=+jbSR_FU+I2lk%%MFNSg.+OHf>'GN05>$RgrKLo_A9Z4d67:I+D`bAhV!FIXE*i!"g;\Bc-$e %."TWO]GlHJ2b*:TMW*<- %ao/O8qpk&MXRl^WbQ&"U1V9EA!tA$,s'6Lj?Whi%?Y2(k]YCX;'-.Tmg3j>)4n8guZ3P:EG.rhSd6jo\6?j's:\AUp_h0uF8'(K=b96s!OaQbu=Y?9HP,9?Bb$8PMlL:X;^.9$>@-/1R]:Ch\Y_jp^GPBA)3Q4ds\D^&EA>H(-COD>-&52>3:b2(]cK_VcF4$Lc,l*&q`E@$?Mq %Afolhrg[ak\2Qe_CkXGFcm/:N=O'Y1i7f6g]e5CrI>H79!3S2Q/8J]2IHS%Z:[lKL24>kfUhm95*tCX\P:6VsObW4F.1A+"D<(87 %6kBuniSG!mfm#2,o2"&tUJ_CT":S,pT[6nT&7?._RS&C?6-h6nTl[`dj&>"4`9-J(=lZreU))b3ua-8\iEelsJF=JiHn7?hJtKSpj4"]n!?#_<6"TN+,`tJ^g.'PXio?3FN2_6\bDCcTaT!ohnKh)W$dN[d?u&Aub2QYEK<8AUbmKutAilsPfuoVl.:ejg %3'[7.jbHi:G7PYKhKS;$_Qu4k+L]OuGeg@fcl))Yn2b_p5FZ=n:%i;!KFc;7A=A^3MYm]A %@)N(tGTs<#,ep4b]XbSMX=P_^1T%N7]<7j7]V;(1VS&e'WhcGhU7uI5@LS@3M='DdEq`%8KKTlbQP#jebHAHU\59Ka.i/-RYggV%j`3P0%5=*di.T9]lL^2>d]n1OHYn %/f1*,_U#rZ'9G5\cX6:Ha")/9gO"?TT%j+^i&\od#K';ABn-"$bUWo;1$#.k@U4Dg>mDs82n@2f %'Js`4)#q#Ec:oJ;S)X3d`q!pR@p^$&YUt%t[P6PI5s4HWq)?:aC^OTXlcQufRX]b$b(HDFS]uY_64-Kj>C2H679Foc#R&Z)J;;Er=3FOrE8$oX?gE6Z,%;B!rZ+%Uob@I*_;nP#RkZ_Y^_g4f/K-qf9'%]'43o`S($em>-B$0Y`(!<#VUHF;.>ksB,+c`)-:YrA51EIno8H$i^9?%W5=W<"d!g`<.f6I/g7#GB1)YI#Yj9`q^Fj(Z[/# %.9aLVa=moNZ&GZ$9WYIXaORTd20JT$YJo4?h0LdH0BK %\m`ieH'oJ,(X-)4Z?X>diO9`*Wf!uu>e,1CjQW29guuhHJqAU2hX6W8jPp*EoPflH#ktD+T:G\\XMh'E`7Y"#SCZ9RknM*tC^%S- %#O%s3A0R"*`ad&`_h(W)4g\Y^]VFcqmMoBk+STZ;SSY!X6B^"h+13VM3g-)W(IN:^Uf]`-nW:dZC=btgMm\kUPX#';k7nMfM`@Zk %n26>L1K>S-[DHL6E"ekPnG=GTJ8u'PcsN=YBD3b,6pB6gFokSL;K2-$+MHR\Dg;%IX-MGq7DrFICiQ)^eW#':bCU`=o/CEdgfjU3 %$LI!]LBbPF_-qE^^k9DS/DB*sCq#><-?$W#d6`3XR+DO_X:J]G'?IF:P^?[Qch^JHePWD`U3SZn$Z64^h@[]>abt"aaJu#dWt3jS %]JPnF$VoYG!ZiVN5Upo>$Feoi+B:1ZmL8`D7!,$,)A.G&!jfV*%gQrt5e:tCUlC#b"Pfdlq$8MTm).;0gNm?[lGH76]Wu>.ZC4H2 %9!n`keG\qHUT7D.Z;`tB21>)WCuPptC#7J7H!qKZauW/D,=Td+bri#M.BUEETuJ@ZbVZEL2A'F>?Ou0_FJ6@=28@3N37r`f.=/hI %_'clfahZ:Y]&]DXVM(u:O7tN.%K5..V(4VTS&\K3`)[_1($Kn^RJ=bU#/irC(3L+H'<1W>P$&IraFs5cK5%dS$$kYi*e4@pA[&a* %0$H^0r9]Da;q3E,`<9-(7'jM13`k?aY!qY_%9EKd5in=Xj.#kQ&(@VUN?3K9q+cZ$cjWhBd/V6K557&D^#q^eLMu^5:Z6h>rm6kU %hD`R:V?J633c\U5-$kdodPHX`F7N+h:Nf+t6%S>:KDi\T't'!Q<0ARPa7Wdf%,kAO&0M# %4;$1,eCWF)<`rck1bfRU)h2\m^27Xd/'GR:&Xp1%Cs/N#]9AEkI!pE[D=&"r;_le,K7!b?WbO$S$Nf*ggu%F55It[+E]:hjUc9O% %Vm3V.ZDbAEK:NjI*i6]E!Ohn?_4Ei/7jl^i`Ib]&>aHF$aFsanP>j_PLhW/+&)tVCHsA#^@Rl5e[W,)YM8KPC54V63]F57%\2i) %q)?uIXK`Mqkd'`Oqm"TbHA:HgiF$LC=AdUalOnXEI_a?E/.#&CijS$?@@hdg\? %UJlKLZ:C.V?b1-"M5l'!Jp4qT5Ttdc$?e-Zk.rgMJZgWBA1b[el[%B9lZW_Ij0f.G-f;,MN1CUrQ^6_`af(5!2$rM">eF_$_ogh$%XAJL^e+(a>#gRTe-uk3knrD%rC?m+k*?lTb!=c"b8L&_, %"jX\S5mBs+huU1h,^B=@rk;PT[HDuW0*5,p#H?Jbd't-mFX9lWXG>$)+H<_t7b@5OmoT?J/U"h"C3.R$orPXE;f#eeFA5#%bhqSJ %T9DJloS1YJ*=s07(CiLh%6b'a<_XJgcA/M,!?JE;@*Z;:-iY1^XmI0e#;$t``>']\PpK;4@6-Gao(%j"$dtL`Wu>_'b"<&b8g#r= %XfAC9BP87':-`V\UlgBZ7[cDBC7FMB]=YGWZpa'`4hW:QXJ%7lnU,kBRu=sSG'\nHVjFj3gUfE5WlZ_O(qhRq:\Vf.L[V#6jU2i+MQ[Uo2;-lASE,RNP@c`P-P^O![=S1N6,nV-q?[oji[nNuq\j7kG.@-[[,>'^Q;^1_i@:%>7t#:1.Q9$cPL@7`E\K&<;:QfKpba"Qmq`bVH-`qCKkjDe^ubXJe_qK`@sL$/ %15gBrMY[^^%H(D)_7qF>r`3C_[@Vshm"@:2&Qn:;l<:)u+Gb_0p`phMTV0L*#p)n[)Y;uq%#d(DO:A)#4s6C-O;aK3)E9h_G)V%' %rKQ["8\b0M]AukN_A9KQ;Dk)rkB:jK]^k,HF'lOl6L%dk1AJGnnRWSd)+3l1'$?PJ>)j>Ct'O+-e\DD"?O_XTS0!^cK<'BX@ %qYX_i/8`$/mR[R:lC@e,KT6:WmCn;Q*r5b08F1n:gk\6+ahEZ!eOg,iU^"3PiPD&^;rkFWJ;+ah.]]RC850L$p.J)9%Lr*>[!^KR %kQ0.VYDTY.oMK5#%7`f*WNis9l2#sU=&g=kC]f2.s%PHD!hFEGQ`Hfr^>PE:k=(Vb%]U?).A&Y=Ht,k%3`CYVSR0aB'e6.-UO/#boDL%Jg$?$auYfm*#FN"GZ>(-8=TdDg[Rm8__0=5H/*Ag"Y1kgj0U %(u.15Y'enul\;UsKeUbe#s,QD%3?6d^TR4WY6q\;$Fd/G2+r-iY)]l)c4$+SX$lH-%"4mlnM(MnK6FK=HImpg5tBK!=)drN6gg`@ihW!?*M %FrlA\_.WaJ=/P(S^g_5BeXi9]4h4Y(F,G;8XpPs$i]_:?($d&Veg6@#9DfTD2rS%P^[$X^kY:P+3!PE"K4("2VpMjLD_u2)aJ\N9 %Ca.oId+=$t;>\C)NSXBX2F:7W(g]+2*aNLlP%PJ0@$=]f)j%,aP,dH.X6cDOdQ0_7`*:t[`7uk(1qbrLU@4q@cPuW>2kJ]@HudK+ %b"X+DqgZqtk#KCD.T2s7W`T:Bu.MKcL` %Dm"h=L1*WEH!%LZCjo8R;gm!es06bhn4co""$bVK"\RGjLk('_U*.NV!E"S"Jq,879poRYQ>X;.?Kn'GhCIE/UjMRDgN9XcjF6:K %S$XX&qnA`\qs$tKh]\dC2n>]mH2cupZl+@N+q01#C7#jmPs#`DL5g$q8j/>m@!/,e!b[W %D[W.b=n$*;cJ;:;p\,rVmnC640_4Y:QtLHH]Ek=>$lN`(]fYV`quq`iHOp;LJDs"p39LcV$KupY2l)4?/pOk2K6oUs'-#iO?UnFZ %#>cF7dAX_\PiD6hE^?:XT1<^&=@u1r?=$N"CJAAO@6Q]PXK84lU8"Nn^:U;H]q]@VA=12BNfT6qQ'&X>.kAbu5/%.=#S[J`-VhaT %3HO`/M2TDM>Wm)tdCJ..'pM_D?W0"6na+;gn".#`aGO!pUa:NHr.3R_#G1qiq3*VHo^84VG0lfHWCNukE>_FsS\\_'UD*GncCGG1 %r)\-Ydq`b(8dMLAJ)d#99&C6K*OE:rfNS8Ya88)GWh+ir&>],CV"TLUN=1lpIZ8i+,MP[6f3LVY>pnM*EN54K8XMY$gh!^h']DmY %m'`6]c-su%f=Lf0A85e^/A!LZY9qU>bLn#oa-o$DJL)'UGX*7cA0BpmER-J#2t+(Vo9o-c%ud;c)iLgT&V!:U/2E^$g.ZUb$!:d? %Q5bD:\oZK%/6[CEq0SuoN]#HM+k?6V9*\?q?)jQt-aVB'l9,s:&$8:WYnpX++M,F>abR_$Ic<$tIa/oH+QWfn&;=/2Jn=@kef2k< %Vm14Hdait':pW1rm8([HqA2-e(W.h2`.Jj.1XMch=,ftQ;hi=23sLT^L9bR7_t,=K)-qFUFd@$4NuXmR6OG[*e!(,0:YjG._hnn[ %.9^ooh@V3i,n1P<;GU`D.@T'MnQ0j/2ik!oM(GumX)LWY[uW-e,h4urf+OfN*23f,@nYV*l5WAAhoi'P3K&EtN9J./7k`Z0U$JT7 %SmiaAC_]sEj.#b\52q]A\=.d8"5E#]Y)h")3lp:eLcD1L0uD_QSf]mAeTar*cNhri.W]@`Ye=P %B!\ELaA?NLX01Q&q[)FLZTn#P_r8Q`=$KHH>8qo_3r5L?m+2la4$hek$Tu1+jDf.HgQD5I^>7^hIX_[_q#UrOiN-724CAs4.u$B'fu8kS]r %q,q8DV,]D'9l98L*M#?XJ\g:g2Oci1nY!XXda_?^RsX,d:\JD_2_Y,QGjh617ugjNme_>YcTM.)bk#mWn*E:4on#%KQ@JD#o]!%j %/j6^Hp;G\H8gb,tT/lE1o^4f=GM?lhcA_>^Cb0'm7i9H!)M='j)t4!^5qi&HJ+jYT#A5U89eAp1]V"Il+/Q;mC)>Y>caZ)#jmr&a %!6t,lMEaRG?49js(SphAXR7*l\RN(jK-U]0=PIIPL6%3_IWS14B$0m9GVH0<-'ua;D=#K(BaAl*gZt]gBW$.1+B*ok2Si)64C(XW %,HY7*jok(4p):'e@Nr/06uo]7`%$3*A\+U/oL,#/#V@eD>HTKh<+kVKQ$KDO82%S&[7k;k.]][YUtei6E:QCPEZcXWBUXSq3pj=m %Qh,fg:9ks$/mN5R<*lB(P0$[QjLtM\252,mf6k! %^8`G5+Z;2&2_hX"/?R;IV8XU\aYBdJ3jr),1\I/7]W,JY4l\eET/;a^FI.fBo&frNiCPL$/XP-Hc;c^kq":&GgiH86h`TAd8sA`g %gZThO;OMr\NCumW$`VW%[`Bnk_E#0X-)p0e\i#RO!XW;aStke7#/grAL_?c01:+1d%G1BQFqM3CHllOHS:4pg=eO%$9sU_Y=FEB6XmBi6ULsPj^hHbe]Fcs2R_bqd=F;\EV227IGHbkEcQV;H=g2M9jM=WB1I#i7pT%L %,_WFZEO`f^9oMl-N:aNd&M,Kj>J,(LR^Jc+h#d+Sh;ZCXQbW.^r5F"n=6c?gG&5:3,I2*F/mP_qcMX:nIq3^!MRgSmKqJ(r41"tD %q#d3(WmtY.?q1ZCK#2%qWH9&2abK)D_jsfHOlluLO(8G*C/S?T9WpjP?4YMu*+LNAW.!&rl*QB>:ECuu!];VB(!hQ4ab?a$$IcTK %TQ#eXoTqh\qe;Y!q;Lbs_%tP,JEk7CPPSqR\G_m)0$mjR6_E_IcDufpc*l/H^@?9MKiUXD5A-]4:M],F`-EX7=R>mmAn%.jn["N# %G3I.SUFZ"tNl29!OX4s[9.X'Jo/254eO:6QY>4E0?*,Qap]1qQ!.Zec %E=lL=!Z(QfY&FHJM^kfqEpgTl6H9tl- %F_nI*BX,!aVQP-s1RmTdb4]SY_2]Zfks]`>%7:Vk56)G*CKRI=J&Yd;n2iP5\j;#S5m;QuBpN02G,/m9j>sc'En>QCF-At=V-@ur %"&giq]+2ob;)3='$-mLKX+6[B6khp^:j7rKFsYtmFan3KYl@e$Z\m>#FaY=aP9(@"O"!-HZAR$9.+7`[ZO"g",F*MmjX64lnYQ^' %?3gaj:7FR^k^)CHH-ZW_!(X+.$Oe]KEtk#llOM=D<;N26C.#fLR=YOX"oAC?CIIR9e%Xq7orL\jQ%T?EeZX&f@'T?pYTmm\QT2n/D?lT3rSK@<#``8gdHfUJef*@eHntj9Kml`:JKp2`B6uHqe1cDW\%i+2eu$e<"`B13G5n2pi:6!V-:`oeUd(.OX41=OWIV< %$C]K&84eQPG\KAYPgoAkIYR`%O8+DiMRCFcsF&4d[p/pISr*fJ0I&=mH_rZX*[,)?JUecROC/^+f3 %4&;%?$]Ajj].UB+W;.HV[SJVJ]/iSpZ@WN1>nj'hna:*i[[\I$4MQnoSKP^p(7YC/d@>eUo`#DiCRie %kYdth6)M/J7ms3^WDnY?VK-\.4LVk;*N'o[XR;XW(JnKT>4gT5;:d,J-SCIE=+s-o@T=K>31X.#hjADe@!I47J-X^[(Nl$`LQJ+G %6F1`IB/\gu]81i:2"N5Z[L]2##0AqmaprNT4omC1K'8b76/+d:A:`#mi49*)k+2NQrqD$)JS@NQ96Mu,",k[c+$A8[LCG`0d.Id6 %[ZrDq2R^M=iW5)L'NZC\rdOta]J,)oOZX`\iGT%]T.+$:Bp^]9#:pXc^:lKU.jHQ+/NY=+.uP)`lXsu%`7@m:0/]Q232NuA]qd?g %]"5DHUTM#?YHtj!1-!Y8gH.5_)=6\WDJM/Ye6!hC3Z!;U935q9^1n5krUQ^8a*6n([X?W#Eu4PRh/N80.0m9DWPj>Pbg_f?=?gH) %#If1\HMhhXPga#9c/`R4e9F399kCQm_rDCD%)rF0rd/IsI7aG"ku&+:8$n%dPf*GZq`A5`=-?i17QESM2n2I:;fL?T-`Hq]?uR]" %s%9Zom9%QDSsi`gDk2aWYXR5q2N"tgJ9p\Yn_ePm4;>e71m&@.?l5]9FEuWRoUK$.Fs!UWDX),;hRT %b2&t2[QIjhJbmNK:Sh*lMCm9W$ak\TkQmkL$clG+XRr$l.KSYm^f8bQ\5B1$*Q7SoVU15TrtamC_&j_gU/;btS,qAKkb %$-o,N8WJq$]&Di./Ei`,;54::P+">Y#Q;Vc5Ro;QekZ/kA %:IGX.!L"86FjjPWq"kB$W,cRX>B[2UB!6JPcZ.RF90rN;';fmBR9#S(V\Z^`jm1k+l;P2=ngZP2-@;`6SNC]T'i1=t;HorNr:/5c %(.%!Ma3G+RF*m"JS;5LY)Mkj;.sbQS"@rg-6o(=@+6bB<.25C$HTS(=\$r[q^rLNjerQ.h9.uhlBDnRDMh(W?3/K(`M[WCMt11)htAhIq5@eT/&5]cC@q$#`BcR1&FB<,of\MO.H5(,o,fE8+Q=uC% %iT+pPltL]+h"j;r]AQc0m/X%\EX0RRbbs-B;VoA6bSKM*&%DY)lcq5l-OU&dr@ %_*l9jOg`i4pr!:]%I617(p3R(>C<:;S9OWou]jLLS\5`?g!KRsR[sbj+%JA8D[T9JB]8URiM:`XDq5$+#4!nI'u$!Q#^?W'*M-3TM$DE6"m(2E*GJmj1&$96QGY %Fgr<&Xl*iqD//Rqbr<]i31^\QiZ=^*6*@4/N35mqG"r_a>_d14HC&=mheX)$H^ln8^m3g=0qG)n;?^o8El^2L)>Q_PRpBNHCj[#@@=A(NL^^gT#cGJ3F]YJMh-*OI$2?a4RR=17DA9q4Xgjr=IZ,XIFc[;X++GYYQn]T#AMpYl;!2s?1[*b9?1q6 %MYg@&l4^GLXWk^._Sg;4/QMnQG6$U8[s7E:':s7g]YuJ3cT/Vh._Y:@O1&ht']g$t@0+icQK?N1G&2C44@#1>fb)`3nC#1'4C&D4 %enJnOYhfeIdi/GPFT>7:D64p%#:I3*C+W2]6l(kap)9hfZZ:-t4q;qI%7+q0R,5QMTV/auKR]3l3YtUTi%h2W8#n+4P>b$/hTk(' %5(5\=nW=k\B0Ab[8!d$=4go!eQ-RkXVFp,M:Od^a?W[+)V6?r#MaHYI+!3'd.df"d&7$M@GNZR3hd6$]_pQBg<.7j!=kG^l"t47.Ah4<&+hdQpU#rV%(ET$_a.ih#"D't)B)i7gI=Yek\.@)(6/Y"BisAQ!d,B_]s5r1 %;:X"XDPFa`SFb4:+Np)!A/b@ZTM`;dEe9gh"0N=eaaU,Ma8]aA\5d.E1I1%5TiE-.TZCF?X()`OlYSjip/VX %fn?T/9Fb5l!icfRQ-$lM)0>VEX>q79$]jkr8dE]iOoBg*[^G=mQL27(L@a!\8Zjt5=_Fts4Ytda#*LK1ACkP@=fsjd?&a-1DhE=r %A6+gFh[Vbl(2fuYAFr9Ukt/8P'N?j"`<-RM9m]1^8d3f %R/AY=";bX4rIY6n4#pW.10e`E3F%Db\.rS!hqLiX67@Z1Dp*MX"UP=dX23h,smLM:"Z!#WA!#OL[j=*SI357R^:1iT[\@-T#N/h9rtX7G$2C/<"e %m0,L361S%fq[nY>,S]sG8Al;6aA4,"5FE2SYpaUm\F1edc'AAT=l?YjkdBmX\+79L_:SjVXBsZ*^=/ZjGJXU7;s@nJ9EGuVi7@ZA %To#_FQ3V*9_(5"?BBsCYCSX,ango_4'bqIs`IiZ#n/!hS %g76,jdg$,(Mi5[@Bo16b_#t",:k,AC%s+J/[ed\0*a_*WlH>7'h6A-Odt'C3K4Z_o((5A]GqGX(S\+2K8l^]Z%7O] %fe#4pj88SS?a=5:Q7[Y9g/aNMh!8g]ghQZR=l@TW\uVdfhBI@\X-".eg(]j70L/S!g.6R.5#*N_[R___S2V6)U62nQK*H"<:o %eOO'TY03b#"r&tb/Z=/+=K2aXFeeVV-V"MA6:)Ylj':-?K$;5fY"n@,bR">iSD338LT+#Ui?l<.>?h]J:iOud#][+$.k']O+(Og4.;mUDU,b%=3e"d_(9^^P %]E@GF`a\N(r6hT.;QND,K)OXH#=PZ:]UZ"KEZOAD;@.W,O@^d,2Is',B %"^[]HFcon!;Q>&IqU1P,j$FJf$YAdj8Gl.I1Sg;XYUEp?f<`iJL$%"-K3-&:2?okg8N4P:Hc`BG*f&[skd6A4g/\3TP:D[FGa0p?SUBd/&ZI*i'L,h7T:'$$HJtVk %_GC;r#Kk33DuC(D.,cUL2L?>QH@%]oH!-P<.ET[dI_Gq,2tI#.@oj&WkP%_7]6sDQ\c%:B0AF*VM7rVhWLe;$`#)_[ol[Z$2Uhj4 %qEdFHB5'"sJAj3lA>+<.\r\$.m6'U?sSZ:BX=-\fYH\pJ3c9#iB/ID)17]cC%Yac3jl5Y!pJs6#^`eZ2Qnij`9;&-WuCP8 %6C9Mbf*LO9555EnfO^ih %%QEPea#_1!.#(s]8@6-s9"5m[HS`\#EL>RG@.W,]7;.Rpc7Z9*E"0IV %@<#H+7b'BiY!p\P"#%QRI'^m,Z\U@u;RK.aV+5?!89/-0CiV?]^:h4FB?Zdo7?iMd!Li>kA`:6-X";]6^]Umb-@)cr1t'04DQ?AdJ%hIqH]q2EC.oI?;VX=n8uOKD67sS?mbelgf!g\Wa47hY#?F@Pik[iU=DXQ:PSYOiVKK7) %A`okcVC04#f[sSI;`/b)pp=?[WTg=@VW$s0kFLV#=Fg\S7N/S0fF`fN"2o8'3MqZN[>u6oP?][930na:;(A<`GNJWLK %+\^gWSB4/L/)mChV@+gB3`EB<&0Z!70ml/B2U%O.DJuIeW@^Ubo,6"7loD`IGr21ScKj18+Kt7Zps4W6!4+.Rc*C)mE-J83[`QIO$BH+.iO)Gp%B1G&^GQ %'#LAO2(&Pa*Yo9;QUp,(&NC=sd.K,W__\*0fJJ9<4'\C]'kG;I8*6B&m%@:jPR(0g*nP:%*j_)H;io(dlOrT!@L8 %5lTe;:^+e5u@-irnS)G/\;^iDFh>msui!1s_[Y3SNa %V1HW+@'N\ts63:+/,t+9_h6Tb@Xc'.H,(kuEm_)^8_gO6<](KK5tF5\\*`kY+P$0s4=F7`,_t=Z-0,8f#6QOk>qPQd>)OV0[GtW0 %#"k].8c'K2NnQdo*2LI2i!`BML]'g4?-0R#n;ia#AMkFU!GjfT51\-8OC+(iT+mKTR?4!]8+a$-0md[+oXrb?Y7.W'2nealrh]!X %:4Dc_VlRQ/AA,g3gR40iKc0C]?;;%BquaV1"+>S"M/[\P4^>[*4M>"5]\Pn@JtcbM;Ua)EC23YB#hbje*!!Aq,;[H[GVmRc;+JleD$U#Qemio_IS/,S_!j8%9F? %W.B\T_7p@kr6#CnqEI[0TYZLc%2K2_=-R$:(X2gqLjq''F<#l5!/Dl=G678fOO/FVY&E+BiSa(;n#2]%!e'"p>5Y0`U""`CC^t74?stLOhrWE+_8aB;,mGfWMKd$8adFr]1m[A,T-pH/Ud_\k2V)U(#=:)4_3q]g+E;o.[oHAte-sY7_dd %=&hX,lD\7?#-b7(9Ea=uhST^5Cf"LY %=_JD1D'#3Y&X7"p($?/I?es.%jmoQ#B"25'RIhpH'qTgQ3$YQ,6/Ntbdj[]*.o+,DbqPm1/@r\QWn90c/Kd$mhV?&u;N%2hf3[K@ %HB9_ZY9%,4+`tBf2Hjs\(P^#IS-rTY09*r/17b(=B,2E/fA6D4kU7kX(6fd=H(T`]XWVVi:s:78O&])(P/(n1Yh"3[^/i>P!stGs %_AZ>1Wo7o3Nq,nneX%-'"gl3'KA0.:pG7?AMS>hB1pl'0oaM&^@4e0`Oo %#!"_Oi(aNf@\2$lk92fj1!kp0&l3C/,59&_O.:q@nXtcX;0qsg*rMd$!"Z:e/\%ljNOLfGVkqF$2FpN%9M()"$$R[VBUL`SJ$@=q.IH.$tUS7TE:G3\GI*ch,lNb3ciR]"AIR_#$I=,' %`0Gk+#+2SD[.&g;m'CVk+H/mFbCnJmk4/f9!T/2'ocN)&_I_+aqtce>C9rNPh6`oYmR3OM$T""llMTqIop`S`]l6d-XE`X*qRl@t %3eTMH+;<4?JG]d/^fU/uR?1C(#OOm;fRd_meEVkhBt%Jd*1hT:U_ %^@qlKZ5W6'bA7q522CZP5O=6cr:!+N@s)u[.fE+qr$8D">[2i@pDTB!F\6sB_[^+8kU!dLjanQ70?^C[?g"-8.e_#i$a %g)]ogr[d$e!N@<:LuMJmi(b>T&*d"`(cKK'4#ZrEUE(;+3aWC.g(QWs`5Xp3=+q?sO*P[^qRpZ(&iA**.qG^HTg!A"#e4e3.c8`Vc.4Wh-cpc\knZ;H/6dIFQYdq*`:%?D7rA1\gG7m%r8:G0E?FCmX %)da[?05k2.02K=_rr@PCLPrL>K7fYgUPkmQ=u*(O]XY\YWhuYnXK5rSF;V%Wp@_NX#S[ULi^%d4-QFZaI;E_1ME9>Cf*Pfuk92:m_W1V7EEL3AoMT^lq5074m2_RI\)WGsiIJ %X^*i?:MDIerrC9T!$k;H$%7Hb_:JZ,()HoiJ@tq>gGf>.3+KFUt1*$l`-#:.RV/6*?fnse"]4@_npM$>$Z %I[lg3^OQRk(B>HHn8=iA^>s9BaZJ6(,+/G %%u0^PSPr80Fl14#U)PhYR&?.$d+-1!"$J"4O5l700FsD0eeKOFPrMI`0+an %Ok`Mmfh"iN+>J[F)e(L7!bn5W/E.A'7c.c@Ti]fmAL,f%0P%4+R>Y&W!ha %]?i@46[@?Vmd>7!2MSg>cSX/eN--1$>:!c?55I%ai+/"OHsVV0>)4WTfuVr6e&]=$:Z'gSbeh?Dg!(6@8dm*D %L%u@F824hXM$`],H+B?&%5e6$Xb7JbUp%86^TV9XTHDS_]X1Bbbq1M!E+tB!F<9jgC22tsl0@VO0?a!Jqm!M?eo6 %4:q?%J1lREIp04HitpkNf`Bk'C\3.AY0]9OOh&%idb:YI5YMuM>6$)[d7)3p$Z@S$23hO!iW0s\_$f8\c5aVV*hY_P3*[q]ME92A %>+/J'i>?G+e6>j(;#cuRqJZ@FNtR:-Q!#h/SIGjWML"8C#`Xl<.cegJJQKAsB&NbsYR>R[@1`to*$hkV&ml+Drc%\:S1A7:%eh!$dnqA<++b#CQ$H#l^%G9XTp7!&)p %VJH@^>hSAfWDUkO[;_i)N#jagY#I!5o5VPMq %8kO\gJD(S,[\F^rD8gu3$m:es(FT.r8p!hfjV.[k=79Kh_r](p!!nUB!seg@H\X8Dq!0Vde'U'oWSS/,,7NUbX?7Is9MGq;oaE["[*:-qmHq!,rOd37&kV&UM$]uRUkFC( %;L*P%7qYJ<fC4GYhM,9kR.m0pR-Wc;ubHbE0[1m6[n,J)kqOSf\H*?5]DGTa:6JGl3OTm%6k %X^/&qD_r&0A.%Jb!\%tp8M)'Y"[icL\>?F:/W_jj;YP^#qPS\&\p>o4OMsj1i4^*?E2q,UNps@'osIVRZr?PG^>c;=_+3s+?5TBH %.%*-KM:Q2!@X/hBXKA`mmk:X23Lgo=5VOpS(Lb/kN^TX<\Z!JrOGRMu%E+S,'D"7j;O\d;G[ttWOD<"WMJ_Bg+a"iW1$@r*kCVT:kRc,_ik3[DTtP3`mK(7SIZ5;pX#1F@@oM&;4k3?3i3pdIH#=4,"pnCT1R84H2?>hk2u\nQAQPB %5aX,!=A_q=Ci$>HK;jUF,AjR'RO[khb#aO%/f?112nX3:rQI1CX8>#EO+eFA8T_XG(i<@l:Ikb4%LmLj#kW7^GOZr)"U4cL`(-.0 %jlC!NQ'Rc%#W`BI+<*l)Mp8^DOk(/2\h5bqjnojh[gt>:5]oc@>#;K7 %!kArV`!Yj]Qj@=Q/_&WGpU6jn--M(A%.B]7-b3$['I"4(W;T")_ZDuA[+lk?C4^ul!:.s]E!:;,":?dO,6h$.!XjofBH:Mo2iP[V %%D&@b4!rP3O_UFcR[k)WXeX#j,0TB$M?aT0\0O#eP:A0%$?/+HLamA#/[5],0d.lI"f=7Kj:[%XPB0AeVpMM7#!W)$]<=2=ZiaLL %R75)1*hnc6Ct'@,YK5eSBObmf?D>2uh?,jiCDQYo[^gn->N<#UgcZ0LAiq`R(I,9j]M/5!?el-RoVds!V^P`Fm7UM3j+j=F)&6:>@WKnZ*S %_:6Uq"0![+9mOE_8h,C*e%g7hSOb_EjDjNY,A0&5(U(n %q\S`8KQr#/'Q>s\n5*:l$F^_Z!e78\`8AIb^;7&>O";&qjK`Ij"#01+&H4ruHn>d#!3fO,m-",p,Q37'?_/qb>A=W`(]d2f9LYd0 %kKLDtQu=W?f2cRfM27Cr`;E8k$Y._0OXVcK/4iA'%GAsnh;Pf#)*HhTOb'O<8=M#37A@Umdt^0@WF&.b>7fpFT(S/r!oPp_i8THg %B$:BkZ3-GG[1qd0%NZGmj>XBt9RHnY9>sk<5V@m[!mBGH*?I`BVS2Z'`gW`1hD14/!'HS[;Nh:r&"`_; %ecVRM":(,Rli'-5eH@7*$O-_]iO*sWW$8s)m#UW-%><7IN%&?6bbb_W9Q_:e;I2BZa5;Rn1a3k.^K&8,_o%+7D/C7dmN+$i7Q3JE %h+\iKI(0n[L6V*b3"]K`6$0/G52+B:3P4a3Du7G>6/=)hFXbMIM='3bmjuJrT+Fns!U".4FFZZeoiH"(?,?7_[-Y9WMu=bVn1*rro?6$IHPn5fPILoo5ZXH%!Ct)/D]+J%`a* %;YA'$b=A0%YYG"]cg;DV#O.e5KTI.K(3FW`0@FL:f9pC.Q@^b>VJ %OeL@Wk0D7q.WlC'#h:k=\4oYU'c,o-V589U[)&rnM91nVL?bMmS8JW_M:Q*]^OWFZL[A=h;UQ[*BPt47:[PB8D=2c %#;DJEjuEE5)_flG6KT$pQ'5K[kj".d:LF?g3*9m-XD!e?*mEioagMUe[d17brG+^]&$/a6f&i+A4ptK,57Li]oD1RY^34VBWc'SN %">"p+)@`S<6:8pFUB:*DggY5c>LG9+fM7DXmtX#5(fUKjF]>nm6CgbYX_h=&@;mB##)@k8a+S1e9EK:ZK6-B'aA1+dcI)IEn\p1N %$o>(8CHmu7?Z['o#i9e3hh&\]lm*Q',+p^eAY?Q^?@h@D*uord)Vf!>I;O,GR5+]4k3CMi=01k_[_Hn5S?sW\k@>Tt.NV'nM(P',4c!kG,,9BjRn5X/h6%;HW0P?/-t^"HYgKJ-9lGUg\.&C/dYnK-)GX%&SqbC%MI2FCDhDGJ`\sXH %p*kLh=6mWjcbY7MaN3)ABAo&[h7H=gDJdkLaBuZonbRB!#Y"3scPd\s1O$k=VM:!s4;pC:2uCtYQilare7VW?f<2`>jPolg"M %:]$JmZmge<&./,3SM:p:QdO^J>?P*8;c>P-fK1uTeC3/U:m=7<#kRP+E,r5+a\AV[`b$+m@_.#-E3R$h*T@*;5_(YP&?&PXSPC7G %)hTU?cGOK3&e_de\u)MU+W*$5D=@PoGJKd",747DEKIH19O0H@27rdPDc[#3DZRs9!1XZ63<=f:XF.M(c8t1_9r%G@=tb0I>%WJr %jN(U=raGNSb$,f-i`AZ?fXsb' %]IC4\++a+%QskX31W2n2iao(984YA]7[_K'l:`\r$-mC39!$(>)D*1+D50]Dj!sHVCWmtq+<*9c<'tnLN2nZ+c`dbu[L('-Cu?4s %k7_4d'pn(tP2mUOlk1.8<`R>Z9G@1aJE$ZWp>qj)$CEgVZX!%'$lAL-'T5\^:CRhY2V7(:`OhVOWa@Qsq6eVhh3QY'3^,N3YB\2b %lZ-lX+Mo"O^HO]DJ\I=+J7K>!'gAX`J%+\oC-BYk!Rc1Bf^7\tT.YdNn1t'EMoGV'XOIjab+_.FM5\NGR>7Np-30Kmrb0hMElTH. %nXmaAcD_o,4?eNfpI3AH1IcK-iQJ)#T=,k[UiS2tR<5-2B()'Mo^e"UWj2cHK!RoQ;YFL8AXYJp4*e2(O %+iB_S>mk\@-mNgQUDn`c#7k;Db?'Z&lU--/#khMBb3HB0mlg'c-6G]S9;.ahYoL$?$VmnYd/!];VE"Z9-9tCV8B@F61O]c.["a5poZW`PqtBD;r:O.+rGq>7r"]#X(Tq((qt9q/+2,/Y %k0ICLa<.s:nrnhnCsnU'k,e=e;>XmWosHJdQ"P;j4g9Fn`_AO]Y1g1'c.3ChftDXTqUOo`IrXrX0ea_-1S^V=3$KibZ+5G%U;JWa %1gQ`-Q6p(IBIkKqKt=9EGhG-Z#7l_bSK&R9n#(8+#!,uCCC6!K_]jI6cBdE$!?I`UOY3$^aOJ=J;I0L-tK %8XSEu0^Vg6qlg+eGHm*"%(+V[Agk=B+EA+T/:%a@-]?)bX7CfbtgOSQ+MHRGP)`3hL9:q07P;`c\;^Q`I<< %=8gs!eJ>8dP[*iOEq+)-1<1._cO1(IQjiD)3G#9:'48r/huSQY<;WS6``E]).:-sL:oq`47$s(\(j%ZoT@8S]IC,do)g+*P^]R8H %]OWsg5!(AMlsY3LN686f(ecMuN@Jc=8?"HW.lI_e1^bn@MCc)p.4:4W>`p%'dfHGf3k?$N=@X(iq]o="B%_lMH,LK.7q_^^EW_Kq %IH*)rA($32M='+^3+q6MZMZ,,RJ'PF(CW0KaMAAms3or!)M_B6C21Dr^*phLde;@4.VJGl?2D_7hI6"G`seQ0btI]lN">O>1MK3$ %OjEXeY,d[l=`m1pCDY(G%^J8#Z(HnuICep../45OXi8.(Z9Sj!lfB=D%i61dP.:b34X$K1nDA>(9PtirCS-!";EhGs2ZW'>p'O#7 %qUi+M,P.r"%I,>u*!;MmPtt1R-<]?.`Ab]g5;]Fp@T!)N^,Y`.55`)I^GJDm0>aXiNi8DoIspB29*McJa7T.c:4M@NqOBn:m\\@f %oNgLb6jNcs:[4_iP6haS;NN-OBXgV7^4?6-P191PAe5pm9I!_HZ?m`9=JYZii6c^2CeuR7Y$\uOX^nuNYoWg!G_!sFD@;sJ&e3qi %K7i%?_6j,!nY#0g&Sc_WK&K#`gpssn;T0S6:=[k0R4`RP*a@A`F(]aG)#q4/JU&I.h,jC8UCW\C>;B8`6?\sM4A#'313;W7$Aehg %Ahggel[aCImtHFfo[CiA^O>7;Gf32PN/tp[<<]r-!uK/q%a9oMmA[rk[5jX199\IneqbK:4WN(JKc7%Pj#YHMkTA7R`J&U,tf[?@PiWk?$Ye'ldX]=X3iV':ij %[pfG-^sN%sb*I[a(nFND+hPM`>J=AF/9iKKon^O$+gJ?RfRJ*m3'/bGbrGLo"3ZV[>?j.fpYF]h#Igc%hP0__G0R46X7+p,nAAt: %c.d0L^VroE[A0N+@51W,66l36GjN8InUO9a\TqBW[t>GlYN/(b7s\@D?Qu'O_r.2BF?5`VcX7ni/?OuNStU`R2W26-T8ju1R_h]XiF*)+4:[/Pr-fXT<2UT*M,EP7(VR#D;Og/)^1eqoQ+c03>NOV %\g]d]VL=,!h86dK&H6ntTEROQNq;%ICLn!YQ?.4>WF,`k/CW%^\H&oAm7'G)=KhB4_==&_2efe'hf0!j.lV<>@(gSLSB`sG[*q+Rh]Q;\1G](3%Y#Lam< %$+RWj/kjIgd!F&UD4N%)b8Nb.JsmcFr0+5f>b8nQs&Og1^_8Qi2b1U]cE:Hbrj*Vl[Vt'8H1PYjICSNs_g?1i'=-X#P,!H3P9X5;_:Y^EfU2/sg5>=dcI)0EZ&)>b)Ud+< %F_Y`'Qngcd8>T':(pqCVW8U7GZuIR4Cj]]bX/E5"Xt[UE23npj*9(1-6=qpL"S%"3UHoM0(`:%],u^dBp`'fL*#]`Ij8a,\MM_ag %Hbo]f0i\tVjd-YC,@!S2V=t3g;o!B;#C.(lK&05(M[(nPI&rl_MiZ/Dp*%^FkX$23+,,WD]^d(0S'4l0fHAO1j %s!Y:SIltEM,Ir+CXJj0&Xi31.c"AjCc85":0b2:Un]*.FkZri^i8>p05eX+^TWQ_,B,@>QjmFB8Y0h]P&\:B0U.6hqt3cGoME<\BAk%(.tIr).ZM %`R6sO;',nlc^$%67:Qq[jo_[G')!j=\?;-LS8_7*>R!Gb)X,<6n=[",$7b-J9Ig:A!\O\ciZq>Fi9O0R*5a-737Urq*!s%445oS; %mAPrZ+cdG2G;l7E^1M(u`A!f-nH0@fZ)ZYm?.1UTll9T<1Tl6]g#Vd$_ZPLE1$l0KQ$m!$=bP3l@/2@=0hc0%BU'W]Dh+J<>(CMK %1-=poh[1Y5e&_*rn2V?o.$X>%Gi^2uc9cT=Brj7A=*GuWmbh,kbko1j,%>6nenGt#aEc:P8K)2^JB[KemKK2'+hYcG\'I(Hf.+K& %7ut)oTbQT&RUH6G[[-%&_00YiMrMR[#Y$9@YE*6ar],3G(jc8QJ3>WL[pT6l(OL2.B`7cl48+2;T1@)<@UFZGe>Yr6'b;;\C<)A% %M\[oV(Ddf"',(rm(D7Je_Eu*`5Z/$Wg:[:AGC(F%qfNjV@I@-K+.01\SZijXMHRI5!#uBl.e!(@s*GZ_PW5+ke`VhfBO>^qdf'bR;TYOeW3B.X$2B^,q2ql)$eg3#i4ZLGCSV3Qg:>r>sSmNOLke_9C&)jW*fFP'fF::XS64D(@?TR+[FNeP\kd/M'@e.oH>\ %`Jng&DguPBm;!C2+"1]'#]0q5Y&#lhZ/PC<%@ocHRK=;('s0+&3!q< %M`nQi`$5VM6Ub90^&Jb[LWsLaV:Zil:r5OcS9XZP.;HtTV7 %nc`KpCjG3nO8JD$35+Y,pHJUro`MbnI1>UtR,/3+Ih;%H&`I2\T+FO6Chmslb0!D/8C62I2)-uA5Zb\XREh11Z$?eDTKq$l3B9)\3]],UKm-N^0JL9M.Q01C_BlaflD`>lqg5ZSuJ4r9gj85r3*a!X=,\qR:6ETYD``fEL[mh(d:(uP?biRGRHk77TSPVK3nfGs8DQ4IN%:iQ"^A=ljcXnMqoV7S5un-+KVg/$n%^]?;k2-]m+(OONH\h<(!Zm%3(p %P#u39>@J5,q:mi"f0.jC2pU-bO)je!pC/?Y'qolfR^n=RKAorpGhKgIS^k(c$WH-W4'M39Sr;`8&T8+Dh?Sh$W/MbqWKO-3qX25#>!6EPKd??tQB*@bO.lGnr*C3d=?7UgQ^KdMe)!QI1Sg;XYUEp?f<`iJL$#jhn0[^e_?9MmAF5d@n-")D %>R&*`47r3(2JhS42>slU_jUK6`;9=LZ2-.f"U(Z7:UfffABgRn;GqRh7"eFd!?-8,-6sic"N4jsV.Csk5$YYbr3:1$mFegej+bhI %X]ctV^A@a01HbmP>;]?eX!Z/q#$=!I0Y:s@s7Q?Z2XB3Z(1^9(2LKX1VQQ&6PJ-H(8_jS %Z6!Bm;b-]3*X\ttStb9i1cerW&_hi&\L:9(EV@NP>GKQL*7_Fa_.e?4Ord0*+EF,(J@D)OSech-3uT'BB+Q!$K-!UVqg4)_9=34l]]gGq9$$"rZ>b2+`@J*`^44B1YD^sAAcN--!73qe!ZG9X>'O,E:N %p(g:m,_]2qTp4!.jgJhuM^0lXZmpc/U/K[hqL?g"&s@-6^OH4?I6Pu"Vj[:lo:@b^o0(Fs/;KK.neKg[(n`9hBGlhQ]`;!)-u1jJ %c=[Oeb^b:S0i0>8hH\.4nr=b+d/7SX^]<11""gU"%h(0Hb2DfJO"^dmDL]s:ln2ZJn9i$f-LeZYecWLs#(s`V_(G\PQX/Occidh5ma+g&&"_`bN'oDZ4p^JPd2?_RI[i;m4FDr,;*97%NmpGP7Ns39FM`%_QAO/rIO)05bs/<$-3@jru[ %_h$p&&4i8a!"D4B,_TVp0VOVf(^u.cQPY_'M[0^]E3aI>+aHqHS%7[]=J*5Os86ErUBQJj[Oo,*IG(dBSj%HeF7_lk(X5$?RX9!H %G3ea';U>6d+5`6;]d.V_4cu7.-?aN[H.VcdG&>L3l73Aeh5u2'i5(%MGOJkQF/Q%>&8!d<(WXaC,Wal& %M/CiP1G5b'!-k_2d6Be43G.^O-pOV"[k:PT5J`Y6K]*m?C[d=grq^Rj8WcNqd-iXb(J^CT$+Z[%rCe@%3./T^Diqkp:_@8OXH]QE %q5el3/+WF9PZL(2F/ra4M(=u5G=Em\EY!u2RiCW/S$I;3k-U,[es%.'4`P>nOc-ipX7i0s>(6;@R;933n'q#ha@[:t;W%:8^E*6s %hp1F5LZ1[W?O#qh3O7;T@SN5262^*)0ZR.2l`5@k[[eptR;2=C=L->Fl]qiVD2u8>K"Pd@E70QE:mjBb"U.7i4:8V3LIpYo`'[?X %"9h+g(]mmXK3lP(J^4b[ZQ!_09"2?]&C*H9Eou&ARTJ>;B5Z)5&cb8"U#d!,Q+,,4VoKbO*GhPO'AW8mPVrrF`<2Pp?[Sl-+n9`o %d:Q?UmI5cRQ0s@q3RA$cJ2QEu*B\kDI/96PSA;M-epffbTZ8a"j?6glWV%'V/cI83&._Mon%$iSZfZ[5"VA?=&91E!:d9Kg?>P_(o`;D)^Nb;fWrGBn*6RH4."N796knm:#1BUZ^[6p0+0eE %IVEM&lLDQA/^Nn!Dgg=6ZG,GNbHC8/oInbOhX8+lSkl!92`3PL]m'';c.1P%COckoX"R_l>^kqcM2I0(B?hJW;1>J/>/eOBG=+[b %<;PPR$pK/ehq&8!f$Ur8L"%"0\bKY+4J_\Xpcc.+[Q-naL-7AqI&f@8e7OS`I`Lrm#I*>`&C`BG0LE-QU;mj8.FJJ<7o/SMI_p;\8f`c>(pn*'JkUm%3 %oOi?q;AeU&IW+f96bN--*,59&;XT,a!ET72q;/CG94R6Is1ao:fRm*sqein3](*M%eoP9IWK4*["puFbpGA(o[f:GBVTB4F]:3,; %Dkmlpl3DM1eVY*LMMbC>`3=g).4kD@huCm-CHqJ]^tQ6ag@a+Oq$%qu899'9MEn?>'5k3,hVmC5T>+E[>hG9c3^;k&ETXrUHhJ8Y/m>aD %(Jk@#Gk45D68="Sij`:j2R>l(QP+Nqq%[%@i$d<"`+22e>X\embd4Mpd1eK;/"?mE+D%A0SZ^= %DkOGGO#/%?Id#hsc1blnp/p?=NhcK4Ofu\Y\19C\bMV4/=*q\Idl\;[PCpZRA`Qu$Z#8";H[fLKF_UR"c"X(V/_If`Ce^jTPe259 %*8@pA#B3O-J4sqK4d@ulTMYX'e"sZ'L]bSE,2-V(M,H^lGp"0A51R_WTs6B2D_s!P/nL[!9qVsYUHKh=,gp8XI %R!AIpoA>SUg[o#cY+TPpn^%)TRpm8RK@@$theq=uqu-5WpE=*dPKn>^?\^^$mV@P=2O:aE'cm>f(i."FQ^*,g5$bf,bi78VLDAJ$ %8`!SPlZkioE.Y&sARKcJkrCGlZMjM#/F4,ns4qr3#[kfsZlnsQJUs$*")pKSZt.992#;PTK9ooH>Y-#"p/Q'lUF,ncGQ9A\oAJPB %N/U!2>S-PSg:I0th^L#JX7@8npsR\1^MnaaT!"N:3XX+7+cn)t1Va+.$"u./$S*S4#8]H\+.o3nW.Zu=/1Zne&$(VV`Z0'=KcYu\ %desOh3gO@PQDMl2lDBa[AK5C"8\;_o1kWGL%h1S-OJFffL>0XYIZc9?Bgc;4:,"X6+L^hasL4F %-#!erLfk9U@Nf>I!HBt/.4q'OZG)$nJX)"rJXraFRBWERJ@lah5Rh2=Zgu4raCrb2(`b\^j`fV`m-knVrV66*[`>p).#nHd.==2EcMjaoJF"fT %-:Wg[kt#I(pJ`O?M;m6EURhTr!"M-_IBN>ea+IBi?gYP_^?9,*p6lNN7p;6bpl]uVYjDb%lkQMt'D_.Phi.l5cCY@9ofof[\##na %EUr!#E1?l!&suhREQ"F`5Y]Q$kGOm3sVq(j=_=BLjqH?@1k?8#S%'&;91gk %GKMEcBPM<;@p"N"#N,WU"UmI.bJVXZ,VCDe/$#>CXJ.$l,GCqbl/QLn3m2>bM#F3)DVGi\XqWIfglcVbe)HSe$,pU%LC9E_]R/UD %oslU9Nh/CVXKLttF*$r2(GEA3_/1a2ZfBNP\d*0hj8gGfe?JPfa.##f:)lE9l9s6YF[cHe6gM %pKrGL(e//e_pO<.oc,C0Y7Dtld6'7-P2B``JL2dPr?:56B1i'Kh>H9"'P]__%MQLQ+1Z!rk6Vf(1#Y*U-'f3:NG[q&(TcV*7?&+t#g'<%-k"8sO$Na',M.I,5gjje"`Bjl_&f_C_V`,uW-Opp\TBWO$c8-LYZaNc"X,cS[7W@OuV %-DY%l,A"qGUqnLW_]bg.+JgH^Cne"J;0M;R=9"M'n%kSIGR(.%9'W*nIsHD$>.UbWKXsF"VtT/p=nOKfGMG0YV^!]dU%:b#EC4i> %/40gV1m86O@mmpb1AW`#U=c/M?:9f!B>+f3:*ka6dXp/bW!k:l1(JkBd7Z,RlTb#94Qam)_WrkMT7#&6J)P\9&*Ntb`Ig&2?3)t: %IIhmWp%La`Dj&FrcDcB.#7"SOhLhf^>Sn7f?E[X=_p(>9$["s6NSHIA5UTrl#LfPEhUp5`RZ4H8p,HVAWH4_hP,=\6Y)8[JV59=Q %%"f*ES!7mTgfW2+R$mbI[`+(ERhrT:O8%:k'"D@q`3hDuqEDU,='JsMna+BHV_2;eEpE;MM\e!G)]&lL+cRIp$nGhV,nLL3&-:p9U#Z:Eu.m\oZBR0O?Ykc5JtF25*,9[Qo",(P9":a:s6-+ik^V4EY.60b4?U^rgCI %GgLHmbfJ_"+6pAfk83Z]j6YI6`s-X&aiaJU&-)+@3*'lML[3EUcbZ:uWp4>n@X*4]!hA,'A3ceb9WW%MS2bDVJ?Z8BK!L]&Tt-V` %JVB)[VN.COPNef\\"t7.akl17QhkPpn_''\Dg@.e7]bd-'sT./[^Nkpp@1N9o,#^kS!t=c?8XULZo#O4GkV("_aeinm^BL8H.DJ_ %[C,DFQVXtp3J=Q]D%614cCQRkQ5gPWkni\NbLLU$T==DK;_9:U&UeL")[i5$-L*B=1ri"W^*UJ\bg+en;]hW:mV08es/qsC %q2S)8qqA&XHm+QKPM0[ED0lk%,87h(0$s*<5c29m>TOTgM-XjX>%ep$?NPt/T9jJ3q9eN)lORMX4HL/A+6g2Odl,@fqShhO._,e- %\W@&#q,.El&uRq#o&g0/Y=,-%E7PPk^[[.O2R;l)[I@a8>a,92'ise#& %Fh#D4EFRY7U?YtbncIeXIR-ieIAA=jaCn>0Tk4rPGaIapdtVO?b@">DMF=A?l*g8IEcn]KX"5-kR%[=@6D[<$(3P0>::('*8OALo %"IVO%F9lo=+:$Ur=.7EgTEmI*J)%df1WCq%I %(Cu8n[^:-D6j0`-9hUd7RQ9tdgC;[a/;0(ZgN%^)EFR_>f/Yg^H?qJ&>6^sALlAtu,;\blQgsU@-@B#o0YpYf0pl0ir(SoNM2gl3 %G]6G[pNqF1hpClS_M)BsNJeR'`tM:fYM]H?rVHop'Yn8bVZmj*j*+9.L&.Caje%n.huS[$mO&FE=duIdVCEss8C(Ok1E97_#`W6/ %0QU]K"9l^h,SUqlF>`^8odt$FoQ9`&)=$Fd=;WJ=%Wu+ok-cJh2)So6As?5+S4D^Nh];OWViL,F]QEGdj*T$&9tc42O.SIZH[?(t %!s:^B]q7N=CjQEVT_[.(VP^6eqBtr3++blO^V'I=^9R5(]u5V53NJI1DVm.sf#2-s"i(4_A\>[_J?b,n[@s=K\k&c`:3MlNmW@>" %\`)`CA$0WPEhb2[l,A&k05@HgT4s;Y-MU%K(D"T:'HVY6>2iB@"UtuK]g(`I>D0i]Ae`KuignE6\Mk`'fu_.0$W`Ur9e'<\S/L6+ %1XgB\Co<<5Vc6PYCq5:1&SZ&HU$)=QBgO>V5U91@F;%GW]'8Q%kr1gY-#;-17I0+>/tHV;YG>_]VeRA#Te.Ci+XWs;V.;2.A'?1# %_oAm3'A!hZ29&3+E8gKbV+`/!XAj<.9PnCfoo"i:P`hQJZ?qJt?thS*#_=ftX._N"9-3lT[FENHeQeT0d_ls&ll'?pn%#cqd=j1k %+j7:@]2UJb,rkf^OBN3k_Z2]icllX6nXPsl*gV%7f(h:!3#aoCR3@Lq$>2hY[Js=oj;g0"Djm-O[+?EAnLP3%@-DdkP2TnntE2Qpk):8 %a3OUpA8pQM4M#e5P*V-:Lo<:fKaiaU0Heb6AeY=\'#Vl/b0&#S`K/'_b:DnW)d6uNZV`P;Q.OWMePK/oOLI'tN:_*o&Kd+JfOr+- %i6913MUs5J-n@?%YiN`Z"6Cp1WiWf(ZDI*AlfQg&?bL>kDL:uT^[#smV[j:[gominFeuD'dcGNd0:k4HW3gAjETP1"pM6tnX\DM] %#etV]CsD7ASo-G9$DKR"1'TcH-@-"_DAN4HHL[R2\`)$3-97XBn@Z?XYGNH57S2%qK59bU*heb3d#/s\=.WGIBk6C\;?8mN8]%;T1t!>+]"u6VPU$7,F/$"9pLidh-j?Y[g>,fE8_;^5q(#QGj %kc67n*Ea2`Z`_5.2CjVBO$uUuS#Q3F)"\jhDqnItj$A-*qs;bnW8estY7fF;'":%L_,V-fIQ&=Tn)$@tGk/WMHkSYTr.F7_7h5ED %jiZ41pPk0IG;%O1Pr5h_*I.I)gcP8NY-+#ZJtr13Rr:kh9El#6X^!l0=:I4`E-CCml"t)NQ#-5RGX1WZ\Al`TG%Qtd[ZU] %m\eW9qu'Q);_(4aFUI5)lWZFUHYjcd//Jf@a9"X99Gg5U'8hL'Q&Ub*i.6ug[$Zf0XL`t9VLa?(\X9t*\;H0.Z@[gsWg]Z`Z)3,L %Y*?,r6-(&3XQ@k,9/dhP]X+X'gsrD4cX&__rO08u9iG,;#@X:;qo]4bIj^Z2"n*d[GEBm/Dr85fHhQr_F%:BfYI?@?d*n;;MX[&V %m$SD$R(mN-dAAH.!u3g66!dkuDCe[gMVC[Q/'(b*I>$_]7(`#dp6RO?Y2st:gDGBL=).]1rSW;/GX*7m!rU&l5-bRB=?I^g8"'?2 %FV-+!)dY[Z/83Q*m[XE>7_N)\X*Tp<@OeQoBM7>jCU)R"V-BK[?*^OI1_fOjO1A&Un.s5,"+i:-Q=8D%ScQ/eN4p4ZcM0'2DsF,c;%i^P_'A.'LG*6'4a$$W7V' %ALq&Jq>+CODqGSpd/$/JjX+^A+FNFN%^_G#$6C`S]fmB^a-#VT.I+2>b74iW:m#Ou.(_>>R6co^$;+c^2XrNh`A?nD7Vu-j(FUtU %P@<,fc*e)-Z:T^`MAQ948LGMhLVs+>RcDYs-I:s-jmidt$'gtg!leX2^`_')`+ %K6TZM<>6)[UT8%Cb9m95)'K-GR0J6,R6f$KW023HBDPgV;%X(a'Jl>%4R#mlM^j$iJ>XQFi7T$F4ak3=RZ %AuerZ*^!PFl,p!;pM(UQ4F6kjDLMK>ViP`I5Io>;kqsH1hqLmMI0Mq)f2Z\CY[Y%rNNSSCP!$LB%'R7:OHtd6RY%buM?FkS`5r@+ %YVKU+CfhYb?3I;[c8OEN+u@SV8)3_]8N%;t1TRfo=6G?D03Rl@_CfRFS-94gEcHV5m;#aAY;EEj@kd2^r7#8BHL_7b[r7+bpA %m"^Sei$oVPV(.":4&J!Td+q"!W3Hm"PH&%*udR.2cHt0`rSblrR1&Onm$IgS=.5f96&:^?#HCL&!WRKn0RqW]U9q^YP0$8#5`C2*bq5YbI-=[6\,S4eTHmOOGCeZ?TVY;`?[.ce8]Sd-,&=E*mHi^(l;`iW7C %!"[XeCiq:C$9I`dECKoMRtU\E`8W,lk22(%mJHTOI+2GEos%#H/6OZeLOk$8?#fDeC-og!p1F>bWp9),/ZVp3@qq>^Rs:K6K*oWX %_/3?.d5*#0`!Q<9'&))1(/=Wo4K/#I*BWWJacdC1>'F9.fV@_*9I;rnohSRKqKE+4QIp_@JldI?/+1bKq %]].qE9q*rU_qaG![W6FH[@3t%TI6_>+_G=7B:pP^Ua%8*a25mt!CC1'JhS0<4T&.^bU2)&Q+tUqhD:Iu"=sD?3j1KD3t3i]""9L3s8G[`)39p/-Q.*"(MK5TSW5!I4,/j[r8-67K$1*8Z`XBSI@18Q^q8 %K`aag"Ek(1KHN,qr^0lsWBFPZ)qf-g(T:[c)?)01p\2R1pZc%&Hr@\8D;[5u5BTn/6!72rH$'u>6WID">n]k0CKl-EO'+Z_3i4]Y %`&5IIN&ZNUeI3J6:M-+:])_b-_na*2cMSR*o#3'>A5I4Fb0lgTgLB;=Og(cRBQF4](m7G&Y=TkM(f_LEn*+sIY2K[o6q<:Y %nEJrOM:XbU`5_*_*2CLR@//]')[eHO)\;B$l]3*A6BR%l"&9tKUEFIDoDei+\nQ;TX]k% %?B^p]Sju2.)(R!YUW:;)%C(f.KYXY0-)cb9;W\aX'9Q(VD_fV*,1BW^e(-m$l3L^3QRl$1Qs0Ecu:T>8cpP^#Qql'9.b2&u7CTHsfDYt'5QH?FJJ"LSJa6BPVbBu[8Jd/3b:`9Vf_ %H1MR:!5-M4\-Kce@.6tN_:[NP@/#/\!:%'H)AHpA#QCHF^CC5poK"ugD!g=!Pd!:J81#*Q2D/$2*!]6#SQC>G6r[,\?:I@X6Vl%` %C:_-1&RIdn9O,`^c/[09MHYR8Rln!`V,"/8!c;=jNOaO&$"HrYi0DIhoeZF %KJ8ea:S5%JQ%EW,nJHERdf=eWj&h<&ZL2d*Ogtq=9d15p)3rGJkg=Fi5V)qn>m6ei[1)E8-$6KI1e?);6SI'ad:,XO%SDBGM5` %Fl3Bg^'M6d64+&l_a(95T_/B1Rml"R8TGl6A)F?0\jm2cV$m;*V=-t^\1lfb%ohb^$\^E"W,iloK=X/QMnE4Y"ild\;Bd^@lb;&^ %DVl*hoTFE)'9V!/4:_l3VK?m1*db-aC28:[44bKI&V%K$HEhtE/OCU4p0cYHh\qqC_1k-ZRZ`j^`e1NSpM]7Q7`OBna2`feD%9$, %CLQ2Z^]RtfIsBehDHm$e4HPjMN;5?n2MqaO&)(*:h/T^OQ/#6`/iapX*d))3*^/DMfCHrti;/g53u]Hgf\8PVuS>@5_3gg!-7 %<`2oe;irC`!oL/h;Y;A:qDE)7\-H'X!EZ`uh`\X;mHoOs %9f5ne#?nr/3[);ISed)H*dKWWdGZFeYS(YE$4%@8%6r3.lXDt]Es^&VT'E>.PbG4AY'0?9ZXT:7UcmZEp1HlR3%8;*M`:Ek2N1:RJFfUI,^[ob5@u;!b/07r%]6SdTBh$ %S<+)=L^&A1"@e=f.g$L'--sT+`$_E,TjY%)D'Q#jW2siJ0[3XJRaoeAB;QLVaKX!@80Ga`.f_29NO/suW&Kdo$.h4N#b=kKY*n>D %bG,G6ToF:F:RU#J!='[U,+p&*L8F5FlhJg1_T6"\/3?9S#Oq)08[4OP1[S"(CfU>(b?Xgh6L=%)r(cEe,5fT%%CR/0>WC-U35G%YKH68ca7s3Y$4$;F^(-Cg3lPpn[2#ie(G'+gsn3p$pfQ[6u=ZP@(^'_*`5K&2Z %9>)c$p;?2#/GMG`#PW=^7WT.[9cb/HnJXe`7NGUKbPT2PIU0[C#@o$8rW4d!8]aVj+Wo>Q#2n!fZ2X9)#r`7l<0e&(]Vtf2QsrUZiBLo35BoAAYj:Lk#?? %)GrA3kU@.HmQjWEhpY*/GAjohShJ5mTdV.bX/+!.)Aq,cETg$X%4/@6'a*. %ShX(H4gb[t=/J4enY!Oo[-$32c5&mP>YXg3Go4h.3WrjS]\>Sa$`[&"_(;4*F`HR55MK)F1g;7nq"ln`qq?*miB>BPk!;,m&+-f4aL8i2*9[GkkIpYhV2= %!F`X6R&$CBKS6FYJr7-kMQ3n:R'Q3Q,dEm+&3C7$bKe1`5lh.M!A3e^+n<3973SJ+/5*4,%)@KI0ik.@ %m#IHRs867g`Rd2"q"#\-#C:`'eA&PB^#A71bD5gEM%6"8IG2D@4+[N^c&D:[,U>Am."&U) %f>@eB&W@bT">[*P"8Q^X_k2YgFC=DX;>:'4d602:gh"3[NN\1;TSeSYMU;i$D0$FT9-l/gBk9GA'kBQ`%21PN4J]Pd8[:kr_%k-&cSmTJ.(/eeoe7mUmFE%"eo6&g*Zp_Z9[.JTFshNS[pqA72YA6a;.+P %0VH*5X>Y8$h:(]-2-QjVU.k&?V0.8<)^N]\YI=b1_$3UK#+UH'*KL$_'1`DO=i14;Y %#OoO^;:!/9aPY;n1rM!lfHr`)/iL^$50ba'^A[&9Vkls+)/2:F\ap1:&rX1LfiYI8G?r)6A`7+Xm-G^24sM^>.lR\`3u]t7(E4/, %"qZ@t(P<\LK>&&+VkHq]LIOS2c.6R/mEt&XUWmu$=o_:tODm*ncDCH3Wi].K,n$bt,h;6eEW::_dn;cp=!-dSF,Sc.5c(i??V`-` %k\'_r"C[sYf!sZH[HlrGl2qhq;_FCf^0d1PB6Wq,^K*9oCq4t)6dr.ne@mhEVc5%%8Gk#diCP5jopLaO%irH[PT;4iC2dk/tG=EM@'0`#;M!!-+msG(16#D %I!$?L`sPs,*ICJ@j"G9)+VT]['1j!`a8gha+bUF?AVOYN7Nf)^8:,VFNh7i3pqdNaT %Kghn'Y,WBgHq<&A9KLo,]3@&,L*e'Ph!t>]hMh-."+BOs1iaG;Q/c'>mkoW5gV')J.Tg=ug0%?1Fo^L]a)U,WSq#qq8_r4s['_8k %G2MBBN?*0ONUAcQG2/KMSNAdu9)^^i^_NK].g/"dXae$-mC8clkKTLR46.KJ$1NHdGQ@6krPoFl%edp)ApcpsD!YD//ma&56=LQO %GUWUs&i0$pifX9Ppk;.)7)]0MS31qK5_Q9#ji`q[$7ZSgc+nj]+:%u^!P,g;TBT`s@A[^TV.=5>i]'kHQFjl/LZ+cV`nk#K&])rV %U?n0mc!HYIR(F[pDggE\9oCrK:0s9RZoBd::l$"I!?2s[2k7"cab=5)EmoL\:ej<"acYWqB]_Sp-H$>,j2<\T"k>`.>>LbM6ATAOY8dKih?/ %\9.Lj!jmmlFjI&#I0<_>jq6GPg;j.>irPQoj$BI#]NB#dD\Y'(5pZmFF\8!4)]g@sqO %QOSJZ.k)>r/b.#NC,WM?#Q^!9?WK)UQG9W.&M>LpZA\bbn\i\SpXulM97:2V1%&71ZErl2GAh^CKhUnoIdP3rDmKaY$2tQn/up\6=f^./oHK)*Pu2,<`UJ)#:kh(K:@dYO,lXrJ5]&WX!l,n.D]ns+mJZT5dVf8RA@Kk")PI %R-EAbXND0R+&AGIDY:)(l[5jP$GEu-k2s)U2;8pJQRoe_BBbrhWAf%!<:a.eh?\%rYb)Xa17iU(KTjeJR? %jlTj!I7$#R1culkdGUXS#?4llMT468fTc%E/n%6C5uLm0MM?;oG+Q-[o4E=Fc#E>6Z#N]$G*#Z\A`]%?Z\^sR4us`-=J\lb1/-K/ %GQgHb&<=)E!?NL==e;lZ#N3Ff\*p29l'.m9b%1!g5pB3Rhn?_/qdP.oh#`5?+Lqh,mP6kTSPF4a'$PY5bQb.jL.9Vj>S-Bl5h4(L %#_RkqF;PQ#!!=-R"8t1Pi4i%lHS1h;[J+kASt#-%CHdCnBjGo@9C<8+[O9#!=/>C_jMPp91G,#pBMnDGe^]5YSY.'$%1Ynnknu?7 %\G?*[phCQ[0HlhWQFQ_aRN+2i3C&c8jiOajfOgen5_9?lpiIpG4^Oc[j;'oCg%6$4J!72&+ %2M&8q!fg+D) %XHJ/do"lr(ZJ>.7U=&B5W<$$[OY_&'m8o'FXpR#/$(I5&Bt6C`En;MYI>N+P;dE10HCr@i\sORgBk'PREVure`V9*Z!IAC&Ep^$* %]=XBch33/+DgLN*Pd?ihcPK&Pmp(*cBDdaPR(@^fFo.MjA^!6-(OpnslGhParTu8:j=W3]B?F]pL;F4I.IVhQ9VdA:^:\W#Ob_]2 %PXQ1*B4``Vo5hClPnXXo@?_]iVg)q6jorJ=[V#,j%(^6C!1"a)Lm4$Y%n#3+5;Mq_8AG!"fL%5SE6d<%f#U$?'tLgag/E]5PD:^A^T3lEugqIFIcX-=-qC3`MkBA37JR*T!$2E&8V!H*#Q2GJ3.:l_8L_<*'It-CV9>.2O=V%>C>?;u&F;PFd'jc5"pTmO&J>[bGBYdNT:n'c %>fkD\dD8Sq`ECs?e/ZI.&F."`oK(c$t5R^E0LkHLqL:C@u@XK#3^T<%Me5tS5eIrI9 %eQWpQZ6m]fj"n[)LC6:(gnp5u;^oCYmQDE`I"q0a%lTtH.]NI7VG5Ie/UaN7T%RaEh279"@+!)%8Qb4kd`N&:kSD;DmdJ#s,r;+7[#"n<[&jQ*)^0Q)-_lL+J?QNJ,]KbT(Ts]ZBHK@XV %S1.^*kjK^J6Sm2?V.n3U^]o=dOgHI71lh]i!gc-#Lqr&BAr9"#dKN@VNKK(8X^)9.:nILsP4?200pL`1*R^T?*fRq/@4'#]5 %'L!Z-C*XgJ:Hm/'>OgXVMp(2+AY#gbpUUHKh7YnY-K,IMg`uREf8i"%c]]a:mTGLL2hctObfhDd#=)Ut=>BUd5+OLm+3+I54egkQ %&OJiQ`8j,LImtd#"8^:E]m+#Rh)eALY*-)0eKZ5GEPMYV%/>KDl,q6uaN_aYVkbjq,DYBteN[gdSeKm>M2L9>9T63^PQ=+E@EU8N %-ToSsb/RW>_G&A.7bub`AuhM0=c9EDBX&\M>s_j2T$3U=>GG0bTXO2Kjn=&MC!*J/H3,1]rD#^bqD] %ED5ADX>46)W\QP>-2qU^+taZ%_(N;j5rMm9Y1[ft/*C-?O1!)j[GDVdO<nhY!3<%4kV:To:<>I5K08'dOjC>BmG5Zh[?"[BCV-2&u`LW29U',W9S_%9,u/J %?*c49cGqps`9XULbE(f*r3\Sm+N>Wbcu%Os)!Zm".\WK#[S"_kTT%09r5/XgCOk8,WfKK[qu"-GFIKq'\"\G+VO>nTQ[+Ut".\NY %\p+D5Gm"uJ'NNCQ`9d8805cIpZ*E.2n,b4m@R=jnioY.rK?BHS=>u4$G@V(U>uR0I/ZYSN'/QoGgO12 %VL;,1Hf+d?i_P!HTOfa?2NeQYP(.O^A)j:PE&deA9[t-uQ'&]MA#6N/k'Oe,a44B,&ee>PkW!^"h#<&'[$m6p]_*L2]1Lu_OD"Rl %P?Hjm,GY6>YGM@3J@+^t"]kU9Ck+j[A7eRjiDdnmn6jmk5c+.CHiBbU-bs(`9+rs%,:FhW,9Y2\-pmrjitp_5jqX)EdPWjZ:fQSq %Ekc@7"*[!OlsWQC\%`?!Rj#Tg%rH(sKn:Qqpe?^sZo+ZgFJUN?Pj*kZ1XEqqcRR:c>(Ef%:nSPo&E;_T;jR7AjMYlgZt)g,L:N!V %?380d`PflL@DSV)R2D;fYWIFC$*c2$9q$$'KHM@?#1'Fpaf2&PB5)mgBd)[*XL1%?-9HKJiQ@hQQ%?Pgk8u9J5$+1UYp\^Vaf<8S %9D@McDd=EsFa7A7Wc.D_n*MR\]9`=7apL@q(O^U.r;!)+#I?cj1]6rXLMb?Gg7'%0rNH-'-b!+S[H6ZGdsZ'B<,sp*!toU2it@,3 %SWUmRa@%M/5lKJbdPF!e<\IEWZ6VK#P#1ah&]%-il(th>$#R<'kU=l@T(d*rrNtkJhf<^n(m,^:K5Mu`1e`L0RZFB/Q)?.tUls76$6h1_^1>&&q8_`WIH.;Eft@>G8P,@4dQ?c)uD]&.WFD#&i$DE+b'Fg %_m^IrD11^63H"WL2i:!e%h>2TKUp_uO5$a]=Ds(Rd5U&,=B\H@%tM=8aLK7235Ng7gtYHVh>JoW;hPBT>/TRE:/7!]1B8nq[r206 %ErG%h_o,"oh!g*Ge(:)#,$dK'=[lGS)op:D/&9CW#k3@O=H!p6LZO(m#KGmA!"VIc`BSe=Uc0Y&KE/WeInmg4GrXRu\ADj$Ehni8\8* %`ZbQ\Qt3%=AYcleeV*U<--QdoD(HB+lr4)MR`#Eb/;nSkb7#oF>BFEUqM2KAi'I\ImjYs=WP'B<\%Z[,n_j)H4&$6dT@HutY:@;8 %iDB3H]i?FP0'lncBL>*PHK`B1_>=U'$BVp#4HKqcAGfNqLPSS9MAW(GJeG&B`'bK0]_DH*G!3T-ZElWS2)@>Wl*L4n.Gcft+jn^RBI1,9IGJgZQ#A/0&@:"`mo"/fiTu6( %Ke"[^ANmkP1jqq8M@nKi'GA@nr_*Op"%/(MAp-X#$W96[T3ID8KaWNB73WW.?&'k7q&^nMh3OZi4F'h+F#m>:^qu[h\>;Mi"1Yjh %%=eH,CH@oE_+b\[gH.Kg*4(/u,k?ta>%:IY%kAod'QRR(mAQJ-jcP0_ %a6D(F0uQk[7SO84AG-.3^pP1E$o#Sp0%ZjgN^?_pK=*3bfhILlpG/k@ZZ]a[C8*Df2\2UVfhj+VBZ0F8.CYrO&gI[UpsGlf? %oT4o9>&gZq0<[%r=]<%CnW@t9GKY29gqDr\`SjbMZHaI=n=AaZb`75kBY+W0%troB])][gJDjmj4U/tn[fW"TJXHhdN'^52ir:!l %irSK]TXtQZpGC7./2RB.3*;?/"K2=CX6lRf?EBnD6>tO>5u6\DLlC]2:is*&mrGIE8Q3]mp*CH%3OahK[%7Xo^UKp.1OoF%_ZniK %JGB9SX=*,]Wl1+uqg4qu)k?O9mrJMbjM&uNNeZqpC0#e*,Mb\hrhgO%P+L`L'2PkE2NnZa%lE<^;7&(Sn^B\kKsh.%1Ef) %oZ4MMc;JldN>AtgFH,rBS%"Ig*-Ce9@mi7E'rZ\gN?MoYFe#r&i,fA"3#eDO@UW"reEbdq#Gt2EcH-+@b;5Zd0K;3#%i[B"o9=@HSF*pq:gt2kqp%g&<^@E %](0qKN!Gp6j2#qc3r_^'NX%R7)n/kU!^'S3Z.6+T.Hnj.`>/>%D\DU_&[*WubVB(X_.<8u*W+`K#F(os8CRZ"0l_`p*LKf+]@PIiDjjCpUeE%s,ojgK0J90s %eDAAadV]l`**F/g-U"a\3F_TCf[nI8hu^Y-\CSL*Rg\:AH?4X0V"\s8GVF26\;$uX_/qV_2kN*Z@6sMF_I=>VLW-k%KUUV?LaqUC %@mqOF/;3(qidgQL'dq3:,/T?N;EqS`e")[m[]`<*X1CXcn$uADK/CqL-%$ToC-?QW %^UU'^MNOKi",@[8BFtG>#fV_`@q&gM&(!j:,Q`[Ml%&j=0ebp&^LR4lL5]m/9UFHa<@A/kdmJL]i<2Gil//Na'fd:*%J-Y].(ro'*B4ls3X9^F0>dhC`j+E+e'Y#4u %m9Z;FfZAY1rC0:`+?BT97c4='[f0#a)#cY=r?PPr,+70>I:Mm"C_N<23i7hgH'/`?$-XBW#$Nt:AXq(Q>$JSsf]n/\Z5;q4)HF'e %iPh!b,]XD(4Cg5/CR"uW]hnXmn6.TI-3b4qG&DALK>Qck/kAT@HN-8OKgM7!;]\[Bl04oTql0OBe1Y0B23Nat"PS;?I!5J]cg'SUA`:pGBFI%>!C7q5 %'SPVaE7/JATYBPC!.I2KY>4K-s!/Iujo"M:In+@4lJRt\PIC_Oo_2AJL!ZErYQ.dEXD,PCcMmn_gg'X?jgOGu>H1lY?[h0CdM-r9 %4e\TgeP/C0'4T'6;Sf\hn_ddM;e.Yg#IH-Eb4:K+G\JU$/Nl+>ARTRM+Ya;7MFkt;+9=SL/h2Sl&/6bR0$c!(>7?jhO$)\Ruo,*47L_2CPs %MZK&\Y1]&G!G*qpSjK0d*LTO`5G,\Fhpl4nZskj:q]Z/9'SHtZillVP5.2a!b3"u0X-K^92O0!#:0?7gF`P,rD@m4iYm9$Ts5),@J+;H(q7]N8%tJ9RY#3SC2`@p;q`jf"!ksM- %CZ)2(CXOQ/]=Y\e],PmdDNs/KM_+;)\9)^=@Agb'O@:ms5q;7qO-EiEQEll-T$PMN2'L*k.G?L/_@DNh31$YNf\*UZ(+d[5[AIcjl=m.kn"ES76dK[1 %!u!(U`Z"][b@V6o`&)!5JZB\f5Pln9OlH+BNKWPMfKq(;G`aiZr7[tLZ_eNUlDM`pU^IL+no2S>F6+l]QNhf.RpW_(,5GV`k(/u6 %?*fGAT%Zo4%RTQ\.0TKlgBH_":XZO>FP*o%j2'8-0-#CM/R3l-*e_J--"Z4LjQ't7(*obgHA=mm3-f;-fQdV<8n'VRSC2eZ8mHsm %@4;YmJs!L('>O^bn2Pq'3JE/CB?f].n,!.:"2l'6b!]Cn(;p,#W0et6g %Z9oqW:ajhTcL3)K!\U^'=_4/Ee %Q^LsCCM<"U@hmrL$hE<*BV[U#)QOu*LQdja7q:UK`?\,Nc,Yl:U-N^0O.2bVC?`NO"Wl=.$E]$c"o3GRflV>#'_.o@ef%,bmKmn$ %Z-dG$@?0l_DG_#>EA>aQWLesO:,s2@AYme\An8#K0l>tI7;'mc,#]&f!0nH!7Ac3&D`=38f9?pb5flU'3EiI17Q\X\g.)?O7Yb0F %-+HsAa3C!;\8XabneHk:()7iM\qNh4S.nNW.E5X12]n!>c^bN;hDci$0_6*p59A/oRg-6gVU\$9aa9oHDOFg5GAo@'f[%>8,S6)p#Pn6C;>,]$bCBj[*Ka*tLq\qtWQtK.`RPd6RiS;ZQo.h(7a]lB#qWUqj7n1(P1<Rd_sb_hobZL!RJ8,;V@;0$d/r8$s[[P+"np>F64f.ZAu+(1H0GY0;>IVA)dh\.Cj %"J"!jM14r=]qnZ#Tp<+#7sJ-@7XSBWEQ,<5Wp.'JjZ\7S#3PU[iJI/lCf&]iAET.hkJsn8.e-,E8gtUn'pUq06R1TS;[Nl0/ml0U %h7E\+NV:'O*l-,[m_iO/0qptUaPR5j8@a!EZA,rnJ.S0/;htki49QM:`kfIq2.0mGhJobUr9$W4&4F7uRBtt$#QkT4[-bNR]sXPX %h3FSA>oeVH#A1kR'*00PekOuJb/+7H9MYBd&&?m+4P!%;,1%8$f<,=5LLG.($r.BaQ7.ZV_;G>88BAq`nB)"[8>>4"`5MJ)KJRYS %8Mho+2aAPuG4"!0+X+[(!^R,slu4P%kadeX01JE19h!X]<7B7OaOF>J`'cs_$`edk;8N^q=K)?RT9A-$TC#u@20@^1/3jL,2_8Wl %h+X])P2V!Q1e.FVC34R%PS>3G?HCt-*(n-#`ura\-oB9r1"N&.+Crb0#nq[KAXb;$h`,#EqP&huSrs9V[e@ %.3p+.hX^M[n)-:#kT=%i[qS=ZS4(,l+KH,NULJ/j4AW:g0:PWWa.%kXHt[f\g!k9G^og=#%ug4*6>Sn5NL+FR0o@kcs."^jRaA*%7BPQ`!l5'it+=3qaTVMo+6KRi"@F9qOjpn`\Y<%008_:li`?C3R^^J7$#fu4eb3:Ek=k^C\"\6KVUa5,!c26Uh %Q1*W1FiCe=kOedf;u4Gl"Tr+'H_1JZ0=%pAKlPs/`!$[Me;$*iVa\#/Bn6$Jp;0,Y>(FO&&&#-tD1=J?9 %9;CXq:'Y:kN9"+t;\q)>5'gc2fN@Tl#9J7,aEg+I!ED_[*$">1Ig>9Wd]5I]G0[:]T+Y6>C0U+9>2j%Xmq&*ZmuZMo7!e`f:*-92 %7d6]:.6o6na(V9RM6Vk$.&c^/$fkOmV`.;B03YX"fZMCQd_j`s1+";#.;N[,fsHX*R3e+UYt8Z<:*'Y$T"4Uj[>)C?e<5Y1-RNIM %N%pDk1=N^O[rq?SM2Y0#P$VBsZ>J>3!b%:0a:sT<\"e"HI(2Hc0F$%9;ZiU^OZ1lO:e$Q,*/1\l"OmAFg]Ti[MmGO>5it?OeGf)$-iM]M@2bJ=2feh>npRD)*<1g %cG$u-.$Sp?V?QS+-&<@FE+J0XgT-B8$RhbjGHVuk?Fum^Bi0c^iahNRaaPPP%U"5jNMd7RJV\7N,$6K3M8\p.QF-1N8@/]fq:%u, %#RiiZllD*]c3J0YDrD1=/kjA:Wgm6rR5;7\UnTmr4j2#-k[d8.j+CFE-8'l_C)k)_@WEenMWT.84+IQB5r$anB`J\7V\U78H5OJN %+!?&b#=r5Jp`lc2]%Nd8%2f./` %A7hiQTEj+7;-b3GXmXXo5Qt%&S@FLt)Q*_HnL>FW&&?OL1qItq0)--VN*nrW)R4PBB6\qp %+X$(qDTJE,6DI!AFVPT*K[j)@b`2+k$,2NF%RGQ@:g.>&F&okbF[]hT8bfBTWCrQnY?O)0hk"t0i;tK=&PXXPX(,^Oq3CHdLAD`? %&&[ljX10ST`)qdDa_pq-rY6`A,u[2&o)af-_PU+)hn3Y8M)9`B(FC^Td/G^gLkO(QIPRZ=1R,(/-[f[0gNW:VR9QScs&p&EVD*1B %H>$Hgn"+*sTVqr2)+dC:+d/MX^.92/b,9fHP8FglpfN'9pRefRHA?:*],KH$LEtqm(/7FT-nC]d>?Y$*:g=VN)`M]_de@8$QgFkr %rp^U<*>+7%P_,;+hGd(u&`A4Q+CmW+cd!sh.DBi!*ZPq&C&%ZDqj2O-,X*A?>TCZ#.gGJC/uSV-*fRE+$P36;Kb8ZN*8j-/eqTc' %YP#p(Gr,,(s"$-N@G<WN*!0#(Qh*$l4IsL;OTX#Ua#Fe0*4kR\lFDIqN[A%5)jT %*"+0mb?(i&ZR:k/ci2_9LK?ue=F5%=\f^t[ai%[0G&mQA/KV\&(1U$KaC*FCaD %;bbi?Bc1^0P%U[_<%!IW3IM0j8>K*SjGse)M;+X-*`+4op]6uA %,LKkBI0b\T!dGiHes91=^hVP;(q\P4L3NsD!B`orbf\2V]6sDe!7r=ARI+3f%q=R^,UMPtpa&aQ]]48+a,!,!*rNJT,PL'JWuX-l %L9ouY7b@&J13AOd$#cTVS_"fDpP* %WgPl*87e#_U;N,P?t80DAMS;s:koo6l2V,TR#n/`"rE!BeQ(_k6&>lA']jT\XOW7#A+HrmF#*pYUE+pg%*OOGC9"!9VZrKQ7S^!Z %>dC;QcgI&4l6(dQTYX7]iM4#0]Tf:Z]Y9CG^]RAVK:kKYn6h1!TKcGckp@7 %jDEed6%B1^]`UO7ON6R\p[s;c**pEY;]oPl<[eGf*%8%+%/-'pV %><8m4p:TCBlB7%IPdJqMa%m>ho_p#VPh]sSi[UHBMEKl:;G*gT-6@?u@4jb&;=UUl.B-0!dJ*(b6Y[\?>jDEQ#Z^^)hbRR-Ogm@T %E\:%A4nq\Gm\e?nIp3c@K;5WY2F)MkZG%\q>?fD#^7*6@-&q!YcR;W?`"j+0Y[k$IU6T&l[7;Sk=u\M+Rp,qV!0@0t!0'oF_@_pA %#<6:;75ZZuP,a)4`Soi)&C#pi/C<=R\FSqLimKT&"&3O1@LMc0 %P7jEA1V?A<,A5?HhR[9,?iLMD9Ft$kUf41PCMMjKGVOJ#YF,\\d,-$,a=Jm`'];8\P&"ZukEKKfY`YOo=BlP@<"uZ`;$DNt2efB! %N5[u74MK5c*.%@RX`]jr-@W%'7&?):mp3W2H?SuZP4TW[Z&=L:XJj:4St3B>`BXt#8e[WDk88]f.+WlWI=.'"cbmdC'_5kp\>M;\91W2r:[ %("p-N-RLaWnDJdl-MC\HGI_ej`Qk.$X_A#e.aO)+]M2]7pKUhph&8>\KI-Ya+O5[f%j1V&L0?UJ(g'n>kSYdalUbNh@#2tJ8RW?E %n4I(:W+metkURO00``K?5/;2VBOt"I7PsT5&H((!4CM0S[WZnVo0CGL2''N^=j6'Ti?jE5Z!d+gF:d:g&I-fQ\SiCDB0cBK]E$c2m %H2cOrYb\7AhD+^fnXa0'1$M+qe)hEGeWa*9k8+hEAnO8Pnq9]1^E9;=egqGremK00nOdJ89;i.fCFO"$H3M87VIep*(@BeaG@Y6; %5TotA5kl6=N&QCkhoXZ!M\Hj^T\'@4p#(='&3O'>pso3kSH'1dSF?+)gmW8fYN-[3p.`uX#NQ>rPgqW2?DgXfnF6i9oV9/bDSm=pGnY&M\J`\;:Yu0qO %@F",:5qu6>IQjWK1!]MN"_6iIW.bW%Lr=`F&#__4&,g?3qp]]f3/pJ*oF;AD+!"bm((qLqS.l=lf(EguHJ3E!65U:uYk$jFTti0R9"=!7Y_LirY;X3]Y6C?[m?GHHCGPDjkml5[/7+]f8fW;Bcr:6qgAVHrq7E)+&;psO8Glr6h0Lh\>@#Z_oO@*,986/MeeH2j^JQ=O* %e[7(d[g`ZP5C1i;qru=Lr=6U8"NXLb(WS+:A_ZLP8T)q7qoJ=#sF5G:l)Ren[Wgr4_TuVab:R+>%q*?MBVg %quI09=>JP5p']5"?Z-2U$T#/s`iQ,Ui&'`f00e25R1.['@I"]\8+>si`TI4KXlk[=to5t8`VTgrHkhk5Hi3!sQ\N!p1+ %L0F[R>@<1h6&#Q-K9Nj\Y?c[^Q)bSJJfuJh,::hj364!+aNE@G3?Z*_4l'c9e?E)^17'RC>J"Led::p^l6)2(H?r7R'oU%gBkX4A %?.`>]!'CPV2i\BWMMnbD"5^8SP3eKsmPQ<8,;1]tg!]0Ud);Q5#Cl78gM3,SN*UNY,3\2'8cf3`+512jHepB/a**hau/Bf>Q7\`5^WUhDHuDip`(?,9nFTqMtnETRU0ZinAMoYuk"G.3&T%&.9$JLP=\l3:q7O[BJ-9DJ<2:7nV!s7NBKd^SHo;2]#DP)XS %qM@VgLk27sL5%h+"&gGWK:TT_Va3_\F[D$M!/'cKZ\T1afLWX(6l3WC.ESaGfcb-3c^GQ9$X^^P7RSF0MW"POn8A"'O\-cQG9rB5 %7TB#&lddR7-qR(To'u3iYX&g)DPX!Nrpf$(?b*XMfq,OO=b>hj`/!^jG8hanZ= %=s=TYoHgQIX6Vc:.iQS\S)\YXboDAlr8Ri'PCp_^^&cpX0nG!V(u]Cm>r1,=tn;+E.;MIRB4'U:!*YQ,16(!%jQA!Bs/NtaHo^oN!aKW14g6d$S[1DgW\Cu(e! %B["K@LLN09rD_F;Q37m4+/b^E\q-HIU_jF`_^iZdP*Y0l&4ajcnJLJ"JjXBrnjA>Y@V=$1fs5J2JO=45*8Aups+1H/Xc_@"?>=1) %X/_n:FKoGt$>qK-Jc97YOgaf-G#;\6#TgoPfVToR-RZGM-9,RJ"`N/,PY)sJk^l.taZ'&hGeg6u6)SpK^'/a88g5Gc$ZNX@.T-]m %lqg\f.k-/f&/=6a7&&Dfg"8=0/Z_"F]-+:?RIjW-_ri2#Is*$"6H0:mZg(9BBMqlbWDb3Ga'$kH^t'mi3?TJ%4sN$:.3LSo4X9.W %gFdI=A.iE9fRN7^V*fcpXHj,]&$9HCC#W`:n)s$XlK'+&@:MNO_'1'8/t#9-REbtJB+nsZnP5:FVP+DfR?L..Ka7Xq_Jh+ICYd^k %]b/ClkR-5C3p1a;9g*L<*V-C0a\_-:$PjefA5WepR<5ZVitf/[JUtJ&L7f4bXe[QU"Wf(Jb0!;si;aqWe[9@2RZ_>8-_I'FZO>dM %VULYCMU6NTBn`LM//3DdGS[gA)7BSN'l]220^oBflftHpH!$LPij@43[5DLEeKc`ja0Y1KQU7]F[V$sA>iLUZpT526Yr8U^k9>tf$`fXODf_9pfDCj2l$Q&-G9=t %T#kRI)WVjIHKtbQ[cC9C3!:L1J-$NKH8dl''q^0=i'tOOqGAqoEO'Z494=)S@`d[Cg;GS?cCcnO]!ZVAj4#oMZud$^[J>J%_(/FR5T6[S %McZ0@:dln5@9'\Epk?9R"Pl5RaCN;S&6Te)DS30JCWO[H"?lCM4pr$F(>)JT.sncI!l+pE&+ija4O(RQ9Kh0jh;+"kI'(7Pm))3X %,/S.0ighm3I+"m:jQ6Z3;^n;'>O])&[q4lp+]u6&OBH[_:C>L&1p:=>).i9k2f(` %rOrMM?n.-qn`jQY:BL;'ohAnUnaJai-W"MRHXcQd1.8@?JC.\k8gHY?%do!=Uf6/La'K;MI\;f?Kanqm:9ZO*%-8a2h[K1i"SeY" %Tc;(,6LhBJEJ\)E8r1c\1[f2L+L?#@PtWMKhoJmg!-'`TITWT.D\i`aZ6!8^o?o"`cn-dRY^r'D=D3(O;=/u-i^sYi;\UVRNa4^Q %@j?dV#,lb@bU%+9%o-P-6"ta=]WnPiZ%Aj,]"Y#)Rt"IVl(c;F).+&:noG+o8>76a_YXigZ#N@X.N48\'G_bfNsYt"::0E$dClr] %GMSOU\6U,/-O48$<>,BSr74TudE9<.Q:r'&W!V2@GQ@BS#9kn'YZOY'cjWuSf1,p!d"a*6c6Qo#0^]P&f %ILf4mj9SAH!"_IrLeW!$9mE$o7"e9#@I0iej)E0GasZrB5HOqTKtID?njO2EQoCE; %k8PUVUEEJ8A1+$8Tn@=V+;&A"9HG;e&!06eaAJmt/u'%NFN@rG8u5J-FQrkr6d`7R72;o:X9_:_>sXi`?&[ML.k*-gk?q_Ap[3Hc %<(P(f1+H^-/>U(c@F1BE$^9)55_[W%p7Ll2.C/S@[e,CKO-igE25_/H5tSV\NLb`mgs+S4O<@59&S#qJ`]XR3l42fom.#cf2PHDH8pM@Ul])h?1:jfQumM4a8LZG"C %Pf#YcQ:rit7.N2t!#h]sh7Jc93&Ni$*CFlS=e)8r:)^6bFD6uZ.YE:Nf2L*U6?*O%>`e8;5`j%(b<@W4cI:7hc;qj98N/3=4uM"@4#=Ah5@d6#o";NtX4NT<=AcA=p%/nFGEL!7FN/p!>Zk3ca%PW0UWq'R %m]JTA.q\ha#T`t"!-f%rC]R"EO@a8i@nBhOS\(6PcruXU%$^NgnDMVDpcoE9I44#gs-/AP5a,a$q#946&!I9OKFr0P8.d,Ge,aCf %I38lh6G@O$nuSjCl1rHOBjm85^84[J)1K$Y!R:l=_6p,M="g/B6/A04&67)6A>gA&1l+!0l9_7%M.P[T-Q+(h8VAf2IHJ]>iE7r] %11g_nG$WSO027VoA*;F/4$#FBP](.T %LKqSV7PA@jcbFc@$p:J(hfHDBfBqEh%D?KQhNjcP=E#;GfXSO&lI`VMK7sO]R4RnVGgOjom26aWDXXNsWm*1@`@iKnQ<.ZT=]!M/ %;pnLk2j9>)7^;QjEV&c-#[:D7,=,[9\E(JgUF0J.i.QI/F!K!f,]*Q:+Wpl$CCfo";G+m^f>5:0bLKVIeQ(5WbqM!,/ih<00.3j& %1`G;g_?F'M(ZfJg3-4EZ--)&7+qfZE<:F]E)^E!k8r?MEQ81A8g"<7uGSpQF#kBd3#dFMKJ63?!""aO-"9k4u"WAa7\Wp6n5'u.B %huOHAnSdF'`)6]03%\ecH%6A^9n3ii-N#s`<8]B0:b0Q"GCsC9&sDerDC1JOB.Y8*$3V9eaE5;_@!g(N"5>Krh08%)&05+@Hg]79 %pW/%R.rdUus,$=oG*,,>k)q\s$(Qj6XI_+L>8!h[K@`t"2n*r%^!V([9)nlfM17#8ef&)Zbh;U),%?`:&"7jbk$@>dqdsBu,_8Wn %Ffjh-a2f&]dQ^&^o6b59oZ<_!kBkY"CHWVSoq$IfFT:alIrnFq9kg5)H?fnZ"<6"[s2eg;Vk105q0IP#BCdd(C%Brf);p#?1\/B. %oOcT?++@,nAn!YWs$s!c#@[70W,(:5]4^hZ,HlVreQ10T`2`(-nVa*N"M"5\!?N)IQjjN8,)7J,WUo7*7^7"XecWF@1A%Kc`uM,@AZ=tQTEa&ahp3Fth^C80!Y.r3"e6,:?lJBM %&f>!Xn/s=-X0R%D>Pgcih9u=OlbR4BM)7\\!>>m7HOCWE5e8Sr.Eps)R>S3KPs?77qt;nRX!!C1;BRd3@[%:&QQX\2#G3s)?eDdZ %nuaQWf<;om@J.n`dge"#]kpgGZD+]dH*PueG2Kl[p;"LMVbS=`gY"bG2@PU>K-TBk@1aE/]cQ5@_%Ms71/dk^E,Np@9E-GC6SELuk97^PErh41_kUf) %Q(2)AP5u2WIqqK`RUE[%5HKS8?n!"-253*nq!G_f7[Zb]D,qPLfYM8)c3@3hL9pCp;r3mtU/A %.Jqt$S9_TJ!O^V,-L;pp? %]o6.;<-h,)28?BJGd\X!%28BVcHbo1Hu.NXJMK(1$4M*;MK/d!HU%k!OcE9Nc;>16_1<@chYL*3eqj].`Pmu2k80AKV=!'K%PB7; %rffuK%rSqi>8nJ0f\3:9\7c'@)Zp9EC_.P"P7Za[n/s:'*3__jT/$+V=sW=GR_1X6:4Qr+,W %.YJ(l0a_?.Gi8CU-YLd(rpn%WOeI>tf<;g?gH.K'GJ3B[D/FdYdFJ*YF\c*-kg0='^U*[f]iulX>humbgu_rR/=O2]\oL/,q>0[1 %oi7h=9!T3K,VLQ* %GRpsWiqM'_Zf#29fJWO!4mCJs_.ER$nC(+@qSkb0n5@ldbMD'#g&ipdq_t^\CBiZJH"RNf%pG%TK+1=qZ/<20RWVllpr/;T2/PhH %@c/c##Tbgbha?N3s.'6Tk5ZL?m/32E#P0Y;d-Rd=U0L/[]XFa]M,44dmuiF?mcge#A!TFaHOrZ!.a?3b?L=[$j2P9>/*(-?f3qhGiVBMN3a8+YWNGJK$oaW.]qkgdJp6Y.8\,;b[8V"ren %fQ?TnmdFKh,PL%T^k85\%6_I4l]'fnB'RM;=`EN?[TA^lSQ&X!,>0ASHjL*5tVpAP.?;;aaIDtJo13,?/EG7ma,1=i+I@LfZ!s5b3&V?U*C.t^I@\S3O\:piY4r=PHpj*k_#+3?K5`M2S %bN%&L"7cg4qtiIR'Nhup7^m8@@q)HUBV]$IV<$U/'-(Nu&T.pE+>1eP0c^6PnFP(@i'Fkq?=*E>q5"/qm>qh]W\QDk#Lj')nBZbK %@2Vh/5T3Tkj<962nZ)9)`Cmrf4oROioZ*!L^2g3Z]k(U0D,4MCle?'=Dk&h3iTRp6SK(rffIc:C]5YH>Et5lI*q5_C(ri-"RQAb&qc%kg/b#PZJ`]ks-'VYqjW6DS&InPJ_*.o,CaV6J-V2/+M]BNfJFbf,r[c!dGi53S3 %l"GEBIJ_KJQVSN+[m'l%Jk-c2q\9YiRra-KVOtDH0-I5\h`VimjD4.P)A&BVlP=C$)/\IVTl'5#WbZV6pN+]a8F-A25&^64-#G:= %%3HH64#nl_?>0KbXI5`OOYFdC$tC`=Z]\>85(E7Vc],krlAE@]KIQ/R=I]F3TP$_Nc8Q-4MoUV(ieM)fYLhUo7E3d!h5)K+>Puo# %2<,`[k(A/'@$JN7G.ZhrkIUOogT]1dqd?lpDasN&mNh4=SbUZ$%gOm$[=RH>pe5thCW$>V?>Ij!RCG'JdGk#*5I(;FN]nKe-W#'# %".YY9]V[F35IpN,i%RKj^Mb_pX9VI-ZDW0N=7G-2'#(lal2=s`18GD#=ld')Dh`ierR55KBZ9'8J)'K7$,_(.o[VLs#S0tepI&h5 %b;n9XF.'D2OhCZ2"fe$D_SQD@r#?lDa\Xf4dO5S@`qDgL!=apb#C9kNmbP0@b49<4$CZ8@%n(!5@^7sn*@#\/;]-kj;)plOC4$Zb %*.ef.K&Rc_Jf($;mDcbK.L#b+O-(mGG4/AYf&IP^Rtqp^^P`J_EoiU[;[ZP=r*KYOjumdJ`k* %lN`d2*aWQ-EoU;D_o&@P%j'MPGk]4a47um?Y!"rFg[7(uF-@e`,/`DW**e`9XQ5WEoM(L=*AZEPX!=1e(FQ:786qgL\0O4\IVRr$ %r032kq=CoPQBc#=>r[63da?'-7iJS%][Lir_;3<'#21JipZ%T))$E_1V!gW$@2FZ;ZM(@UZ)K2+9q9@##O;de*kA&1IUfjr#G(&]C\t@":rAY%)hf=`iScM$06$cALu["hV=?[B %1@0B4!htiuGblolY9Tos&L&YR5"S]!86d&B#_@o'Q]m>_iEot0;.3B)grU/@T[?*"@;\8XQGLD'!*WUhS!-9gqrQPm3LlAR+1<$_s4QX$S)hBK70gcVjNM4^l %>(lqn^d(L*r%lS`qqP&dXJ8<2oY#PLjPK"McYrQehApn=JGu^*d/@nhN8@(nH65$$3;i">ag^Y=LO/A"qX5*/mZ)DEl?d\cW:fj\ %qqZg`].K*/gL&`\o(Z+(e(L&ak?8hN[Tkk=G2rFe8Qi%<+`TF!;3kY\NmV!Y#ua299;;T$VYVpf$!:1I=VPV=l"-7CXB->!;A&4V %`S3R0h#$*&9c*MA4269cWBFIHCg2DJke:8fIKoj/WN6>d"eJ->]^'e1p^0BlFJl&M1Tt=l'e>fOXcf96]!8`-XtrPpq!bPp:t6uh %pU_6C@:uYEGT_UH&]In/.[U<#&AjnE_K[2SZ]e=n;pd=Lr3C/phuO:iM6qZL.Y`$'moU,"W3pPcN*L9C*ZrId?YdbNgU0hm^Gtn2 %#@H#PM0=E+@pd[bU:A$Zr_gp(lPn#0e980.jDV:((f,ItOFQA;5N1Aei-jX+pknamar/S3q>4C3/<\WK+6/DU*dL*WP7T.$YP/:e %*FY9/7(n(i&eZr>m0G'j^kn%,cF#J[GD'T--2.Im#7D'W>(<@^i5(TJSUR&4;\&MdS0Q7l8Qtq?]V1UrRY:ttq;K3SU,[$C?j`#e %KN-o`U3TSL)d&V/-rk>#j"rRn*q5$0E!hG3"UElk5R2+5n&Z7$+ %.jlSn$4$\'/3jCBNcEMK)]K_X#F*Ma_$EP#H%e[H!s%ib2g'2jF*,oRG1?'Q0T;!7R?H]tP9#aLkR-5k+l;P[M4LmDJDB&6V5'f] %W$H7iXCuLKBOdn*l)s40U$M0L(E-s&&RlUi@L_hBhq',%^`:4DE4_X1d(J/4IJYrYljo'3&6,NJ!Enm/GNg468_R5PJO$+D^Ne7I %o?uTPF*eue*$'La80<+@3r!B5i8L^ak>*(&6%&E7\G,^/p8_\/2b;c67c2uDG28i&4soqdlu0N/kiVF42idTShAkaloqA_^on*WH %V[m.iZ2L$;R2J1[JL3Zc!XVIhm)^gDnE0C %37r?Kr5$Vsg7'23s4Lu`a7@oe-"E*5s"He>A`MaB.5MrR.W!"[j:+6!Wn-b+eoL'L$@3bNg+K[UUYcQQVSOQ,TitbE8>S+D.dD9K %8t#:o)G-l0#[iX;0h2'X6NOPK^]SjnHW+_S\%Mm.c[nZ:X<:Z*"gEU)6(nL*e@`jat/\5`u"bJu7uIr.?CY'V(23\i'q*._T+`u=[10W;WR@9(,6bEqK59O-9Go9Xg6V^pX=$d+nU7l4;UMFJ$ %;qEaJE&FkC`2N![@T@\!jZ.u?!?SK:$Rr#he6fgO7]$`ro\RFYC>/M6R_#fqG\IRe:fIkB_'jha,]J_Z8cj7Qab`nrOUdFko*_en %oJH4219e)`Za!AI[+FmC2+pR29V:;R#U)4M/LJK:+?t2,p6B1Z&=#RqIb^`LQ.4F))&hEieAX#a'`\O?4:(F2N/M]Z,:;2#4NI`4 %*X#Z"LKR&93i&YL-7/NVhX`7[3(R\j5"'8gR7\Zc7k%ESGfRpU,(E-q6jS>tp&Wsu2f4TthlFM5]rpnJdC](,DOfU*l-.`MVq1mV %IEn7u.pCD^pQ!iK_sVP;*cgm8pgW0t:E4ih%q$Qq*uGi>s4K`14SFKNIjF*+f#EHAJWD5K^:CMRZ)RD'FLEoo$\r&+ %][lcAQ-NqW])jn"-6MG#4Z>:n->@$IH#l&Ybqg+VYiI1Q`5aMDJUhQ(.WU+fUGJGKWHl#]>2!Xi^&8gY`O(!)jEGo/_K%j91+M>ln->J>L&5HC8EU\+;n+c[%m^@*-.BmkBlnu8BgNZ\73bPLMCZVl;1]AWJ8g=*ZG %TAMg+nt@@BG0d/B4o=Ss72M#e]_;7!qW$o0?/0l*oX78Vp>.s=mHNa205hnH355C/JMhR3<\CAoYK]cp8r1fSM(;:q.8*Dc8W9(' %R5hAu@pVI@N9nKON4:nD8^k!ib,5ACAd$C/MQY56YKi-//Ph]qM %J[DG:W4\8"P)YF#K=mWt^k:0QG;(L:_0D&F:lA1n`p!DQ8-Ir)#Z<7/:81V%ngYh4 %;9-mcccXD_cH4GQ!O&3?,:^Y[.4^6VQ;Gqi;UQ2qW<#,3EZ9:Xf_>UFKZ,&+>g\[WLW.WB?'1H(P?F#=45YY@mb.4^7YX[t#m$nn+h;H+>fB6J`$EGA"ae)n\Fj^j,]VE+\f9T0]u6'J^TU4RW(h<,7nuhCXI4?0T&-3h7\N:BYV8[0dE%F6!6SQ>rR9DOnW&T>V!&;O,"c:p6u$LcoB9%m %rP_E20@`!d\_?oX;.IMkPtUZUe%M&B9A$[,Hgd-q*V6TqbY$Tu](#@+H2BO#mJaM&n+pbQ\S'L>3jQ-R`9QpRCJ+=R %c/$.?@-<^.2)CCs<%"fH8m,=9^L1g+JuK@R$UX%;#0k9uWCZQOSJsLN,I/j\#`PbQI`3a%0g7Mc5u*7KScoudF(UXV3OlDumSHu/ %a%u.l0J8bh3P:TiqT80G*/V!H9U=,%cW?]OpYfaG\]ks><,[gN.TaF+o:YdPj:(i= %L4Z^#P#sek-(?22erPn^RAhJskB`8r8Ds/db=d+3*q:fZXjG")=2[Y4!>o/O4CI-aNtI+fiE5EsL@1$tk!E/EcIY(sqePJR9C@mk %I7f5L(LF?8J0j)KQo4E$#'-M70AJFP=Y:S`D0k\b_krD^78lQ?"+5O0ih@X?Mb=O#37ud3"*Wqq)tXT#Y]+c.%1[IM%E,2[EX>D. %9??[[9CkfMKk53IZ;=@AYF)DA,T=Y@O5f-]j:aqdEM>F<:i'BE6P>"`Q'N0tQ/preB67`rB,aWNi8EMpD`CQA@0'+,S@FQ\Y(*+( %]q_I,Xg5(:P)5<+:k3[nN;JjhJF_-A=Yhhqbg$3A:g'BoF@gb$:Ah+l$gd5^0PDm[,7PSrIkKg;%=]gqM,<'Gqe>TB(^YC77FUN5G"8D$O%Q`hhD>dAem!>>*$Gr^hpbH>+f+PBK85MaA^JYYb2:0aE33so_1SA?"=uZcnDJQW[YCqZf=QLBg_7B-,QE!q %D*;=H4fG(GT!oD7=X9;\2hB0C!"pD13WMHC"EY7gm_qO;&YSkd3dl\XbHa!#Y$MqK"T`$Q#mmj,B,;6Bj$fZ[pdND$Xes^qBmf^" %1F1,_43/X/:0Lei;b3(B:CP2e_C6bU+Xk@L0?V0c+s8;R-R?h`.#d.%@-8=mE=&:7i#"`JK>A&*=("DcEXT+HT^kV>8A5-Egg>U7 %C>]BSiA$Kr-5raNMI)l$*^5>@<`j]a,mtrV\%<\`rWi='UaC?2S.:gj3k\^qqN6RkqCM@E)Vgo?p(#f6B_o3uH/2!!G1U/c3 %b"0*bmhUa8L/^s;:i;[(7lD!I^qcn=_!Gh>pLU3speE4G75W@M6m!W_^MJ=Cnmq^mX]L#,F(E=go;@UG7gJ72JaV+OHO].U?9(#] %>5m)IMeV5n3dm)b++a]rT2$DQdsNO^h[@KNVk^%)(DEF3=(Yq/-\Z\bE/CbN/NnH.2kBMj9`@:$o[9`;FM_81cn!7Pf>r`V,9u$^ %UYS9Z]'e@RDn8eAk^L4@]Yjl,]P)0Ne;]P5YVcWE@DR'9OIg2qc@kiZZ6bFg&.:n6j6EaL;mtpn$*2CpP?"-p<(m+ %:6IR*(/qRUQ8diie<^fk,GbR^:)7taN^_O7N*g6]eq %:32$]mG$_q+%!,m0n&0+'oX+J_?778Ka2"qDKrb_+Xefd0YAU:u\$OEen?]L,;_p=qe'SeLFjQ!UEsN5`EHBP% %oeq_fG"IX:/'JAO/W?,_.j)W\WG*uf[Tkqb82Y"&#'HS`\CkDh6o>[_HKpB6W]3+E80/uu.Y`PV)I+\\P/Q57[Y"DG@H9SGbAU[t %Tcf-Rjl_2r(iZK$-ZmM$?pNo$NoC`6Q&l`A:Q-/4L-hSJ,\Y!YIZ.#DL'V5)diZC.V#N2J7Y*I+)_%ZZY'XR?AUAPj<2cOkYIJ,K %WThu&$_KYn:E(=ON.$5;%_f[9YV<90V[q3(mmr*q_JhFV#P)h0:YV'b.i3rP3b(THKQop^gsT*C[M#USU+5[O-7RJFZgQ_`3&JuF %37'fLg@p&hSN>=bqQ?);gst<>2E:>Tn9sK3%m7;iP!(h*8DepV,tV02Kga@r_XW*;#SIftIh8+G9`$ %ht.?oc.ctP>3_ATCE$5\m$<7PpD-c5'#@fAUCAI`bV8;t;Mr&no9Ku&$p82F*(q`PX'm%rVsTDins7^GiPk$Nb392l9KC$K-#b32 %3d16=^0l[@I[LXo!$9LTGPM%-lSuO$K[-)m0Hkf[4ICf^s",;945%4BG0fk$]c5n$$cOLjk?]=7I7W5J7QK8AC<=DWmjCs#?RY&o %`cNt4!>BuR/K$+(BiJPcH`)e(M[6L7#Ld(CrnB66_#eZEjE+JriWi*V(#>\9LjjHsj1`o<>Q@K7W%? %7R;8AK]3S36040;^giCoNJkm\'?V`0_^_%C%E6B4aj2Id/YSr?DQKaETW)U=XR>%^Amq]KTJ'qeVVp]UlJlMkn"f:-CuFYp@.1&] %kbo@;@Zcf@1OE1!d`!M4PuB69[.jbNFDsL\"/,n.p(Carsr]OVX)?5`.ERTS8#RSX1;dNc=No %_2/ns**h:3gt^\9Jc7TG>TXim!WR?H(uo#YS6)reKT%m=e. %S^/sZ3e3LS]5VM7=*mkYlb-A\YX(Ru#/OUK(#C.L)BNmADX2ID-a>9.la6D]=C2pQF"rQI0XaOOk/-f+F7")DarnNE!9d*^=Lq %$SIQTN-B3VdnZ"W/^(dcW#R+at+140ejAcF%@IE(O5PDenXJp%5rhT8k#ebEb.A@N]!eO^o?9h'*nWL`"Ok!@Ch2^ibV!L=U&'9oe`O[arBQD/6FlAdIN^FAi"&Ff:Hb5D:VsOflP]0 %/D"1>cG8+El2L2>M2=F4Te>UKP&RfEKa9(l%u1#h+n1Ohn:TbX!(&Vd)Nft'@0&fI,Yk,^jr,2l>&DbcakFSq&->D#qbt@RDh``B9-YI %SihDpFN+3:eN]S'\Cau.\Z+Z@WTkF>e^r-sG@#$$.aMlYb!&g/W'M_FdA^%n<+Ri(DP^oQHH*:_kIa6I[s:L;nq[Rkms=I%`UMe% %jUC?MIsfi9Z7"2F.r!J8K,.\cbZns3UU>n-WCIZOAk:1>M/,^5kt*=<2mo7?rIYYDGlDU%hIGI8\Q7]>Me&_&D%,=C?D8H3:H^jAf/m'4$K"Hq#aF+UimQjtRPkKdEG^<.LkI$j0n19*u#c'k]9ik]eZ)AVirI[dNYI*O]a:23IYtNP'BRNG'^qW#0eoVO"fp[[N0F %S7tW_c(-q'=3j.fctKH,Cb;DBC(&7neuUg;g+q.!AO57G%C'njrdbP714iU:N*fC87)AB++AkZB!8`OjT`[9fJ[e)Tdcb^\MGcbj %FFjXP#VYlq9OUdAk#A`;lK[X9,(^KI"[Kr@:R6r%AJcf1U.o;@##?B",ULFmbaVmC>/Kl36=T<9FToSc,NF0<@/-Q?L!)T+Cr!), %gq)9i,U%H$V#19NEA>6IUG86Ml6_73?Z%KGFKOqX;ec.nO62=Y+)da(`m+*4f?R@O3`gaCXg5Y9E(a>'?DNqN)C'T=9q+/LQ>4`# %T5LX3]ie@U;;6&jX0YH!@7"aTp=WpZB<$!Ab>QYE*BMeWI'tC%6>^.$bE5FL7o%ST3n$n@m'^cB/5mGp_uL>R5SO;WjWrW)DL_Mg %HY@6ZWtt=eT=-]P#=o.#<;`n12XbY"64qkmq.]l34_\-5Oqqs+dkS:s:g_#"R7S">V24AtKs`>')WD+Q`FUp0>eA%OJ3ko;n%3!5 %O"fBm)^L[b.;CGu:c"e;BFoi^M+fedT"fZj*om0_jSsi_s'M<[J:b]$Z:;kD%-K!&Rc>cU7aK7+V#nuJ1M0a,\Yk4 %Q"=\Z#RZXn;S0udb;?RPa(%YN.[`W@'1dq81Ak2n)A!/SO,(OR((GAmjHhKh[;OM];e%s2*>N-sBb_@#%1RRb6hNl@o[^19[Dca< %PDcuThn&#RGF+ki`5MnA@KDL-#DlD@:hVtSY<]B5E39;77>Z6jPsAj-`:UG!.*/D5N(E03^a0L(JZPTC3cDi^>pF2870-7WN0G0@ %&dA[e5[>!M:4[Bd"cHn_Y\5MO.Ocr(hotl9m[h8BM0f*G[[jW?'8QI-g)HN3C@GW97\K/6U$R`r>u&SmeLX_]k0\thACN/UAshbi %l_fe10];*H@lM\4hRmgob,M\)/]D?@EmN7tSu>2`08aNr:8l?o/WB0)ggK1s>B.jYWQQ#6:#oo#M4;RYYEY2QY\!4!b_(8'B#chF %VmVhd\!3_[31AB&Sp%+`X19c!JfJWMKuZeaC`=jOUkPCLTd$N==1J+Z\F+Co\Q-sQ7^OT=ESU#ZiCC[8j\Atn.I"H %'S.T"U.Gp(9?G^r9OhqPQ7S]'!>-bAO9efo;%WsHjUGFq5("FH)*Ac5'g1D- %3P6+3KJYX-lL!j4iUWs(a/`)Jp]`d]osA%9[7Fr"L!b?fgX>_BXd,jpV=Ea6F3cf+[8\L'(!"mj*3:_Q=Ec0j[NN/an6f7.7!>eE %kQXC-';b*F<34WJR7g40='7RYIYFl-iG=KfhR721`J<(.,ug]_BZQ4:9FIuF$nk9_30YJQSIgmR]GDJTl(N!\RT\ %6JlAQ>^-&kBaMD@RW@/d1u>fll/)@X?'"IK]m`.-np)5O+HjrW#'JBEe@>]74Hio"6h$MDHg);MG`@5RU6hA7\#*"=+.- %9aP"qKdEY0lMlgU$h3t?+'GcG#H-dg<7/-NdTjeV0qa?%i<86Z@N#5"+X*oPm2OI'rIS[enMbPIrbc^a%"8IBI3j\Go_4@bliB1[s08?jI?mm60pVK(bdehK'5tJ?J-RL2YfNS^lI!h,@575X0)PADQ+g %jplThB`m\DY4)*QptUK+#TiUS[J+<@H=L19ldbWF5LR)]W1-`]C2C2\EAG78nFik%4tL*Z$e+>'keOFer.V@r+rVjX$4"/GEEoX^ %A?"QIT,MTB%\]jMPs8`;P#M:UbAF90.irl/h]+Q %;eWll`FM#.8"#B,o5'cO#nMe9#Z,6p1m/OV.+3Va'BmTfF]Ci,.#Y-#Q.F;^:t(^FAE!dW3!p6b_SS<6!l.[;'Vpci#gu<8$;=Ci %l!*O,"8D4=24Z$=aFM&0%PE"A0EdF^4D8CN5Qnq4+Nsrs3l%D5oEZEP\J]sLV24+gF]?ng-o)Vr+^l^[6kKu@Wi.Am2`4jL_.;7V %l83@^@?jr^Y>Jc-C![?LD(C;Le,]XZ(E/seU-PYJ6eiOlFM?0LPm`#g+#nK(K;,< %*_Q+KM04g4@oo2e?[!ppBPpptAoL>1ImnNoEN1+&:mrG%Rd+7J*Eo$Q<#"Lo*4J!ogaU*P^8JN=e!q;AC-(s_oOl1cqG-F)Fp)jesP^i'DV:]Vn^8niHMU5hk6W"CTIdE"p>Hu"MuEHpgkSe"inbD31;@+P9fJWrg^Djj %-U!*#Q@s)S^VYUKTUj#">]08^Nc'>r/]m)>=i1=Dd"hK:I")pZ8^YmM/>(u>pj#og%:e-IKpl@1lk5_3C6`d#h8YQWD]Q;:6.Wf,'ia!5SaTFaY,F)/U*cZY>B;\jR3Kr(M75JeS[5,9\2OHt\46QQXOO,o\?8'4mgi %%J5l6b-H*q)2>)k+C14`_=8mB//F/a&s.bHmss:0.4HL;XQHrfPRNe1Jc.'pe52?l"QS_%EirU`&4-rH^i0G\E&9j(.7G]/%2O./hYkSO %JG7XTr*qqDKC]O7JGUK[\0E)fgqA$5ok8\Fg"GYlCQ1@+ldII.NGl!p^=Jb/H+N4jWI3pfE^NH'`PI;'d).;=H-oBt&IntAcM5e`m(aPtA]dM7^qep3)t+\:4,CO'QtdIs=-!qLZciHma1.O5_Z-o'XD./%54,PYK4sL+"3^5Z<:>>$rgLE%5%1 %$C\++/c#P..XZJkUc9KAi#/&X12XSUo[QWL,Fj96^bPqZ%T#Il0N!'$)H/q&8c;b>'Dj0Cm[p]7hiLM1I/F(k^gZin_1iE-7%sZU %5QY?7PORM^nak`A\#0qc"B9^F*$/c@]gTX9YIK\i'cuiF"8?!+72QOV,^&\')Xd"Xl5tQ8JP#LEF6=,(;-Vf+q7Y`Q<*a]F3t)Wt %cLl*Kjd7"R_"o+'/M5c!?^X?mXIMb8h^Mi6Um"DCSl7reuZ4HLZc9i8,g(HAU- %m^A='==:OkM9'L-N4K:V,f=tY\@$kh!sK<7<(92A6Y#s,%5h!s!>,XDg([+Y70tgh.3/Tf\XFu`F4Jmf'8^h_4VTcK0M42_=NY%] %_,M?T%Y03YUG4_O#sl7M1\Mgp*WOincg+LH)1d9V-<=)pEIlB$4Q$_B4hGpuCr=eho(,lGfB]a22eLlab.-#iErSi4djsP9m:?gR %(>NfN]\/`sXZV4bi0(=%gF1-;#Y]/1D>:6Ib^=^YEa3%EZ[]b\\h\`tLI7h"dIZ-p& %]Pg6On$*"V\l=a19](TkJGRL*l;ZXc1%'f@FsM=6M0E"ih,V38<8KnJ(Bqgg1L#7gqC1;[2DffJ4jXP>STG;i %/i\"a5Zb=\5d91UhuQ`=nE>On$D#Q>*c:j=Q&a3epjTaA$`no&fX^JF7)&>!a?'>Nl--3Y"[.mdqLdLVA$3*n&C6'38OM[_pg1dh %#OLu;rTlLfBOq&$Wq@.3?3.jq=t^2$0aY7Q%Ts#621`4O.K9.X1]qWd`H?5k#j %:CWU#qV"N`(TrKs8D%/_Rl`CF*?6)KdrXtM>?,!,6;V_hAB0hN$`-_`as55smlsqfg/8X7^[7MunnLbPRG)*r91'6#PqH1[^Xbq;. %%,J06^t(&UX8/@Sb<7a:=Dp*7_a9!.Hebf3B`7Mi3LftmI%h5EqbRt+_.RuYX.:XTBl(G-pW&\[2J^Ok0QkZeWMsK-l3el7bbn%s %'1B7de,`\Z71k_\epRDV)=fDq3lt1oqDUEZ:7njf8Ri?2;NE*%N$aHjWM4nk+tT]J7=1WgFQ6LN9BMZr/hF2nfT>Y(hAD#2b%AIZ %?,K$:r[O$!Io6q',d2,?Rmq&-?`lt8ibudh)B2BjPE=>-^XQc1ce1>c2HFA_,['VT@\Gpc&%".-_?6UkiI,8;s"'@a8qa/4^38KS %?kV&d4h6D3GS!g&STEa)":sKf]inRcZi/N\hmSZ'/$+uim+`XWqBS1jN:.M.loN(?#@floTohY7#B;b;%u$c7aIrXC>F.uN4,=n^ %"+k.c5>@6NR98Ys<4YGiA.']!.>Th_rbE3L'+?ld(1Xitli\1W:Xn76;qOJ%T)4-qt[R$l4L&7Z>,4G850@Yb_u %OH?9FKXA+j&01kD.Y%Vu1T90"`T)+KVmt]Lh*@D>-.tf0@i]Nr"L2hohAaDr:&tbc:Q>J$7YhlK2648tGT1)oXuA<4M3!RY,M018.EJ?*>;&6RQ3n^>Ve[6sE4YPM643Ap6 %@Wt.pHX`(Hii^gGDC_GDbX,QI#Oe)rlspfBB=-Q6cMejUK,s(YfgEYfO2F;6qh=H=V@IBRRnf;J'?=c:#jee#W%,u=hL1Fi/AH?c %NfB)pa;FnNJVYn:9k5a3bfoGZ?)qg*e;RE_j:(N%RR.>c$Id4aFNii'kDQAXLa_JCbA0Yj/!jkh-.OAX3ssW %.>?G#L5MpgQB6cOTX_)5[$lf(MNk3.Jr6^g`\\]'UM9Q\hgHD.^]8`g!<^p:n:\C$f_:+*r(IE;X>WKTJd5BZI1dV!Zb[\"(`<5i %Dg(oB=8^?H"$aC0HPjJl^\m!d7IWa2WugG0Ga3UCVI,tN5(Vn8/AS1oXL`8YXpX;.[1D%NjGLbMd#>r9Ga %3I/U>B#]j+>PS;91sc#;lDDf(X#%ZE`-@[EDNI'sA5KO,V)FcC(FrT!VRgi$[(HYg4cRKiM$.MrRFQ(S_+VoF %f_?:**fRB9HY>h5?t?":&Q(cCE<7[?^`%e_\a6S^"%0bm\(Va]j^cF/-\q)JnD4ejC[F=[%H1V4_q`i1EEWt5Z#8GOMf?-_d@M;O %4P%PtDb:A@9aS+"$J]mQa!/N=:&OGPn_WTlI5cq.XnCIalF4mRcYA$3c28TjZQ6/\g96u`DkZR*9%RgG`hoS2f.W`,C;H,L-L.WJ %l.W3tES@iV.bs&dAa\C/QX7?&nboBbOg.b)1)U%ZXRZ>\@JeO3@I3.Z.:aR^)4oURj/:4nGD@^6/gQ7Q/*`MI0O?P^Z'D$I>+O?i %,ZJAh)Am8R)G\bN;-K:3AUbO=I`31MgN91C@\`>?l"3iaH>&fsK./=SXJVYb.OM,[(R,VEapF!JPA*?3!Wc]5hRe44?9Mq^Hla4I %6HVR[1![l(a;=&\6?VqHJqW$:#EeUG#_$ceqFTP?^?c62'$p2Q(A-6\p%u-+7p]W#gh.Ecj((9@KEm_2 %>lr,[_`G&0jURsVlcm%*=@!f8']l7u!g`_"&Nf2MNSOpOm1R]dTVf43'0S%+DBNp(..a)u#U+7D.SLZRlVN3M:Kss)Jn3AVqF6!- %+daoAX/J/o\ArNGok'7j3tl(tR[^@=[iB+bccf.->@f:hb^a0*M-6fRZ^`>ICpnp<)3*i,@c661)QYhCGZumCQ;]Z.5R=p#TeHIPkYEV[VrGH[>[%^+CVV0\;#l<&&Ah%4o0?Z`\oPR`psh+5L=+k4$%@g+3Z.:/RMi>$[QMe'fRCf:BQA/FWBJ+IDr/1]G/!W?93#5 %fKHk?n$WUChW2CnL%E='p8a0i/7rd*4`e6sbj?ni1.$.`U^)23=DY<]k4TOr*ELIl=ugr#L!f%joB`RpH9/2=n_"hcf-rH.pJB__ %`p/.KBBjA?459_oNP:a?5(7l64SjMGjMRmh%kP3l_Atu8\9WHpT&g"Q6T+ji]#oLK4Q+U0iKONG!7n7>IGO&uGee"O"qCN"o9O9c %[:$,ngm+?)Aod253ejL?E>`0kkp[N#AL3c"dFVXsbE5TV'iehGeo.!i@]?$%cOc>;:m_Rs$I[@cJc[,_JeD%g#Sp'e>BjOC&3L/P %SnU//-7Z[2\t\f9PtrLZEnJ8o/$++WGjmRm/<8"44W$K&NZ\:S/CL0e[jL.m&`7U>EmDEl3S1aOYfEU:c(7iYYs5HJ&ahsAO-A)3 %anRIhe#qZ/T#4VpJEJr<_h[tp0`X&WSjEs2Rhap.]mBk6]+MtCiffm(M66tpYFY$8NH53T*h[I"->sn1dK7%'JFdmF2KKmI,0sL# %:V`*dFk(Gs:;cUZdjOI"G+A$f\8_.dD9&L8S(Ck^?ELs7_r8a#h^qJ95C>;+(hGF(dm"*)/mpG2^(OOo?;/r>$dI2@\PP\7b-O!1OjTRoI858Z0--QNg=_Lfbc"=[l9WiBf6j\_CLL+K3LG^sHJ+=7n@hXUBM`_9_N?`YtA]te*^^+L'M3(=JI4WA8si='bIL.;L+%XiRb %q#9C<[kBu=5C;/>rOi#6cf7*2DRD^?Fld(J_q!5\O7'A6[$9\h`Rblb7Dme?B-4ei8=c`dctb+E])CiIXW8S3Nm8pohJcnXg!JlenH\P'@&9i\Q4b,FO>rh1/k %O\EV*5^cASnAiRVqJV?(4j>WRRqHIk?-cVH<*=`(5pjt_(_f#jW2upc-INGY;&g'uoY\O*%jn,,W&Q)K#ur;9nBdiUThfV?@r1d! %_LnJqDJV4XajT9AJITlmo9k7:s.9M1\5;']eG_K>mTeu1;#8&:iEaO8dJ3%om"]DeF5cX74ID)r&egpGJg6&p9fHtF!m$fiCRW?$ %>m;G^i__>HfU_4o=&STA;W:GFU)Q/E';YJ6PMAF.2^)r]n=BtLFb#O0OQQg2+YqA6%[olo6ju@TJtA8?IIURcc&T-$*TnL!])4pB %bOoYkDdI@0rSl7\(DfhHa#gGl^;Q+I3fU`)9MJE@J8c#JYY$&S)!Ol*=fQ/Y!hT.mKVV.ZU0i;X8Too19sE,%HX_[H[QkXnbQA_= %6,"d=Sd0"9K^+/d'El_Nhl`_*K>A`,=0`s:.4ukpN`g%KoiOus[g$hV5dgd9F[B`OP;7Nag&C&"_>*S/:_O$_Jl0R_Ur%<%B%nQl1/Z$#3up7j0f"s8;,GaS[m>j;9Wm"&@aH(;9^c;-N;c<#F(2@%&;auqT*G5AYf=ZeTu#h#Yui+$lq]9\]Ali<2qm[RS7B.[@bmRM %$Ur"q!/a=C#a)';=<%@$!`Tf",0tXYj2C`5Hc'djSSY:0Zs7EX3)a']ML`N@#`*@&'e_'\MM6C]dJ,G_.1DG"UgL6!s3.ZULKEqN!#9P!DoGhW7bJ2i!G.uk6oXRK0 %gO86SP&J3lcK-]>PHm`Cnbn'kd`AKJ,(!G[K835VY^eHqU!3#_1A/FXfoU'L-jK`\K9TYp'Gj`G(Epd%Q0_)dp*&Pa_BuDd&SOL]*X6K*;Ir %)](#_PB]D]^h32\RR9ATIL;?k_YL5t_)tOQ%o]P3b03-1FHEI(Nm,P.(kWoXQ"gE_Zi+KP[krc+N&Z?]^mmk3BsNa;`d.q9fS1)W %$fZ"hi`;0,,J;>,)mCBSkp(R$"Fi6BJd7r0P#;iXhm+*GYmH&]r`:I2TqP4["0C2T#o%7A(PD %6!FE)0of5*U7C.+>sT=$+/"Dm*1fVG/mbg`iLY+9l%<=Mrqp5Npdma8BCRX`duAio:>nJNK^$;$BgbSJ/G@^EM_dK4)BFNTW%`mU %#';O[3]]#O_Em/tbl[o3r-G@?qF'6P+hS7sEEQYYj+CUfa#>Xm%=d_sQWj)*4LcWrln:%eK/:U1I"nViYXeE&39r_LaVfC$k4Rcu %kR\,[7<<,+03c(S>"i%S7M_fI.0Li[5_0F/,ePstXsCiNXs.o#/q:7s:@EgnHbAaQPf0AO8YU;8'ak9q^1nG04[b6df9?rtT-C0W %7YNOr!#e>,;e9[D(MKs1T,JYVFn8#hKC^DF=/u+7+90u9@J&)%m_E?#i%sfB9CCF[#X\KCkJhrPiS9:D'(>d,:Z/p)$RfAI'bFt$ %LZdLtSX#A&%Y7Xh"J8P%T%l9i)`,pGINiGVL.!0uaX#`Hs70AP)?6kt_h5t,V&catg>?FHkdN!fmbRM!!`;[Fi5*gm@jO:%n6e#c %=,cb8A^IT%FG)^_m:K_uJ&#R8d+kQM0/-<:m+j";?jA^9(8SL'dWXV*>Z&Vg@#l%YF9615\:WP46>'Ps""AR%4_^gi^OsBs4T]*a %N'gPO;0X)IJ#"qr]r&O'ioO2H(gOgqp.G4=CKF:/GiurML[?aEm!*oYjnbCnp^fN824=Mra2Wj/KBMEC1W1'RP,+l3foU^d79O&q %8_1jH'!4HOiL6Ff$rs52lB[",eY(cZ<+6&01dKl+O?47hY5sJ<,'eO:Zt0Dd^/73j3h:k'QD#k!_t7&,$+:%p%Np0Im[teBbA=C\ %"%:hib!Ti"qb_LpTI("M),5tY&Cb %!(+-4Oq;jlhg!]pCPXXJ"&Js`^T_;KT6oIF^;NCR,%c`Q?5!8SJ)^El#7Q'0K7EkFOp7iU-4u %ms6(7I!j3WlJmo=iJrEGE^)j"SGdVi"o\:8SDDL4l/`>g?k;t>2O)HFdXi827J$2rB2:IUV7UE4Ts0YT"=Dh4dh"-@et+5#m/mYUE]q9[HO)SV,IFs[2/NW:"ip? %[^.=2[3F#+,2&_P4/f,8nm$V>p!`$KmTt:sFaL?rRkrF3*VmK^IHHP!_LMkDf)4#)qGYCGN'.X[?JUI)@R<[Kis60Y"8BBGCQ?r? %!02Y@TQn,:Y6/5#+O.SJhGq2C>ic[\-fMmkC%/Y<9g+98p(qqca[J<.FuR*m]J,e"LL'b@;q!sFaYXmm0qbM%Qb,_OI,WTJi$aLO %TFq5sS.$2!;hF@IJo/o/2B^26PeIg%m0PP]60'X0k6$lq6+RTJEs!`V\3h=Ee6Tq?pihr[WMKq#6$CGRNAV[l:YWERcUaX"jI%u;8C0pV1mg'tDMG#"#m4P45@*NjaioCLor@aZS_Qkp0a-1LTQ*'W#c"k_ %j4iSt/qd^Q>sR_!>)!@)=bb4*-_+_N'=)*KYLaCjUIFB?@1(jfS\BM4h01))_A:'MJV`^/Bb<$t!"iEAV0g8u+TOh7Q/SA[&m#pL %3(k8h8OCQGBIo$,!npO[b<1O2;JM9sI]ioicUnb_&S/J)b#$Fr%2r`lM%n="#*'u9(<#F+kRkO[#@q+GS[\(FZ2"0B49_OqY7pE[ %pbPS@f6d:VGOgXp#[@]S)2)?1p>E8)G;#M)XKBQ-;3QF52?2J%]UP6;4+F3r\p(IWa)g]Uh]*^(iio"p\U@`N.i,0uFL6Cbo:4!M %q4[QAM.:,/CtA&;cS*%(_`Qh_#Ru-oP,CsF[GU!AGIk#`3^;-%gdNR?S$hpj,9s_%LZp1/Le=%>+*^tRB1I.X_OZe67hN6Z\Q57R9&G9\m %WW0+<^Ag"F!s#^'C[_;KD>S)VRf?mR*;T)sq-!ODrj&SCo%W%]\hnU(rj9ZHf67&U>>)']E<"/h>cU8"C/ %-CtKnh&aLAX %lKDZ_E7J3Y7G?b@.RGqf8;?.!"c@m]1nJh7)JRoW/JJX''P4+s]_&q)kE`Qr"&[ptbm?.B&`cD?CoK:*$R_O%_6kP?`].oa"^FYF %20e-P/OANHd-<_>dn2aJ=^OIbJA[S-(bulb"Gr?ldaV!',$l2kp/htP*O,'OKL??nmaWH+:\8T[nOP@45CfdtJ>nFCQ:K-O//'92 %5_/M8E#9"r8B1#2Pf$C.nZYWJpa7rT0o.#e:o[_ZL6gMQ0kKbJ":-^f*c$pV;E2 %*8f0F^Z2*e-4Ol%Tbaj;Y?YhslfT7r`t:WA4V(?G^/f`r0!hf@]t$>=Xei8(-3)E(_tQ22H_4U]pi)G?lJciUYWgt-Nq@?VoR(t2 %X5eGM?>Q:,j4\Wfdkt]m9#=uX/>]ad;qID:>0N:;WlNb$-CsA6.)Xq!44#6#cn$\d6(4E;N5t:`?*e"qk9g%m-$buUq3bk#0$+E) %PS7`%m;&0S0gEd(=\@VPN0%&90q0,f[Pqc74-AuV[G((qD8=E-4h'_NFZ(8MgD\Po)LJM2k0Bs9lYY$NgC@J&M)\iaHEqS[nKYjt %Wt(Z&8\!-3Fg\@,EfoQPF`r%l1`>\JReG.#*W8q2N]M"JkpiQlX8i1B3.4U1jg.@H8u9B-K@2,(nZW`eM.@" %>VJ*D!6m+t7(fBJG+C(-h[Y4Ym3$![aS-mS]+AFp&GFab,!?'B:[V&".*0-cC7A3KuUp\/sRhM0s$0:q> %E$?B.PCRP.-_E`[,8B?32,A]G)dM[OR-S!#h?G'@4F>'LR'\#OD)kulq %0n^cK@f)[\`/$*E&uI(O$$\:g@t:tA2"0OZ&5*SF%`E2gBhkTo!%+\Mh#K"uZ.;GkBDZDcZ+$db+4=1$c,3Qfa!+k(TocN`pK\IUdM` %?Z&`>B@8?j%PTaksFQsGrnd^3M.eVFgBB]7"K,XM;,"MpN'=u*VG!8>J`GUU@+P.^NVU3 %_>2:[(\8\S/:;]Ao\lXPqj5GI>d""p8kmq1L6&NVN!sfJ=af)1O%;X\#b9H*Y[JQL_#YB7+]O+R+5UqEf`HBc6=6%^klP0o!VSC' %EB/S8Yd:Z?]m;Qk8VG"\^$kO,1D"d2]/9)2IE_nF!a%JQI;P?/5G@#&e2'3`b59Gp@,Pt1C&[//2frqc#%<.I.rTG]^Y)r@)gL@P %9KSb-3:[L'*_[=c-KD]AXF%`#+8nYE$-me9`CC$+qCa,tk%TB[GK%6r+O^djkmVUGkg9ln5[/'+[of_mE0;S+b6),8M+_sknN4f8 %p'P#*9u3SPU^s_\9E7I;49tp`0gLg+%iQ2hP(4YY5nGHSAdZ"F+rkU4(4edJ.T$2$#E$Y:3(1On9>ZpX6tI^6(FLYC"bDPMd%!X? %:*S::;X2\$[AffR#LoO\C(gkc1C.V[:eb^jOT@GQ&S*so\87U.U#IdhX_&Q<5p@b6@cqE'f$:%:De/:D,6VlgWF#Adh4S:3W8%om %2qArKNA7qQdA#kk)QHJ5?O7)G(_e"rJ-6akd%bih,j_c=#M+Jd>?$=^ktn<`AW1VWo!23I$X;&eb@=).):GLH1KQ2l_&sI.+ot'/ %O_3hB]mDst!=r<4`o=ke)#mV6k&(+/#Q3EW"W!!ugD`GN8Yfd9FE$19\PmV[[0VhShos/r9d*"<;W;?WE8SakY20C"YFI"&?Ck,' %'O=pfi)i9a7:'BnOTSZ[AG8;Nn`>&:E::1FO"Xg(=]U]s2tMQ(?F3p_Li$IOD.USEUIj4pMS1WD"hqX31\S+_oNBFHGLbX3WkQcQ %V]AnNcR%F0*I%DA_QB[L72YBVKD^!\]uRCTURq]cZX-KG,WaD?C,)% %S$BXZe"JtWV)`Hq;ncI7^'?b-]T3lr5#E);p?966[8H=NE@mF\c8MTU3-XS8a0DP5D'-?-N4nK<%eOaDT6Qn=@j;)WdD;NM05_j! %(]k<;D+*p:GR,dd+=TY0)Z!VT)B+KhLqpe*0,5:J^We.*^HW!_Lou*Erj120eSCAilL*Cr3CeHT`^&tUJN@I3j;dJe]1]OhH_D[)6U3A %mK,,g+OUF_*[mKB,FImnbP/m8H[paW+O6Ahk,c"%[O;G/o+Bo`/&WWWWj8k+$<)Tb_ZLr'*9Im,i7]X8RmdEE!!=&<#c"X;a@;_R %:rX+D3/pKp*#=&I;kTgF3B^9D,"\e^+)!bB:*a=b%YEgWd$auqVh3WIE'+fcQ;'Oa&$EnZ2Qsu1Bn=u4kcClp58OD^,r,KJ0OXE1 %MBG8O6AVY:`13koh6Kj:`*OY9R?ZidaUJ5?_Ei<$W/C]d#LQ6NEK.5t7Jj"HahZ"-4M\d;2&$TFK-^`O>o"9l2)+>=Fuqb@O&?[o %?C&7dBY)+'*2h8r)]Q)i/(:m9m[64B?Nb(;7h^`0:*M%i7h0,2]\%-A&0ah]FIXW3#E_6 %=hiuV:H*k7=7(bJ\YFd^c4Kuu.G8:#\U_NCq(P160$ioh%UM75.(5J>nFVQ9)OcY:\jG4)smS/8*5K/^gIc@1&m %1=FpsI=%C2GCK%,gYfEhJY(noH]Gjh=::*!?$5TX<;5:Lm-?e)l=S_)cE]B1oD5V#^?Wf'4r\^T2<%BSrd1bE\!nU[2XKn2g=q'% %W1tR#@[Z-,,*+NK?EH6U/\,18EkGTI]V0=-c_EMTXWbmLGeRs'NXGt,Wmg=7dd<'+fN2_>*R!/CPNu,1moGY4hXYr-?ShGToJ"(o87j?m=iaO)?3gg'HIf.ML2Y,dW7s&?TZj2aC8=CqX%/4kP2cgb_#=(6L %2HerJ[p'u)rB"kbs*4%D#7V5,kV$]>cbB%k[_14ml.pfaF5]qSVt/BjD`0NE:7m1EPOW8eBp`@hoD%pi]e!t+2qn<*[@EC02!7,N %Ig^=WX9d!'#I3>qKM2ou$">j4uB"6W$J!9!,%2Q;$/2LB^=3X!3 %8k#/AXGmJs1I-s.I_1eS#' %F!e_X%qmd<#h-RE,=/drpcY*4e5fP31EeQ1=VNrm35r-Ii'& %'u(+e\B(-=&na(YWj0mH--ds0/8:,;"j^(U.mGi2qRgsU"7mTE"6A=fRY"ab$`.H9mMY@RkoP-T^(Cig"j8t7n17:&VS"hGB[(;OAY#N5]u\1iE"'p+51f-,_h/!X5eD^VCsi$%g,/j"i,]Fo]1Y1m6g*eDI9%Xi4W<13T4]J+@-;`R%_q'i+Q4.+i9TWkn6e$N9RQ6-k0MZn %k/,K^jlY[HF$;A+On#e9$J6i#jM?f4"YtIFM=)M2'@n?[nAaY%K%S[Oc5'Z6>*I+K%VnR]t(HFo+]]PM[;uVL*R=l!eR.s/qhRf!1M$&7g.Q4YnsK$+C+=F)T0CJ6qh)A3"ZPD %-=]gsJEf[%*s:6F^mRl84qV$0!S>2-=(;D<'9uL+k05[oJkHk-^7cgC/ta==TckpsU*UN=3$U(uH>\g@krsLZ5#;st_ZT+o&+9Nh %&!.,Cjl^=Y!SB[N#PXU\J1(Me)_bMJn\J6K_4I12PEG5chg2*'#!`r9D9am3]Pj"96YL&>"F,!I3f1_)Zc)!n7%dbXES8`Q^=2:-mQ %o]>&ZJU<.Wm[$:SiE^*\LHC9bK=A<^hVK^Yr1NW:h8B1M$=@,cJ)6dIFWpGWl%I@5K/X\mY15sG5.^2l7EE$WHd3?&[2!tpjLH;W %g=_SPHn=*Q14H_89Ac9adMbW@Pc/9J(IK+Nb5:$kc?ug+9 %5gWYA%K1V@4ni-e2@+j3QeB&h(-KepcjmmD[bfuXe#)r%#=!Q3f3Ql9_h41pr>Q81Ffg!0Eh4"qNLfZs"03blrfcP:@gkbGlS*hAFBLi85hm7$SBLkbK.!K0Jo]K %/Mb^ZB!,^U7!#-"k"`PAd+Dc:kj<3Sn$1["A#7@l1P`*0FuCZd2B$Gb4F?%-NB%@\oF.S(Fl\>]B_R,kU/Gi0)*4ie=2ua*qgc]b %R$n-8A.#L4+R2WEiHQPO'W@&pXf7[$);nID\6Ja.L%X<_:SErcUS[TGlT!=FUY;Q2G2_K&>?WX.7c7&jq8EUc6g,6rn% %%cHV]5]$pW=%C0EYulr1gcD>JF+_W %=$]ZE"O5e9:7^S=20-\`MX:@Yn^#&S%at!iqNf&1,M@(N.eWRiYg=4b-6),.8VS3Wg!_q-h>$)G1Q2mV %C;2m6*IM,jVAGL`T]j_sQ&^V=.PmXFEd>=Et27;m&[^V!\LiIj/!9;1nP;gb1t+8?A&A%dGe!_b!c-$GfRIMXecp$GAND*Q\N]#d#3H&PF+Zn %"W>G0@9D0:KpXF,R_YH8c@t-U#sXVH)WqJ4IWOW'=%G$*QnaS[%ukW8&\d61H]0a=cCLUWYU4UR6\m&NckpP;Hkc:PJ3*g%#.S#H %NPE2R]d^KQc/VopCN[eF,OtK(;:Yp0CK(-H=2`k%k.>!"%X['(pZ4]c86=ie_qMiM,9OM^5!>lppLD`XBQR8J5<@6mYd3`sOUk7Q %#.ba;F]EqXWqc)Qn=+bnDse@QIsC2%KDbHS$U8`u]Y/gYc2C"Jn!1Ps?23\c+TrNdh_'IMB,NQ&DB41KZg@LqZ=d63]/p@lFQ@Xi %AG9?Nm.hgrkoo@,;S`#+pT_A=u:;UUG$JgpUpnp$0L(%&[:41i_+I,TQC:d6rlSq+io:adY. %B/W]9A-Ub!ZZLku?J&#q[I+&*HL:DIS9 %B/F1/>B8,nRAa%EUoINnT;>YSC2R[Z9r.i7'8=:7#UF(Q?"0nKheueG`ie[t9g5$\@s6"l\20/"17Jb/lD$X<$FV[S<1YAAa*Yr[ %!s(NS%7+E2pICl%KjDAWghS,t_?Q:gN&g/rWX0hKL'Se0T3<4E?CGc$)Rj.YpWWFhYrq7hJ-H,`=\+Wd[:)4:GoP_X##%.C_+An@ %O5[q$7>CW'#1uOa%-b:t#m6-FO+1SZL45;!*ZKfUQsmH'!,RGk]3>`\^jnShq>BsBMoUls?FbKul2$7L[_ndjLB:WdrMl$)*)3_S %aE4RZF#]V%;]N=k4`G,@hr=S'ou/Yo'hM/;AUElQaF/64m,d%J05d-M4ZNARlfcA:`s-j)#(#Qh?8Os7miEHos/>kEj77cAj^5UB %+`&he$=DW2I;q(+g%DH7+#73Yb^4=cs2FXnA/)\h=k)&+%"X(u\&s"Y%n#AGB??&NKhno6FLA%&^R0'1;VI\b3Rsu'2H]6YW!Jg8 %ePfCH(8+Rfk\E34XDun"_V&\#V6TINV6`'2e6BOMGFk+bY(E=H"A,*jdsqQlPORMF0DM9acuu/[qTXrIB2%EF^n^^?8\?*3+rZC>g7s0(h*XP$L3BXJlhsZSRkNS.*d9G"=*hKi,0i/58WpAj\O)OMd]k4"@UIIp %M*6l@/f[N[QUXY5I))sE=ggMH=I0>_F24b;R&O1-$>NJ7o&G_sX>MFP=Ql/DU>`@:"p)6f/^00^3L7:X/BQ:VX[9r9\HE_bUS/l+ %;=qk\i-5U&3f0.?+WIq&P2egEGZVN-JN!lDik!s9H%pG1(G@Q*'@mu,f6]piVqM;arP.KA@h0o!d %H\XgJWdOiNir+MI"'lY6T,d\%A(cW-`?-.dIX$,4h>Pb\GDi0BJ*52EgI9I8HuQ!Vmrn]R>IcD)A4$G[Gh&5VZEmqJ'_^B5G]l7E %8OrKlb?5A1o<+TS?+WX%PRNK[V[/F3o*l+003rD'ETKsMq@n-F8"YG=r>:jJK^7Pe\!>W`>[o%;`k&agd" %3sb3Y?TdSH'B$S,;$!c$1>6T/ki#(Sb#$Hl+.SA35o9TR#NZPD'aJ@$VaraXip3qdnJooA"k*ip,!f_-FE*K>#"rMGq>[^D-#cQu %?$rF,IFVN'p:dW1/ZimgC2If,>AMd?=R.PR^$sTd;&U%-+C[HpE(E5:>iAX-.[LDbEWdX-5n$;GE'qD`i6YW=]eatGqVQeUB=`9Hd^aiYr-f7RPA`[3g%a\.T8k;$ro'VJ`Zsp^7jdlKVL]p70+LAMWH`4@:2l>Q[UZY]#d\aR3fIddnb'c:H] %WSaBJrVKB\3&1)LAok:^d@s[_>FQNG*Zgo72@LL[cXT3q)LsF;X8t:lp?WlAmpoqR@o\`*"`tHJE+^GNKg[YW-u1*b%Z*@CWf-PZ5C`G(&9)4/TRfSML(>1Vq:1&_1ReIef7pf: %pHH9)ophBC/0/<8Ic/2_UEXOZ?+2]&HtJ3#TbmE>RL$'Pce^>mcA?'gIa#;<7ZX&nIb7_#-iWW2R(6.T(4:kgC]I_d40BHLuP_h@I?YVhRbJ^l#t9RTlur3?6fK?P;2oI]'tI8$j(5@q-Q[WXc?Z-rDP_YdFX=gpp"9eg]l6 %CF_dfKn;EQHraum9'h8$=1rL)]#9:`a'X##Fi>*n]uajA\AYt-_'.rmrQU7\M4m\W.uq_Qlk;]]-!`NrEg;c+nS!_E!2N^2DU\u#aIQ#f+ku$dLQQ10n>Hd`[]$0Km8iD+.gYjI8euLD`DN45`1\K1Y]*$Wk8Q]IJ+bEhRLkVf_+P#D_>D %Csu854;e^a[*\D=_V_q]`3M5#X7'!118b]J`C&\9n%T9LQc'cs5N79K83jFM#H8U/H+OD8H&DXZa-mf9QYmt %<6*e-i7%DU])[9+OFmHK>6%-7pook,9?k^UY\8q]NNEhI[XAgr8`AfZ*a^^C_TN%1Sb(:?j6m3,^NfR];:J_oS-de3MK1P2F;T8I %@8N3+J1(Z`L/\,Q8!.T<8Bbf/lIh"!\%Y(Tl:_*Q+bKuY)DlEcY^sm9Dia$?mLNps]\pHAiQ0GIZX;KE+$$h8#s:8;CGYOem8tIr %nap*?Y?EmtS2F[s>Sdpra_We]iqV+[f*>P]o$)0l^\V?TbUMSeS2BC"pL!^jK@,l1NuU=l/hT-aS-134(d;o[R.7AnG4[b"1SG?O %aadSpC)1pjc])]+j?<9bHCu9rX$'+LBU1U!W[g\7$hNdthp+cMiPf-;)&X9j%TCS`70QEL3&4(XLsJ?lO<@ %$#h-A/iJM')dmm-s6L3?ku_<4N;uF[Pk0kc7JRmjqd_k"8DZs=ch=RCBMM6k/S::.S"7i)?!=rZ_?QPY$+2o]1@pSlI!Pi)299(r@>6$bJ_k)Ne[VK^.!pS&?< %JC7D`&tVI1Z@-_EIISo0Op"g2P'^2dGP,h1dNm.`h]iA,J,RD`n#`2Smej'E^3I'C4!UDJi5>'ef?F2c`MS>!#<&liG<"%%3;g2\ %e=NLLpT6Q_GNJA..qi-'m5(RUnlI378"u[^=2j%/8rr%*Vd#+E00c#?Hh5C^COTU!pksj`]sqPF@+0hNaZUl]X=*k9IbU1^WgW*%^]-',ljIb`,X-X)Io=$i6\`APlK]].4s0s*!Q_JGa:#lj %%Q/ip%0.Xd1'#T+3<8t!4D=KtA&bWVpP8BLD5WW2ruNh,QRBOlI:F?VP:**qL?`gLS]p/JQn-QmJ_)MqU+Z70??3f(al1sm1Jnr` %7g5Hp(47kupgd(Fam4M:k^\lK3%"^#I2J*IbNcA<2S.rMt!V0NHD*9`l(IkhdbV*?^N6!h@XNrU/A'S>Ko@:Q".Ef^i@[8p;&GdK?r]#,ifi#L)u!_EDigLGh"DQOu7\7ItgUuYuq_@4h+=bneDi1J<8qG.TH_gG=tSe %\$u*e++B$p!'ghp%NL:CpVU#nmY;*&UW8B8PKW(Adp(SYg1dap\[f9:'gYF<0iY.6W3SuN1`jUtNXBKkjO`9)_AEE-I.5ht-YWr` %R8"06H8ic[Gc:T)O>B6KSH2aS++Ce.SGa#%'uJg[>WRm530";hrV4I:=&"-jBClIG=bf:15o!m:)#jBMnK1MPB?Iii^AH'7koUiB %r$?%jAKZEAGl!G?He4;WD>)n%`lPd\$lUr*>PW0P!,u`PqtH^)%P:'/WdNSPF8L0I")qt7C#7mCcET4EDgF=^(TBWqBs))Ni %af'+-OK18M"&)-sJJ0:Yld5o.:&2K8lstQCGeu>A)cp1o/hk3@:LEg,!*o3%\`/%-lV7\pic4Urq[ouhA]VCaQW]Ud?mk8*W:OBJ %I?%l/]+h1>!JlokpCbafh=eS/A)C)P&1+sM-+N%-4S!C#VHuFGF`PZnrJT!jruQ3@RW-[>B(3W=58]-2oA9W]mn^V3!<:pJgC6\? %N;$)MrPi?:B:dlXCTsq?kE-M)[,7FuIq/KRX66b]5[GOe]JU[Ss$c"4M9$KOo_\L@!T/A\kaQdY\Pn9cq#4jinrM(bcf*NZ2"2#I8G>Q:HOqh' %:QtRL<4cRS0'isq@$AsS\q>$\PbA_!8L?b4,?T.2>&D%an;k,j\LU^^(boKQedg]B"+3\nGMdg--n#A?"@h%kp,RUf-%I'#>"S*Y %(eVe#KF'QON=*X[=Md0'k`'L]WYO@k^;EIUd#U#&BRaR\f<=X%SRT-@7)7Kt([OB_kaP/?je)*XWIQl[[A)t\0K4]Kn@_ruG"OIb]+CiHP]lrc]po7J" %=\G)6mGGZC^)u54)?>-b?O@%L)Z'2Xj,Gu;*#ok9)g-(bORA!t3KrrH%9cbK@i5u"Z6OLU)O7:jC7:qE%n_M/fu]Wgii7erRmHpi"INKh1Q1q`G;ISiRXMu!jc&5hJ+c+;c-:9gX6"*"@iJ]*=.Q*?*d89b(pKjl=7> %Q="!+2eUpcT>tf?5@N0\=8+@tgWK.5VV-&Yr-F#YQ"]AI6ubJ\`%S7HK3)n.<)/@7SkOkh2Yn6+'cVct#^Sal@0@,DXmO,FS5:V= %]6dT>'jPMaeqr %>XT4K07Z%tJ.@-/m-X0T$3gFrJC+U$iLP2L!?0>X)rT7u4[8r@@s3#R\0IMH&^k1::Cf9Y_3L&>FGS0[P0f'qE)8#lVFsR7U/"Tl %P+.eX.mQG1q]`;Ih %9D:#1odJn3h<2<_Uc@7gZd";4Y2Y1R!iB8GDto'lco$^DO>L\jSQ\mc.kUQC-)(ke"8#TUeLT" %e`Tu1hq7@bF,i_0[eS\%HM'`(c-[SZ;_B-6Nj-&9D^WYGD92=Nb%)6afSNOp-0`]DBhnDcR)3'NGrM.cS+_+dC^#sP[kAgs^9I', %^n*O2XuPkjfA'';Sc?4d?kRL'kp>I)KD^+M4H4%*>_jg%99X1LT4JfYJ48-(UYE!'r9+9)^b %ASuCg^V@OCN=D)n]RKg*Bk_9U:!LS2&%.E!S-a:@mgn=,RTajS!/+r-*bF)b6!.8T%tGPe&%sr;lpQL0]Cd8Y4*na0f9a]']m&d1 %k>NII41lV^DHt-U(V,Pn6^XX#R'/Kt?er-X`=8k3*&AQP1"`Eh@Pl;Be;GQlNZL2q_Shd3fM#"Y]dOLKGQ;Fh>Z(TWEJIsA"-J%b %q@+K*BGe5p6L$dOfW`>Q(B[:Vaju)K!71,RN>lNSQn`0pP`J'.q"sNp5BGtG9IA6%45Fa?H6RZJ@.*LrUQWb8bmKN6pbX$o:]cZ8 %PcN1mQ+ZBjPqXjQ-N9H0M#SnW=3L4/J%F;#EjOTiF`tCoWD1[hCY_ag^W\U^\F*I`mOM'*.YksXYE\JXQp?oZYZ_S`aTA!K^Q8[F %%]('"="_@^"]-&'&E>f:Aee'UsN%QpFS.)q:4oPs4b+mlKVO@c221u:QnBY^WNq_PYk/Q$Tk)^O/k3dAH@=LX2H6AX[SZ`b1DW%!fm?V"j0rXG(oYXTikWK2&L>E"V\3Pj9UoQf2)LC0LJFIkWbkaPGWU-YW[Z$#ClE)ciRM16c4TQ*IRB2_K)f7D"q="U)8XuCqVbp1"qD\bDJoI3_#p2A!JP4U'[Qi- %"+4al>[CW=X8T!J;Orh,91VjS.m7RrXOu0[2HGYfd@fjE,*Xe74#k6 %+iNb$?FX9iZ?K0W?YMR!1rC+0G8-;4]o$`T_2/UWI0rp/!<>\>'mKgu:lsg:R-n7EaXIkt(C:03B*K.@!t0!f18=.">CJ8oou<`j %j6(eLr5VArB>.gHeLUS_)23H!)OWW)qCdS9ffoL=5O\WHu.BF*F"!\cpGd*$_.`+HqplX %ijL-sfN\u);gd=;d"Au!#N8Ch]N`S]SjQqdP:4O-mhVQ*/k*8*MJoikD_=Y&2X!q-85DW@%jHo/;QIHT %%'J+H$b3Co],XjES?8g,M'mTi"$j:8S&E %q[X]<)@T^NSDpdq112Lck2kQ<0/kIA^qZm9TthP.p,d'g.R4)\)/Tcck>t'-iE6OHOdB7NZdtlqS$(llRh:efF %WDu&3]e;p4%e9$r*][qCH=ZJb#&PrMYq>VelV=0EIYq38RTHK([k-"//;me^(V1!ml(.l$=[$5CE@0TJ?=7!]pFaSKPR.E6mK?SO %>V$\GP[jq6Uj>OX+8Bp1-VM/loBXobhnFqpo,@2]bK+=+OQeFms)MBEBk.#BHLT$I`-;EX$D_BG8^h!6AkYI%]S_+:l\scaWUq#H_9Fe1GiuAMS@tD2m`s'uJiNWh\"'aBfc0]& %,,G+1aZ[-@k^g[1-ZMk\?5l_L2uLKHCug1lTea,Ee`24?[8j&_pG6$_%oHP+;V.38J8P)O.CQ:rpI'3\$47+GP45Xkh'<#K"8A#F %Aeh?#])T?]#OM_eUSt'TtjPBd&*,_o!80%r_;f,n$B1*qjhPHKEp4A`k1-?m9Lq*0=qlo!_C0K\Cs9gh&u3l %35ZR%JVlO?PrV-A!rqiXJBI5/Z4&sJb+.D4S;[-)fkFN)XYtU:I/95&h]FQ,#?ptkUVQ#F6sM9iPjI0F6ZQiPC!CJpN`C`m;dHHqlrl@oOW1]^G%8&]:E+@ltRHaflQ2J %7<4bqp.YekFh>/AoG\rqY`TcB_U-%7+l*o.m_P/Th\'M-+]A0'.fi6*/j03C>B/LfSa6G1HUTOiB&]Ei %g3R3KUh>-J91pL#![56-(d3D0!f[:JV+j1i(Z9oJgfbDoF`f:BaE)k\+#mX>SF1V%SCQ!m+UoH*1i1NhiC!J#M`Y;K.gGYk%3.K@ %`7UD'34lC;Yu,9$<2(9I76sdbjRjIINu\&Y=3]VTF7b/;X2Ss7/%KPE+upFoo6WLkS)$lC4`bRn+auC;Sk:irK?JsT4@fu&i[)d] %MfrF03IFJ.b=Bn9;o2ZN7GXgGnf-cBB4e,!0D>Muk_f!D[1fAje1NdC)DHj-X>*'@`IcKiAcVb2hLQiWWo-jf\,jCB7^Lqhpt_GN=eO*qt]1L9qCL>SjFp/`N8#GSimK$18' %AmE;![b*E)@M'hLQ%h>0CG*-\>L<1#oUo5_q$>/M,k.Ot/2^l4h:176k1\6t(6#"(bS,(.2cX.(PHf4a$-cHWO5Ao52%rsQk6Y3V %0;bSB&Inq1H:rAD!SW)GW78t%3FiEJhc4j)U-(4+>>.C<@j>:3V)TPBUhS#'e.9dT;Nacn[gSefCSd>#jG@*l+s@G_Xe'KJ0*@_<\.X:2hoiKO(l$tBCr62(6j3ZHHI1P_por-nNh<>64A]Ou&/mof/L,fupoCAsKjm%TXT0qta&AsR:PFjXi+IY6;Hq\K6:(Oa#7dJpX,RD*IU2q1jbrqXScM_4O:bSE8[q87ok`U3;( %0=rIcG/o!tO81gXj.*IHF$Aa4ZM&?"IZErbl&)+Js88G6hL"M&maZdLD4]5&77cm(Al@`K"mu[3Nq!is@`mlEM:Vr3m>YkVV^Uo; %;4%/PAp3q4!DFh6f438aM[rsA/D?%V]@**0KUX.V*9FS>!o#a-BEA.s*+iG"CYg8l,?tJ(*e4Wh3,sOh+:uu%d_7>0qqE0(OL8IL %C@E$>2E/JV)))9s*(,7A9[Dc]S3$gl`;5Q3a"J_b)qBn*<6:UO_Pn>/077_>,F5s%lC&-?(athVA8hV]3N*40d&5r%lSir[%XFk: %5X3KnS59A,.G>q,fG1)N[;'hI*$_G/Lc1K`gq.tb_EZ,Zf'Vgt'/WY2>q9Sm(7T1=Fk<'BlPE<'1]QLco0,"! %1O8*K8Lo7;D\WJ?fon>&KhQfY>ApfQu.*44t+0Ce)(7PEi&p;7>KsO)p*0c:?O4o^hcH[]E^&rE&H;qS@Dtl=t7Hj3RD-,,@.c'3r%hh`[se25.l>Y&Y[i %]]#U'4@X4PWc/NDlscg_^$a8MQpCG>W2i5I+.Y^X$6VKfZfj^dd]!Kb/B#g`N24_rJ?md,^pPjV&LeG_`lFpM?.0<<#%DIf*ZZ7q %ciZU\9fdQJnK'oD7+lGi:_`QonJ)U8bmJaN_T6Chj$Sbc!#)P:.+BhfU`6N>9e@s)d/TcSS]4B+L3j;5dfFMeX&LRZ]Fg&Q.,*hW %LS4s))tZ;XAU8m;+PPfh\g)"E:;j&Be7f56'>d'HM?[ms`[8)[VJZRl%4u_aP2[H)s(5M(5d7^"l`?qBji1f;eVC(rbY73g>!$NS %MOC>7^=p)9g.'4$Rspp>RI^0V`F&SSinJm^^,K9dm@NDqW%5U9[M7)@:Ug?fFiWLfFtK;sHc]pMV4WCT]9dr[BBAd!CVJZ?@Fh,X %^RdX!lJ.6`Y."5`m14J'*VMO<`67t)L;qTEV*#u5aFr2B%kBhPeYD/BbD*B8=mAU\TJB,r,-.nOQm@$#XJt,ktUEpB@fj6;o!Ua=tZ.o,L%g/ %[@3Og_:r+qKG3W[IUDlq`^11ckUO@q:/Jf$`l;%ZF'J29k"fk:k,.Yjiu&+S.QI)BQD6uAhj-bC?2/aRd6[7;J416[Q0D)"2pM27rCj65QTLO^[UZT4cJOV*A"`fbp!9IAQnA3`CrP"<-K"j6k(io)k$.4YDL6&\ZOQU*.5Oa]F)UrjHlW`:S.BY1 %%XL2V_ugJ*p2kMj_Z?hP^Er\2%PBc(CI`D%q'>W+4AdR6Dm6dVjgeQi<-SeID*)N*WWfh:K+ZHr@5`\0#DD`HFEZoXdf\t;G'jB+BkU@Ph*4.-YRq=h?P'm/jY0@:(NI7c"#c6BLghY,CcCa4&ZpR3-I&M3]q %aKq86U1\W6n.Rco,;sCKWuOH/Y?'432cNfnh[u['?4ZejYg8n&@psS'LQ2*O(0c:cF6ub&'/F^\2nN,7fY)YnFnu(2XSC4[jd5jF %:&f"R9ZmmONbt9/fL%ZCFM4a"B(kI0?FEgh1OHg\R!G8]oBto`)]u`>]4rLB&4-ZuQ\flb"9e/?_U99#Xj&F) %RB4,+=sM>=8kcj:.FcDpjeV]lo03`QpjM%sq1#RIESpU7pTBou[X+hH+5bMtI`P;bTA0Ga %K$RRUCtRcsYI!AY-c*[Ora:f%2QCN"9;ibT0HDlD"#(%ks0noEMI`C@a?pgUjW48;`=H/_8!go91)h3m\lX[U>T6Xuf*j?uH1sdZ %Spl)f4kcm8a%0>A%@V8D9.U\%0_8aq5!5J4EFhW163UKpZng3X/p1<7oq= %V.al[_SuLg&tbD'2f-`mIGk]Q&mAC]:em-.<78H+R/fAM`k))&0>\@cS6Y%< %>"kh1*BE[t5]onbU:Qf?&"s=s%tk#a(/K0R\l)M^ZLq,C9Ctnc\A((kRpF^SpM(#eNm=iWQANhd"+/[72;14Pnn)3>WQ>9f" %>8E!i!I`cn!-V+Y&;%Z81U)gu5!@hR]H9gF[,r'oiQuIB)FeI=6K8AN;1l5RC[0Nnh1oQo[,s@Y)=)@L^Jh&_`Q#prq7)0Al %@)Fe8W39Aj,;/+G4?XQ1ajeKF6KfN&H6ETB6m*]H3eZKOMlYDe1rV[KLQ/jTq&;7>NuF:6+TFG1(a*(hUa\O#cjufW64\_N++qVh %%PIE+WdS;%UCLr+MW$T@^ma<>I#>Dsm&d:NLFqUC7A7*a*C_s>O,"R:Wn&W-3B.])_(EZp^u=K4n1c'OJRaiGR/V#,\[>.Ng(?Rb %L)`ktc)Cm>>g/Lc6g!@(?T?i3E%EO>5r7."E;r5q[a8L)7\#,B)V]W!n"%UE8a0hp?cG%D,6MIGa"o#8$M)hO5h?&M/BD5C'k7Qu %+qFN9L:aBh@136Z*H^bn4csF/>_jg%99X1LU_DGR9;:f%]D*IWd^cKujO62?@^U9.+gs %>Ef94L8:ImB4s:dQ">pXKK^o@J>9@H(KB(uH)R1$$`I%o3#heC1G"]D%^#dXgI^itcQ+IRAGKCeO0`>bS6elWr&X4RFAX7D*g$]N,5M^fYn0*h0B0XO>\+bc_"2`@4s< %knJUKfm9h)l['VYkO/7Vl+k1q]HIJbnI2d4i^mc'tYAoUh#G)Lm %i-Wu+eI)\EI>7@oo-D!!U*sdV)5Yd*0>B@=6e>DdY@"3:fmd'fKIR6j&.7b*>%HkkWgCplO3U%AT_sia6f>GJVAUqN46sPWbBlt^36\t`jo>NIcCU:DC%;C9jm.OURCoAST#_kP*!3qd"et% %C<8^KrfcLdkXl_Y7L8?>f'O:%1ms(t^><[Pr<9DJ!eRWZHBKHr@);tF,SS%*8;aAQbZ89J*%ls>X*lk %#X&_+lia'cr6LaS2hr)E]LDd5r%B(EJQYmd'7^f%#2SuRASIX%cBZI`]B.`$W6i]sE+nhL\T_g2;bW+RgkT0L!5Go2ITopJ/$*52 %5lKkEWrWNplZ#^EYf#KiaFS&Y1b=PMb%"iL"dVh6@?U._"'sF5^IRB@R7rXQ$1o!`Ba<"U?qg$XqLFI6)"eH8ia=ao!W7_`%V20F %6'FGC:%#ht5S#,H4GdiOp"Fr%^#9K^@3ZT$$^JOCWbr8^e;@-MbIKhQQrIJ!T,rMpBUK(_$)dJA-OrlW%mAF\1/UOse9<)D/RNu; %%Kq;3WAV2t&fVdB5Akl\_BhkE?_>[UTaF*sKE?:iCP^YX27RWJB9i-k6tuXqD=jT"nuu[HqqgVR_X=#jj8a;])s:K(Hb-(R5<1lg %nX5c42`@LHZ4EnM,#N=V?q4^:4O&ruMF'd_p%S+q!$[CMCC!BW6+P?bJ.5Vr"uan>6p/OE9SL.o?I7aS>Uf;>JI=NN-<./=/YZRR %Z+?c>AtU1hV16#gOG"\%KBcXP?8p,4WhAG[X?ViS]b=n04F#.aY(kY%jI1V6)7Z*WQ69g,Q#X&_+m"A-R %2a$Q&I:AIDg@FUsqpU1!gjmk+!bq7idfX!fGoW:(el,`AEj\#sg3El)B`7&F>R(sNqB&Q7O:BG_#%"3Ci!G9X!uAgsmFbHVD[g[8 %GRlDCHLW+@E/9i3pFb0F\`WB5Ke4B0InpA`9bd8R*Yaj%T:LtmodAZ`LQa(s4,;ub%fiDfOTpWDkA5[628XeP!l/Sj;")"_9q`T.^cMioC?UCG*-PWaNhH/7ON36R3F%nl'8$1UcFt96V.nek46d(SA+'eo`;> %i8!6Nd"Bb&B+4d@oi[NL4A"al:^*Nt6@k\8E'sYikVbU!k6k#q:5'IT:T+9c^c&W1!F*2kUh5*MZZW:b+WGP;/'mmE*XG14T!b!S %`"S^Q2ZP6eHF,W&(NQKWXt5LPTQ(]]GpSY`nE78^!0kr*qYA0a:^*Nt6@i;WgqFHoTY$qd<;C!SHUUDiAG %$G1-(jh?nGh(sB(CZ[T$LNGW@3AHL@U'F2KoZ$JhY*>m8d[2S3N#J/k_r/jZY5k1A)91666fGV'e:&-"rn %3uX9um5mTE\gs6-_PR.t8X6Fb'm=S^$eaMK(FA2uEgp$^K+MT1/"Q,HN#kC_ONcVSlq2.SasWQ1C7gR<^n\X45nirN`UgfO%.g.EJ*XW9Mc6_ZF[>1?uHfd>]='fI;D3Kr0Ulo>Xk)C^!R68m]`_<%!Q/G4KQp_CWbK7!gV:D %7Eok\8eRbrI<+Qa$5BV'BBX5c%Ic>9M$14!:p!tG&`eI$K/'c=-rE;%M^kR_@X9[13-s$haEY4q59H@I%"J$NSK:\/n2Wp7<$@8n %n#R^R+^_<=;)Ru:80r(e!b*?BeV\TP?9I9OQmuX#-KG4OWg:XeAY>_5THF0M.YO5\]IKLI`ghlEJ4X&^]Ob!\80n.;52""g2Y %Zih;VWP6(VHa311&P^X%@\%KK/`@9nXm\(Q/St^Z)4SN=MAs')H>pPs"s")udDS^6VUruOati`gnE<+bBG7!IDiH8HU%7KL2nD0\ %](DY:D=EEg>7RM\fH1>[A[QoZ&q?YQp+t7$L/c4V2p.esVOkg[MAK]M)]=+ng/b$L`M-.6#.O'IQQF40$sJa.kOhC"3QllRhht>qWWA?=ZZb.19u[$MTK_5%?Mb2Lu=t^q6o[EVZA,pD(KjH08['$S7_a2Ta1'Q/o/HF %PZ#T]BpSa_2jRnDA]b1(SW^4]R&d]:m<3hbW<@E!2Y.d=&TApHPsd#4i%oD1Zhi+N9T=r$9MMi2S3!)#$U91`Y#Vn>o`9F_Z_NC5 %3$PG-Bq7eHc0-Y"U9KksGohPC"`h#,P=0sd6lhbe?kW#%g?9QLYR+O-]a%r"Ghai[r!!)o9qho_I'&]OP(4C9@LIoi1,_]4O[W3PFK_l`cX?]u?dedM$X"K%ZpZG$WA-/cCqSNS4-$#;+6At9r.Lf< %#@U&kPU\=m[7c*^NF0f"iDOH-ejMb6/l\ft`F.]KA6'3lTrbr-_0m45-EK*s^ac)2\0 %:SJ(LFS"MJbYEqk;gQE4#.9]Ls*^T.?it`,ls:n-hE6m3KK+Q %H>0\G-P]AD6+GQ3:#_@7m=qaRYV5p*9gT6R%a$4uL.Zct,a5eukeZX#dR>c;-ARhJ7Cj=$>!*goH4uVT\HeR?5S%\id1]BGF15Yn %!k/R,k^8+sV`@e1DjKAHJVLGQGaaEgRG!EoBu8^KN=j4'8/6r['UG(b %`^s^[85S(9D:Yp>\s0904m8\84V&Immh^*Ff*J#S`\a=q&HscRmeAPt %-LBp;&2a#%U,X8iig3^5_5fI'.]5^,:Fk#udUd,m',B5.@sqC8+5^`V:eUbs,eS]1l.g9,GYB;))(3n794Vr_Tp>K_Tns1CPq9_Gn= %iUfBmd=[lXd=[J%eG#X7LZeagM':pF-;_5s*Wr*T5jOVXaZ[FP9.$-q;DCgZ@U^6,Pc:KAE%[-fV86^YTK_];FpMPY/rh%ndm^S* %R_YS!F!7:1029;TQ$De5[Y@a$\mBS-7H#jeK8PpqCK21o%HK[.eRT/OQQt8:Bn?_Y@J]5>[dZa:P0&beRXo$sm_O9ldN6%*S15AG %*[%h9>YV0(?As[&3r6b1_^46kik?`o=+GQgTuW>>_ZGWBGh#$9*[GHc"ddcX`&HX45Css7n`XRn6lm$1o"_050>,H]nh^*4HD!aEnVR7R5CMmh"IE6fk7>*%B1j@kSd^Q %:CfLd9DtIq\Ps!&/-/d8==@We.l&&BK6<@_E-f;*Nkg@ljU/4.^(ar%M5"u(e-dB^W0d*(]sgo2'8gBfkA/%e)u-:2]-UNs$n==M %rU:L3H4L(8U%c?!_$,@I/m,tURr!;eBS0'!iHA2tG*l'PiV?H?A'ggcfTr2OIHnm?0@9lT7gj4_@=ei+I"pc@#[c<1@^0NbELBY% %aTU1,[\2&V>'ihif;R,B_fdnaS@=88"i#=r;@Gb-,9[P[VA`hSXg,VV>ZdEmN%,/;VW04*1S/GEP%K_.[,JsD>l'IlJN. %'T4q6^iL#!pq?BLH>s-F*ehHiTqpu@W#Cl9+l,:QA6>5n`&4GVg"^99GMjA5c5f''.Is&u&)a<+EC](h'E(iSngGh&VP\`5Y>$Fs %X/[jK2)4'6RFJKG9gV(KB%BOT]hn+&d_fQp6nru(2W(:`oX>g5X4/b=V@Pmic`A:47V?j,:V51:6tD/t&Q1L/B-N%mRfQBiSR&TURu1L&g0T4i6UXo`nln`!,FPH*4=PjJp0E"!\ri*E8iY#$!P@,o`-$>`tbUtus4 %pl+FQ^CS6k\qDO=4V.teMW\^*qLT!'J]I+=huP0T#E[[\!k<=mfSCC!fMV]"^nS_X&WeC0V2uaGCfc\W9/_uZcd@Bif0X]inog9>Sh;4[_pBE[;h7s%n"aaFRhC\4HfdG^fF3]ZLL[&:3P%pie.Uq,t,>+]>SDI@R$:+r#p+g9@>me,ZJ)-S?3] %nK9u6LmK[e0%tT]k3ssV/K[-*:C1Z.'`DP+_6^tmi)+oGpijBn#ZVF^kWAQ`..DN92*uUXFOsc:\Q-J)X'!:URtaHb@cXH8:hOUh %"d^8_k\PK*:=+XVO+53NN)0\OLVACjLB=*G>pH.H"K)L&64;*e;uh5P)dBRt/`%=BZTk>?3`Y.#NOjt3k6QB!d"<^c^Z/<`W.NA` %hnd&pJ^;fJ&^i2(Ta0@>O!Mqr&6nt"Vg-ZZaar*1'<)=O+mN4+FQq)&cE1qmE$PAK+?k2*i6PT!"],]&+$p]q*XDpmJjBeb+\s>E %*\<8W#q][8=1[D-"]+[Ncj(>k0Y"p@6bSAB/pU5b%pF+oi(!XtU%$DPK0O."(-jm5IL&pbH6CS21 %^=tg0^juTmZ*@)"&"8uWT+=)H7W)(qRAJ3Ag^C3V/(-f7:6`EL?*7Lq+_WNt]m$ZrT=$ijTH$9_KIR6j&.7b,_cf)fH6C#eD`ARp %E+*9Ae+bZ[WUF&fcT!FWGWp>2Do%ati]Z(;ZchIrl#)NANbN3AO.lk_@Xt1d91t1J/oPQBHlb!G_7,K0lpUt/]#dh`VVb,(rF2o] %SS#\tC;E"ukAXAM,rZ]7B:.%NV=<\3c;l_eKmoWO_>[e,kTa"<#S8+DJAC2RBNmtt\)lbsRm$k8TDoKYE*4`+>N.(&eB,`e^TktR %mjq`'T"!EX[.7b[I]Lq+@::@B82!-Q;?JJGd+m.#f[*H%=822e2`=B46(d%")Jt%!Yn6$SU`g""nQu+loIDZO&72^o)*psp;MDQ; %B\ZQ.D$D5f!)98QpP.lZ)p(9A`gT:XHZ^lKdaW5*XXJ8=js6"-siP';k*#l'TGFX?[P<7]'JU9HM'qOEllWg+n6H*."uBIrl/V/s/#0&ap=7"$dC4KrFk[ %_LXt&!!*-aK;!-&+l/I&`'bZ-Q^jQc&/)VK&GO)e=,(RK)6rAE<8,kKFT3"LhUeo9bK\;6WiE)$Xjp&T<%8__:ahb52eZlde^cna %gn:C9lrc8*@&[Q9iIdl-#+^6c#)t-`oH;GR$U%%Y,"SpL40(&u2Y.$n?<70Z_W4+\<"KohQ\or\5JS,WH;hc4MkC:X#S8-*',]HS %&^kLsoH6"K2'X>5+;"&9(!YsE^*Lm16_d5d/!kWP$J %d6#_X=X"lSBe/XKr[q]X8PGX!H0.laeiA]Y]:J1PW^#Db"?dkmJ,k-A_pGiPODYHa"1N4+#2C=S0<_%ATW"ff\@?Jbu8T@@e-X)U- %,cV$V?%Wa96")[&TEbT*&0T>0(u!OQdV[C[KDbXp5R]+m.fh.#9E=P_KgL?bW(Nlf)ipm%/M5n*I9iDNVP^B+]VMc.FSB\HZYjTp %;Thg1f1QY(eVWoI#Qc^/6X^#+d%X5&lb3sKcJ7"3@6OpYS6JJY,rmLD)mHG#6Y;D+OFU8g=?kaDhe&kPM`M-)%G'*p@bF^kN>qKhN#8&Z8E'9JK$1%]dJ`'$\@!PXO/VMk'=SarFEmC"c`lPP$= %K#+'7!X&c?prC4t[Z6Ge9/"_Gg=i!qWq,0/_L)$1R@3tch:M]j!62SmEY#D10Kn?4RD@9:;@n'Jbb8Obhus/b#S?2+$uP;9l*`]; %4TG`Ko(n)qLJk+IZEgj9W$!`H-R7[FF(fL-;?M8OII_1H<@_b$`I*K>-NbZ_H5T$TrBhdC5_&h8!^2(-D&jeUQQD>n.=`RhW?TE@uG %FmqS0ghH]j!X&c?+Rlio[&n\(,2l%rfkc[L^ikA2)ZRDI?MXTBOimO %KZ4ed44Iq+e&9ml-0`r!km_sc-uY8+eCR;7<)lt`L^WfqL"Y_gPfG]tgj=!E6o##&?o#UDdHn??,.L5q%\Xkn,[oRPRPJ5&6gVJtHQ2%hYE1DE6]e:g)&pNhH\CX;=DV\%ZO3";AGT"8O1J*)CD2p*da %9,h&6:KS.;mZW#]>ke_;UpDbNdWgZ;%V%t2Vpf1(PE+h1"WE+<'*dJS1AOi;;%`s?&!]ln4,rLpYXDUsfOMc652eGJ689`K!4RUV %%BQ9\4/3eSa06QI1t[U,gFnY+a-3Ap7`)H%EB`lRD[JV6I53S8OVHmYpB7@T&u)%S@@AM,!lc=P_N:-uA2SsdEr``d!;C<;79fnU %-k>&+B>M>8d7?S:ZJdt-VisY<$?Gt[&t/kh#.Q;f@)oI>+)'VoLtSp;iLu$q==3<(LAFZ`I>c]i,02e]"TJOpnYFngnCs,.1cJug %JWu9cg&o'bXl1?n!X&c?kR5bF4)(ONT@i8LEm8Z!P,+F%RYR4Rk%Q%NAK89^<`S&6YAR=<;G.qtD6^HZo'&6/fEZ'Wb]^V-^e6ML %H$=\@\A!lXQSbA%maQ+^/UHH1!0d^j`[Q/ZLmHU@BT]-d%VLa)bQ(]dHn]t"4VNn[r2fc+R\r.O9eVCm/Gg=!s'??5S9`2%Nla%VHg6`T`M#u.b*dH %GR?/V4d`U:F95AI2cm=M.ZEHT8D:BbmPO)n1-hE>]A\=,E6fRu#cP6T8bVJ38X>gOgpSDjbVan?4Y\"N&Et+G$M72G4iq-"2=FGh %D1,b%n:YS>aN7r5P+Q.XcW23L)j&Dm'7#r./Ou_1q$m.E#@b.PH#GqmZDJ1?LAOpR-egZ@4iC^R^O]@-`Ya(KH_+3D=a*+Y0A=G0sH %eZR*7ppEBKi:#ti!a]k6\P*TB=T9iA0%aYHE$(JH(QW1f-\>f#HjfL\+@uQ4i@?9!f_N)V>u=>fhcS>[+r@JWrLgBF;9.o%9)sSF %pbmiJf.AP2@%'n?FKQHBOejKLj6:k6FQn>VQ*C?QTQ$]i(f85*e'"lr[@uhN%(6VVhT0_N,OYJ'gl+qJ[ %1P"QK6ZVc@g&(!,#L;WrdY#Z;fWb&XLN\!jZ(A,jJBL4!D=mn#i5^Ku3nhEbIiUmOSj:L9,B%Hh:9WkP%m"$WTEbT*&0RVLYhchf %SS0UZ]N1>",6p.'7Y/cE[QLsn&SqP;hl@]A-j*Rj8H\7>=9T%]F>dd[WGJd>r[*LAn7#Q(A3G1rLrQM,4.$N!_tmd2Om?SFn(*Am %/_X[co4?gao$"sC'#aISV\_R6!B]i#5<*^3WMi*ZdBkL4GjrOk^aLO5B+7"k%dH9*X/`6rd>W6%Y+\lhYTSQQq8fI$"X5V@YkE+:Z)S]V\e0JM+pdkLm:dMqhO6/GSm:bS(tW3!'K()PiN'ajr3*cTk(/nDmfWj(-A %'PNLL>Hp=_,g`sVkQ0qa1^%ekd_m09=Sea,"dqd/lj6@;_$)S0>R8fEaW['JLZF$K$m)3t/.sUi %A-m^a\OZi=Vsk+K)%Q@%OEYjm!=ZZ.6epZeST#+-@JE?U!!*-(q?"_jGuL,T6$L&A[\]1Qf*,Of6OouG^:_horbT&+@$^oHBf2Ui %c$IP8cEQVT1#N?a%j+m?@rNSq](9r^^/"VEjc.I^mK:`7^,+E]GBT;o89mn!"6b2%&2KM_Wpp7SR,Au1q\WJ5tMOBFIT8qZDQtZQ,%^JY!>MD7kdl##_n43R.$n-dc$f#f4 %?!A0R.4VgE`X"q$CA>aAas_RX!hQ/serZpV[sGGpGY*!.d=c-b%]f8J8[+J#BTOB,86` %E4;jbe[nB`0[DtB-F=:K.#[8UJhFemR=YSNYge8b+rAZaYllO`Wi%a%^GDR>JC/YB81'"[K48QsZPE/]g46Z$PgWBLpT(=mBdB2Rdi&sY33b>ZNOXI,u!!*-(r]B>0Bm*%1gaJ7%O;i:1pZR05J7TqQ[DNYgU3I0;(!LG( %/srjHC,IIGQ;rsW#p`2"mT#p<3]d?6dfWRn7A/s3N/E8("rmL'JBO+$13KckDpX,_H.0Zcq?PFR'pTM&ikdg:*q5bDA%NaS_HdDV %+!<'-fj=)<^k9B:6B#P&5_&ic:,'2+J$/E/)NTk@?tg(XPfM,R(T;TJ^c4db#;a5+=FXZlg_Ou34"q`XKr*%"ll99G"Q_u%!B+8f%j5m`@Bg@]L2Qf*jHANtLAhYc#S8-*pL%t;r]KFr9Jc<+2gcb.d^:kQ:cJO#d*&])%OciraFgI_B1pUJ %#C'u->:3C$-RcD4=:irhB2c@S28JaS9b.L@+@(Im561ttn'BO2n-D %I=WI;Z_TPDJP=p"gJD6%I8F6Q+VkmVjpJHCq8ihZNlFH;Gu-OuCfk9-9Bu]R\AFS/@J,fTO"SrB4DVkbHcs7gY!N"DhJZg %#U+lnXr,s.5h34Qf^O#T#cU:KZsGMkneYu*^#ZG4_Ud\80='s0<[WQ>AB9AW#ST:BZs)j&o?o %N%$OeZh"-/n(m#m\CSC_m-"B.q:t_XpX?V-h0ZWc?$fTaNMfr1'l1/lc`($CeQC-p;)Bb[&J^-MU56a>"OtFiC*ea,X[+\!e7\u; %)O584+:-p;@"8B92Jr&gR7Y8W9MQ-3>3>RiC*tfQelnIJlh\o7euWGInX8S^)Fk2\LJG[_TbAkQ\2e;n)Tk&S.UVH2Mf]UR2#MIB %FC;cGd3UGBJ\C/XR$+F[(DF-\Z2c%mkDg0!7#2Bt6*3cl`FWn(m)J?*>++G3ic+g[Co,C5.t'MIO+28&J] %'4-jDTEP7c-&(\]e;1mPW`ZAZBZ,E$3-(QjbQ;#leJc8,/2E&/:s(cUIsf$<)K:MVknp1GfOU/\NTh1i!tUtdPWJ=#`Au&&ZoKu2 %\_A/!X/%,;BlcVo-Sc.hA&W\h^.3P\@anU(q$ba(RBK9V6E %`WRA-E@df4K3BlB$$^85*F4<>r^7WZ6=u^R@mEO$b2APPW,)q_(`[.\nqph.+H`W3Y!B(C3g'IGTRs-t\X#2DT[D+ehkI<.)CfL% %PIHhUIg"fF]g=EOMoEs<[<>RKt1)n&3F*-PcuS%1[ni5hPoZ48a5L(6l,qK6A,UVl6.p, %/f8GK_@1rY`QQn5(;L$CPY-N$?k94K[n^RU76)PQN!DE@'4F#]R/8KE)Pp?4KCOK/cH40?!s'GTb73qQ\WDU?0[SmJE<[buR %kDOEe*l*C7o5T/h:*EJ9+9@$R!M!sW*jE^9q8UEpiPGS<S;ZPFs0AhA6Is$^-fi9P'X9huN'=1q>cX3H %3u.a4-HpY$-D17ic&AhoW@JK;GLr^7DF?rG?N'W(kMIZVp^LI>POEWC2IZP6.j%pq_8PZ(-fRBI8LI76kq %lLDc=0#n_W:XI2,bLV_PAcVct0%a!EfO)E(VMVXC.)?LcPp,Lp"h&Z;HNnEpJHpi6&L'X,O"$j?BK&?+B[H!Kqem]uaW5"PK$Wb% %,+QSZS!26jbY`Wf%j45T?&PiiWdg&e_-OQl!I^fLUWHttBF"ej:P?S%:fj[#/;VCp[OeVN.)M9$d:C77%6cBBIP$#RDL!&^<-3^"[(d\T?iaIQs>5BZT %:##LVGX.&r+[UhfH!T6W#Igkj4mBF[\ZV[ZFs)A7rjlQUM965AAbkU(4#T@!#=mr`AkYU"?L[]C,XdG"+[Uk]4R*D=aLW]t?JttC %%1`tg2%Ht@[a-!jpALG]!;umibB?I?&*ug %8%=4k4amjf^X.?"iaoCP)`khA,Ks*XGKW:n@s&"t'G`g@FR^o$fbaFr-dpc%AX,rODo18mdKpt3/+:?ZTiKc%_op\@(V.KN#JEQc %qQkWhkQiDl0MfsN/I*G[eP1:MIIjI2V#i824Ztqc2)R!=/ui\Zg.X/[)6]$lL.2S4E5Klg0(*(BXM<+TeBj`JpP+\^_?fb_r7uDg %::Ba8:mj<^GMa]g.D:[7,-ggtb0I!!JHtk&f%LmF'i_B>W@Cc34oonirrAYb#s?M.OHAQ5Tqnr;p[5o31L#'a_7go:>Z"1R5j0Nb %HRhT'@>&d?r7uDg:?\,SQ3Ei@>T/GeiH]4tAg:8q:KHn03@/UB>O_\.j:/("Tqh]KiV;9.[!a[<7)Ci7c'6=.4V_[5j+m\%Diij\ %3Pl%HX:sabQG[pP?#r %L!%k)?.moAI"2H.PSFq7S<+dbQUd$\GieS@fO]B/OQ/5%ZIPR<+L.UiD@9][)DXjjEf_@%[2ZoU`$ %U]BT&OkHq]/E]mH\M-XS"X&XqB@=s98Yj:6gQ[CY)MuU8Joo)>"\aF\EXmfAb&P"uZO?`:nA$Mi49=hki#bV:&.km$rqY[M]B/OQ:cs9'!beN_`E0o_flj3(&VFV"GYu[1OkDMeFdW]#(uues!+/MK1#O@DoPtcZI^VoF*+V@oCXiJAM+iY*HI?b',d(CM %G-\E[.sV4-l3sdP/NO>m#K>t+H!s!Fre&s3^ZP7,"quM`2B_MIcRm`X;=4(is7;R=s5u%2EXl8sQ7%[oHgXR5;![E%>kHg`*-^/N %Xq]Kiem:(+B>0]K9D@GrG2qE[P9rbgGAjS3pY)SLP+R.LNWQcgm+,_`cq&8MJ,t&2!m\%k%r>.\lT\;#9Ic#L'=0)d-3Naj'$f7jb0DWie*t/Gi$3#>Z.(2a)!#Y7K]Z`K %#Bl>`2%pm0mS7b@p*hbA>9i6f'V29_+eQl97P%`#Zr_9pIiUYt'cu;'gPs0pX(a7&dqlaRCd/]IrOojG\ehQY)`Ii3nhnM6g75B/ %`0GA;%P6`TCp/VnflN+P&Ve/f,]nWU2j)@)mgR4Bfp?gdK_mdHIZG=(gbgnXok0V1F[9ADjMJ\f;K5#'.<8Q(;3]Vr<1a/48OAOO#[318Md^Lk&g'K&kho*g?qEj2NeW$=oRJG*XB&A*$H1QJ2R* %YWoT9MsD^*8?%CENQ"kUo/pZ,WAqK#eha/o.Yi56ZA3T8%kNdo8`4*,6V9:D2f/Ep6`Z<>Sc0d4M3m#8>Rd4e5pq*o %mIq"891n`/roX-r(:Z^brba`D-fi69W_N-$I\gY<0S`"cp/!0QfaH@(lS9\_!tS0D.+&PZ*Xkfnj/(8@!=HC>4BY$3i##N"]1n&2 %41cYSi.9J+>2>pRK^-`qWf#<&Nsc>9]8d-SP/15CLu8enDS4j-`O@giUq$QIHM?dZQMp25`-m5eCi8F6J&UhZX %=MY(?"1col16j<83@K\QU!Qp^2BCRV$C/n6HL4F-66=4s-69MWLM`J_Np:;V1_=LdqrDTs?<(e,Yb%JWEGt-hgosWk)gZGfjDt.+ %Zn'*Q_iK%<_Ta],13IWZ,NQ,72E$&"Iq`$S_^uO`6PG?;\Q9YCXF(W6B_Tj1&q%abHu!ph?3JO\/p`cCYgC_/pdiH!0_! %&Np_TOSd;(F@4]e`-G./*M/T^[1C8aEtmTc`Y5`dcYGU%X>4sK]/9J+K!\f4Y$m[@G(6-YV^WYGjKQlWEMkQ$]CO\u5MK*\SI:gh %YbONHIOV75)S!]umg9#;X&SjO"Jff2b%2#&b&r(F3W]58=]G($]2!'ZaRE5GQ/ti.T:'r1$coa>>64*HiSi]4d]0Qi*&pi5r5R`uKiY4WKB&=S9',X&\\t %)Kp\.K2p(N=PoWP?^k#qiL$`*V(9MJ5DhX6#K,u,3CsgC5XfpB]sSjj?W(%:X6a`:UWQuZ\@%t-M)ZQ(XS?d)]7\*m73!&"b<0"G %CVX"r]$C4.EP&!pCE/[I=1DDIATId9,ur#RlbbuGZc:M;fhK:^bkC&Ve@CWV"&d%pYd-ru8lI+Y_YToO3i\%5oKLTAi6%B1cg6h! %H.CIbZ"4BC3CtA,lf&eQhISH/Y=nt;MZ7?FJ-A"VaDE3$=$sKK* %Ti>cQ*ZDiZR(mc-SSHV*c'n=h#BMpJSh05?!,Y;hBY %Ag^ZK:J!!%g\.iT[UfMc(cX?*4;aI]Gr@X`VS0mA6eL;110?>nEY#N><&SR%Du[Xs\YDL] %Y2Nm6HO%OP3\Mq'E-eLIdXgF,goa0Vc(!E."0oZEB/^!@"`,eT?Cd^Y&'r/gYM@(cB8auYi2@u7?Q[r"4C2ri-[I"P\%BA5%donb %om`:JH#=)I@lVrG12^3rLNm5,2'(Y,,]%Lm3Sr3!`"HU"EYHtN-ht=8+QTf?JHrT;euBKk"]Si4;cSY3_>X9:fq#/Sf(2Lr(HR\o %$QM[L&@XDsZT,&5C3T0_^/047@BMj%3Y'Dl#Bm#33\J)ma8AbZ-ZIRFG3D(O4E,IB3k3e% %(E_8BhVDb&Yore[r"51WW$[gG@e"6A#:lFPZ(W>IUNGZSinZ\WlAo3A^$Rs?p3>NV?.#h6Jc43=4[lX^*fF-m\&;H5PV&H]`3Q/S %rba_%Gq:&qJObnOQese'4R_@E\)7csdKM$?N&a]E6-n!j=PDpEjXO#b.NT)g2l,#=ii?lT)HPLPlW/.[U"0kR8;B?7N7 %55T?3,s^;CW]R4S#@CjFn&mNIO]+U8i3iRp$cT`4dOf6Ba/%CKp9VZ-$P#q8EEp*:C4majSrML8i9[=K.V=%tENuace+]g3>#)n5 %$O/Sh8SuMn"cM[g>#t(.`VFX7Nt@X'>+eZ*psGoF\_[ %o9P!ihY08$OOY!\*U*C;5D*nf"DWrbrF2VhZd2DGd#?ib)HpiaZ&jK/Y^6Uj* %i+aG;G5F8+^ckd`!]Qh?kO,c#X*+aB-1S,PVqGl('%TQ6eU/e>BWRaL39XCS0SAI4_:F4^m6>b("T1UV*'qV+rrjHikNI$.V,s0D %pPDEW9sBRj2\5i(>WTo,ah9(`p>#'_e`U)0"B-,d2((Pni.8e72",kFrd?@;)qN^!>^%fq;`sR5GH8#/?h<7Nt@b8Y\&@qaE"kak07WgYDk](Aj.JMd8dV-f80b_mrO(W'ciJ%=$?c0+55%0rb5Vb%$K!dVJs %Xhc#IK+bAE,cu+b.UnB%A^XSar8kuJ*i87U;'nB66<1sn=\`4K'C'qe"]QBTE9J` %cS-YDb:Y+S9/Q?/H.M&fBTP53jTfR;@_CN0%Z08F+S`7NmhBm?1A4nh]8?N)3='o3m_FHfq*2K5n_1$8!_ID/SrsL_/DgFc.W2TU"OWTN&A5cF^G8\nm4aaI(XG!Y5/UempJ*Td:J+D17Nt@b6rQ&W&D"^[PX=<0P+,HMR@n!8Tfa6F-sJ-QWDPWS>=ON.D2JFUEo5D_KKak1j-(0OSVMVXC.)?LcPp,Lp %"ZUagHNnEpJHrk_%F%E@hTuj((h'#)^KV17*m5LI:h==>BPCL7FX8o6B92YjiQ_W!Pt'AM:p.=N(]M2`*f25-63IoW*b^6>e7=D% %&l"4HR,ePfk%1&V)Tkd:D\@.PCcR>%&!h0nZ_IA %=KT0][&kF$#JEhbIjsu96Uqu%KR%>3DE.uJXRLtL;e%ahflADtq.WjEIQEMpW@fnQ6E-1MZ`^KI\"b0lM6G=+!#`_TT:?X%r %qc>$/O)Fr72@+T0m_FIVb]CoWLOnoKLBhpT'3pu,F]WZ.2)UYDH%AdF(\msp+'^S?LR;-?Q\eK.QG$gi/\c`SI.]9b.?!q")554=@#2Ki4L;rjG;2VNP([&ptK_hM3'cO;I:LL&sRZQBbDV)#ACjI=s`t:bS8ItqH6G6+L %KEp2iMkD?s/iu3Z4Z,)S/hScVCJ*;S09-pRNLtqp$p&@Ec0m:Q?Vdc<5cAl6mSd6X?[-B`/jI1Hc(T-+kjYa[>+!*77^HU"3H6Ua %0\fU!hO.uK/q %#e`jg2d&qT^0[dRKO9uRl)LrK]mu9ro(NW4pCu`r;R;C>NObP`R#U&aP %J_t*[EH+]I9jOq=pp5[p&DfEZn'_l0%o\$3/(/n/&EmjSBhFQ)rFsKl?Q#EAD;2K<2f=.4rUOM=&G`0d:5GjNNVqUKpeX"#Q%TlT %UbMpHaQp'sWA`J#L7?!?=[5HQR)]pjEK5-85/77Rb)-O,:.UGBbG3'>]Q3j[m',jFj+tFshOrh_7t/D#C'_jA[+Np %+[")m=F %iIsK3*a",U-_R"?r*u]D!b1iB5JI18Pa@a6],!Ou:@LLe)oBmPUBcNiQ44o!,V2i4JtYPXVph"84@ku1huOufmRS_g_+;@PJq%;l %"/LVZ7[%!]]5\0.#:*,bC%+#NL-5o'J]kAV+pNn@;rmGX@OW7(KM_'T0B*F'W%bTW>FJPg>p4e)H%jT4jQC*"Z'(-#%42(#p[)dl %!QaTS)F@_06ULtNQ28*.4[isF\G;[Y*[m1=WQDW3?A8H)UTRR'=_\Bs0[Aci'>'jU4IQ'CWrFF8J.Nlj12;Kn#C6SET[:Q)7)jpB %$)h+]\XUJE*?@:\!gdOKGZ5<@YK5[CAHku:dAnn#Vr<1m_'bF;A;_iM.S$FVGo"U,8$+FtSkj/]u*g; %7iaW]9un<`:kXq.,4GR_i2Oo'+,=3>`*_W%>Z]ll:@\l;iPm_:a;j?1lR*6bQkL\)Q?4n?#T-5r&k!;nEmQcGlkd#hCn0\i?38oQ %p-m(,9F;M0aV9)c&A3Q!cODI;#TbC/1ifuR\+&(sq0CRR=!'4>:jH`5"MHdndoY`%BG<5]\[`'<3Bcr/:Bp^K>^502Ep3um.81)] %P>%[sGZ+@s*3P#&MdJ<*?^-hNOnal?8i %1q?WsJ"ZD*Mcn+<6NK_#AY\9#j_uS*n>o/6[MCLsmS7S$>,d$*Ka_uC=(+qs-mY,.MdN98iW5Grf4rin*#A,?:?*c3$6C,OB/"#? %s#CY[ZV#==-],j9RpupJ=`$p83Rkp?,LHa^#)`_Jq3;W/>%h`(/l6_i!JG_G"],F7GpUF?'!pM#_hMg70FErjpdtWLDM1\ro/LE6 %LT*#mpb['=K1tHDnZ%S*OB@ZtZtQ7ebTYuSc:-ZBN;t,4ARtm/J7AksL;RpPH>="3qajE+7-&6XP&VXNQ@Icr&$1kCE'e[LUd=a" %M?c&rbn2!V5m=;6G3Q!rP*K&!]&idZ3:$!e!W*8a&eArbX`(/2(ZL!@`bG/j8/"eZ/(%`+dd3H/NtAf:SW%#pp)PEofnkiM"!3+U %;A"[/Q4%A6drg?!IJ$E!kTH%IOlItbn_1UQ#NIr@>7YEnuEsgtg %@W;nUT*ue7C",[Vm=Pr-j8]N`4s;`$-BeMVK$;f7bdE$aI<*7-JOg4n_$'qr\U]EeE3"tI7g]W7AIabT73FAg3XA0SF*r*9&U7\2 %1k5Q3+X\k=H.Ss#>E?nr*9O4^\>gc5kX)H?-L?&-$_gqCig)\Vo(u!5fR_SkpHnHfP2!ADo(Md6k!?QJBH#i_O %"0e78F.fo#rb(J7kPi9Mbk1P'abYSpJZPOL.Yhs9h!K@%($,_Jn.#nc#F=c%uF4jQ>:3+jN3BOe-N25*BJ4K7LJLEQ"#c].u2+Wg'n3Bb(]3hJ4!rUXi\0IBL\P_D&lp7Sl0?Y'f&9l1?q7Ci5dq`s/Ri,?uo]ApYbcg+@oluCs]s]I,sa'1a.qA %%6RH+>#[)u,uEF()U0M7l@.]ugY$&P01T^UFGa'HS99/HgF9Y^k:WU3>,lR\:Fn[ml>PPN8SCL %T+V[g^F9cu5iMdH@g/7fQ>`"4PR=01V>Md-?cS-YDb:Y.Yn'F+S)L1^H+[?Pt;:YfQ1mMru0EGQ@PM %MH<=t'aOqOWHVF^,36!74Re=1*["KgWDUNpK^)J$GeCm;\YARJ0X--)02M*<'9Ujf"V7tb_[jET3,bEh(\,:$gXttueQ)ZkhJlpF %p.*"(0H6Aa(hg8c)GK=G'0NLB)74SpfU6AO5iBI#='iq"a0Me4jB9!4""=2T=I:8V8u%J=oH2fW5^=W.1aPFdEgoKAE?rM\KM\O" %(KmkHAX'DIlSJ$?#kq(Oa%o%"GkDLh&F'QGnA$sa4A`"S+TV_u^kS_de7rYf!VM<#c3:ie&0/C)?j29;iIRI/i*Ne9JOa@f([U-s %C&oL9>TB7.!7oB<:g_TM[Mt>pThaJok_-/.KOpE*s.QR/ea7P_%'jlKEVE_>3NqI?88NM$$h0e$JHo`j]s=K6(t(UMWI4JCFr_Vt %R.%hg"%]p?8Xbfq>te:sOJj[k5f&rj:3Nm)+bZ+,Qc4\uZ6e9TTi),h"+UWLTX_KkT*5c3o`PkOi#C?3/DJK&N-1B#+*A&(OQ#V6 %k[JM[7sg.*S7=gNWPFf#E!IRJ%K`$0$,EsNWE%QKT@?38%5I;0U\K*\R$P*Y.Ec8kuGQ=GT\_lur,a(E$GI1P4 %]R^VME.J6g>0W9.:ZAk?O,eI*j\1<@*aEu-@u^H:"PsS"3&,p_U4&t`4Fl$q_;'6k^tCO;&[Dnq@&^-sU\nU#;o_JHr"UI7[(1&YshQ;jmYfFq*kE^gCDK,E!"?SBEj@ %X=Mh&QD-HT-ZTe]#drZCTU\9QqZ2BHZipYEEE1er(1 %!XYF5K6mIk#'%md.VLc>=^-"@r2o1%iUT!iYFf`HWd_+lLHn"lSG2\ude+8ucp@&D^`eh#kek/k6"Wc`*#;3Sa:9Ge$I>t1K4il2 %,&7H,M^KW'#".UK:/mg7-ctZa4OEKmgsPEYU4iX7D"DE4H:]ZE'Wl<'(_u9l&r@SJ!(0]lg@$.mO@&&O)O#bK%1Jp6#Os'_CX?-! %R^Vl6n=[7I^]WJmURS4Mf[kX0>2BQ,343Kpk"bE][Tn%G^`VE%c#ZUrhdGr=lg'jH;0g50,@kG!;8# %rk[R,oi::'a1''H0L4QI)%T7XeA]V`<#37O-HWj]ZB.g>irOk)""ss/(5mi4Z.o=4m`\-]+P?[!WM2KDi79c:PPt^jk$/GKM+95=56$Q7i8crA&mltcghc!m"2aLGF5G&.ipT*BmiPV^`&]L_#4.hp7WJdJW^NbKp`:=af(KQONn$:]0QHFJp9*bR?[m4 %J.)qLaP]X9RJr^gV`XAj9D!l-8i#nlnF-F=3PcBXWpY`sUo("%0/)r=&U<4QfsL6nh#Uh"iT:;+nB.eoh.g&HnHBsjf9OO"EY%QY %hQup%b0H_S5MT[9Ahb'-AZi2:;)^bKGI+otTN=VoaBU5:+H$UpI"l3e4Tl;2*[/ufMa+iu1B]AF_\BrM+KPEZr:bAe2t0(Dckj/J %'gc)L9r1Oc]R+"+l1>8lFC6$"A3=hjC)LhnLOk=Q\:efaILX]>d!J1=JT#(9eK#6RQcu/Da,JI@*?K;9DkLI8j*M#KiIT1$))S@' %H`1(O._^.Oho>"GN#N(#Gm@ibU/n/9$b;mL]Vu@?K?s*P"(6U4eid.,pFj)_a_7oLKHF>8TZ9d;f,]*4q"3.6$cde@^;/npOl")1 %X(7*8'Ac3F:@Mq$Go)2Em(2d(Ap>1Tllqd`rLgWU^]WI"D=+TF^69%Bqjm-[(7]JKfR$3&B4p[PK)>@'l=b,VrGW>dE;Jra!X9n` %pi]UQ?TG:IZ>T)6GO#5bS$hZ.A,c#K@?m[H7NDWa!2QT4oJh/)6]C_<5iBJN9\k@,ff.3N9@'N6Vl-Dh`l5ncF\PGsl,>C;[IM_m %Ld;8@\G=t"rTemBrW46-MSK>OaPIrID]T&nSE#/>I^:hHi&hA*;09[gF)`NY[&8L.L19G0J"%@a>'6NjT29W=fCds^\OY>^i\hdS %:VW-u[CKV=.8r&]!?S1on,`kr-H%bhLYOV6s.TgF/8sHtd\&7.Bi6'un3F+?'L'3uTDOfY=a_?FKIh$I-&oUMG8K\q+m %MIL#t68&q%#JQ=KoUs[aFZeksoUp7Zi$;s]:XjW'W.qVY:43e'[@?H].OE])NZWXn3;+\7Sb+TdCWD;SMf2_&ZObUns]NCu'=Z;"+`)V-klJC#6'i.5fWmk6^ %XIP@n9:&/.H$JHCbLg7Vho?>P1]?Q,i.5h-I[@E'Gi1?9i$KMT#7R/]%!3^8F>5P$HNELeYI:F=^94agqf6[[>g`(ijYnj8H1?.b %2I+u2mEq2-dc?sC@j>lnrAJ`;H2nRAUB4gtc#q9*`8Bo':OaoXYR2=F5W^"]a&4\r`9N>3IM$h_)nCe1\F-"'h$nP![+a!nIsVYH %FI`Q=+5Q*:i.>ao"m*Vi$b/8tV`Cb!diNoRP8h9.^N+M[hG'.8dFY+Y*;O3@kdt,[L8LifcMMq;S/c9YDVs$Q6>_J>ApqA-J'ip3 %Q8e<+`1Sc"Be"Y?O)$6:eB0k,:W<.?=+!hiCbPkqAHMuErnJ^e^c-<5T^!".fQ.+#be2Ya"?_1n[RTMPE.SaDn9DAB#9aJF=FiP> %#nYqp7daN,HY[B!YK.@nS4\$l(99F*^Gof.S?kr_A+I<#Z#\]P>O7@6CVDeCp=YWkR8'fC:4`[.Y2B4'\d/,99hKL0+G%87naVh? %WN#E0qHSOMU'Z+HSq[IS"eQ2VAn;^2c4Xe1YHn\d%jmENa5=.^;Wr55n:Fdlr`oJTO?4ZU^c/Q=d+sE]p=@bOK0"PgNcT/k#;m:_ %Hk1h,nGu^RS2s+$Nt9]_5>KoZ40hA*349rPDirsKd>pliA+A6af:?+@A\s%U`H#[n%Vg$<#FJ>7Qq"kk%aCFm"4@#7CJet2TJ\5\2A:>s7>O!WM?Ch32bXh*#>;qcPC#d2f"c=qp6`@Uci.5fWn+,67K:[Su(bb=f*d$#g"E(Mr %2[udP$W?RU>9@gO\OUi*E`n8AP=l'L*Zj$ami_4JoTHS"6Se@RJQa"Dj)3@60"VX5X]\`o4Fp6<am;em%N%+bK-K1J%DAVD;5Tr42=]NNV7AQ"U)tjs/&Wo9&jE$oqQ>KhNhUaV5)A-\YX&^u,7=;1hb;UJU/*%\$3:OXX6j %7u:aZab=gdKnSgPjf%.&jWaP[)6jGgW,m1hQ]S0T/i"0Mae02Aa]'UV70D5iq)[:f%a$!V>nM>>)qda)h.-V37NhKL,C@>.mArg^ %!P%%s%9<5%%fm<),]&Vag%jS'\4.&lI %))h3;_1@1+"pDY)R+uVqJ@GLUM^IMiH70g7,%**m$p#:i?q]IF@^PW.R(slo==cM=S#*sZ!U`g#DBp-kMX720'^.s9a?CNR+k.A% %135V`"V71%R_bX?s5uZ"HpA!3Oc)'KZ3Y_["-\=Mk*&V<)>bu9toD`6gTGs>,U\I7%!fpS*%S8H^QRq.:a+!9D?jRg9DaZCRj %moKIUb$Qlm22dWG,,l\r\)KYpp?#pD78FhE%F&2B8$IOIW*+KCVt@0):\o=c^p.C)NuTln5lZ'*omaI>gMce2lX0Yj72,5o4VeR4 %@L2&QO?oq5jbTa*X]CjB/8@J9lItb.pF)ILR3=9$X(9!rBN$oH3P=>TaAWE9150I>(BK\]:i3SX1q3Mf$jNJj %1cAojo2ZGi7,WW7jQO9>Q[Yrmp8\)6H1mp@mpO4?VfJBV%5N1`Ru6A.Op"(,tu %jD6jl6Od!4"qP[6U:F.3Ebit9ALi"IYecUjYiT'Z\LX>:D;^(!Ks:@QkO10c[B)";*CBHDnA$u'1ucfW8+RK25d"GbGCpR?'`WCF %lg64RFXk2?Qu921S?*GTcafSG?-Ld8J.2u=0;LenEuo]N=*-X6l6h$P7U!WrUcSgV7GCmK>!)W:p=4:^Fd&kW"Lp-nDlk[7\IX/g %HLB:D7h$G1me=QpI0T?4)`2ln7ir6[3[.tMLd3_@JA6T]KKPPZn=[jq=0seIKsVA+.dpU2i,\#G@i]o(l*o9M.HXHWN!"EV3_sKE %C,lu44MX+;FjnoY1aQ''%o.YN>WSE%P\G"*@1)(W<[/=c"X:2a1>96bQY?^fkWTT'i#B4sfZ,MI![f$2(CqL6o]>*tmN+l*To22= %)bt?i9s)e68!:p5p(dOn;Ba=^?i*-SF\[#LMW.lEq1Y%7hYmb8@W(R9pX@-#^L%RVR!6187@UHj[\;@l+h5DoD85:aQ5HU#^Tj)jkK<5:^dXU72coumu4Q)*;"AJ,Y %>aMS!N#Z5H3Cmh8`)F$e/ndZBl!t]*,iTu`#]^DclgA&U+n20%7,WW7AFC_uO!Jfh*nVk6-r24>hg&?GI;=%)HJ3AsZqnO/7\QuY %Y!t#C+h:^"ZL6QYm@Bkao!3$%`TeC,4uj\YLq=mEpR7^40BRu7@aRA(_VSOlHIAmqCP#j)]28WV1VD)j]QnY\7fu5/1gp*_IUduO %T*CG"jF2Z2mf`j,$iV1FkqHX]id:\(72,5oh22Gs[?[kjO5Lb@\+N,?:;SXOgpuf.7h8DjF?]BhrifUnld/L\1K%aEV9mYu5bT>g %"6$"q*YRsth)(Q5h,Xeaep[W(:MR6VSWSN96slp9KF8>k#&+m^TY\(RELh[7n#e[)2]$n&eKC1Q6M %*tK:c;;f6@+-;6d(c$3K>AsK+h=pn)Lcf(tY^lfWhPhgNPLS=?lEV#k!Zn]a)KZ-7NDH5$r0iN?lB7[S-X#,*5Q#PTAmSHA\e$dc %qTGEfCHa\lK+:S]:6UM02?k1tF$,.S7i;aIUbau$D$2kMUFn<@SVE6X7Gr`8eA^phhiY]7*]Qs[FZdIR?dO4;d+#.6e3Wab'ik!H %eE#J2IToo,5-MVH6?soWi;!;`#<&H_SWg@$`eN0=/Jp]#I/54kViBK:CqAiInVX>%SO!e$`Sa>C5d+s>1[&:lDZ8jpraWur4Sn*3 %6\bj=)t;^gNs:"fHJ%!@]F"(80Y&9N=TA-Qs3LT2p8K=e_ZY@uo1#CBn=\a_Ia`l#W=,4NB,l:O!` %O2V3N3FsJ?M!6.nL5e>9ZgtgUZaI1Q)b+d0 %jPZm6JaD77i;piO%i@"YSW?8M*\h[hH0BdP_Zoc/pZm8,o.N?:AFb9F53Vn;gMc`Qf@Sp*OH?TEWiDRJ!o8D4cQ6>8i7X$0Os=[^ %6ei'Q\\C<>WJXkI.I*$AAt70]H%S#gq?V %Lh-RJRuP7(K3"qYXgq#)[rB`bJ:;p%%N5&ha+FI!pDYgEKDti,"!?EKkH+aeq[FP#5;9>A^YaFPhhQ+Rp3U"2W9;9J,N*],Gl6Kt %QK1lt\:HCqKJXu@;jPUr6_YT`=SM-e=X6muU.ik)6DC'5-k\k7_H\NGLn1Y'8m%>N)>LFUmo4B+V"Ab %$/rsN,N'"n:`+<#=rQL!XfSBdqY7lAGVEPiN_e8=Voq]TiJhp%aNViYrNb&ejuK\%qm--K/lY\#:C(B?ah^_I[Y-^g<9bTJ!:Tt& %'GhQU";#mE#X%U9S3+c_J-Yeok!"9HTqMX11gs5p@ZR^_H$D)NOaag%J#J0i:memgF`=s?n"qV9kC,@> %b5C1KMoH964+/qqqWQ"g_&KAG7aM'mp%A)#@:T+i>P+Snf0d&U!)28W2)j(Uc^YjNkskjP^HH$oi("jmp?Vb8^qbC$!^;#+h7*CV %`]j\;rX](I]C3G`n%Cn0L\1J"gi#`uGPph7$/rsN,L@HU!"U2g.!ILg^kf)6.$RpLW!!Le(B@QWd9o0d95Z6&A-N98f;H`EdBpDW %8=l65SSPSSIXhu#<)cgt-c5gRO_1V1W+S[\S11b<$>d"l+_57IO9 %d)=>LV?_VKop!d9;hBX(R@WhS`Sq$er3RV(QFO`r:9poe.iF[VcuE\s&&7chfS>j8$-WflF^=s388NK %8)686j8.nVT.BJ^H.aY`?PI@EGBMBOL?e>!Gf9e&%s0Dr9s'EBD^N>M*4g4M`7^NC-T3V=]p8RQGGd4pE029Rhu[!U6V>JBPbOE@ %o"@_"lj!9W..Q#F4(`oL@J]2I1Ic;QrlMTO5oD,`g=i!"i*HV76@]KZZsLLjC!>N>VT@LqqE5L:lF_h:%\0 %a&)/URYlHQ+'dCaq]$+W<;\bt/4+mgnPg=D>#KAIpV %%t#VbNk5rWfXWMV-UX5l[hFGQFk?'o%,e:LN:,<,2HCPWak4T!"c`=m`+0hisXkR>_If#Hk[fsB_\ %[A?[82Mqa!3_8/ZpakmbO5!a51$6G.S";m;IV.ooS>>B8mpkf,g\i7[rV.N$0l(:uf!Q#uf4D%NZYe9\FaspuNESRFj3?)H*T)f? %QaWft]$0Y:$NLodjegT5A3^2!8hp<[&>Yqi1OIj>Ns6(Ap\T()Yb9C]#(>-J_FK6%/H0tqlM:$!"jd-Vm%R(fF$BVGC$6?@ViKNE %E#.`",jBi.t?$t8!VE/kLd_Lq97"*B5+3kS>BdK%g`hqfB& %/Sa]bk.GaI;mVX2[/OrjHL6JDX!aNCo(h>3(Kt1Q1`:DS5@8cI/+QR6&cE*.-$fR\!.%sP+P=([,pM\PpoOG;VXLqn!8RiC!J'D30J@g:#[KW?0Q@?5j/iDXXC#LiqDYSOH`Yo0i>*bDk69Ju'jTM)Om!iZjq1/kOU),P+qg%_iF7Bk(ZQ;`:QImf'.eT91g5\.P^kH$&VglLJYNro) %H3(SGP)rR.P#V4jg(/5\#]p:mA6h@$]TNDF2`s4eZ<`#S3HL44Mb^HT,NBR'lF$@`5BSM_b#]oIjD=c5jV29044M?'1DGJ'eTEkJ`3cY&4);b2!.)'D)`[oPMNIpuTFFhSJZt %6Ot`;RR!KoE7slnD^XrVKFmk1eleb,c5=*+qM5TEL.6PSB]Xn5X2YCIrLCNh12-'n+WDa;M[UK;C!+Na?]Nu'4,CLpk4OoC.TB49 %0D-u)[V?arSik8IqBVDK@*^d\`_@9f(c3f5bL&[X:&f%aS;B`O:(s_&8Wn:)AT,,AY`0a2G3@kgeqHkXS.bso;'tXt"8GFEJ33r- %Q)r12e;Zq1^^nu7LaQb>DL]IA`<0;lD>pg@pc1J\/M/RVoso)g,teZnicZa@A.NT)JBa2"@f2s_SO#fgBEC.>>%2$R*%A*sP/0qM %A'mUEct_LiepIh1o6+@E(,KD]Q;Wg:?VoO!keIFJnB@PPdaImrWm.TD\>q6mFoRK[o^R1i^bjN*SZic/b@'Ws$rf@+-%tUSCP_tD %hBN(4Hm;lm,ciCKnDPJ[$YX6o.R3BnmQ-Vl`Vru-'-?S %RXTEoaN]rTXY;?"_ZR^=OM8+o-,4pdEl!=G;rSTh3O!U?h#*"Uk"PiaHg^YL@@qD)q$HZ9:SO_I>Sf.P@KHN3J-UX@."bQ])n;1H %(d$dZ5/T\YY7@SUJ+nb3=.@1lmEt>PD-h!@u`naFK41BW< %m+b/O^RCh#i'BE`.*]K6*=lP%X?p3?VB$^[3YsR36IF'^IK]5+*m':LZ(e"g=U>sX4NLK9atlDHF+>2Fa+[pk^ntf::':\P_r:W= %4G_163s?m$n>^m[bMr'6d%Ro#;lh;)i^%JEAT=m.GOF@RS2UN*JHpm)1,!3XJ]kdCk<4sa`\+<&ML6)o7[Hm>BNo`uL(19- %Sf(kS%_Ke.Aj>K2,4NB,EmpN4K0a!GS]/2*PWa0lJ'"8uokL\a;I7)[JGC8c/j0m5SZ[I#df$L)RRA62";&^hk?U'[s %TF>!gIaIe&,?\B63q1DHA'62@?T%fCR[HZHIE>bU0mUb!A"11ma4Yk'Vj$:>-!ra@CWk>ZK1kC'Y+BiRj]=YtRO2,N,$P#S,]8%u %TpU1^@NHS&M5$=W?8QfV2fSC3*t@@fZI1PWOO3g4G[N.m(71%8#$S8Q,3OqN2rR&GenLCA"^H4sH?s=@C0Y"K<7dAA %R9gjh?5l*QcqKU^k)gqTF_J"`3b^]WK8rJ,gBo!SV*XgR/>G6e+i)@m`JeXOnl*p@o$\/JoBbOV+:fq.u`QSXKrp%s,dlpt4`K2qjahZJU %0kA`L_`!8Yh_oW1K[7Rr4\Q=!BP*APtaO9n-A>6"XD]17`L'4_!-h2@9?iW9f8jD!gK5fCEqM_VCGP`'7s5QDOrEria` %NCJ%N!=;*rd3@LWMmh#cK$8lJo,46@pAup@N`THOMiH`+:eNtW/NZts/=O-1!*X\dc_1I@i'IA@M]d*130-BmQ8aJ[`noO`$hqq_ %h)tu>E^Xt;0eia'Q\`EZhoMY?[D5$44@%+Pd=Y34n3+Vj-`=K':!ao6J=TmE[^Kf8oAU/4^:gajp:3o(05[5E3d4iK\lkB/bi(.B %+T(DY)i'fTmIRJsRs.#5%FhE,[mCimJA=)6Z*C-"='kO_(ip"1n`5*kVX@YDH1/3UGCN??o&V6gVN"\H)7Xq`qb'D.5S*9)0n(ft %nq^E5khWSb*1AhFfWqKS,=2Qr?XI3d^UWsk%-G2Y)oGEfq!,f/R7GhUa^+ciBE,K+K]u!mLW+a!q95;n;/RnrWR'LO>it%KTZPtK]@ESde:Z8&dd=&1fGaUoMhVpN?\K1]g2cBpdU7aPWQZ7mN0um*$=Ce %;nPWRH8/%=R>qB]\V>(m5S"`=Zm]gfs0-*i@,9%K`f.fV7\=$T^!*"kS,_0P9lmL-dk5J7lK$A"P1,0p^DIM3BZ(HK,=2YFL)50t %e,.1o/nZi-'Yg0,5bG;oiPZbo"E4;iBZ$(n&`X+ur%%gX*D@BcA&]n_3TCb8J&,DQjk+V=l%n$UUM*_P/A93#rtN*3_R9X2\'7.L %A_sBqZW$RN*&nn%H9*XNsAFOn\'hcfYh(9"0Zemi6P2tLKa=HF'E_JT%0`> %9]@WAIl:d4e`aQ1ekNoj1$m2:[T1(LE),EIDcDNW&/uj!&s+^#JUC7H_+/S&cP;PI=`Wb%bQX3iYqqR?KkEek,]4AK?(KIUfO;AUX(,kZVMK:)JEol^PgAAFh?C(1Pi/`Qs^lJG=M$LO5d5g[a.!j5d$ugTmWMQl=T[,clE/?S6A)hCI_)8]2(YmlXi)+6R/8=/3>t`Ir:VNF)[Ik>g^.f-!4b3DD %)I$F8Y`PMrK4ds]9mBlHo5ga5.QVq8d#R1p@J^go!(bc@ok-?Q7n6KBffK)lkc#eNIhg113%8_?b`chZc>NRRd^E_]S`/2o0lA=U %\t-@qk(31afFdA)o*u$iq+`p;E;JbN.BNHcfE[6fo&SVf'"7hQBn7OMrgiHK]6i,aMHP4=mFUNeJ!n#mqO,=Or9XdsiQ-9hi!hen %@aI8UC!;?In(1Oo;4!(HT3TXUT@(X/a_1Z03_tA;-30`U5iBJN>6H#B7]r]**U=9V8'G8;nFY\qXrre3Fl`K_LB3uf!kJX%BQ*sF %[8j-7o)2;Vo'fWTnekC&r7AEq9m3J*7J?AVgV\L6D!J]R3HR$,?kmj(@%Ht_h[hQ2A5S(d8[?sT5>C(oMFJ2t*neCF="oG&!NKc(oFLj[21-B]oF84g=?o0<3CuK^K/6mllXj*c!W08Z8;DZ_F@,F[X1MHpQ+a,V![7A0I8I6e&.Nl+8BgaF+$k/IC:hMZ#J0TZg5>"=u(.PD/rV> %fG.bPLn#NnA+mG2[#gNkduH)N=\5QIWN)u@GpC\bnA(t7_,N[s10?(SSVeNk=e2(@&e1+\MFqFe %gqn[V+`6!oVSHIjhk%P\?d3k9H?fkm$8W=NAJO*\^]WK8;`q9EMO=>u^,<1Ibi6S,S%LJG^lAF8*bpG]5=3WF$OEoO_"os`/KV8! %8$AtCH8.j7Ggd\Yg,fO?!_Bi??C&9GTm4n8CHE?&>WV!=k%34mf#[SA+3-n/41`aGd-D[U6#0]2H,J)-?iWp5^@C`Q9.-,!K?L-h %7"h+A/j@#t,!BQ6O3E<0/IO4L?2E$QmrUO*\ViR8;aafd(5+"(' %n'o2Bd=MTn:&-bl7aSlRnO`="q:cc"Z5)lnc5V>;j+1nfM(ldP,E+=nS6.A:StsrgUT*R(n9`Bp %hmNdf\h1_-FhfZXrEh6C1i3/aFjk]7fFa[\hkLEUc2[5;1Ir:.X_$7C?T7_5[hldW.nI"K?P1q^pJsr*CLCf>WWDUGd2tIQ42]p$ %M5Hg#f,-U/\!Z-OR"6P>80t;AJi#,>pM8X4:Uf:M?.:!4EG3WDWrfN[kf0s*n,3_OM0]Qcfs^_lYjn4aT536SQLueF5$4l>2!!8K %PsKJi>R,9d$8$tHA)im6k!Hu1=W,f)Ndg2+@05D9">LMM&!br"_*4k?`pqM>:L]:#Vla,_;Y(Dj.&lC+01L5#mt!X=8X:k95+bEjkd>?dSdRsfel^MP"@(,bSmb_Y/cPM7;=;pU*pm.5@P:q'FC %&Vq\Wf>@DlBo%:&B`iDnZ?'tb(teXCh+#?!+\i]ZQiQ&3MdSb5Uq5B+pG#iDFFja2-J"`)X'lpGh?<_QhjEllj$D<:D-P+8UB^nu %FiOc(5,Qt@cseR"OO2deGjl)-*=lc[2md.`lh'ROD8^?s4t+Aq.pDePd8D9[Me9[eiVfB8D0@*nUr&#t&#gol#lS"0MK6A,%"fX5@Rp %AV"Rr>/9%s$jLJJ+oG+fj+$CHr4f\*Dm[kuf.Hm3DT+j5oRA\NGn!sP$F$BRYNhFemVcH&aCCognEA8pU7ri^#M%AT!"T&9!$2,> %J?f35#>Ioe%e(S@_1Md.Xo3a+majliBO19iXE/;coa:9;m&qK^I31SsRs#3n?"$j-B&r1IF\!o'Aj?[8oA9b2]1;Ri4qqNe]OgJ7 %<@drGYP`V3g&\rmS&$K]*`rII\BKJ(VE0kKG5c)PMA6Zap\bi]`cb%1k"s/:B\YYYAc;MQq@?qLCO]alI1$YfR6diAMK"t)o@6>. %\nuCjj9Nql+#^3H"-+#86PhVV2AZ>]Ea^S)IgBK=m_AakcWh.N,]4ReX3 %hi7W'bRjibTS6f-2SH;$rIupEmTImgIgqJ>YbpM9%l>J=KI6Pr%81'->Oi+8r_FfKTj8*JfKmCC0uI=8)5pd:T47#6b878u8hfU&s^h5Q_:/L]AoTO'nl` %GQ8dIJ(kV*qi\^]QN(=@[_]bp2Z0ffgPlenMn]"Z#P7rJ&e4_o(E;3\Z:)H#=1jS^eDH"i='H\(=h>5:&G?kl*ggDl2U_V;c@Pup %[s'HD9cWI#kFAP7_b3D`X791%2%mbNgtU`b*P?\n]r.-1jWPlP![0Z`]Tf(>:do'Douag^V5L]m*6$KDMl9lt/Zki[WKl18'p+1_ %JO"_g_BW`E^#/0oOpqO%@b'%'5LX1eAp1JJSaX>NHo'f-%6%;rj+Z"AKa`mIa0,A\NhcjiZ-am@KLeQBoP=?Vd?S,i@i[DgHLpjK %W.t\im'1>]4h[r3b,HdTE,.*2n6J8Aad(<^!X0NOUb>EkeShTEe/'tR!!$Y5mTBi.1rQM#L
            =X-?,BNlQIbl=]%[+gC?oRF<*N+%g&@ %\mV0OZN*<_>M5-H9qAp^N&<"n)upNG+ruq^Duh%`#ds!XdbmniQsE4\c(ij4qE\9+1!pA_p-(BsO<\@[+\TQk`h_SVo3F-WKqIIR^A''Xg9M\Vt?m4i[Y6AMF7 %=u.7F^p[N^0,)Wq$LD^%BMBYWG2^Z20Pc2lgS*1MiH>RU1XLsfCBkk,3i:ij]590BM;=:(aN$PrOJ!O`J7Jc:Y&N0<+KueU?4+Xa %mYDD+j^;>%Wmb$l-J/lG+N#36gMd6]?]8KuORPE#eK6NH077@ZQR:`p.3iFRpi`s)`sWD'a$mKfc`NPC*#En\LD@Rki#]G9X6'I) %N,LjU'lGPC^"W4]I^KCt^A6o\ZEc5.KM.XarUeQ7f<8PrET767!l.'";\MG#F`hgkr:%T,XU"ed((\C:Nc-jqD@\S7lo/!?M&_JZ %^SV,1*:MAn@4=oS23L)22LMdgU6=b.dFN?c@a,J%U[GUNnkeDh`e2$8B6)K*6,]??q/9eQL%/:0hQi5#R^X/q %o#'/5a3iNEHa.(CpreV/rHePLMV@5$7:E$,B8+Z/hhHOVI-XQnr1;m&X]VhV!fr:r([2sTcP]5Cla#U7AWFF;G4Km.9&SJp$%)>\ %>I=)&9aAH<4.#Uf0'F+rl\hC5gbqYD2,b9!dohbmr6D1:Iq0$(D#.l;++N).u$T,'\T2?\D#,oni %[AXHi],bKB5W'eAF.NF]h`G9pAaIm%%AFgC%-.Io&;6Btsq*.8D+7>TX:ckaN14)4cVmK_9l")boc#3hja?o:*_AkW3p?eQ^O':0"L&/-$*$:fS&daqaVh6MJ9J]7g:gGjrrNpW&I&r3[mi^AI?1daHQ> %55BjK@efK6+0T"a>$YDCOAK.c>>nI!8KK8Nh_@7GEWg:Mt$n3_mP:H"4>]%M9c&dlh7.9 %16-5/32Qen"1XFLC7`h'n3kA3N<_t[YgV%&7:\WIQ^<9:^JgC>J8\BGRIu_!3nP">3fRqW(&TBIQRF^/1MAp2QAlV,MAMZt$Hu*b %KO[*K'63*9-c.k06o5Am-4G@q\9O)UN>7X[:u0uT@FEkTC":DU5^qQF%?>3*bSn!Wog.^L*6[)agI@37"39E4hi)?be$!Y7$;N#`Q`/@Mj5Ig.aTa@I[)BeNSnQ%Hnd,+FJ:TBn %>c`J`i1P^@h&^=])):`CfGU<+pa\EF(m[$b8Ts1Q6G-;g!dn0Dj"9>s[X\TG@5HY`erg$>VEbN'0T_7,$t;Qj>'Ml`c;Bhg$<_UU %(OrATL%Sb8+qu-CjJ naEiZIrJmp,6#>L(l+TXM%OQq=(c*kK^6=9m">n92\u*,jFJ %K*h/obHfL4_9'jj_=:pWi.C7\]-g93"X\iqi%sgOe9._\oN;mG.Gu2K;/-AF9AX<74:Z0-iSqh7hgb\)ace*uS2jMKiR@P8UIm#E:34_ZQnHuf/B'G;H@;,9:H%.lG=>I`6W.V>,k>#W_@FO %)=)a%'?G%,m%2.,:ThEVo&Rp3K>5HXd]X?GCUH-"TZ8jRDeq`U9jo5h6@c-EN(rlkr>O/$F7lGOYkL&:/o#kI'NNGH6PdUH?_3l6 %Gsd\4f%>UY=hKo>?:@.,p/0?/4,MU(lA.ecp0mmgs28WXTXpg0p0afmA#HW:PC#gV(QB-teJeQLZZkJ$ps:(^ %QB0R^OT,MBn_TE&#_TqHi?P=JKq-F,H2H]sS63JYSr=[R'K^>5s)Q(-_[$-1TLoc&!`dA]Gq3F$3"(@BS_Au:B8mU[%"GWsjQ%g9 %-!;H?Z\IB6Y?oWSY%U=>\8?YeRJ`hMb-8E,\aUUalN/YR\!hqKqjUnZ(bh^'6%WgYhi&LYbo1O`!*ZnDAU;nhVga*j.>X5/c+gl; %Q2Ui>]![TRe(=4>d^u7p'`\G)LG".+-Op5!TermYLKLs<==KtW"2k%/P8#>lnt_[/%3ES'O<=XWaAO'hf."(h$8&%T^_lr,SkWFF %D-O5e)io03@nC@(VW)h9/$f75UoFGPGO?bGn'uVII36#LeD'g/0Z(XVMPa9B,JGAf&+U(@.%`(!q;@*M-k:3Fd-P %0'!'M>`u'0:$t'.Yde,Kr_p;ePKj+rH2k[K[l,c9tf\LOgj=u\>TsPbCB97?LN:>c:T.07pD1ELC_Mpeg6jj$-+>lCl %>Gb\SH.pU>\-#RDNBf:-"uHP#Yu$8?C>=S@Iu.Y^?pe8c<:8>(rT#m+'kJ:WqtWJGhg`/-te#)"Cf*SMre %cp8-rR((%=Zi_nu>KT?7?.!hdi;bn3m@cFTI%,;Nf(VG[FS4)XiZe[a6(>1j>ld&I`*.A@:oWWslOI6!S/4Q2VN6QM,&7>S6i1YcGa>h"!U8PM$eRGg%M(/s\',jOu3`;AA)Qgrg@\NmL7u.Q=.9A"F0/.\G(4J5B[%ecfP+$Y/JnCqlrIH8J31M>!<\T4O]^OKd)A5[Z&"rE(ENV]AA=JXUKN$iaX6isEDG-+)3"ls'0et'+(ReUduXLae\_oZt7UR#C=o4oQZ3AUr<)tDiXPM[r5r%f@-Zids'reA'fhm0n*(&?RqY;2@j6#&,C %9PQU86]20kU_))Z'CQB!Z(C_VOHLRh^eG(6gk+p?:-0PKPW)O*H(0UR/h9!u4-P7??h&in.?A&=:>sefTl3*@i.O%p,\36]1g43% %4KG0=ROj0gHY(mr2'fE?rhKGE@K)WF-7428lJgm`/-C,N.ej:EiDs?_R>'Lm:N9i@Q1So63Ru6C@ggD*2%AanT.lFEi2KBDX7C\\ %F5?qtdf&H_+>RM7qG@1_nsERh6EI3cB3a7J:&_ECH;M$FO$CBAmFiUh]@SQRh+Yp;G0U6ofFFgAr.uLX;hZX4C[:(9Y34CXF&:.0W"R0P?!4$,YC91c^Wh4_Qm;XSq\"n!sF?Hotrg!N*=i\3&SY55&CuIgjVZ!i9aAH/k*l_icBji'\4TWPZ94j) %H"TuR\QX]1'Msj_?u2VoInYIt7^&K[+-cf=^a-[W3%inY*f1-V4tlrMY3]Ul=a=#h`TG2S%g_Je59?8_RfO.XV3O,(4+S8K.b2nm %ciijR8^B7KMImD11pXJ@E8GQflCm::05m@:Pd[TMLXt=mk%BFPf14&2=PgF4FoY6X=B+Hrh&'hYOZ[6k@eWJgBPPJ<-V]'!G]5L? %I^DRc(@sKCrm=@g:?5CDM.$SVl=6V#*f^KI=D:t(q=1C,,HJ#$1@O)5"MNR2YQjWi!d+ %o6&+=_`[MgM7oVJE,%fsffqs%-Yno7E3q:nh?Y(Gm#ttBZr!+p3]aU+cSRAEj6aORreP"K!IXk+H)\(N5m7!2Za)P\Ic*Zc>-S/) %M:<.JEDN+JO6,TWOO);7.`k>db4aA-idmp"g3#V:DucMfO*0$+T=7LtHMK!tL24NA %n,Y9\>^[LC%=f"-"IS$7#9K70Mk`V@s,:,("TK@KGh.:)Sg>1L_F:kBBi"*E4jeT0lWro.U0?aZ,T;W^"5FmK4"\SM]hMA3l&17K1H%PNHnuBipIV]DcuIgfDtY"S44N(!&hWj %/Kj4D3;?W(e#rRC-Nm:t^M-u(BmkM7A"`YB5kEaH[/Qktj9pG5Rf9,%3#g)-$dk>nN,Jf7JA?\FhTR^0:G!N-7*JX!?:D@P4EN%i %/L,UIPrn0O1jFN\&nkH8BG6M]!.QnF1VXZBg(e<(f[CILKf]_bP$C#QNcZ]1$9',nNEZVDdGi!Y95*8ARmZ^jRS%.L[[P`VAo]K0L]^<-5N"&pSuLt;0?,#qB84`^:FThU4is`-MZ/"/tFn#j"qUhfcYa#**b1+0_;T5Yn'1+f7U:p_:Z@+\AKKXFc_m:P(hbT)/N(YSm%YLMB*-MYB;6nUZ,"dfrEg-U %"%@]OcZj\2'i2JO1TQCSYlB-#jJ2@0,DdYa)'_Z#`o7#:q;48=OGo`&aW88^$pU>`1hYlfDI`2ZpAgEn20:fNou8>oq %qKPh*g'$Q&T?+k*?9<8i'm[6s[LhAb4Q=o;D`rk2GD"qbhFCMuaAF"_!BYG5p`pN$4Q`lj'6h=GZOTKF`ZG#1$3e<:7)6'Y&*3_%eH %pK+lS!t0Vap%@u]49,d,(`8X_i52.3Bj^G*c>E1&d5G+D>bo;:=!3nl%.#`KaR3%kd>l6:KdTB>[s7CK'))/_o_HLbfNZa*nLN7[ %Cg>>;Hi=l$8ubsA1j1F//E,5fm&5I(3foTgPkcItaNrbuP)?V,neq3n5c/ob8MdaTMKhu&5Z3brAg1o#0:FG<;0b-H4?V!'M9e2/ %(c(MF$STH4=HkG:3h/dXK/qeak$:l.1^?+%%rdbF<+bamq9O=bP]B?([VgKqWq@K8KUmAlC8E3od-Os%6#O9,4BkXJbqDZOS^!1r %5F/Ei4WnMGKk%%/W$Ve'#H)XCFCPY)-L;O!ZZVQC!djcM@1iP_$+@J]JegdS;Xg?N@sKktFsGB3RdY?-T])eCJ#M*!8&CF.%C[Ai %_[6Y[!mMuVk,gbH%_bY-R,-]GB7!&T'p5i`68Y?cq]"/f)j"P:"<5-BY_=pdZCPp98m\CNLX@4cr&W%U/frrbPUg]2TFhA2&e?gP %d&%rh3,XU3CQ>`7NO%mb3)3^/W>^X#,67_oJ$@&QS:V!19/Po/X+NV3t5BN""bPo%t+6#G:l8WoHJrSTe`\PH9cs./u>lESPOl%`3N%9Cp:4`CM0BT@^,/hf+1\C.(O/rT&.+rBE>15b?R]s#_ %0KA.Tn!2jqZAIgpA=7+*`U'p[RYd$\)_)1m-#`:\VGgI2psoOI!BG^_Z,*oG34n8Pq0)3YgoaA;okF[kdF*\e!mE)@I4(URXpCja %>#tjRf-:M/=:\(@1!^VGU59)Mji"_#"Q[uJ!O@i#Kd;(0iXc=#Ud2,l3ep)rL?`:%Wl'r[#DNmP,KI+>KgAl\nE-Kg&p/a7W4%%V %6;/>M/C#jq/J`3,,`L`*='P?\kp-#71mD.mURS>s<< %>fJHm(lJV.%1U%c^6^K[mdBKi"rAV%4TIT@-9a5Q\dDEF>dRTK]20D'n$ZWt<2;[o5p`>_NOBu]#'uf$ZiD.+j8KE^Lcfs-n/QTs %"Zn'N9(;*@n/?aJV"6`/Q%9\.o,gchBMA_F_SV_2W`\%B0qMbEdLu+^qub4%!i/&PNL^XFQ?q!fh@_[,+]3_l,g*BcklW7Z9*kP_ %c([[@(1DpZAO$3S9GuCTJ?>C;J8.0gHJi;3:(0@<\o^/[.bRXhf=.:(VdI?T,qmL\Rk>mF&M49V(JS1@.ecs!E.\Icb)qE!kMPFL %A.^V%_JG].=iq9(\p")7cC(1p_>IRgg/C]%fVVLt@((:_tI=_k6A=1Ff+@+Q9.$V18N %+QK4/q0(P,J-HUm?pICc!&b+VF[aRog:_gTdhkS(pBQ3R\?*6IHc888P2;c$]9DE"-TQ>sfP4+f`+$nL+e[59Ch-70^-!'Z5pFZUF8o-*^DN0L);a[0d?4n%H8Rgm+#Nt"iH(5`23(agupq(2*Lo#>9U\32R\d(i)6++XdG@YqhJX8SpI!&Fa#^Vh(? %dr0:YJI3)'p4*BRJ-UH@=dWKALm8[iJD^HfZtQpCj'n.^5W2[X'a@,J&RH8_,gudPTR^ATg,U#D^s4QV%i1UI*gR?Tol\tUconZ5"Kf5*<_%!M%<0^\aN2_%3!70MF4ecRG>8+BW921S(nLipsC1UJ9f %n$HhWjoRfbbs:mo:`DFK4TW#5!#m%CYgp%1P`'ntUqt!IV3GU=%C)(M=efDZqm7;)"?[N6ciN#+6FCT3GHt^kJor$HT-Ja7nY\5M %N-OjH$PtI&e.+)2!5upuF#Y-@%m]f3F`e-(%o3cJ*k5sXEe7Q[0HdUb:S84+c'<;?N8$/emc@$\"2i!q%Y\>0o<oa?5:%#,qraZfiJ$Xi %,$,Z1mV&7f[KeH/p\CN]l\>-5$*@o(l4OtMmd=93+e76&ZY\aPRR=/%>k9;6V'O]N=>RK'iIP"T5EKqr74`.b_<5:gW8H;s/>7HT %7r&F?^E%%kKQ%rn!%k=3%l*iRYOa\ik0\\a3l>fskoeash_`.fiIR:(\6:ROCsWr,e]tXi7m#_r/,mQXJ-$&$_-P4uaUT2[l-'WR/fW&Pa)9rFuF[6eFiHC+2ffee7ORkuh-qTH1?rLom)3=s)!J#_X %goa@Z%k1`X2NhH@@"[cLO9)%0d(GWV/IeuQ%'re:ck]9[:g,pd'b:R.#U\2Sf#G2eN3L'\i\^S#%P(3i&i("D%&D>+7$]b3%jDQ+ %W*;m@TNF'"Dg=o,3UXi-3\EdW?)EVO&Jn'Zk]^$L&Y."](G]#_8r/$STA22dK*V^lAqT,`-(s;=0-[s;$dHg`[idiFGNFTb0^]X+a_4Ad;UcK0Y-f3[eEZi>8:qQjH\eJc2 %bd4t,Z`4Nk8#mU4Qi\>a#W-H5bZ)O+BaM+_jK.\ua\a,iXXVE4MIUOF/T6>F2fc$h6EFD/Cc`g]U_3=n*6[Yse$!VV:/"umop\gn %eZ:cu-4RBN'%eWWm(:+7%g_KP*T@*Q#?3UcJl<]K2LY,ABNu#o63tfU'+Y@8:o?=_D7@.inl2C2'XW)kBQ6K_.!T3L6D(@W_Hc/9 %P$9)J.7$.qhP.[\/:;:X^$QE^OpY[7.M<8lnpO3Eo;aA3!"'j>">+46DUBetPp$sm1*[o82*>c$0T>JTGg5-9P`7#V\^(\DT<^3& %):NeJR!Qe4\dn_H7fL("h;]u#D,$s/\mJkqj6"p17&s"VKr0JBj?$D'qQ^;UE#G3PJW=q7FX=,:jN?T9'-eP\*-q1&s++8_db!:- %]kUC11pCJ(ch;XA(rot1HoM'Pi.j8us]#^_9@Xom;V,i$+nB13hA^tF5b$a])XGoC$.nTTQiGLN(XgF[2.T"6@- %B3Qd!%GrMSHgL$O`\=sm %\Db8#b!JENeqMa8,-e,*=DSNZLm3$\d_um"6_-HEXFh9,XG3l)d4;R/pNpW+Sk'cKBlcl@S>HkWfVBb2jk_9oMjH7)=uD4=@XFX2 %!%MG!r&o=Q`/"X/7ioA#T,\o^N'2m]#.mq=$n+OP3[5Zl#_LE0-Erd+XlV9Y'X:I.-SJJV=0r;#RY!,/Cdf!M$+GJT^]kDEO9O:0 %.G1q3@#p$`_JC,Nk)D[h^]mLVeqad^WX-&8iHC3so"#b[5 %7695[[;%I5nG!JGB^fE?FINHlJL&[.YS\'Tj%>W6])Oi4pJ_7EMRP.U(m=#"UjQYAp,B/aZL>S9W/6bjpiE:j2Xl<*(VXiB!u0kV3e*X]W4@mRE,#t5oYI>9i/%Yl$OKesW3kO5-&k2I %8`Tq%T6;)-neXR2Hdr/K\X,8/pZ'q^;O97rfH@4]OA1)7o>in9E$*[P(-dGSlT]T4%iI/?5IW8`Yp3u509j+bWJ#'9,'&j%j$_jE %:,(4HeD]p6,9AreH1ZF_hR3KcUq7bsq\.#&1koc.Zb6\;cDXn5!]t+`9tS3L28i*"ACe[R^Wj*L764'9p&G:Ur+Egg5!1A=\%!3u %4A.qq[P7$5:`<.rHN^R(m7iU06_J^GW(i`]R0t9Fb=2u;Ue/'sog+mdJ`bM^VH:7"]pXB[l2o0T5o-T=l;d*B,g'[@s>:]@Ip!>Q.5UcmJDc(YE_KDHe=j$&a-M_oB62..?r_\.6FbIcL-eg.2o"Z'84t %)ei%*p:(96JXVblEel&Pge'qVYj$sNU53.bpn>#8UcID4?rG1U_/!L(&Zlt0#PWGu3sk+df#81I!nX,&kjb%XHdL!r?RU1IEHZV/ %LcP:eq2=[t62Hs<#cTe0a9D8&^hf %G*?Fh`3n%]e4d/6K`0iSjnQh&b]-V^Dh\U@Gd,6]cL,6H*o)W.X"NIHhrq-1j%?,il]J6!W!$H+95kWl+hS9)KMctZ"G?f7HqF9V %Zl!fug+ZCeS7'9f.>K]lc@#WekMLdhrRYD+rP]R[Pa.N)5C`[mO$>gVqXh=>kK:ukY?\V9;cD.uLPIn"TU"(KCPI&:l;(U7Qcch- %qJ6gCC,UM)R[!`hnGYUFkf9&*RMZ!XC?4463Y73+HZT*7/CR$/N`;o#eWhsQKG,4>&/jr44PQ(pX>4d`Ep'I-FgrDJ8"&WDePjF1 %RY`+P'kW#=E_;c.*WeSNt<<[ZH%K*Q3KO`9Zp62:85;&8^nbcd1oLc#h`EjN]Y]X.:2\rr&R2]f.LG?-JL#>4'ROB\\`=6CeUKU,5@8^QFIEH#?diA%_]ps0i1c+()q %29=,cG\;5dnnffOg87)?chDrPf&2,!+b;**Ru^J>dN**3AUIOY9/H$sV@J#'b9V`S6I`>0oi0Zdi6O#1'E<,r.q06^13jQT/AJsc %%dlUY\e`'38F!K-^8$?s:=.;BM3Ntg?j5IS/^jD-s6`?=0T+id4F&Hn'Fb4U95r9[r@VSh`X6_:7D4&u@#EDRZ'-AD2R#JKZt$#\ %:)Za:`A*\^R7RHSCOE$cg%:%AdJNpM3okO9<]dt&"lB3U2[#QmB>n7O&.+[;_B#mFclDQ5(>#iVdLksK[!tl)_a)`j.IDt3pLW);8=lU!BA--9,\qLErQLaWE\Yg-#u_'Q)qQs\qO6(8Qk;1`[`>/aD;7u %#uekHd/MSGe[;fmFa1EI#DdqSq"I/EF-;S="$^i$b=V<5B?\u!1AYEbd+2Z)R[N"6kTs/4"#oE*A@/*,^m %DuCdMq,a_(WI!b/=#fK2C<\j>Y83?Sp%9n+[t=nsVN(lt/mJB7\>'AD]tMc*Z?#N,[hd6Xs87q00/pXGrP'.PhYFVI]o,G39Q*UC9Q*Thnau:!gK$l;[hmfunWbs+^"P\?PXC5Mid^mB %a.G^8XgRme[.(#:SMSi!oIR %'ci(ElSs4$"5RTj-ZUQW.!d2Dd6I4l!8Xg%oRElU$e%B`ZQ/ %Q?dCfkJ?&F6PGXd$+,]!A\9nW#taHW_NLY%D[T8Ne,KlOS6Qm`GZ[7=o5[_qe2AK+C$f*mYd!2uc.eFK;:63O"b>hrKniPKPVAa:Q>PNM/h_/Nnp1Nd[j5b'@B %S`Sfralq^#@!R(.6];shO8:e\I8nYY\/-Q5Srf#tkt]iqmHFqbFJ\:Q5Xn"1RVJ!?:la\-BJO;:/!S5#@scSChN@gpE0jn(7DR0& %43hkd6.LKaCaR'K`Kc9t+PWIGl0@6e"lB)uW3O&D)UWNj61-`&h:@l-DI(l].u1cB2* %g?,?I(8W1\f^NB,=aCP1c'?+U]=:aZ]"'OtDD!K_6_]LdeNb):a^homj(Xle/oV1\>o>1/'7N#%(;n>7*Mm-ui%u+t@*FUVJ[UjT %5J*-J[V_^XJoJ:T+Z);RYUt_"#+YiY)'3[%pohHdbCpF#e]l:n:"qjfca?*nC9g'DVls\f4W,epGVWBCc^=gMp:a%\p2WSL2!jW#1nPUn&8bI:$B0(-[T,X\f9Em]K*c=q(u1Lg9l1?jM=:r,Giq9H8.Opgo*pU %4cGtKVPdab)p5-qRk,?dW0n#tfBRH4]&0"jLUTG>tWT'614ASG>=/k4IXZuKWWKE"60_kaY# %Lk/$jhs/5:W;C:On;BSbB1%k1HRBU_='),*A2Da7C:[I0`alTkZ<23c"*K=$#Mk;E)oPN-+WK/>.`h74'#r2:p2KbV34M3"&1A@E %1r!D3i7c:Ycp5K)h!%`:VQ\[_4__5FCjSK.gg/F]P'>7!]j%gG&/tFT-u_EY,c!O#VtYq\OMAi%M!YLg`$r"8L2K,$4(#ephVa+a %ns>QYYl^NbdX'OG*"\-ZB\+\V0.uH]HdJo:bEiPK'ip[unN63j4Afi-ofh6\M(f9*\U0\Apk;dr#D\pL[S\pNWN4?ZqO %PRcU,Q,/2=K@8^o@]1<]-B?g=CNF7&E[]b]Mb/o%e3On+/d.HeFnsD5]>6-ml13dWDI+C\?,#HMNVi32[8tXU]/'G;>Zoc&KaF/0 %ae*'#l<[RFE9#lS5tJqEhU5>0$$agbE%=&i\1>qj$&=HXaB#&"m&;YrTE;)VYATF>X1lbR%rZr1jR2&QA[+g8Sm4H8;Y_X"hH\< %PGg!'o/u9?7sP_5dDFA2s!0qWb$qWh-r2$80;^,OPO]u6@jk-I/l)!`,,GMT3orqd(_(IbiV>\/N)P]*m)9e-!hb!;:'8 %0ql^LIPT$Rm&>=c;.&;`g-F-26#Qj'Yi0Fen$s^8&9p;,?pU(Jq_rCf-@./p:`YcmV].e+&*:L-WHfg+2%]KgPl\:X-bnD/':9;+\gQA!V`=ElV9-ALq"cpP9Z6hUP3#gP[E"c5h*'*RsGaR)&d$tRa2 %?aadac_"qO%lIpT_#~> %AI9_PrivateDataEnd \ No newline at end of file diff --git a/www/analytics/plugins/Overlay/client/linktags.png b/www/analytics/plugins/Overlay/client/linktags.png new file mode 100644 index 00000000..ba118008 Binary files /dev/null and b/www/analytics/plugins/Overlay/client/linktags.png differ diff --git a/www/analytics/plugins/Overlay/client/linktags.psd b/www/analytics/plugins/Overlay/client/linktags.psd new file mode 100644 index 00000000..31b770b8 Binary files /dev/null and b/www/analytics/plugins/Overlay/client/linktags.psd differ diff --git a/www/analytics/plugins/Overlay/client/linktags_lessshadow.png b/www/analytics/plugins/Overlay/client/linktags_lessshadow.png new file mode 100644 index 00000000..e57f8ad6 Binary files /dev/null and b/www/analytics/plugins/Overlay/client/linktags_lessshadow.png differ diff --git a/www/analytics/plugins/Overlay/client/linktags_noshadow.png b/www/analytics/plugins/Overlay/client/linktags_noshadow.png new file mode 100644 index 00000000..3290b1a1 Binary files /dev/null and b/www/analytics/plugins/Overlay/client/linktags_noshadow.png differ diff --git a/www/analytics/plugins/Overlay/client/loading.gif b/www/analytics/plugins/Overlay/client/loading.gif new file mode 100644 index 00000000..4df2f1fd Binary files /dev/null and b/www/analytics/plugins/Overlay/client/loading.gif differ diff --git a/www/analytics/plugins/Overlay/client/translations.js b/www/analytics/plugins/Overlay/client/translations.js new file mode 100644 index 00000000..9665b920 --- /dev/null +++ b/www/analytics/plugins/Overlay/client/translations.js @@ -0,0 +1,30 @@ +var Piwik_Overlay_Translations = (function () { + + /** Translations strings */ + var translations = []; + + return { + + /** + * Initialize translations module. + * Callback is triggered when data is available. + */ + initialize: function (callback) { + // Load translation data + Piwik_Overlay_Client.api('getTranslations', function (data) { + translations = data[0]; + callback(); + }); + }, + + /** Get translation string */ + get: function (identifier) { + if (typeof translations[identifier] == 'undefined') { + return identifier; + } + return translations[identifier]; + } + + }; + +})(); diff --git a/www/analytics/plugins/Overlay/client/urlnormalizer.js b/www/analytics/plugins/Overlay/client/urlnormalizer.js new file mode 100644 index 00000000..72db6126 --- /dev/null +++ b/www/analytics/plugins/Overlay/client/urlnormalizer.js @@ -0,0 +1,198 @@ +/** + * URL NORMALIZER + * This utility preprocesses both the URLs in the document and + * from the Piwik logs in order to make matching possible. + */ +var Piwik_Overlay_UrlNormalizer = (function () { + + /** Base href of the current document */ + var baseHref = false; + + /** Url of current folder */ + var currentFolder; + + /** The current domain */ + var currentDomain; + + /** Regular expressions for parameters to be excluded when matching links on the page */ + var excludedParamsRegEx = []; + + /** + * Basic normalizations for domain names + * - remove protocol and www from absolute urls + * - add a trailing slash to urls without a path + * + * Returns array + * 0: normalized url + * 1: true, if url was absolute (if not, no normalization was performed) + */ + function normalizeDomain(url) { + if (url === null) { + return ''; + } + + var absolute = false; + + // remove protocol + if (url.substring(0, 7) == 'http://') { + absolute = true; + url = url.substring(7, url.length); + } else if (url.substring(0, 8) == 'https://') { + absolute = true; + url = url.substring(8, url.length); + } + + if (absolute) { + // remove www. + url = removeWww(url); + + // add slash to domain names + if (url.indexOf('/') == -1) { + url += '/'; + } + } + + return [url, absolute]; + } + + /** Remove www. from a domain */ + function removeWww(domain) { + if (domain.substring(0, 4) == 'www.') { + return domain.substring(4, domain.length); + } + return domain; + } + + return { + + initialize: function () { + this.setCurrentDomain(document.location.hostname); + this.setCurrentUrl(window.location.href); + + var head = document.getElementsByTagName('head'); + if (head.length) { + var base = head[0].getElementsByTagName('base'); + if (base.length && base[0].href) { + this.setBaseHref(base[0].href); + } + } + }, + + /** + * Explicitly set domain (for testing) + */ + setCurrentDomain: function (pCurrentDomain) { + currentDomain = removeWww(pCurrentDomain); + }, + + /** + * Explicitly set current url (for testing) + */ + setCurrentUrl: function (url) { + var index = url.lastIndexOf('/'); + if (index != url.length - 1) { + currentFolder = url.substring(0, index + 1); + } else { + currentFolder = url; + } + currentFolder = normalizeDomain(currentFolder)[0]; + }, + + /** + * Explicitly set base href (for testing) + */ + setBaseHref: function (pBaseHref) { + if (!pBaseHref) { + baseHref = false; + } else { + baseHref = normalizeDomain(pBaseHref)[0]; + } + }, + + /** + * Set the parameters to be excluded when matching links on the page + */ + setExcludedParameters: function (pExcludedParams) { + excludedParamsRegEx = []; + for (var i = 0; i < pExcludedParams.length; i++) { + var paramString = pExcludedParams[i]; + excludedParamsRegEx.push(new RegExp('&' + paramString + '=([^&#]*)', 'ig')); + } + }, + + /** + * Remove the protocol and the prefix of a URL + */ + removeUrlPrefix: function (url) { + return normalizeDomain(url)[0]; + }, + + /** + * Normalize URL + * Can be an absolute or a relative URL + */ + normalize: function (url) { + if (!url) { + return ''; + } + + // ignore urls starting with # + if (url.substring(0, 1) == '#') { + return ''; + } + + // basic normalizations for absolute urls + var normalized = normalizeDomain(url); + url = normalized[0]; + + var absolute = normalized[1]; + + if (!absolute) { + /** relative url */ + if (url.substring(0, 1) == '/') { + // relative to domain root + url = currentDomain + url; + } else if (baseHref) { + // relative to base href + url = baseHref + url; + } else { + // relative to current folder + url = currentFolder + url; + } + } + + // replace multiple / with a single / + url = url.replace(/\/\/+/g, '/'); + + // handle ./ and ../ + var parts = url.split('/'); + var urlArr = []; + for (var i = 0; i < parts.length; i++) { + if (parts[i] == '.') { + // ignore + } + else if (parts[i] == '..') { + urlArr.pop(); + } + else { + urlArr.push(parts[i]); + } + } + url = urlArr.join('/'); + + // remove ignored parameters + url = url.replace(/\?/, '?&'); + for (i = 0; i < excludedParamsRegEx.length; i++) { + var regEx = excludedParamsRegEx[i]; + url = url.replace(regEx, ''); + } + url = url.replace(/\?&/, '?'); + url = url.replace(/\?#/, '#'); + url = url.replace(/\?$/, ''); + + return url; + } + + }; + +})(); \ No newline at end of file diff --git a/www/analytics/plugins/Overlay/images/info.png b/www/analytics/plugins/Overlay/images/info.png new file mode 100644 index 00000000..12cd1aef Binary files /dev/null and b/www/analytics/plugins/Overlay/images/info.png differ diff --git a/www/analytics/plugins/Overlay/images/overlay_icon.png b/www/analytics/plugins/Overlay/images/overlay_icon.png new file mode 100755 index 00000000..a960d1bb Binary files /dev/null and b/www/analytics/plugins/Overlay/images/overlay_icon.png differ diff --git a/www/analytics/plugins/Overlay/images/overlay_icon_hover.png b/www/analytics/plugins/Overlay/images/overlay_icon_hover.png new file mode 100755 index 00000000..10296ef3 Binary files /dev/null and b/www/analytics/plugins/Overlay/images/overlay_icon_hover.png differ diff --git a/www/analytics/plugins/Overlay/javascripts/Overlay_Helper.js b/www/analytics/plugins/Overlay/javascripts/Overlay_Helper.js new file mode 100644 index 00000000..1342bf79 --- /dev/null +++ b/www/analytics/plugins/Overlay/javascripts/Overlay_Helper.js @@ -0,0 +1,31 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +var Overlay_Helper = { + + /** Encode the iframe url to put it behind the hash in sidebar mode */ + encodeFrameUrl: function (url) { + // url encode + replace % with $ to make sure that browsers don't break the encoding + return encodeURIComponent(url).replace(/%/g, '$') + }, + + /** Decode the url after reading it from the hash */ + decodeFrameUrl: function (url) { + // reverse encodeFrameUrl() + return decodeURIComponent(url.replace(/\$/g, '%')); + }, + + /** Get the url to launch overlay */ + getOverlayLink: function (idSite, period, date, link) { + var url = 'index.php?module=Overlay&period=' + period + '&date=' + date + '&idSite=' + idSite; + if (link) { + url += '#l=' + Overlay_Helper.encodeFrameUrl(link); + } + return url; + } + +}; \ No newline at end of file diff --git a/www/analytics/plugins/Overlay/javascripts/Piwik_Overlay.js b/www/analytics/plugins/Overlay/javascripts/Piwik_Overlay.js new file mode 100644 index 00000000..2d4c8c98 --- /dev/null +++ b/www/analytics/plugins/Overlay/javascripts/Piwik_Overlay.js @@ -0,0 +1,252 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +var Piwik_Overlay = (function () { + + var $body, $iframe, $sidebar, $main, $location, $loading, $errorNotLoading; + var $rowEvolutionLink, $transitionsLink, $fullScreenLink; + + var idSite, period, date; + + var iframeSrcBase; + var iframeDomain = ''; + var iframeCurrentPage = ''; + var iframeCurrentPageNormalized = ''; + var iframeCurrentActionLabel = ''; + var updateComesFromInsideFrame = false; + + + /** Load the sidebar for a url */ + function loadSidebar(currentUrl) { + showLoading(); + + $location.html(' ').unbind('mouseenter').unbind('mouseleave'); + + iframeCurrentPage = currentUrl; + iframeDomain = currentUrl.match(/http(s)?:\/\/(www\.)?([^\/]*)/i)[3]; + + globalAjaxQueue.abort(); + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams({ + module: 'Overlay', + action: 'renderSidebar', + currentUrl: currentUrl + }, 'get'); + ajaxRequest.setCallback( + function (response) { + hideLoading(); + + var $response = $(response); + + var $responseLocation = $response.find('.Overlay_Location'); + var $url = $responseLocation.find('span'); + iframeCurrentPageNormalized = $url.data('normalizedUrl'); + iframeCurrentActionLabel = $url.data('label'); + $url.html(piwikHelper.addBreakpointsToUrl($url.text())); + $location.html($responseLocation.html()).show(); + $responseLocation.remove(); + + var $locationSpan = $location.find('span'); + $locationSpan.html(piwikHelper.addBreakpointsToUrl($locationSpan.text())); + if (iframeDomain) { + // use addBreakpointsToUrl because it also encoded html entities + $locationSpan.tooltip({ + track: true, + items: '*', + tooltipClass: 'Overlay_Tooltip', + content: '' + Piwik_Overlay_Translations.domain + ': ' + + piwikHelper.addBreakpointsToUrl(iframeDomain), + show: false, + hide: false + }); + } + + $sidebar.empty().append($response).show(); + + if ($sidebar.find('.Overlay_NoData').size() == 0) { + $rowEvolutionLink.show(); + $transitionsLink.show() + } + } + ); + ajaxRequest.setErrorCallback(function () { + hideLoading(); + $errorNotLoading.show(); + }); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + } + + /** Adjust the dimensions of the iframe */ + function adjustDimensions() { + $iframe.height($(window).height()); + $iframe.width($body.width() - $iframe.offset().left - 2); // -2 because of 2px border + } + + /** Display the loading message and hide other containers */ + function showLoading() { + $loading.show(); + + $sidebar.hide(); + $location.hide(); + + $fullScreenLink.hide(); + $rowEvolutionLink.hide(); + $transitionsLink.hide(); + + $errorNotLoading.hide(); + } + + /** Hide the loading message */ + function hideLoading() { + $loading.hide(); + $fullScreenLink.show(); + } + + /** $.history callback for hash change */ + function hashChangeCallback(urlHash) { + var location = broadcast.getParamValue('l', urlHash); + location = Overlay_Helper.decodeFrameUrl(location); + + if (!updateComesFromInsideFrame) { + var iframeUrl = iframeSrcBase; + if (location) { + iframeUrl += '#' + location; + } + $iframe.attr('src', iframeUrl); + showLoading(); + } else { + loadSidebar(location); + } + + updateComesFromInsideFrame = false; + } + + return { + + /** This method is called when Overlay loads */ + init: function (iframeSrc, pIdSite, pPeriod, pDate) { + iframeSrcBase = iframeSrc; + idSite = pIdSite; + period = pPeriod; + date = pDate; + + $body = $('body'); + $iframe = $('#Overlay_Iframe'); + $sidebar = $('#Overlay_Sidebar'); + $location = $('#Overlay_Location'); + $main = $('#Overlay_Main'); + $loading = $('#Overlay_Loading'); + $errorNotLoading = $('#Overlay_Error_NotLoading'); + + $rowEvolutionLink = $('#Overlay_RowEvolution'); + $transitionsLink = $('#Overlay_Transitions'); + $fullScreenLink = $('#Overlay_FullScreen'); + + adjustDimensions(); + + showLoading(); + + // apply initial dimensions + window.setTimeout(function () { + adjustDimensions(); + }, 50); + + // handle window resize + // we manipulate broadcast.pageload because it unbinds all resize events on window + var originalPageload = broadcast.pageload; + broadcast.pageload = function (hash) { + originalPageload(hash); + $(window).resize(function () { + adjustDimensions(); + }); + }; + $(window).resize(function () { + adjustDimensions(); + }); + + // handle hash change + broadcast.loadAjaxContent = hashChangeCallback; + broadcast.init(); + + if (window.location.href.split('#').length == 1) { + // if there's no hash, broadcast won't trigger the callback - we have to do it here + hashChangeCallback(''); + } + + // handle date selection + var $select = $('select#Overlay_DateRangeSelect').change(function () { + var parts = $(this).val().split(';'); + if (parts.length == 2) { + period = parts[0]; + date = parts[1]; + window.location.href = Overlay_Helper.getOverlayLink(idSite, period, date, iframeCurrentPage); + } + }); + + var optionMatchFound = false; + $select.find('option').each(function () { + if ($(this).val() == period + ';' + date) { + $(this).prop('selected', true); + optionMatchFound = true; + } + }); + + if (!optionMatchFound) { + $select.prepend('', '')), + 'actionToLoadSubTables' => 'getKeywordsFromCampaignId', + 'order' => 9, + ), + array( // subtable report + 'category' => Piwik::translate('Referrers_Referrers'), + 'name' => Piwik::translate('Referrers_Campaigns'), + 'module' => 'Referrers', + 'action' => 'getKeywordsFromCampaignId', + 'dimension' => Piwik::translate('General_ColumnKeyword'), + 'documentation' => Piwik::translate('Referrers_CampaignsReportDocumentation', + array('
            ', '', '')), + 'isSubtableReport' => true, + 'order' => 10, + ), + array( + 'category' => Piwik::translate('Referrers_Referrers'), + 'name' => Piwik::translate('Referrers_Socials'), + 'module' => 'Referrers', + 'action' => 'getSocials', + 'actionToLoadSubTables' => 'getUrlsForSocial', + 'dimension' => Piwik::translate('Referrers_ColumnSocial'), + 'documentation' => Piwik::translate('Referrers_WebsitesReportDocumentation', '
            '), + 'order' => 11, + ), + array( + 'category' => Piwik::translate('Referrers_Referrers'), + 'name' => Piwik::translate('Referrers_Socials'), + 'module' => 'Referrers', + 'action' => 'getUrlsForSocial', + 'isSubtableReport' => true, + 'dimension' => Piwik::translate('Referrers_ColumnWebsitePage'), + 'documentation' => Piwik::translate('Referrers_WebsitesReportDocumentation', '
            '), + 'order' => 12, + ), + )); + } + + public function getSegmentsMetadata(&$segments) + { + $segments[] = array( + 'type' => 'dimension', + 'category' => 'Referrers_Referrers', + 'name' => 'Referrers_Type', + 'segment' => 'referrerType', + 'acceptedValues' => 'direct, search, website, campaign', + 'sqlSegment' => 'log_visit.referer_type', + 'sqlFilterValue' => __NAMESPACE__ . '\getReferrerTypeFromShortName', + ); + $segments[] = array( + 'type' => 'dimension', + 'category' => 'Referrers_Referrers', + 'name' => 'General_ColumnKeyword', + 'segment' => 'referrerKeyword', + 'acceptedValues' => 'Encoded%20Keyword, keyword', + 'sqlSegment' => 'log_visit.referer_keyword', + ); + $segments[] = array( + 'type' => 'dimension', + 'category' => 'Referrers_Referrers', + 'name' => 'Referrers_ReferrerName', + 'segment' => 'referrerName', + 'acceptedValues' => 'twitter.com, www.facebook.com, Bing, Google, Yahoo, CampaignName', + 'sqlSegment' => 'log_visit.referer_name', + ); + $segments[] = array( + 'type' => 'dimension', + 'category' => 'Referrers_Referrers', + 'name' => 'Live_Referrer_URL', + 'acceptedValues' => 'http%3A%2F%2Fwww.example.org%2Freferer-page.htm', + 'segment' => 'referrerUrl', + 'sqlSegment' => 'log_visit.referer_url', + ); + } + + /** + * Adds Referrer widgets + */ + function addWidgets() + { + WidgetsList::add('Referrers_Referrers', 'Referrers_WidgetKeywords', 'Referrers', 'getKeywords'); + WidgetsList::add('Referrers_Referrers', 'Referrers_WidgetExternalWebsites', 'Referrers', 'getWebsites'); + WidgetsList::add('Referrers_Referrers', 'Referrers_WidgetSocials', 'Referrers', 'getSocials'); + WidgetsList::add('Referrers_Referrers', 'Referrers_SearchEngines', 'Referrers', 'getSearchEngines'); + WidgetsList::add('Referrers_Referrers', 'Referrers_Campaigns', 'Referrers', 'getCampaigns'); + WidgetsList::add('Referrers_Referrers', 'General_Overview', 'Referrers', 'getReferrerType'); + WidgetsList::add('Referrers_Referrers', 'Referrers_WidgetGetAll', 'Referrers', 'getAll'); + if (SettingsPiwik::isSegmentationEnabled()) { + WidgetsList::add('SEO', 'Referrers_WidgetTopKeywordsForPages', 'Referrers', 'getKeywordsForPage'); + } + } + + /** + * Adds Web Analytics menus + */ + function addMenus() + { + MenuMain::getInstance()->add('Referrers_Referrers', '', array('module' => 'Referrers', 'action' => 'index'), true, 20); + MenuMain::getInstance()->add('Referrers_Referrers', 'General_Overview', array('module' => 'Referrers', 'action' => 'index'), true, 1); + MenuMain::getInstance()->add('Referrers_Referrers', 'Referrers_SubmenuSearchEngines', array('module' => 'Referrers', 'action' => 'getSearchEnginesAndKeywords'), true, 2); + MenuMain::getInstance()->add('Referrers_Referrers', 'Referrers_SubmenuWebsites', array('module' => 'Referrers', 'action' => 'indexWebsites'), true, 3); + MenuMain::getInstance()->add('Referrers_Referrers', 'Referrers_Campaigns', array('module' => 'Referrers', 'action' => 'indexCampaigns'), true, 4); + } + + /** + * Adds Goal dimensions, so that the dimensions are displayed in the UI Goal Overview page + */ + public function getReportsWithGoalMetrics(&$dimensions) + { + $dimensions = array_merge($dimensions, array( + array('category' => Piwik::translate('Referrers_Referrers'), + 'name' => Piwik::translate('Referrers_Type'), + 'module' => 'Referrers', + 'action' => 'getReferrerType', + ), + array('category' => Piwik::translate('Referrers_Referrers'), + 'name' => Piwik::translate('Referrers_Keywords'), + 'module' => 'Referrers', + 'action' => 'getKeywords', + ), + array('category' => Piwik::translate('Referrers_Referrers'), + 'name' => Piwik::translate('Referrers_SearchEngines'), + 'module' => 'Referrers', + 'action' => 'getSearchEngines', + ), + array('category' => Piwik::translate('Referrers_Referrers'), + 'name' => Piwik::translate('Referrers_Websites'), + 'module' => 'Referrers', + 'action' => 'getWebsites', + ), + array('category' => Piwik::translate('Referrers_Referrers'), + 'name' => Piwik::translate('Referrers_Campaigns'), + 'module' => 'Referrers', + 'action' => 'getCampaigns', + ), + )); + } + + public function getDefaultTypeViewDataTable(&$defaultViewTypes) + { + $defaultViewTypes['Referrers.getReferrerType'] = AllColumns::ID; + $defaultViewTypes['Referrers.getSocials'] = Pie::ID; + } + + public function configureViewDataTable(ViewDataTable $view) + { + switch ($view->requestConfig->apiMethodToRequestDataTable) { + case 'Referrers.getReferrerType': + $this->configureViewForGetReferrerType($view); + break; + case 'Referrers.getAll': + $this->configureViewForGetAll($view); + break; + case 'Referrers.getKeywords': + $this->configureViewForGetKeywords($view); + break; + case 'Referrers.getSearchEnginesFromKeywordId': + $this->configureViewForGetSearchEnginesFromKeywordId($view); + break; + case 'Referrers.getSearchEngines': + $this->configureViewForGetSearchEngines($view); + break; + case 'Referrers.getKeywordsFromSearchEngineId': + $this->configureViewForGetKeywordsFromSearchEngineId($view); + break; + case 'Referrers.getWebsites': + $this->configureViewForGetWebsites($view); + break; + case 'Referrers.getSocials': + $this->configureViewForGetSocials($view); + break; + case 'Referrers.getUrlsForSocial': + $this->configureViewForGetUrlsForSocial($view); + break; + case 'Referrers.getCampaigns': + $this->configureViewForGetCampaigns($view); + break; + case 'Referrers.getKeywordsFromCampaignId': + $this->configureViewForGetKeywordsFromCampaignId($view); + break; + case 'Referrers.getUrlsFromWebsiteId': + $this->configureViewForGetUrlsFromWebsiteId($view); + break; + } + } + + private function configureViewForGetReferrerType(ViewDataTable $view) + { + $idSubtable = Common::getRequestVar('idSubtable', false); + $labelColumnTitle = Piwik::translate('Referrers_Type'); + + switch ($idSubtable) { + case Common::REFERRER_TYPE_SEARCH_ENGINE: + $labelColumnTitle = Piwik::translate('Referrers_ColumnSearchEngine'); + break; + case Common::REFERRER_TYPE_WEBSITE: + $labelColumnTitle = Piwik::translate('Referrers_ColumnWebsite'); + break; + case Common::REFERRER_TYPE_CAMPAIGN: + $labelColumnTitle = Piwik::translate('Referrers_ColumnCampaign'); + break; + default: + break; + } + + $view->config->show_search = false; + $view->config->show_goals = true; + $view->config->show_offset_information = false; + $view->config->show_pagination_control = false; + $view->config->show_limit_control = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', $labelColumnTitle); + + $view->requestConfig->filter_limit = 10; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->disable_subtable_when_show_goals = true; + } + } + + private function configureViewForGetAll(ViewDataTable $view) + { + $setGetAllHtmlPrefix = array($this, 'setGetAllHtmlPrefix'); + + $view->config->show_exclude_low_population = false; + $view->config->show_goals = true; + $view->config->addTranslation('label', Piwik::translate('Referrers_Referrer')); + + $view->requestConfig->filter_limit = 20; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->disable_row_actions = true; + } + + $view->config->filters[] = array('MetadataCallbackAddMetadata', array('referer_type', 'html_label_prefix', $setGetAllHtmlPrefix)); + } + + private function configureViewForGetKeywords(ViewDataTable $view) + { + $view->config->subtable_controller_action = 'getSearchEnginesFromKeywordId'; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate('General_ColumnKeyword')); + $view->config->show_goals = true; + + $view->requestConfig->filter_limit = 25; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->disable_subtable_when_show_goals = true; + } + } + + private function configureViewForGetSearchEnginesFromKeywordId(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate('Referrers_ColumnSearchEngine')); + } + + private function configureViewForGetSearchEngines(ViewDataTable $view) + { + $view->config->subtable_controller_action = 'getKeywordsFromSearchEngineId'; + $view->config->show_exclude_low_population = false; + $view->config->show_search = false; + $view->config->show_goals = true; + $view->config->addTranslation('label', Piwik::translate('Referrers_ColumnSearchEngine')); + + $view->requestConfig->filter_limit = 25; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->disable_subtable_when_show_goals = true; + } + } + + private function configureViewForGetKeywordsFromSearchEngineId(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate('General_ColumnKeyword')); + } + + private function configureViewForGetWebsites(ViewDataTable $view) + { + $view->config->subtable_controller_action = 'getUrlsFromWebsiteId'; + $view->config->show_exclude_low_population = false; + $view->config->show_goals = true; + $view->config->addTranslation('label', Piwik::translate('Referrers_ColumnWebsite')); + + $view->requestConfig->filter_limit = 25; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->disable_subtable_when_show_goals = true; + } + } + + private function configureViewForGetSocials(ViewDataTable $view) + { + $view->config->subtable_controller_action = 'getUrlsForSocial'; + $view->config->show_exclude_low_population = false; + $view->config->show_goals = true; + $view->config->addTranslation('label', Piwik::translate('Referrers_ColumnSocial')); + + $view->requestConfig->filter_limit = 10; + + if ($view->isViewDataTableId(HtmlTable::ID)) { + $view->config->disable_subtable_when_show_goals = true; + } + + $widget = Common::getRequestVar('widget', false); + if (empty($widget)) { + $view->config->show_footer_message = Piwik::translate('Referrers_SocialFooterMessage'); + } + } + + private function configureViewForGetUrlsForSocial(ViewDataTable $view) + { + $view->config->show_goals = true; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate('Referrers_ColumnWebsitePage')); + + $view->requestConfig->filter_limit = 10; + } + + private function configureViewForGetCampaigns(ViewDataTable $view) + { + $view->config->show_goals = true; + $view->config->subtable_controller_action = 'getKeywordsFromCampaignId'; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate('Referrers_ColumnCampaign')); + + $view->requestConfig->filter_limit = 25; + + if (Common::getRequestVar('viewDataTable', false) != 'graphEvolution') { + $view->config->show_footer_message = Piwik::translate('Referrers_CampaignFooterHelp', + array('', + ' - ', + '') + ); + } + } + + private function configureViewForGetKeywordsFromCampaignId(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->addTranslation('label', Piwik::translate('General_ColumnKeyword')); + } + + private function configureViewForGetUrlsFromWebsiteId(ViewDataTable $view) + { + $view->config->show_search = false; + $view->config->show_exclude_low_population = false; + $view->config->tooltip_metadata_name = 'url'; + $view->config->addTranslation('label', Piwik::translate('Referrers_ColumnWebsitePage')); + } + + /** + * DataTable filter callback that returns the HTML prefix for a label in the + * 'getAll' report based on the row's referrer type. + * + * @param int $referrerType The referrer type. + * @return string + */ + public function setGetAllHtmlPrefix($referrerType) + { + // get singular label for referrer type + $indexTranslation = ''; + switch ($referrerType) { + case Common::REFERRER_TYPE_DIRECT_ENTRY: + $indexTranslation = 'Referrers_DirectEntry'; + break; + case Common::REFERRER_TYPE_SEARCH_ENGINE: + $indexTranslation = 'General_ColumnKeyword'; + break; + case Common::REFERRER_TYPE_WEBSITE: + $indexTranslation = 'Referrers_ColumnWebsite'; + break; + case Common::REFERRER_TYPE_CAMPAIGN: + $indexTranslation = 'Referrers_ColumnCampaign'; + break; + default: + // case of newsletter, partners, before Piwik 0.2.25 + $indexTranslation = 'General_Others'; + break; + } + + $label = strtolower(Piwik::translate($indexTranslation)); + + // return html that displays it as grey & italic + return '(' . $label . ')'; + } +} diff --git a/www/analytics/plugins/Referrers/functions.php b/www/analytics/plugins/Referrers/functions.php new file mode 100644 index 00000000..559fd5ba --- /dev/null +++ b/www/analytics/plugins/Referrers/functions.php @@ -0,0 +1,283 @@ + $name) { + + if($name == $social) { + + return $domain; + } + } + return $url; +} + +/** + * Get's social network name from URL. + * + * @param string $url + * @return string + */ +function getSocialNetworkFromDomain($url) +{ + foreach (Common::getSocialUrls() AS $domain => $name) { + + if(preg_match('/(^|[\.\/])'.$domain.'([\.\/]|$)/', $url)) { + + return $name; + } + } + + return Piwik::translate('General_Unknown'); +} + +/** + * Returns true if a URL belongs to a social network, false if otherwise. + * + * @param string $url The URL to check. + * @param string|bool $socialName The social network's name to check for, or false to check + * for any. + * @return bool + */ +function isSocialUrl($url, $socialName = false) +{ + foreach (Common::getSocialUrls() AS $domain => $name) { + + if (preg_match('/(^|[\.\/])'.$domain.'([\.\/]|$)/', $url) && ($socialName === false || $name == $socialName)) { + + return true; + } + } + + return false; +} + +/** + * Return social network logo path by URL + * + * @param string $domain + * @return string path + * @see plugins/Referrers/images/socials/ + */ +function getSocialsLogoFromUrl($domain) +{ + $social = getSocialNetworkFromDomain($domain); + $socialNetworks = Common::getSocialUrls(); + + $filePattern = 'plugins/Referrers/images/socials/%s.png'; + + foreach ($socialNetworks as $domainKey => $name) { + if ($social == $socialNetworks[$domainKey] && file_exists(PIWIK_INCLUDE_PATH . '/' . sprintf($filePattern, $domainKey))) { + return sprintf($filePattern, $domainKey); + } + } + + return sprintf($filePattern, 'xx'); +} + +/** + * Return search engine URL by name + * + * @see core/DataFiles/SearchEnginges.php + * + * @param string $name + * @return string URL + */ +function getSearchEngineUrlFromName($name) +{ + $searchEngineNames = Common::getSearchEngineNames(); + if (isset($searchEngineNames[$name])) { + $url = 'http://' . $searchEngineNames[$name]; + } else { + $url = 'URL unknown!'; + } + return $url; +} + +/** + * Return search engine host in URL + * + * @param string $url + * @return string host + */ +function getSearchEngineHostFromUrl($url) +{ + if (strpos($url, '//')) { + $url = substr($url, strpos($url, '//') + 2); + } + if (($p = strpos($url, '/')) !== false) { + $url = substr($url, 0, $p); + } + return $url; +} + +/** + * Return search engine logo path by URL + * + * @param string $url + * @return string path + * @see plugins/Referrers/images/searchEnginges/ + */ +function getSearchEngineLogoFromUrl($url) +{ + $pathInPiwik = 'plugins/Referrers/images/searchEngines/%s.png'; + $pathWithCode = sprintf($pathInPiwik, getSearchEngineHostFromUrl($url)); + $absolutePath = PIWIK_INCLUDE_PATH . '/' . $pathWithCode; + if (file_exists($absolutePath)) { + return $pathWithCode; + } + return sprintf($pathInPiwik, 'xx'); +} + +/** + * Return search engine host and path in URL + * + * @param string $url + * @return string host + */ +function getSearchEngineHostPathFromUrl($url) +{ + $url = substr($url, strpos($url, '//') + 2); + return $url; +} + +/** + * Return search engine URL for URL and keyword + * + * @see core/DataFiles/SearchEnginges.php + * + * @param string $url Domain name, e.g., search.piwik.org + * @param string $keyword Keyword, e.g., web+analytics + * @return string URL, e.g., http://search.piwik.org/q=web+analytics + */ +function getSearchEngineUrlFromUrlAndKeyword($url, $keyword) +{ + if ($keyword === API::LABEL_KEYWORD_NOT_DEFINED) { + return 'http://piwik.org/faq/general/#faq_144'; + } + $searchEngineUrls = Common::getSearchEngineUrls(); + $keyword = urlencode($keyword); + $keyword = str_replace(urlencode('+'), urlencode(' '), $keyword); + $path = @$searchEngineUrls[getSearchEngineHostPathFromUrl($url)][2]; + if (empty($path)) { + return false; + } + $path = str_replace("{k}", $keyword, $path); + return $url . (substr($url, -1) != '/' ? '/' : '') . $path; +} + +/** + * Return search engine URL for keyword and URL + * + * @see \Piwik\Plugins\Referrers\getSearchEngineUrlFromUrlAndKeyword + * + * @param string $keyword Keyword, e.g., web+analytics + * @param string $url Domain name, e.g., search.piwik.org + * @return string URL, e.g., http://search.piwik.org/q=web+analytics + */ +function getSearchEngineUrlFromKeywordAndUrl($keyword, $url) +{ + return getSearchEngineUrlFromUrlAndKeyword($url, $keyword); +} + +/** + * Return translated referrer type + * + * @param string $label + * @return string Referrer type + */ +function getReferrerTypeLabel($label) +{ + $indexTranslation = ''; + switch ($label) { + case Common::REFERRER_TYPE_DIRECT_ENTRY: + $indexTranslation = 'Referrers_DirectEntry'; + break; + case Common::REFERRER_TYPE_SEARCH_ENGINE: + $indexTranslation = 'Referrers_SearchEngines'; + break; + case Common::REFERRER_TYPE_WEBSITE: + $indexTranslation = 'Referrers_Websites'; + break; + case Common::REFERRER_TYPE_CAMPAIGN: + $indexTranslation = 'Referrers_Campaigns'; + break; + default: + // case of newsletter, partners, before Piwik 0.2.25 + $indexTranslation = 'General_Others'; + break; + } + return Piwik::translate($indexTranslation); +} + +/** + * Works in both directions + * @param string $name + * @throws \Exception + * @return string + */ +function getReferrerTypeFromShortName($name) +{ + $map = array( + Common::REFERRER_TYPE_SEARCH_ENGINE => 'search', + Common::REFERRER_TYPE_WEBSITE => 'website', + Common::REFERRER_TYPE_DIRECT_ENTRY => 'direct', + Common::REFERRER_TYPE_CAMPAIGN => 'campaign', + ); + if (isset($map[$name])) { + return $map[$name]; + } + if ($found = array_search($name, $map)) { + return $found; + } + throw new \Exception("Referrer type '$name' is not valid."); +} + +/** + * Returns a URL w/o the protocol type. + * + * @param string $url + * @return string + */ +function removeUrlProtocol($url) +{ + if (preg_match('/^[a-zA-Z_-]+:\/\//', $url, $matches)) { + return substr($url, strlen($matches[0])); + } + return $url; +} diff --git a/www/analytics/plugins/Referrers/images/searchEngines/1.cz.png b/www/analytics/plugins/Referrers/images/searchEngines/1.cz.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/1.cz.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/abcsok.no.png b/www/analytics/plugins/Referrers/images/searchEngines/abcsok.no.png new file mode 100644 index 00000000..8f076357 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/abcsok.no.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/alexa.com.png b/www/analytics/plugins/Referrers/images/searchEngines/alexa.com.png new file mode 100644 index 00000000..115a89f2 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/alexa.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/all.by.png b/www/analytics/plugins/Referrers/images/searchEngines/all.by.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/all.by.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/apollo.lv.png b/www/analytics/plugins/Referrers/images/searchEngines/apollo.lv.png new file mode 100644 index 00000000..1f69668a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/apollo.lv.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/apollo7.de.png b/www/analytics/plugins/Referrers/images/searchEngines/apollo7.de.png new file mode 100644 index 00000000..4f16ca9b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/apollo7.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/arama.com.png b/www/analytics/plugins/Referrers/images/searchEngines/arama.com.png new file mode 100644 index 00000000..7735bc7f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/arama.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/ariadna.elmundo.es.png b/www/analytics/plugins/Referrers/images/searchEngines/ariadna.elmundo.es.png new file mode 100644 index 00000000..9f752b02 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/ariadna.elmundo.es.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/arianna.libero.it.png b/www/analytics/plugins/Referrers/images/searchEngines/arianna.libero.it.png new file mode 100644 index 00000000..686672ac Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/arianna.libero.it.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/ask.com.png b/www/analytics/plugins/Referrers/images/searchEngines/ask.com.png new file mode 100644 index 00000000..0a8883f8 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/ask.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/bg.setooz.com.png b/www/analytics/plugins/Referrers/images/searchEngines/bg.setooz.com.png new file mode 100644 index 00000000..c1528359 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/bg.setooz.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/bing.com.png b/www/analytics/plugins/Referrers/images/searchEngines/bing.com.png new file mode 100644 index 00000000..b25db7d9 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/bing.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/blekko.com.png b/www/analytics/plugins/Referrers/images/searchEngines/blekko.com.png new file mode 100644 index 00000000..e97d841e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/blekko.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/blogs.icerocket.com.png b/www/analytics/plugins/Referrers/images/searchEngines/blogs.icerocket.com.png new file mode 100644 index 00000000..5836b4d3 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/blogs.icerocket.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/blogsearch.google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/blogsearch.google.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/blogsearch.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/busca.orange.es.png b/www/analytics/plugins/Referrers/images/searchEngines/busca.orange.es.png new file mode 100644 index 00000000..78fd0e2b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/busca.orange.es.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/busca.uol.com.br.png b/www/analytics/plugins/Referrers/images/searchEngines/busca.uol.com.br.png new file mode 100644 index 00000000..d69e3ffe Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/busca.uol.com.br.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/buscador.terra.es.png b/www/analytics/plugins/Referrers/images/searchEngines/buscador.terra.es.png new file mode 100644 index 00000000..4a393496 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/buscador.terra.es.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/cgi.search.biglobe.ne.jp.png b/www/analytics/plugins/Referrers/images/searchEngines/cgi.search.biglobe.ne.jp.png new file mode 100644 index 00000000..c9c258a2 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/cgi.search.biglobe.ne.jp.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/claro-search.com.png b/www/analytics/plugins/Referrers/images/searchEngines/claro-search.com.png new file mode 100644 index 00000000..bd412398 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/claro-search.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/daemon-search.com.png b/www/analytics/plugins/Referrers/images/searchEngines/daemon-search.com.png new file mode 100644 index 00000000..950b0132 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/daemon-search.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/digg.com.png b/www/analytics/plugins/Referrers/images/searchEngines/digg.com.png new file mode 100644 index 00000000..9a68450d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/digg.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/dir.gigablast.com.png b/www/analytics/plugins/Referrers/images/searchEngines/dir.gigablast.com.png new file mode 100644 index 00000000..56a15084 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/dir.gigablast.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/dmoz.org.png b/www/analytics/plugins/Referrers/images/searchEngines/dmoz.org.png new file mode 100644 index 00000000..92366084 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/dmoz.org.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/duckduckgo.com.png b/www/analytics/plugins/Referrers/images/searchEngines/duckduckgo.com.png new file mode 100644 index 00000000..50af3029 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/duckduckgo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/ecosia.org.png b/www/analytics/plugins/Referrers/images/searchEngines/ecosia.org.png new file mode 100644 index 00000000..29674eec Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/ecosia.org.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/encrypted.google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/encrypted.google.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/encrypted.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/eo.st.png b/www/analytics/plugins/Referrers/images/searchEngines/eo.st.png new file mode 100644 index 00000000..d2366201 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/eo.st.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/forestle.org.png b/www/analytics/plugins/Referrers/images/searchEngines/forestle.org.png new file mode 100644 index 00000000..62337498 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/forestle.org.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/fr.dir.com.png b/www/analytics/plugins/Referrers/images/searchEngines/fr.dir.com.png new file mode 100644 index 00000000..da8ba370 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/fr.dir.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/fr.wedoo.com.png b/www/analytics/plugins/Referrers/images/searchEngines/fr.wedoo.com.png new file mode 100644 index 00000000..b44da23e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/fr.wedoo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/friendfeed.com.png b/www/analytics/plugins/Referrers/images/searchEngines/friendfeed.com.png new file mode 100644 index 00000000..dac26c1d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/friendfeed.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/gais.cs.ccu.edu.tw.png b/www/analytics/plugins/Referrers/images/searchEngines/gais.cs.ccu.edu.tw.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/gais.cs.ccu.edu.tw.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/geona.net.png b/www/analytics/plugins/Referrers/images/searchEngines/geona.net.png new file mode 100644 index 00000000..c3cf0149 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/geona.net.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/go.mail.ru.png b/www/analytics/plugins/Referrers/images/searchEngines/go.mail.ru.png new file mode 100644 index 00000000..42b90d06 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/go.mail.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/google.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/googlesyndicatedsearch.com.png b/www/analytics/plugins/Referrers/images/searchEngines/googlesyndicatedsearch.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/googlesyndicatedsearch.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/holmes.ge.png b/www/analytics/plugins/Referrers/images/searchEngines/holmes.ge.png new file mode 100644 index 00000000..6185d233 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/holmes.ge.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/images.google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/images.google.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/images.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/images.search.yahoo.com.png b/www/analytics/plugins/Referrers/images/searchEngines/images.search.yahoo.com.png new file mode 100644 index 00000000..7908ccae Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/images.search.yahoo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/images.yandex.ru.png b/www/analytics/plugins/Referrers/images/searchEngines/images.yandex.ru.png new file mode 100644 index 00000000..b6cf192f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/images.yandex.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/infospace.com.png b/www/analytics/plugins/Referrers/images/searchEngines/infospace.com.png new file mode 100644 index 00000000..0518db97 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/infospace.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/iwon.ask.com.png b/www/analytics/plugins/Referrers/images/searchEngines/iwon.ask.com.png new file mode 100644 index 00000000..0a8883f8 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/iwon.ask.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/ixquick.com.png b/www/analytics/plugins/Referrers/images/searchEngines/ixquick.com.png new file mode 100644 index 00000000..93a69e1b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/ixquick.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/junglekey.com.png b/www/analytics/plugins/Referrers/images/searchEngines/junglekey.com.png new file mode 100644 index 00000000..46ea9868 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/junglekey.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/jyxo.1188.cz.png b/www/analytics/plugins/Referrers/images/searchEngines/jyxo.1188.cz.png new file mode 100644 index 00000000..c329eda9 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/jyxo.1188.cz.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/ko.search.need2find.com.png b/www/analytics/plugins/Referrers/images/searchEngines/ko.search.need2find.com.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/ko.search.need2find.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/lo.st.png b/www/analytics/plugins/Referrers/images/searchEngines/lo.st.png new file mode 100644 index 00000000..e28d1b2a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/lo.st.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/maps.google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/maps.google.com.png new file mode 100644 index 00000000..562aeffd Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/maps.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/meta.rrzn.uni-hannover.de.png b/www/analytics/plugins/Referrers/images/searchEngines/meta.rrzn.uni-hannover.de.png new file mode 100644 index 00000000..be093990 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/meta.rrzn.uni-hannover.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/meta.ua.png b/www/analytics/plugins/Referrers/images/searchEngines/meta.ua.png new file mode 100644 index 00000000..a98d7fef Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/meta.ua.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/metager2.de.png b/www/analytics/plugins/Referrers/images/searchEngines/metager2.de.png new file mode 100644 index 00000000..011683a0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/metager2.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/news.google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/news.google.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/news.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/nigma.ru.png b/www/analytics/plugins/Referrers/images/searchEngines/nigma.ru.png new file mode 100644 index 00000000..992fadd2 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/nigma.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/nova.rambler.ru.png b/www/analytics/plugins/Referrers/images/searchEngines/nova.rambler.ru.png new file mode 100644 index 00000000..3e16f1bb Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/nova.rambler.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/online.no.png b/www/analytics/plugins/Referrers/images/searchEngines/online.no.png new file mode 100644 index 00000000..5fa700d4 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/online.no.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/otsing.delfi.ee.png b/www/analytics/plugins/Referrers/images/searchEngines/otsing.delfi.ee.png new file mode 100644 index 00000000..730ff396 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/otsing.delfi.ee.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/p.zhongsou.com.png b/www/analytics/plugins/Referrers/images/searchEngines/p.zhongsou.com.png new file mode 100644 index 00000000..f7d0fd5d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/p.zhongsou.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/pesquisa.clix.pt.png b/www/analytics/plugins/Referrers/images/searchEngines/pesquisa.clix.pt.png new file mode 100644 index 00000000..1b081257 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/pesquisa.clix.pt.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/pesquisa.sapo.pt.png b/www/analytics/plugins/Referrers/images/searchEngines/pesquisa.sapo.pt.png new file mode 100644 index 00000000..3782a811 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/pesquisa.sapo.pt.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/plusnetwork.com.png b/www/analytics/plugins/Referrers/images/searchEngines/plusnetwork.com.png new file mode 100644 index 00000000..e0bfb366 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/plusnetwork.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/poisk.ru.png b/www/analytics/plugins/Referrers/images/searchEngines/poisk.ru.png new file mode 100644 index 00000000..5887dcec Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/poisk.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/recherche.francite.com.png b/www/analytics/plugins/Referrers/images/searchEngines/recherche.francite.com.png new file mode 100644 index 00000000..fd80433a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/recherche.francite.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/rechercher.aliceadsl.fr.png b/www/analytics/plugins/Referrers/images/searchEngines/rechercher.aliceadsl.fr.png new file mode 100644 index 00000000..71c8be9a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/rechercher.aliceadsl.fr.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/req.hit-parade.com.png b/www/analytics/plugins/Referrers/images/searchEngines/req.hit-parade.com.png new file mode 100644 index 00000000..85df9e05 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/req.hit-parade.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/ricerca.virgilio.it.png b/www/analytics/plugins/Referrers/images/searchEngines/ricerca.virgilio.it.png new file mode 100644 index 00000000..d2b36519 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/ricerca.virgilio.it.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/rpmfind.net.png b/www/analytics/plugins/Referrers/images/searchEngines/rpmfind.net.png new file mode 100644 index 00000000..7d2de00d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/rpmfind.net.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/s1.metacrawler.de.png b/www/analytics/plugins/Referrers/images/searchEngines/s1.metacrawler.de.png new file mode 100644 index 00000000..0ccebf08 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/s1.metacrawler.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/scholar.google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/scholar.google.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/scholar.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/scour.com.png b/www/analytics/plugins/Referrers/images/searchEngines/scour.com.png new file mode 100644 index 00000000..725e2316 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/scour.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.aol.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.aol.com.png new file mode 100644 index 00000000..0a65239f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.aol.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.babylon.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.babylon.com.png new file mode 100644 index 00000000..59a80346 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.babylon.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.bluewin.ch.png b/www/analytics/plugins/Referrers/images/searchEngines/search.bluewin.ch.png new file mode 100644 index 00000000..6522b29b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.bluewin.ch.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.centrum.cz.png b/www/analytics/plugins/Referrers/images/searchEngines/search.centrum.cz.png new file mode 100644 index 00000000..f7d29907 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.centrum.cz.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.comcast.net.png b/www/analytics/plugins/Referrers/images/searchEngines/search.comcast.net.png new file mode 100644 index 00000000..c49a83ed Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.comcast.net.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.conduit.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.conduit.com.png new file mode 100644 index 00000000..f0bc7974 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.conduit.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.daum.net.png b/www/analytics/plugins/Referrers/images/searchEngines/search.daum.net.png new file mode 100644 index 00000000..55e2aa3c Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.daum.net.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.earthlink.net.png b/www/analytics/plugins/Referrers/images/searchEngines/search.earthlink.net.png new file mode 100644 index 00000000..8cee1b21 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.earthlink.net.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.excite.it.png b/www/analytics/plugins/Referrers/images/searchEngines/search.excite.it.png new file mode 100644 index 00000000..369a4806 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.excite.it.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.free.fr.png b/www/analytics/plugins/Referrers/images/searchEngines/search.free.fr.png new file mode 100644 index 00000000..9954781a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.free.fr.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.freecause.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.freecause.com.png new file mode 100644 index 00000000..05976030 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.freecause.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.goo.ne.jp.png b/www/analytics/plugins/Referrers/images/searchEngines/search.goo.ne.jp.png new file mode 100644 index 00000000..d64ce99d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.goo.ne.jp.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.imesh.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.imesh.com.png new file mode 100644 index 00000000..8d8f1a78 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.imesh.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.ke.voila.fr.png b/www/analytics/plugins/Referrers/images/searchEngines/search.ke.voila.fr.png new file mode 100644 index 00000000..1266687f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.ke.voila.fr.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.lycos.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.lycos.com.png new file mode 100644 index 00000000..f3204076 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.lycos.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.nate.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.nate.com.png new file mode 100644 index 00000000..89560471 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.nate.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.naver.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.naver.com.png new file mode 100644 index 00000000..776ddf26 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.naver.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.nifty.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.nifty.com.png new file mode 100644 index 00000000..6e763487 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.nifty.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.peoplepc.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.peoplepc.com.png new file mode 100644 index 00000000..502014c5 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.peoplepc.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.qip.ru.png b/www/analytics/plugins/Referrers/images/searchEngines/search.qip.ru.png new file mode 100644 index 00000000..d5f547ff Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.qip.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.rr.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.rr.com.png new file mode 100644 index 00000000..6b0a8faa Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.rr.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.seznam.cz.png b/www/analytics/plugins/Referrers/images/searchEngines/search.seznam.cz.png new file mode 100644 index 00000000..7764196e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.seznam.cz.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.smartaddressbar.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.smartaddressbar.com.png new file mode 100644 index 00000000..588ea02d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.smartaddressbar.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.snap.do.png b/www/analytics/plugins/Referrers/images/searchEngines/search.snap.do.png new file mode 100644 index 00000000..fc8131e8 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.snap.do.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.softonic.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.softonic.com.png new file mode 100644 index 00000000..293b3e50 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.softonic.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.tiscali.it.png b/www/analytics/plugins/Referrers/images/searchEngines/search.tiscali.it.png new file mode 100644 index 00000000..f1045220 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.tiscali.it.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.winamp.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.winamp.com.png new file mode 100644 index 00000000..d0c29c59 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.winamp.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.www.ee.png b/www/analytics/plugins/Referrers/images/searchEngines/search.www.ee.png new file mode 100644 index 00000000..ff3dac11 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.www.ee.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.yahoo.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.yahoo.com.png new file mode 100644 index 00000000..1077fb3d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.yahoo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.yam.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.yam.com.png new file mode 100644 index 00000000..828e735e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.yam.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/search.yippy.com.png b/www/analytics/plugins/Referrers/images/searchEngines/search.yippy.com.png new file mode 100644 index 00000000..8c6530c4 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/search.yippy.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/searchalot.com.png b/www/analytics/plugins/Referrers/images/searchEngines/searchalot.com.png new file mode 100644 index 00000000..22664b3f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/searchalot.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/searchatlas.centrum.cz.png b/www/analytics/plugins/Referrers/images/searchEngines/searchatlas.centrum.cz.png new file mode 100644 index 00000000..6307949d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/searchatlas.centrum.cz.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/searchservice.myspace.com.png b/www/analytics/plugins/Referrers/images/searchEngines/searchservice.myspace.com.png new file mode 100644 index 00000000..b8052943 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/searchservice.myspace.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/sm.aport.ru.png b/www/analytics/plugins/Referrers/images/searchEngines/sm.aport.ru.png new file mode 100644 index 00000000..92796e9f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/sm.aport.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/smart.delfi.lv.png b/www/analytics/plugins/Referrers/images/searchEngines/smart.delfi.lv.png new file mode 100644 index 00000000..730ff396 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/smart.delfi.lv.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/so.360.cn.png b/www/analytics/plugins/Referrers/images/searchEngines/so.360.cn.png new file mode 100644 index 00000000..39ee2a3c Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/so.360.cn.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/start.iplay.com.png b/www/analytics/plugins/Referrers/images/searchEngines/start.iplay.com.png new file mode 100644 index 00000000..47103d6e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/start.iplay.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/startgoogle.startpagina.nl.png b/www/analytics/plugins/Referrers/images/searchEngines/startgoogle.startpagina.nl.png new file mode 100644 index 00000000..f4610199 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/startgoogle.startpagina.nl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/suche.freenet.de.png b/www/analytics/plugins/Referrers/images/searchEngines/suche.freenet.de.png new file mode 100644 index 00000000..c3c6f71f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/suche.freenet.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/suche.info.png b/www/analytics/plugins/Referrers/images/searchEngines/suche.info.png new file mode 100644 index 00000000..05b425f2 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/suche.info.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/suche.t-online.de.png b/www/analytics/plugins/Referrers/images/searchEngines/suche.t-online.de.png new file mode 100644 index 00000000..37eba045 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/suche.t-online.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/suche.web.de.png b/www/analytics/plugins/Referrers/images/searchEngines/suche.web.de.png new file mode 100644 index 00000000..1cf156d4 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/suche.web.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/surfcanyon.com.png b/www/analytics/plugins/Referrers/images/searchEngines/surfcanyon.com.png new file mode 100644 index 00000000..baabfe54 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/surfcanyon.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/szukaj.onet.pl.png b/www/analytics/plugins/Referrers/images/searchEngines/szukaj.onet.pl.png new file mode 100644 index 00000000..9df61e4c Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/szukaj.onet.pl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/szukaj.wp.pl.png b/www/analytics/plugins/Referrers/images/searchEngines/szukaj.wp.pl.png new file mode 100644 index 00000000..53a9a6be Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/szukaj.wp.pl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/technorati.com.png b/www/analytics/plugins/Referrers/images/searchEngines/technorati.com.png new file mode 100644 index 00000000..5a5c7440 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/technorati.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/translate.google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/translate.google.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/translate.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/video.google.com.png b/www/analytics/plugins/Referrers/images/searchEngines/video.google.com.png new file mode 100644 index 00000000..6745d4b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/video.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/web.canoe.ca.png b/www/analytics/plugins/Referrers/images/searchEngines/web.canoe.ca.png new file mode 100644 index 00000000..fada634c Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/web.canoe.ca.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/web.volny.cz.png b/www/analytics/plugins/Referrers/images/searchEngines/web.volny.cz.png new file mode 100644 index 00000000..8289c943 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/web.volny.cz.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/websearch.cs.com.png b/www/analytics/plugins/Referrers/images/searchEngines/websearch.cs.com.png new file mode 100644 index 00000000..6245b6d2 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/websearch.cs.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/websearch.rakuten.co.jp.png b/www/analytics/plugins/Referrers/images/searchEngines/websearch.rakuten.co.jp.png new file mode 100644 index 00000000..aa089090 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/websearch.rakuten.co.jp.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.123people.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.123people.com.png new file mode 100644 index 00000000..30d938ea Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.123people.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.1881.no.png b/www/analytics/plugins/Referrers/images/searchEngines/www.1881.no.png new file mode 100644 index 00000000..7e1019f8 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.1881.no.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.abacho.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.abacho.de.png new file mode 100644 index 00000000..fb35c78f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.abacho.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.acoon.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.acoon.de.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.acoon.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.allesklar.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.allesklar.de.png new file mode 100644 index 00000000..d93f686f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.allesklar.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.alltheweb.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.alltheweb.com.png new file mode 100644 index 00000000..9b635649 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.alltheweb.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.altavista.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.altavista.com.png new file mode 100644 index 00000000..6f26cbc7 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.altavista.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.arcor.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.arcor.de.png new file mode 100644 index 00000000..7829fd1a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.arcor.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.baidu.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.baidu.com.png new file mode 100644 index 00000000..31664716 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.baidu.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.blogdigger.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.blogdigger.com.png new file mode 100644 index 00000000..10b7b003 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.blogdigger.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.blogpulse.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.blogpulse.com.png new file mode 100644 index 00000000..b44009ce Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.blogpulse.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.charter.net.png b/www/analytics/plugins/Referrers/images/searchEngines/www.charter.net.png new file mode 100644 index 00000000..9dbbe861 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.charter.net.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.crawler.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.crawler.com.png new file mode 100644 index 00000000..3e71d3ce Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.crawler.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.cuil.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.cuil.com.png new file mode 100644 index 00000000..e4cfb87a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.cuil.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.dasoertliche.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.dasoertliche.de.png new file mode 100644 index 00000000..1050f393 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.dasoertliche.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.eniro.se.png b/www/analytics/plugins/Referrers/images/searchEngines/www.eniro.se.png new file mode 100644 index 00000000..b0b7b298 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.eniro.se.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.eurip.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.eurip.com.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.eurip.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.euroseek.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.euroseek.com.png new file mode 100644 index 00000000..4789b910 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.euroseek.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.everyclick.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.everyclick.com.png new file mode 100644 index 00000000..91345abc Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.everyclick.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.exalead.fr.png b/www/analytics/plugins/Referrers/images/searchEngines/www.exalead.fr.png new file mode 100644 index 00000000..b308c6eb Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.exalead.fr.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.facebook.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.facebook.com.png new file mode 100644 index 00000000..80561bd5 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.facebook.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.fastbrowsersearch.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.fastbrowsersearch.com.png new file mode 100644 index 00000000..cfa0fc35 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.fastbrowsersearch.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.fireball.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.fireball.de.png new file mode 100644 index 00000000..b2b94bc5 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.fireball.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.firstsfind.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.firstsfind.com.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.firstsfind.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.fixsuche.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.fixsuche.de.png new file mode 100644 index 00000000..713d5ca1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.fixsuche.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.flix.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.flix.de.png new file mode 100644 index 00000000..a99560bd Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.flix.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.gigablast.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.gigablast.com.png new file mode 100644 index 00000000..56a15084 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.gigablast.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.gnadenmeer.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.gnadenmeer.de.png new file mode 100644 index 00000000..4986298d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.gnadenmeer.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.gomeo.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.gomeo.com.png new file mode 100644 index 00000000..b190f143 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.gomeo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.google.interia.pl.png b/www/analytics/plugins/Referrers/images/searchEngines/www.google.interia.pl.png new file mode 100644 index 00000000..3e6babbe Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.google.interia.pl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.goyellow.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.goyellow.de.png new file mode 100644 index 00000000..63acab12 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.goyellow.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.gulesider.no.png b/www/analytics/plugins/Referrers/images/searchEngines/www.gulesider.no.png new file mode 100644 index 00000000..e02cf014 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.gulesider.no.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.highbeam.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.highbeam.com.png new file mode 100644 index 00000000..badb1cfe Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.highbeam.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.hooseek.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.hooseek.com.png new file mode 100644 index 00000000..fe21a1f1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.hooseek.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.hotbot.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.hotbot.com.png new file mode 100644 index 00000000..b2b94bc5 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.hotbot.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.icq.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.icq.com.png new file mode 100644 index 00000000..6a926e82 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.icq.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.ilse.nl.png b/www/analytics/plugins/Referrers/images/searchEngines/www.ilse.nl.png new file mode 100644 index 00000000..e377b212 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.ilse.nl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.jungle-spider.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.jungle-spider.de.png new file mode 100644 index 00000000..6996b1f4 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.jungle-spider.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.kataweb.it.png b/www/analytics/plugins/Referrers/images/searchEngines/www.kataweb.it.png new file mode 100644 index 00000000..16d5a907 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.kataweb.it.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.kvasir.no.png b/www/analytics/plugins/Referrers/images/searchEngines/www.kvasir.no.png new file mode 100644 index 00000000..f1c56613 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.kvasir.no.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.latne.lv.png b/www/analytics/plugins/Referrers/images/searchEngines/www.latne.lv.png new file mode 100644 index 00000000..38c34b8d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.latne.lv.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.looksmart.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.looksmart.com.png new file mode 100644 index 00000000..55b9df7c Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.looksmart.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.maailm.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.maailm.com.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.maailm.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.mamma.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.mamma.com.png new file mode 100644 index 00000000..9f25b2a2 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.mamma.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.meinestadt.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.meinestadt.de.png new file mode 100644 index 00000000..4e14ee5b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.meinestadt.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.mister-wong.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.mister-wong.com.png new file mode 100644 index 00000000..ce05c105 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.mister-wong.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.monstercrawler.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.monstercrawler.com.png new file mode 100644 index 00000000..bf26708d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.monstercrawler.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.mozbot.fr.png b/www/analytics/plugins/Referrers/images/searchEngines/www.mozbot.fr.png new file mode 100644 index 00000000..538ddb1b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.mozbot.fr.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.mysearch.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.mysearch.com.png new file mode 100644 index 00000000..ab08d98e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.mysearch.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.najdi.si.png b/www/analytics/plugins/Referrers/images/searchEngines/www.najdi.si.png new file mode 100644 index 00000000..2898171b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.najdi.si.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.neti.ee.png b/www/analytics/plugins/Referrers/images/searchEngines/www.neti.ee.png new file mode 100644 index 00000000..60691aed Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.neti.ee.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.paperball.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.paperball.de.png new file mode 100644 index 00000000..b2b94bc5 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.paperball.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.picsearch.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.picsearch.com.png new file mode 100644 index 00000000..268174e5 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.picsearch.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.plazoo.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.plazoo.com.png new file mode 100644 index 00000000..bfebffa3 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.plazoo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.qualigo.at.png b/www/analytics/plugins/Referrers/images/searchEngines/www.qualigo.at.png new file mode 100644 index 00000000..7ece5c84 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.qualigo.at.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.search.ch.png b/www/analytics/plugins/Referrers/images/searchEngines/www.search.ch.png new file mode 100644 index 00000000..a37e8bec Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.search.ch.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.search.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.search.com.png new file mode 100644 index 00000000..5afc0229 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.search.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.searchcanvas.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.searchcanvas.com.png new file mode 100644 index 00000000..04a7a0df Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.searchcanvas.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.searchy.co.uk.png b/www/analytics/plugins/Referrers/images/searchEngines/www.searchy.co.uk.png new file mode 100644 index 00000000..97d1fa25 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.searchy.co.uk.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.sharelook.fr.png b/www/analytics/plugins/Referrers/images/searchEngines/www.sharelook.fr.png new file mode 100644 index 00000000..a4cb50bb Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.sharelook.fr.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.skynet.be.png b/www/analytics/plugins/Referrers/images/searchEngines/www.skynet.be.png new file mode 100644 index 00000000..25bb6734 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.skynet.be.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.sogou.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.sogou.com.png new file mode 100644 index 00000000..23af99bb Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.sogou.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.soso.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.soso.com.png new file mode 100644 index 00000000..cd5019d3 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.soso.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.startsiden.no.png b/www/analytics/plugins/Referrers/images/searchEngines/www.startsiden.no.png new file mode 100644 index 00000000..f8c702ac Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.startsiden.no.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.suchmaschine.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.suchmaschine.com.png new file mode 100644 index 00000000..f6277333 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.suchmaschine.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.suchnase.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.suchnase.de.png new file mode 100644 index 00000000..2922396a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.suchnase.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.talimba.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.talimba.com.png new file mode 100644 index 00000000..4403a249 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.talimba.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.talktalk.co.uk.png b/www/analytics/plugins/Referrers/images/searchEngines/www.talktalk.co.uk.png new file mode 100644 index 00000000..90162cd2 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.talktalk.co.uk.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.teoma.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.teoma.com.png new file mode 100644 index 00000000..8307f542 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.teoma.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.tixuma.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.tixuma.de.png new file mode 100644 index 00000000..5bc3b0d0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.tixuma.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.toile.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.toile.com.png new file mode 100644 index 00000000..d6969963 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.toile.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.toolbarhome.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.toolbarhome.com.png new file mode 100644 index 00000000..5bd6311d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.toolbarhome.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.trouvez.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.trouvez.com.png new file mode 100644 index 00000000..fea4b8a1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.trouvez.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.trovarapido.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.trovarapido.com.png new file mode 100644 index 00000000..178db0cb Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.trovarapido.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.trusted-search.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.trusted-search.com.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.trusted-search.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.twingly.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.twingly.com.png new file mode 100644 index 00000000..90bb98e7 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.twingly.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.url.org.png b/www/analytics/plugins/Referrers/images/searchEngines/www.url.org.png new file mode 100644 index 00000000..bab829ef Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.url.org.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.vinden.nl.png b/www/analytics/plugins/Referrers/images/searchEngines/www.vinden.nl.png new file mode 100644 index 00000000..dbccde0f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.vinden.nl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.vindex.nl.png b/www/analytics/plugins/Referrers/images/searchEngines/www.vindex.nl.png new file mode 100644 index 00000000..735107c6 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.vindex.nl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.walhello.info.png b/www/analytics/plugins/Referrers/images/searchEngines/www.walhello.info.png new file mode 100644 index 00000000..e02068c6 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.walhello.info.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.web.nl.png b/www/analytics/plugins/Referrers/images/searchEngines/www.web.nl.png new file mode 100644 index 00000000..0740cef1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.web.nl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.weborama.fr.png b/www/analytics/plugins/Referrers/images/searchEngines/www.weborama.fr.png new file mode 100644 index 00000000..096891a6 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.weborama.fr.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.websearch.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.websearch.com.png new file mode 100644 index 00000000..a161533a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.websearch.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.witch.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.witch.de.png new file mode 100644 index 00000000..e44548ec Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.witch.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.x-recherche.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.x-recherche.com.png new file mode 100644 index 00000000..0e44c0d9 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.x-recherche.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.yasni.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www.yasni.de.png new file mode 100644 index 00000000..9440cb8e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.yasni.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.yatedo.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.yatedo.com.png new file mode 100644 index 00000000..b77082ed Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.yatedo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.yougoo.fr.png b/www/analytics/plugins/Referrers/images/searchEngines/www.yougoo.fr.png new file mode 100644 index 00000000..6e0e19c4 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.yougoo.fr.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.zapmeta.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www.zapmeta.com.png new file mode 100644 index 00000000..ff98d107 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.zapmeta.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.zoeken.nl.png b/www/analytics/plugins/Referrers/images/searchEngines/www.zoeken.nl.png new file mode 100644 index 00000000..b66c4e36 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.zoeken.nl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www.zoznam.sk.png b/www/analytics/plugins/Referrers/images/searchEngines/www.zoznam.sk.png new file mode 100644 index 00000000..c6e9963f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www.zoznam.sk.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www1.dastelefonbuch.de.png b/www/analytics/plugins/Referrers/images/searchEngines/www1.dastelefonbuch.de.png new file mode 100644 index 00000000..8912f991 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www1.dastelefonbuch.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www2.austronaut.at.png b/www/analytics/plugins/Referrers/images/searchEngines/www2.austronaut.at.png new file mode 100644 index 00000000..bf9c47da Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www2.austronaut.at.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www2.inbox.com.png b/www/analytics/plugins/Referrers/images/searchEngines/www2.inbox.com.png new file mode 100644 index 00000000..25459242 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www2.inbox.com.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/www3.zoek.nl.png b/www/analytics/plugins/Referrers/images/searchEngines/www3.zoek.nl.png new file mode 100644 index 00000000..7c5a5a11 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/www3.zoek.nl.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/xx.gif b/www/analytics/plugins/Referrers/images/searchEngines/xx.gif new file mode 100644 index 00000000..5ddb8b8e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/xx.gif differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/xx.png b/www/analytics/plugins/Referrers/images/searchEngines/xx.png new file mode 100644 index 00000000..fe2401be Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/xx.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/yandex.ru.png b/www/analytics/plugins/Referrers/images/searchEngines/yandex.ru.png new file mode 100644 index 00000000..b6cf192f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/yandex.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/yellowmap.de.png b/www/analytics/plugins/Referrers/images/searchEngines/yellowmap.de.png new file mode 100644 index 00000000..19e486fc Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/yellowmap.de.png differ diff --git a/www/analytics/plugins/Referrers/images/searchEngines/zoohoo.cz.png b/www/analytics/plugins/Referrers/images/searchEngines/zoohoo.cz.png new file mode 100644 index 00000000..ce7993d3 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/searchEngines/zoohoo.cz.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/badoo.com.png b/www/analytics/plugins/Referrers/images/socials/badoo.com.png new file mode 100644 index 00000000..aea21cd1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/badoo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/bebo.com.png b/www/analytics/plugins/Referrers/images/socials/bebo.com.png new file mode 100644 index 00000000..35f19be5 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/bebo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/blackplanet.com.png b/www/analytics/plugins/Referrers/images/socials/blackplanet.com.png new file mode 100644 index 00000000..99387eae Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/blackplanet.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/buzznet.com.png b/www/analytics/plugins/Referrers/images/socials/buzznet.com.png new file mode 100644 index 00000000..837837a0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/buzznet.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/classmates.com.png b/www/analytics/plugins/Referrers/images/socials/classmates.com.png new file mode 100644 index 00000000..3b03cb44 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/classmates.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/douban.com.png b/www/analytics/plugins/Referrers/images/socials/douban.com.png new file mode 100644 index 00000000..3e0b36a6 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/douban.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/facebook.com.png b/www/analytics/plugins/Referrers/images/socials/facebook.com.png new file mode 100644 index 00000000..5984d34d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/facebook.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/flickr.com.png b/www/analytics/plugins/Referrers/images/socials/flickr.com.png new file mode 100644 index 00000000..d1aa296e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/flickr.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/flixster.com.png b/www/analytics/plugins/Referrers/images/socials/flixster.com.png new file mode 100644 index 00000000..94f96d32 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/flixster.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/fotolog.com.png b/www/analytics/plugins/Referrers/images/socials/fotolog.com.png new file mode 100644 index 00000000..03bb1dd8 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/fotolog.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/foursquare.com.png b/www/analytics/plugins/Referrers/images/socials/foursquare.com.png new file mode 100644 index 00000000..17d29508 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/foursquare.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/friendsreunited.com.png b/www/analytics/plugins/Referrers/images/socials/friendsreunited.com.png new file mode 100644 index 00000000..b12f23d8 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/friendsreunited.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/friendster.com.png b/www/analytics/plugins/Referrers/images/socials/friendster.com.png new file mode 100644 index 00000000..91864143 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/friendster.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/gaiaonline.com.png b/www/analytics/plugins/Referrers/images/socials/gaiaonline.com.png new file mode 100644 index 00000000..720fb21f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/gaiaonline.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/geni.com.png b/www/analytics/plugins/Referrers/images/socials/geni.com.png new file mode 100644 index 00000000..89489a9a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/geni.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/github.com.png b/www/analytics/plugins/Referrers/images/socials/github.com.png new file mode 100644 index 00000000..3f7a5b4a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/github.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/habbo.com.png b/www/analytics/plugins/Referrers/images/socials/habbo.com.png new file mode 100644 index 00000000..4e6437f8 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/habbo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/hi5.com.png b/www/analytics/plugins/Referrers/images/socials/hi5.com.png new file mode 100644 index 00000000..ff369f74 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/hi5.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/hyves.nl.png b/www/analytics/plugins/Referrers/images/socials/hyves.nl.png new file mode 100644 index 00000000..dcfd56d9 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/hyves.nl.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/identi.ca.png b/www/analytics/plugins/Referrers/images/socials/identi.ca.png new file mode 100644 index 00000000..d31743c8 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/identi.ca.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/last.fm.png b/www/analytics/plugins/Referrers/images/socials/last.fm.png new file mode 100644 index 00000000..9362fd4e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/last.fm.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/linkedin.com.png b/www/analytics/plugins/Referrers/images/socials/linkedin.com.png new file mode 100644 index 00000000..e6a5034b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/linkedin.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/livejournal.ru.png b/www/analytics/plugins/Referrers/images/socials/livejournal.ru.png new file mode 100644 index 00000000..aab56265 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/livejournal.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/login.live.com.png b/www/analytics/plugins/Referrers/images/socials/login.live.com.png new file mode 100644 index 00000000..7db0eafb Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/login.live.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/login.tagged.com.png b/www/analytics/plugins/Referrers/images/socials/login.tagged.com.png new file mode 100644 index 00000000..d2a0eb42 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/login.tagged.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/meinvz.net.png b/www/analytics/plugins/Referrers/images/socials/meinvz.net.png new file mode 100644 index 00000000..167bbd5e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/meinvz.net.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/mixi.jp.png b/www/analytics/plugins/Referrers/images/socials/mixi.jp.png new file mode 100644 index 00000000..69bd263d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/mixi.jp.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/moikrug.ru.png b/www/analytics/plugins/Referrers/images/socials/moikrug.ru.png new file mode 100644 index 00000000..5629a38a Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/moikrug.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/multiply.com.png b/www/analytics/plugins/Referrers/images/socials/multiply.com.png new file mode 100644 index 00000000..96068378 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/multiply.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/my.mail.ru.png b/www/analytics/plugins/Referrers/images/socials/my.mail.ru.png new file mode 100644 index 00000000..8d9d2495 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/my.mail.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/myheritage.com.png b/www/analytics/plugins/Referrers/images/socials/myheritage.com.png new file mode 100644 index 00000000..f55732a3 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/myheritage.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/mylife.ru.png b/www/analytics/plugins/Referrers/images/socials/mylife.ru.png new file mode 100644 index 00000000..cfa4ac14 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/mylife.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/myspace.com.png b/www/analytics/plugins/Referrers/images/socials/myspace.com.png new file mode 100644 index 00000000..274cd180 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/myspace.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/myyearbook.com.png b/www/analytics/plugins/Referrers/images/socials/myyearbook.com.png new file mode 100644 index 00000000..6e73c715 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/myyearbook.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/netlog.com.png b/www/analytics/plugins/Referrers/images/socials/netlog.com.png new file mode 100644 index 00000000..549b3d79 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/netlog.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/news.ycombinator.com.png b/www/analytics/plugins/Referrers/images/socials/news.ycombinator.com.png new file mode 100644 index 00000000..f64ec3b0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/news.ycombinator.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/nk.pl.png b/www/analytics/plugins/Referrers/images/socials/nk.pl.png new file mode 100644 index 00000000..378fde1b Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/nk.pl.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/odnoklassniki.ru.png b/www/analytics/plugins/Referrers/images/socials/odnoklassniki.ru.png new file mode 100644 index 00000000..b20fbbbe Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/odnoklassniki.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/orkut.com.png b/www/analytics/plugins/Referrers/images/socials/orkut.com.png new file mode 100644 index 00000000..d0b72ab1 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/orkut.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/pinterest.com.png b/www/analytics/plugins/Referrers/images/socials/pinterest.com.png new file mode 100644 index 00000000..d1f61b99 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/pinterest.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/plaxo.com.png b/www/analytics/plugins/Referrers/images/socials/plaxo.com.png new file mode 100644 index 00000000..9d1351e0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/plaxo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/qzone.qq.com.png b/www/analytics/plugins/Referrers/images/socials/qzone.qq.com.png new file mode 100644 index 00000000..355b9d13 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/qzone.qq.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/reddit.com.png b/www/analytics/plugins/Referrers/images/socials/reddit.com.png new file mode 100644 index 00000000..234a096e Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/reddit.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/renren.com.png b/www/analytics/plugins/Referrers/images/socials/renren.com.png new file mode 100644 index 00000000..05dae81d Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/renren.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/ru.netlog.com.png b/www/analytics/plugins/Referrers/images/socials/ru.netlog.com.png new file mode 100644 index 00000000..ac2dc071 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/ru.netlog.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/skyrock.com.png b/www/analytics/plugins/Referrers/images/socials/skyrock.com.png new file mode 100644 index 00000000..9c6c336f Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/skyrock.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/sonico.com.png b/www/analytics/plugins/Referrers/images/socials/sonico.com.png new file mode 100644 index 00000000..74450ea9 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/sonico.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/sourceforge.net.png b/www/analytics/plugins/Referrers/images/socials/sourceforge.net.png new file mode 100644 index 00000000..9e0a44c0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/sourceforge.net.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/stackoverflow.com.png b/www/analytics/plugins/Referrers/images/socials/stackoverflow.com.png new file mode 100644 index 00000000..7ae008ff Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/stackoverflow.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/studivz.net.png b/www/analytics/plugins/Referrers/images/socials/studivz.net.png new file mode 100644 index 00000000..640e8d27 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/studivz.net.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/stumbleupon.com.png b/www/analytics/plugins/Referrers/images/socials/stumbleupon.com.png new file mode 100644 index 00000000..a76f8526 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/stumbleupon.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/tagged.com.png b/www/analytics/plugins/Referrers/images/socials/tagged.com.png new file mode 100644 index 00000000..ff369f74 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/tagged.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/taringa.net.png b/www/analytics/plugins/Referrers/images/socials/taringa.net.png new file mode 100644 index 00000000..edb1e205 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/taringa.net.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/tuenti.com.png b/www/analytics/plugins/Referrers/images/socials/tuenti.com.png new file mode 100644 index 00000000..db0ad525 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/tuenti.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/tumblr.com.png b/www/analytics/plugins/Referrers/images/socials/tumblr.com.png new file mode 100644 index 00000000..8bae05e9 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/tumblr.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/twitter.com.png b/www/analytics/plugins/Referrers/images/socials/twitter.com.png new file mode 100644 index 00000000..b90f3a05 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/twitter.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/url.google.com.png b/www/analytics/plugins/Referrers/images/socials/url.google.com.png new file mode 100644 index 00000000..8de0de27 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/url.google.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/viadeo.com.png b/www/analytics/plugins/Referrers/images/socials/viadeo.com.png new file mode 100644 index 00000000..1d0ad9d0 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/viadeo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/vimeo.com.png b/www/analytics/plugins/Referrers/images/socials/vimeo.com.png new file mode 100644 index 00000000..4909f559 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/vimeo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/vk.com.png b/www/analytics/plugins/Referrers/images/socials/vk.com.png new file mode 100644 index 00000000..0ce476e6 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/vk.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/vkontakte.ru.png b/www/analytics/plugins/Referrers/images/socials/vkontakte.ru.png new file mode 100644 index 00000000..e984ddbf Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/vkontakte.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/vkrugudruzei.ru.png b/www/analytics/plugins/Referrers/images/socials/vkrugudruzei.ru.png new file mode 100644 index 00000000..a320387c Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/vkrugudruzei.ru.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/wayn.com.png b/www/analytics/plugins/Referrers/images/socials/wayn.com.png new file mode 100644 index 00000000..4932edc9 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/wayn.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/weeworld.com.png b/www/analytics/plugins/Referrers/images/socials/weeworld.com.png new file mode 100644 index 00000000..267dfb74 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/weeworld.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/weibo.com.png b/www/analytics/plugins/Referrers/images/socials/weibo.com.png new file mode 100644 index 00000000..3b25d6ae Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/weibo.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/xanga.com.png b/www/analytics/plugins/Referrers/images/socials/xanga.com.png new file mode 100644 index 00000000..cc0a2609 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/xanga.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/xing.com.png b/www/analytics/plugins/Referrers/images/socials/xing.com.png new file mode 100644 index 00000000..1d6a8a43 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/xing.com.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/xx.png b/www/analytics/plugins/Referrers/images/socials/xx.png new file mode 100644 index 00000000..fe2401be Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/xx.png differ diff --git a/www/analytics/plugins/Referrers/images/socials/youtube.com.png b/www/analytics/plugins/Referrers/images/socials/youtube.com.png new file mode 100644 index 00000000..c5346b61 Binary files /dev/null and b/www/analytics/plugins/Referrers/images/socials/youtube.com.png differ diff --git a/www/analytics/plugins/Referrers/templates/getSearchEnginesAndKeywords.twig b/www/analytics/plugins/Referrers/templates/getSearchEnginesAndKeywords.twig new file mode 100644 index 00000000..ce2f5f2e --- /dev/null +++ b/www/analytics/plugins/Referrers/templates/getSearchEnginesAndKeywords.twig @@ -0,0 +1,9 @@ +
            +

            {{ 'Referrers_Keywords'|translate }}

            + {{ keywords|raw }} +
            + +
            +

            {{ 'Referrers_SearchEngines'|translate }}

            + {{ searchEngines|raw }} +
            diff --git a/www/analytics/plugins/Referrers/templates/index.twig b/www/analytics/plugins/Referrers/templates/index.twig new file mode 100644 index 00000000..6a5db262 --- /dev/null +++ b/www/analytics/plugins/Referrers/templates/index.twig @@ -0,0 +1,118 @@ +

            {{ 'General_EvolutionOverPeriod'|translate }}

            +{{ graphEvolutionReferrers|raw }} + +
            +
            +

            {{ 'Referrers_Type'|translate }}

            + +
            +
            {{ sparkline(urlSparklineDirectEntry) }} + {{ 'Referrers_TypeDirectEntries'|translate(""~visitorsFromDirectEntry~"")|raw }} + {% if visitorsFromDirectEntryPercent|default is not empty %}, + {{ 'Referrers_XPercentOfVisits'|translate(""~visitorsFromDirectEntryPercent~"")|raw }} + {% endif %} + {% if visitorsFromDirectEntryEvolution|default is not empty %} + {{ visitorsFromDirectEntryEvolution|raw }} + {% endif %} +
            +
            {{ sparkline(urlSparklineSearchEngines) }} + {{ 'Referrers_TypeSearchEngines'|translate(""~visitorsFromSearchEngines~"")|raw }} + {% if visitorsFromSearchEnginesPercent|default is not empty %}, + {{ 'Referrers_XPercentOfVisits'|translate(""~visitorsFromSearchEnginesPercent~"")|raw }} + {% endif %} + {% if visitorsFromSearchEnginesEvolution|default is not empty %} + {{ visitorsFromSearchEnginesEvolution|raw }} + {% endif %} +
            +
            +
            +
            {{ sparkline(urlSparklineWebsites) }} + {{ 'Referrers_TypeWebsites'|translate(""~visitorsFromWebsites~"")|raw }} + {% if visitorsFromWebsitesPercent|default is not empty %}, + {{ 'Referrers_XPercentOfVisits'|translate(""~visitorsFromWebsitesPercent~"")|raw }} + {% endif %} + {% if visitorsFromWebsitesEvolution|default is not empty %} + {{ visitorsFromWebsitesEvolution|raw }} + {% endif %} +
            +
            {{ sparkline(urlSparklineCampaigns) }} + {{ 'Referrers_TypeCampaigns'|translate(""~visitorsFromCampaigns~"")|raw }} + {% if visitorsFromCampaignsPercent|default is not empty %}, + {{ 'Referrers_XPercentOfVisits'|translate(""~visitorsFromCampaignsPercent~"")|raw }} + {% endif %} + {% if visitorsFromCampaignsEvolution|default is not empty %} + {{ visitorsFromCampaignsEvolution|raw }} + {% endif %} +
            +
            + +
            + +
            +
            + +

            {{ 'General_MoreDetails'|translate }}  + ({{ 'General_Show'|translate }}) +

            +
            + + + +

            + +

            +
            + +
            +

            {{ 'Referrers_DetailsByReferrerType'|translate }}

            + {{ dataTableReferrerType|raw }} +
            + +
            + +{% if totalVisits > 0 %} +

            {{ 'Referrers_ReferrersOverview'|translate }}

            + {{ referrersReportsByDimension|raw }} +{% endif %} + +{% include "_sparklineFooter.twig" %} diff --git a/www/analytics/plugins/Referrers/templates/indexWebsites.twig b/www/analytics/plugins/Referrers/templates/indexWebsites.twig new file mode 100644 index 00000000..adfea9d6 --- /dev/null +++ b/www/analytics/plugins/Referrers/templates/indexWebsites.twig @@ -0,0 +1,9 @@ +
            +

            {{ 'Referrers_Websites'|translate }}

            + {{ websites|raw }} +
            + +
            +

            {{ 'Referrers_Socials'|translate }}

            + {{ socials|raw }} +
            diff --git a/www/analytics/plugins/SEO/API.php b/www/analytics/plugins/SEO/API.php new file mode 100644 index 00000000..6e59b118 --- /dev/null +++ b/www/analytics/plugins/SEO/API.php @@ -0,0 +1,95 @@ + array( + 'rank' => $rank->getPageRank(), + 'logo' => \Piwik\Plugins\Referrers\getSearchEngineLogoFromUrl('http://google.com'), + 'id' => 'pagerank' + ), + Piwik::translate('SEO_Google_IndexedPages') => array( + 'rank' => $rank->getIndexedPagesGoogle(), + 'logo' => \Piwik\Plugins\Referrers\getSearchEngineLogoFromUrl('http://google.com'), + 'id' => 'google-index', + ), + Piwik::translate('SEO_Bing_IndexedPages') => array( + 'rank' => $rank->getIndexedPagesBing(), + 'logo' => \Piwik\Plugins\Referrers\getSearchEngineLogoFromUrl('http://bing.com'), + 'id' => 'bing-index', + ), + Piwik::translate('SEO_AlexaRank') => array( + 'rank' => $rank->getAlexaRank(), + 'logo' => \Piwik\Plugins\Referrers\getSearchEngineLogoFromUrl('http://alexa.com'), + 'id' => 'alexa', + ), + Piwik::translate('SEO_DomainAge') => array( + 'rank' => $rank->getAge(), + 'logo' => 'plugins/SEO/images/whois.png', + 'id' => 'domain-age', + ), + Piwik::translate('SEO_ExternalBacklinks') => array( + 'rank' => $rank->getExternalBacklinkCount(), + 'logo' => 'plugins/SEO/images/majesticseo.png', + 'logo_link' => $linkToMajestic, + 'logo_tooltip' => Piwik::translate('SEO_ViewBacklinksOnMajesticSEO'), + 'id' => 'external-backlinks', + ), + Piwik::translate('SEO_ReferrerDomains') => array( + 'rank' => $rank->getReferrerDomainCount(), + 'logo' => 'plugins/SEO/images/majesticseo.png', + 'logo_link' => $linkToMajestic, + 'logo_tooltip' => Piwik::translate('SEO_ViewBacklinksOnMajesticSEO'), + 'id' => 'referrer-domains', + ), + ); + + // Add DMOZ only if > 0 entries found + $dmozRank = array( + 'rank' => $rank->getDmoz(), + 'logo' => \Piwik\Plugins\Referrers\getSearchEngineLogoFromUrl('http://dmoz.org'), + 'id' => 'dmoz', + ); + if ($dmozRank['rank'] > 0) { + $data[Piwik::translate('SEO_Dmoz')] = $dmozRank; + } + + return DataTable::makeFromIndexedArray($data); + } +} diff --git a/www/analytics/plugins/SEO/Controller.php b/www/analytics/plugins/SEO/Controller.php new file mode 100644 index 00000000..a4888499 --- /dev/null +++ b/www/analytics/plugins/SEO/Controller.php @@ -0,0 +1,47 @@ +getMainUrl(); + } + + $dataTable = API::getInstance()->getRank($url); + + $view = new View('@SEO/getRank'); + $view->urlToRank = RankChecker::extractDomainFromUrl($url); + + /** @var \Piwik\DataTable\Renderer\Php $renderer */ + $renderer = Renderer::factory('php'); + $renderer->setSerialize(false); + $view->ranks = $renderer->render($dataTable); + return $view->render(); + } +} diff --git a/www/analytics/plugins/SEO/MajesticClient.php b/www/analytics/plugins/SEO/MajesticClient.php new file mode 100644 index 00000000..3fbf69a8 --- /dev/null +++ b/www/analytics/plugins/SEO/MajesticClient.php @@ -0,0 +1,97 @@ + X, + * 'referrer_domains_count' => Y + * ) + * If either stat is false, either the API returned an + * error, or the IP was blocked for this request. + */ + public function getBacklinkStats($siteDomain, $timeout = 300) + { + $apiUrl = $this->getApiUrl($method = 'GetBacklinkStats', $args = array( + 'items' => '1', + 'item0' => $siteDomain + )); + $apiResponse = Http::sendHttpRequest($apiUrl, $timeout); + + $result = array( + 'backlink_count' => false, + 'referrer_domains_count' => false + ); + + $apiResponse = Common::json_decode($apiResponse, $assoc = true); + if (!empty($apiResponse) + && !empty($apiResponse['Data']) + ) { + $siteSeoStats = reset($apiResponse['Data']); + + if (isset($siteSeoStats['ExtBackLinks']) + && $siteSeoStats['ExtBackLinks'] !== -1 + ) { + $result['backlink_count'] = $siteSeoStats['ExtBackLinks']; + } + + if (isset($siteSeoStats['RefDomains']) + && $siteSeoStats['RefDomains'] !== -1 + ) { + $result['referrer_domains_count'] = $siteSeoStats['RefDomains']; + } + } + + return $result; + } + + private function getApiUrl($method, $args = array()) + { + $args['sak'] = self::API_KEY; + + $queryString = http_build_query($args); + return self::API_BASE . $method . '?' . $queryString; + } +} diff --git a/www/analytics/plugins/SEO/RankChecker.php b/www/analytics/plugins/SEO/RankChecker.php new file mode 100644 index 00000000..588254bf --- /dev/null +++ b/www/analytics/plugins/SEO/RankChecker.php @@ -0,0 +1,377 @@ +url = self::extractDomainFromUrl($url); + } + + /** + * Extract domain from URL as the web services generally + * expect only a domain name (i.e., no protocol, port, path, query, etc). + * + * @param string $url + * @return string + */ + static public function extractDomainFromUrl($url) + { + return preg_replace( + array( + '~^https?\://~si', // strip protocol + '~[/:#?;%&].*~', // strip port, path, query, anchor, etc + '~\.$~', // trailing period + ), + '', $url); + } + + /** + * Web service proxy that retrieves the content at the specified URL + * + * @param string $url + * @return string + */ + private function getPage($url) + { + try { + return str_replace(' ', ' ', Http::sendHttpRequest($url, $timeout = 10, @$_SERVER['HTTP_USER_AGENT'])); + } catch (Exception $e) { + return ''; + } + } + + /** + * Returns the google page rank for the current url + * + * @return int + */ + public function getPageRank() + { + $chwrite = $this->CheckHash($this->HashURL($this->url)); + + $url = "http://toolbarqueries.google.com/tbr?client=navclient-auto&ch=" . $chwrite . "&features=Rank&q=info:" . $this->url . "&num=100&filter=0"; + $data = $this->getPage($url); + preg_match('#Rank_[0-9]:[0-9]:([0-9]+){1,}#si', $data, $p); + $value = isset($p[1]) ? $p[1] : 0; + + return $value; + } + + /** + * Returns the alexa traffic rank for the current url + * + * @return int + */ + public function getAlexaRank() + { + $xml = @simplexml_load_string($this->getPage('http://data.alexa.com/data?cli=10&url=' . urlencode($this->url))); + return $xml ? $xml->SD->POPULARITY['TEXT'] : ''; + } + + /** + * Returns the number of Dmoz.org entries for the current url + * + * @return int + */ + public function getDmoz() + { + $url = 'http://www.dmoz.org/search?q=' . urlencode($this->url); + $data = $this->getPage($url); + preg_match('#Open Directory Sites[^\(]+\([0-9]-[0-9]+ of ([0-9]+)\)#', $data, $p); + if (!empty($p[1])) { + return (int)$p[1]; + } + return 0; + } + + /** + * Returns the number of pages google holds in it's index for the current url + * + * @return int + */ + public function getIndexedPagesGoogle() + { + $url = 'http://www.google.com/search?hl=en&q=site%3A' . urlencode($this->url); + $data = $this->getPage($url); + if (preg_match('#([0-9\,]+) results#i', $data, $p)) { + $indexedPages = (int)str_replace(',', '', $p[1]); + return $indexedPages; + } + return 0; + } + + /** + * Returns the number of pages bing holds in it's index for the current url + * + * @return int + */ + public function getIndexedPagesBing() + { + $url = 'http://www.bing.com/search?mkt=en-US&q=site%3A' . urlencode($this->url); + $data = $this->getPage($url); + if (preg_match('#([0-9\,]+) results#i', $data, $p)) { + return (int)str_replace(',', '', $p[1]); + } + return 0; + } + + /** + * Returns the domain age for the current url + * + * @return int + */ + public function getAge() + { + $ageArchiveOrg = $this->_getAgeArchiveOrg(); + $ageWhoIs = $this->_getAgeWhoIs(); + $ageWhoisCom = $this->_getAgeWhoisCom(); + + $ages = array(); + + if ($ageArchiveOrg > 0) { + $ages[] = $ageArchiveOrg; + } + + if ($ageWhoIs > 0) { + $ages[] = $ageWhoIs; + } + + if ($ageWhoisCom > 0) { + $ages[] = $ageWhoisCom; + } + + if (count($ages) > 1) { + $maxAge = min($ages); + } else { + $maxAge = array_shift($ages); + } + + if ($maxAge) { + return MetricsFormatter::getPrettyTimeFromSeconds(time() - $maxAge); + } + return false; + } + + /** + * Returns the number backlinks that link to the current site. + * + * @return int + */ + public function getExternalBacklinkCount() + { + try { + $majesticInfo = $this->getMajesticInfo(); + return $majesticInfo['backlink_count']; + } catch (Exception $e) { + Log::info($e); + return 0; + } + } + + /** + * Returns the number of referrer domains that link to the current site. + * + * @return int + */ + public function getReferrerDomainCount() + { + try { + $majesticInfo = $this->getMajesticInfo(); + return $majesticInfo['referrer_domains_count']; + } catch (Exception $e) { + Log::info($e); + return 0; + } + } + + /** + * Returns the domain age archive.org lists for the current url + * + * @return int + */ + protected function _getAgeArchiveOrg() + { + $url = str_replace('www.', '', $this->url); + $data = @$this->getPage('http://wayback.archive.org/web/*/' . urlencode($url)); + preg_match('#]*)' . preg_quote($url) . '/\">([^<]*)<\/a>#', $data, $p); + if (!empty($p[2])) { + $value = strtotime($p[2]); + if ($value === false) { + return 0; + } + return $value; + } + return 0; + } + + /** + * Returns the domain age who.is lists for the current url + * + * @return int + */ + protected function _getAgeWhoIs() + { + $url = preg_replace('/^www\./', '', $this->url); + $url = 'http://www.who.is/whois/' . urlencode($url); + $data = $this->getPage($url); + preg_match('#(?:Creation Date|Created On|Registered on)\.*:\s*([ \ta-z0-9\/\-:\.]+)#si', $data, $p); + if (!empty($p[1])) { + $value = strtotime(trim($p[1])); + if ($value === false) { + return 0; + } + return $value; + } + return 0; + } + + /** + * Returns the domain age whois.com lists for the current url + * + * @return int + */ + protected function _getAgeWhoisCom() + { + $url = preg_replace('/^www\./', '', $this->url); + $url = 'http://www.whois.com/whois/' . urlencode($url); + $data = $this->getPage($url); + preg_match('#(?:Creation Date|Created On):\s*([ \ta-z0-9\/\-:\.]+)#si', $data, $p); + if (!empty($p[1])) { + $value = strtotime(trim($p[1])); + if ($value === false) { + return 0; + } + return $value; + } + return 0; + } + + /** + * Convert numeric string to int + * + * @see getPageRank() + * + * @param string $Str + * @param int $Check + * @param int $Magic + * @return int + */ + private function StrToNum($Str, $Check, $Magic) + { + $Int32Unit = 4294967296; // 2^32 + + $length = strlen($Str); + for ($i = 0; $i < $length; $i++) { + $Check *= $Magic; + // If the float is beyond the boundaries of integer (usually +/- 2.15e+9 = 2^31), + // the result of converting to integer is undefined + // refer to http://www.php.net/manual/en/language.types.integer.php + if ($Check >= $Int32Unit) { + $Check = ($Check - $Int32Unit * (int)($Check / $Int32Unit)); + //if the check less than -2^31 + $Check = ($Check < -2147483648) ? ($Check + $Int32Unit) : $Check; + } + $Check += ord($Str{$i}); + } + return $Check; + } + + /** + * Generate a hash for a url + * + * @see getPageRank() + * + * @param string $String + * @return int + */ + private function HashURL($String) + { + $Check1 = $this->StrToNum($String, 0x1505, 0x21); + $Check2 = $this->StrToNum($String, 0, 0x1003F); + + $Check1 >>= 2; + $Check1 = (($Check1 >> 4) & 0x3FFFFC0) | ($Check1 & 0x3F); + $Check1 = (($Check1 >> 4) & 0x3FFC00) | ($Check1 & 0x3FF); + $Check1 = (($Check1 >> 4) & 0x3C000) | ($Check1 & 0x3FFF); + + $T1 = (((($Check1 & 0x3C0) << 4) | ($Check1 & 0x3C)) << 2) | ($Check2 & 0xF0F); + $T2 = (((($Check1 & 0xFFFFC000) << 4) | ($Check1 & 0x3C00)) << 0xA) | ($Check2 & 0xF0F0000); + + return ($T1 | $T2); + } + + /** + * Generate a checksum for the hash string + * + * @see getPageRank() + * + * @param int $Hashnum + * @return string + */ + private function CheckHash($Hashnum) + { + $CheckByte = 0; + $Flag = 0; + + $HashStr = sprintf('%u', $Hashnum); + $length = strlen($HashStr); + + for ($i = $length - 1; $i >= 0; $i--) { + $Re = $HashStr{$i}; + if (1 === ($Flag % 2)) { + $Re += $Re; + $Re = (int)($Re / 10) + ($Re % 10); + } + $CheckByte += $Re; + $Flag++; + } + + $CheckByte %= 10; + if (0 !== $CheckByte) { + $CheckByte = 10 - $CheckByte; + if (1 === ($Flag % 2)) { + if (1 === ($CheckByte % 2)) { + $CheckByte += 9; + } + $CheckByte >>= 1; + } + } + + return '7' . $CheckByte . $HashStr; + } + + private function getMajesticInfo() + { + if ($this->majesticInfo === null) { + $client = new MajesticClient(); + $this->majesticInfo = $client->getBacklinkStats($this->url); + } + + return $this->majesticInfo; + } +} diff --git a/www/analytics/plugins/SEO/SEO.php b/www/analytics/plugins/SEO/SEO.php new file mode 100644 index 00000000..334a5ed2 --- /dev/null +++ b/www/analytics/plugins/SEO/SEO.php @@ -0,0 +1,45 @@ + 'This Plugin extracts and displays SEO metrics: Alexa web ranking, Google Pagerank, number of Indexed pages and backlinks of the currently selected website.', + 'authors' => array(array('name' => 'Piwik', 'homepage' => 'http://piwik.org/')), + 'version' => Version::VERSION, + 'license' => 'GPL v3+', + 'license_homepage' => 'http://www.gnu.org/licenses/gpl.html' + ); + } + + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + $hooks = array('WidgetsList.addWidgets' => 'addWidgets'); + return $hooks; + } + + function addWidgets() + { + WidgetsList::add('SEO', 'SEO_SeoRankings', 'SEO', 'getRank'); + } +} diff --git a/www/analytics/plugins/SEO/images/majesticseo.png b/www/analytics/plugins/SEO/images/majesticseo.png new file mode 100644 index 00000000..a42875c2 Binary files /dev/null and b/www/analytics/plugins/SEO/images/majesticseo.png differ diff --git a/www/analytics/plugins/SEO/images/whois.png b/www/analytics/plugins/SEO/images/whois.png new file mode 100644 index 00000000..ac5957ad Binary files /dev/null and b/www/analytics/plugins/SEO/images/whois.png differ diff --git a/www/analytics/plugins/SEO/javascripts/rank.js b/www/analytics/plugins/SEO/javascripts/rank.js new file mode 100644 index 00000000..1a9dc07d --- /dev/null +++ b/www/analytics/plugins/SEO/javascripts/rank.js @@ -0,0 +1,31 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +$(document).ready(function () { + function getRank() { + var ajaxRequest = new ajaxHelper(); + ajaxRequest.setLoadingElement('#ajaxLoadingSEO'); + ajaxRequest.addParams({ + module: 'SEO', + action: 'getRank', + url: encodeURIComponent($('#seoUrl').val()) + }, 'get'); + ajaxRequest.setCallback( + function (response) { + $('#SeoRanks').html(response); + } + ); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + } + + // click on Rank button + $('#rankbutton').on('click', function () { + getRank(); + return false; + }); +}); diff --git a/www/analytics/plugins/SEO/templates/getRank.twig b/www/analytics/plugins/SEO/templates/getRank.twig new file mode 100644 index 00000000..f513609c --- /dev/null +++ b/www/analytics/plugins/SEO/templates/getRank.twig @@ -0,0 +1,53 @@ +
            + + +
            +
            + {{ 'Installation_SetupWebSiteURL'|translate|capitalize }} + + + + +
            + + {% import "ajaxMacros.twig" as ajax %} + {{ ajax.LoadingDiv('ajaxLoadingSEO') }} + +
            + {% if ranks is empty %} + {{ 'General_Error'|translate }} + {% else %} + {% set cleanUrl %} + {{ urlToRank }} + {% endset %} + {{ 'SEO_SEORankingsFor'|translate(cleanUrl)|raw }} + + {% for rank in ranks %} + +{% set seoLink %}{% if rank.logo_link is defined %}{% endif %}{% endset %} + {% set majesticLink %}{{ seoLink }}Majestic{% endset %} + + + + {% endfor %} + +
            {% if rank.logo_link is defined %}{{ seoLink|raw }}{% endif %}{{ rank.label }}{% if rank.logo_link is defined %}{% endif %} {{ rank.label|replace({"Majestic": + majesticLink})|raw }} + +
            + {% if rank.logo_link is defined %}{{ seoLink|raw }}{% endif %} + {% if rank.rank %}{{ rank.rank|raw }}{% else %}-{% endif %} + {% if rank.id=='pagerank' %} /10 + {% elseif rank.id=='google-index' or rank.id=='bing-index' %} {{ 'General_Pages'|translate }} + {% endif %} + {% if rank.logo_link is defined %}{% endif %} +
            +
            + {% endif %} +
            +
            +
            diff --git a/www/analytics/plugins/ScheduledReports/API.php b/www/analytics/plugins/ScheduledReports/API.php new file mode 100644 index 00000000..e5926e1b --- /dev/null +++ b/www/analytics/plugins/ScheduledReports/API.php @@ -0,0 +1,958 @@ +Scheduled Email reports in Piwik. + * + * @method static \Piwik\Plugins\ScheduledReports\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + const VALIDATE_PARAMETERS_EVENT = 'ScheduledReports.validateReportParameters'; + const GET_REPORT_PARAMETERS_EVENT = 'ScheduledReports.getReportParameters'; + const GET_REPORT_METADATA_EVENT = 'ScheduledReports.getReportMetadata'; + const GET_REPORT_TYPES_EVENT = 'ScheduledReports.getReportTypes'; + const GET_REPORT_FORMATS_EVENT = 'ScheduledReports.getReportFormats'; + const GET_RENDERER_INSTANCE_EVENT = 'ScheduledReports.getRendererInstance'; + const PROCESS_REPORTS_EVENT = 'ScheduledReports.processReports'; + const GET_REPORT_RECIPIENTS_EVENT = 'ScheduledReports.getReportRecipients'; + const ALLOW_MULTIPLE_REPORTS_EVENT = 'ScheduledReports.allowMultipleReports'; + const SEND_REPORT_EVENT = 'ScheduledReports.sendReport'; + + const OUTPUT_DOWNLOAD = 1; + const OUTPUT_SAVE_ON_DISK = 2; + const OUTPUT_INLINE = 3; + const OUTPUT_RETURN = 4; + + const REPORT_TRUNCATE = 23; + + /** + * Creates a new report and schedules it. + * + * @param int $idSite + * @param string $description Report description + * @param string $period Schedule frequency: day, week or month + * @param int $hour Hour (0-23) when the report should be sent + * @param string $reportType 'email' or any other format provided via the ScheduledReports.getReportTypes hook + * @param string $reportFormat 'pdf', 'html' or any other format provided via the ScheduledReports.getReportFormats hook + * @param array $reports array of reports + * @param array $parameters array of parameters + * @param bool|int $idSegment Segment Identifier + * + * @return int idReport generated + */ + public function addReport($idSite, $description, $period, $hour, $reportType, $reportFormat, $reports, $parameters, $idSegment = false) + { + Piwik::checkUserIsNotAnonymous(); + Piwik::checkUserHasViewAccess($idSite); + + $currentUser = Piwik::getCurrentUserLogin(); + self::ensureLanguageSetForUser($currentUser); + + self::validateCommonReportAttributes($period, $hour, $description, $idSegment, $reportType, $reportFormat); + + // report parameters validations + $parameters = self::validateReportParameters($reportType, $parameters); + + // validation of requested reports + $reports = self::validateRequestedReports($idSite, $reportType, $reports); + + $db = Db::get(); + $idReport = $db->fetchOne("SELECT max(idreport) + 1 FROM " . Common::prefixTable('report')); + + if ($idReport == false) { + $idReport = 1; + } + + $db->insert(Common::prefixTable('report'), + array( + 'idreport' => $idReport, + 'idsite' => $idSite, + 'login' => $currentUser, + 'description' => $description, + 'idsegment' => $idSegment, + 'period' => $period, + 'hour' => $hour, + 'type' => $reportType, + 'format' => $reportFormat, + 'parameters' => $parameters, + 'reports' => $reports, + 'ts_created' => Date::now()->getDatetime(), + 'deleted' => 0, + )); + + return $idReport; + } + + private static function ensureLanguageSetForUser($currentUser) + { + $lang = \Piwik\Plugins\LanguagesManager\API::getInstance()->getLanguageForUser($currentUser); + if (empty($lang)) { + \Piwik\Plugins\LanguagesManager\API::getInstance()->setLanguageForUser($currentUser, LanguagesManager::getLanguageCodeForCurrentUser()); + } + } + + /** + * Updates an existing report. + * + * @see addReport() + */ + public function updateReport($idReport, $idSite, $description, $period, $hour, $reportType, $reportFormat, $reports, $parameters, $idSegment = false) + { + Piwik::checkUserIsNotAnonymous(); + Piwik::checkUserHasViewAccess($idSite); + + $scheduledReports = $this->getReports($idSite, $periodSearch = false, $idReport); + $report = reset($scheduledReports); + $idReport = $report['idreport']; + + $currentUser = Piwik::getCurrentUserLogin(); + self::ensureLanguageSetForUser($currentUser); + + self::validateCommonReportAttributes($period, $hour, $description, $idSegment, $reportType, $reportFormat); + + // report parameters validations + $parameters = self::validateReportParameters($reportType, $parameters); + + // validation of requested reports + $reports = self::validateRequestedReports($idSite, $reportType, $reports); + + Db::get()->update(Common::prefixTable('report'), + array( + 'description' => $description, + 'idsegment' => $idSegment, + 'period' => $period, + 'hour' => $hour, + 'type' => $reportType, + 'format' => $reportFormat, + 'parameters' => $parameters, + 'reports' => $reports, + ), + "idreport = '$idReport'" + ); + + self::$cache = array(); + } + + /** + * Deletes a specific report + * + * @param int $idReport + */ + public function deleteReport($idReport) + { + $APIScheduledReports = $this->getReports($idSite = false, $periodSearch = false, $idReport); + $report = reset($APIScheduledReports); + Piwik::checkUserHasSuperUserAccessOrIsTheUser($report['login']); + + Db::get()->update(Common::prefixTable('report'), + array( + 'deleted' => 1, + ), + "idreport = '$idReport'" + ); + self::$cache = array(); + } + + // static cache storing reports + public static $cache = array(); + + /** + * Returns the list of reports matching the passed parameters + * + * @param bool|int $idSite If specified, will filter reports that belong to a specific idsite + * @param bool|string $period If specified, will filter reports that are scheduled for this period (day,week,month) + * @param bool|int $idReport If specified, will filter the report that has the given idReport + * @param bool $ifSuperUserReturnOnlySuperUserReports + * @param bool|int $idSegment If specified, will filter the report that has the given idSegment + * @throws Exception + * @return array + */ + public function getReports($idSite = false, $period = false, $idReport = false, $ifSuperUserReturnOnlySuperUserReports = false, $idSegment = false) + { + Piwik::checkUserHasSomeViewAccess(); + $cacheKey = (int)$idSite . '.' . (string)$period . '.' . (int)$idReport . '.' . (int)$ifSuperUserReturnOnlySuperUserReports; + if (isset(self::$cache[$cacheKey])) { + return self::$cache[$cacheKey]; + } + + $sqlWhere = ''; + $bind = array(); + + // Super user gets all reports back, other users only their own + if (!Piwik::hasUserSuperUserAccess() + || $ifSuperUserReturnOnlySuperUserReports + ) { + $sqlWhere .= "AND login = ?"; + $bind[] = Piwik::getCurrentUserLogin(); + } + + if (!empty($period)) { + $this->validateReportPeriod($period); + $sqlWhere .= " AND period = ? "; + $bind[] = $period; + } + if (!empty($idSite)) { + Piwik::checkUserHasViewAccess($idSite); + $sqlWhere .= " AND " . Common::prefixTable('site') . ".idsite = ?"; + $bind[] = $idSite; + } + if (!empty($idReport)) { + $sqlWhere .= " AND idreport = ?"; + $bind[] = $idReport; + } + if (!empty($idSegment)) { + $sqlWhere .= " AND idsegment = ?"; + $bind[] = $idSegment; + } + + // Joining with the site table to work around pre-1.3 where reports could still be linked to a deleted site + $reports = Db::fetchAll("SELECT report.* + FROM " . Common::prefixTable('report') . " AS `report` + JOIN " . Common::prefixTable('site') . " + USING (idsite) + WHERE deleted = 0 + $sqlWhere", $bind); + // When a specific report was requested and not found, throw an error + if ($idReport !== false + && empty($reports) + ) { + throw new Exception("Requested report couldn't be found."); + } + + foreach ($reports as &$report) { + // decode report parameters + $report['parameters'] = Common::json_decode($report['parameters'], true); + + // decode report list + $report['reports'] = Common::json_decode($report['reports'], true); + } + + // static cache + self::$cache[$cacheKey] = $reports; + + return $reports; + } + + /** + * Generates a report file. + * + * @param int $idReport ID of the report to generate. + * @param string $date YYYY-MM-DD + * @param bool|false|string $language If not passed, will use default language. + * @param bool|false|int $outputType 1 = download report, 2 = save report to disk, 3 = output report in browser, 4 = return report content to caller, defaults to download + * @param bool|false|string $period Defaults to 'day'. If not specified, will default to the report's period set when creating the report + * @param bool|false|string $reportFormat 'pdf', 'html' or any other format provided via the ScheduledReports.getReportFormats hook + * @param bool|false|array $parameters array of parameters + * @return array|void + */ + public function generateReport($idReport, $date, $language = false, $outputType = false, $period = false, $reportFormat = false, $parameters = false) + { + Piwik::checkUserIsNotAnonymous(); + + // load specified language + if (empty($language)) { + $language = Translate::getLanguageDefault(); + } + + Translate::reloadLanguage($language); + + $reports = $this->getReports($idSite = false, $_period = false, $idReport); + $report = reset($reports); + + $idSite = $report['idsite']; + $login = $report['login']; + $reportType = $report['type']; + + $this->checkUserHasViewPermission($login, $idSite); + + // override report period + if (empty($period)) { + $period = $report['period']; + } + + // override report format + if (!empty($reportFormat)) { + self::validateReportFormat($reportType, $reportFormat); + $report['format'] = $reportFormat; + } else { + $reportFormat = $report['format']; + } + + // override and/or validate report parameters + $report['parameters'] = Common::json_decode( + self::validateReportParameters($reportType, empty($parameters) ? $report['parameters'] : $parameters), + true + ); + + // available reports + $availableReportMetadata = \Piwik\Plugins\API\API::getInstance()->getReportMetadata($idSite); + + // we need to lookup which reports metadata are registered in this report + $reportMetadata = array(); + foreach ($availableReportMetadata as $metadata) { + if (in_array($metadata['uniqueId'], $report['reports'])) { + $reportMetadata[] = $metadata; + } + } + + // the report will be rendered with the first 23 rows and will aggregate other rows in a summary row + // 23 rows table fits in one portrait page + $initialFilterTruncate = Common::getRequestVar('filter_truncate', false); + $_GET['filter_truncate'] = self::REPORT_TRUNCATE; + + $prettyDate = null; + $processedReports = array(); + $segment = self::getSegment($report['idsegment']); + foreach ($reportMetadata as $action) { + $apiModule = $action['module']; + $apiAction = $action['action']; + $apiParameters = array(); + if (isset($action['parameters'])) { + $apiParameters = $action['parameters']; + } + + $mustRestoreGET = false; + + // all Websites dashboard should not be truncated in the report + if ($apiModule == 'MultiSites') { + $mustRestoreGET = $_GET; + $_GET['enhanced'] = true; + + if ($apiAction == 'getAll') { + $_GET['filter_truncate'] = false; + + // when a view/admin user created a report, workaround the fact that "Super User" + // is enforced in Scheduled tasks, and ensure Multisites.getAll only return the websites that this user can access + $userLogin = $report['login']; + if (!empty($userLogin) + && !Piwik::hasTheUserSuperUserAccess($userLogin) + ) { + $_GET['_restrictSitesToLogin'] = $userLogin; + } + } + } + + $processedReport = \Piwik\Plugins\API\API::getInstance()->getProcessedReport( + $idSite, $period, $date, $apiModule, $apiAction, + $segment != null ? urlencode($segment['definition']) : false, + $apiParameters, $idGoal = false, $language + ); + + $processedReport['segment'] = $segment; + + // TODO add static method getPrettyDate($period, $date) in Period + $prettyDate = $processedReport['prettyDate']; + + if ($mustRestoreGET) { + $_GET = $mustRestoreGET; + } + + $processedReports[] = $processedReport; + } + + // restore filter truncate parameter value + if ($initialFilterTruncate !== false) { + $_GET['filter_truncate'] = $initialFilterTruncate; + } + + /** + * Triggered when generating the content of scheduled reports. + * + * This event can be used to modify the report data or report metadata of one or more reports + * in a scheduled report, before the scheduled report is rendered and delivered. + * + * TODO: list data available in $report or make it a new class that can be documented (same for + * all other events that use a $report) + * + * @param array &$processedReports The list of processed reports in the scheduled + * report. Entries includes report data and metadata for each report. + * @param string $reportType A string ID describing how the scheduled report will be sent, eg, + * `'sms'` or `'email'`. + * @param string $outputType The output format of the report, eg, `'html'`, `'pdf'`, etc. + * @param array $report An array describing the scheduled report that is being + * generated. + */ + Piwik::postEvent( + self::PROCESS_REPORTS_EVENT, + array(&$processedReports, $reportType, $outputType, $report) + ); + + $reportRenderer = null; + + /** + * Triggered when obtaining a renderer instance based on the scheduled report output format. + * + * Plugins that provide new scheduled report output formats should use this event to + * handle their new report formats. + * + * @param ReportRenderer &$reportRenderer This variable should be set to an instance that + * extends {@link Piwik\ReportRenderer} by one of the event + * subscribers. + * @param string $reportType A string ID describing how the report is sent, eg, + * `'sms'` or `'email'`. + * @param string $outputType The output format of the report, eg, `'html'`, `'pdf'`, etc. + * @param array $report An array describing the scheduled report that is being + * generated. + */ + Piwik::postEvent( + self::GET_RENDERER_INSTANCE_EVENT, + array(&$reportRenderer, $reportType, $outputType, $report) + ); + + if(is_null($reportRenderer)) { + throw new Exception("A report renderer was not supplied in the event " . self::GET_RENDERER_INSTANCE_EVENT); + } + + // init report renderer + $reportRenderer->setLocale($language); + + // render report + $description = str_replace(array("\r", "\n"), ' ', $report['description']); + + list($reportSubject, $reportTitle) = self::getReportSubjectAndReportTitle(Site::getNameFor($idSite), $report['reports']); + $filename = "$reportTitle - $prettyDate - $description"; + + $reportRenderer->renderFrontPage($reportTitle, $prettyDate, $description, $reportMetadata, $segment); + array_walk($processedReports, array($reportRenderer, 'renderReport')); + + switch ($outputType) { + + case self::OUTPUT_SAVE_ON_DISK: + + $outputFilename = strtoupper($reportFormat) . ' ' . ucfirst($reportType) . ' Report - ' . $idReport . '.' . $date . '.' . $idSite . '.' . $language; + $outputFilename = $reportRenderer->sendToDisk($outputFilename); + + $additionalFiles = $this->getAttachments($reportRenderer, $report, $processedReports, $prettyDate); + + return array( + $outputFilename, + $prettyDate, + $reportSubject, + $reportTitle, + $additionalFiles, + ); + + break; + + case self::OUTPUT_INLINE: + + $reportRenderer->sendToBrowserInline($filename); + break; + + case self::OUTPUT_RETURN: + + return $reportRenderer->getRenderedReport(); + break; + + default: + case self::OUTPUT_DOWNLOAD: + $reportRenderer->sendToBrowserDownload($filename); + break; + } + } + + public function sendReport($idReport, $period = false, $date = false) + { + Piwik::checkUserIsNotAnonymous(); + + $reports = $this->getReports($idSite = false, false, $idReport); + $report = reset($reports); + + if ($report['period'] == 'never') { + $report['period'] = 'day'; + } + + if (!empty($period)) { + $report['period'] = $period; + } + + if (empty($date)) { + $date = Date::now()->subPeriod(1, $report['period'])->toString(); + } + + $language = \Piwik\Plugins\LanguagesManager\API::getInstance()->getLanguageForUser($report['login']); + + // generate report + list($outputFilename, $prettyDate, $reportSubject, $reportTitle, $additionalFiles) = + $this->generateReport( + $idReport, + $date, + $language, + self::OUTPUT_SAVE_ON_DISK, + $report['period'] + ); + + if (!file_exists($outputFilename)) { + throw new Exception("The report file wasn't found in $outputFilename"); + } + + $filename = basename($outputFilename); + $handle = fopen($outputFilename, "r"); + $contents = fread($handle, filesize($outputFilename)); + fclose($handle); + + /** + * Triggered when sending scheduled reports. + * + * Plugins that provide new scheduled report transport mediums should use this event to + * send the scheduled report. + * + * @param string $reportType A string ID describing how the report is sent, eg, + * `'sms'` or `'email'`. + * @param array $report An array describing the scheduled report that is being + * generated. + * @param string $contents The contents of the scheduled report that was generated + * and now should be sent. + * @param string $filename The path to the file where the scheduled report has + * been saved. + * @param string $prettyDate A prettified date string for the data within the + * scheduled report. + * @param string $reportSubject A string describing what's in the scheduled + * report. + * @param string $reportTitle The scheduled report's given title (given by a Piwik user). + * @param array $additionalFiles The list of additional files that should be + * sent with this report. + */ + Piwik::postEvent( + self::SEND_REPORT_EVENT, + array( + $report['type'], + $report, + $contents, + $filename, + $prettyDate, + $reportSubject, + $reportTitle, + $additionalFiles + ) + ); + + // Update flag in DB + Db::get()->update(Common::prefixTable('report'), + array('ts_last_sent' => Date::now()->getDatetime()), + "idreport = " . $report['idreport'] + ); + + // If running from piwik.php with debug, do not delete the PDF after sending the email + if (!isset($GLOBALS['PIWIK_TRACKER_DEBUG']) || !$GLOBALS['PIWIK_TRACKER_DEBUG']) { + @chmod($outputFilename, 0600); + } + } + + private static function getReportSubjectAndReportTitle($websiteName, $reports) + { + // if the only report is "All websites", we don't display the site name + $reportTitle = Piwik::translate('General_Website') . " " . $websiteName; + $reportSubject = $websiteName; + if (count($reports) == 1 + && $reports[0] == 'MultiSites_getAll' + ) { + $reportSubject = Piwik::translate('General_MultiSitesSummary'); + $reportTitle = $reportSubject; + } + + return array($reportSubject, $reportTitle); + } + + private static function validateReportParameters($reportType, $parameters) + { + // get list of valid parameters + $availableParameters = array(); + + /** + * Triggered when gathering the available parameters for a scheduled report type. + * + * Plugins that provide their own scheduled report transport mediums should use this + * event to list the available report parameters for their transport medium. + * + * @param array $availableParameters The list of available parameters for this report type. + * This is an array that maps paramater IDs with a boolean + * that indicates whether the parameter is mandatory or not. + * @param string $reportType A string ID describing how the report is sent, eg, + * `'sms'` or `'email'`. + */ + Piwik::postEvent(self::GET_REPORT_PARAMETERS_EVENT, array(&$availableParameters, $reportType)); + + // unset invalid parameters + $availableParameterKeys = array_keys($availableParameters); + foreach ($parameters as $key => $value) { + if (!in_array($key, $availableParameterKeys)) { + unset($parameters[$key]); + } + } + + // test that all required parameters are provided + foreach ($availableParameters as $parameter => $mandatory) { + if ($mandatory && !isset($parameters[$parameter])) { + throw new Exception('Missing parameter : ' . $parameter); + } + } + + /** + * Triggered when validating the parameters for a scheduled report. + * + * Plugins that provide their own scheduled reports backend should use this + * event to validate the custom parameters defined with {@link ScheduledReports::getReportParameters()}. + * + * @param array $parameters The list of parameters for the scheduled report. + * @param string $reportType A string ID describing how the report is sent, eg, + * `'sms'` or `'email'`. + */ + Piwik::postEvent(self::VALIDATE_PARAMETERS_EVENT, array(&$parameters, $reportType)); + + return Common::json_encode($parameters); + } + + private static function validateAndTruncateDescription(&$description) + { + $description = substr($description, 0, 250); + } + + private static function validateRequestedReports($idSite, $reportType, $requestedReports) + { + if (!self::allowMultipleReports($reportType)) { + //sms can only contain one report, we silently discard all but the first + $requestedReports = array_slice($requestedReports, 0, 1); + } + + // retrieve available reports + $availableReportMetadata = self::getReportMetadata($idSite, $reportType); + + $availableReportIds = array(); + foreach ($availableReportMetadata as $reportMetadata) { + $availableReportIds[] = $reportMetadata['uniqueId']; + } + + foreach ($requestedReports as $report) { + if (!in_array($report, $availableReportIds)) { + throw new Exception("Report $report is unknown or not available for report type '$reportType'."); + } + } + + return Common::json_encode($requestedReports); + } + + private static function validateCommonReportAttributes($period, $hour, &$description, &$idSegment, $reportType, $reportFormat) + { + self::validateReportPeriod($period); + self::validateReportHour($hour); + self::validateAndTruncateDescription($description); + self::validateIdSegment($idSegment); + self::validateReportType($reportType); + self::validateReportFormat($reportType, $reportFormat); + } + + private static function validateReportPeriod($period) + { + $availablePeriods = array('day', 'week', 'month', 'never'); + if (!in_array($period, $availablePeriods)) { + throw new Exception('Period schedule must be one of the following: ' . implode(', ', $availablePeriods)); + } + } + + private static function validateReportHour($hour) + { + if (!is_numeric($hour) || $hour < 0 || $hour > 23) { + throw new Exception('Invalid hour schedule. Should be anything from 0 to 23 inclusive.'); + } + } + + private static function validateIdSegment(&$idSegment) + { + if (empty($idSegment) || (is_numeric($idSegment) && $idSegment == 0)) { + + $idSegment = null; + } elseif (!is_numeric($idSegment)) { + + throw new Exception('Invalid segment identifier. Should be an integer.'); + } elseif (self::getSegment($idSegment) == null) { + + throw new Exception('Segment with id ' . $idSegment . ' does not exist or SegmentEditor is not activated.'); + } + } + + private static function validateReportType($reportType) + { + $reportTypes = array_keys(self::getReportTypes()); + + if (!in_array($reportType, $reportTypes)) { + throw new Exception( + 'Report type \'' . $reportType . '\' not valid. Try one of the following ' . implode(', ', $reportTypes) + ); + } + } + + private static function validateReportFormat($reportType, $reportFormat) + { + $reportFormats = array_keys(self::getReportFormats($reportType)); + + if (!in_array($reportFormat, $reportFormats)) { + throw new Exception( + Piwik::translate( + 'General_ExceptionInvalidReportRendererFormat', + array($reportFormat, implode(', ', $reportFormats)) + ) + ); + } + } + + /** + * @ignore + */ + static public function getReportMetadata($idSite, $reportType) + { + $availableReportMetadata = array(); + + /** + * TODO: change this event so it returns a list of API methods instead of report metadata arrays. + * Triggered when gathering the list of Piwik reports that can be used with a certain + * transport medium. + * + * Plugins that provide their own transport mediums should use this + * event to list the Piwik reports that their backend supports. + * + * @param array &$availableReportMetadata An array containg report metadata for each supported + * report. + * @param string $reportType A string ID describing how the report is sent, eg, + * `'sms'` or `'email'`. + * @param int $idSite The ID of the site we're getting available reports for. + */ + Piwik::postEvent( + self::GET_REPORT_METADATA_EVENT, + array(&$availableReportMetadata, $reportType, $idSite) + ); + + return $availableReportMetadata; + } + + /** + * @ignore + */ + static public function allowMultipleReports($reportType) + { + $allowMultipleReports = null; + + /** + * Triggered when we're determining if a scheduled report transport medium can + * handle sending multiple Piwik reports in one scheduled report or not. + * + * Plugins that provide their own transport mediums should use this + * event to specify whether their backend can send more than one Piwik report + * at a time. + * + * @param bool &$allowMultipleReports Whether the backend type can handle multiple + * Piwik reports or not. + * @param string $reportType A string ID describing how the report is sent, eg, + * `'sms'` or `'email'`. + */ + Piwik::postEvent( + self::ALLOW_MULTIPLE_REPORTS_EVENT, + array(&$allowMultipleReports, $reportType) + ); + return $allowMultipleReports; + } + + /** + * @ignore + */ + static public function getReportTypes() + { + $reportTypes = array(); + + /** + * Triggered when gathering all available transport mediums. + * + * Plugins that provide their own transport mediums should use this + * event to make their medium available. + * + * @param array &$reportTypes An array mapping transport medium IDs with the paths to those + * mediums' icons. Add your new backend's ID to this array. + */ + Piwik::postEvent(self::GET_REPORT_TYPES_EVENT, array(&$reportTypes)); + + return $reportTypes; + } + + /** + * @ignore + */ + static public function getReportFormats($reportType) + { + $reportFormats = array(); + + /** + * Triggered when gathering all available scheduled report formats. + * + * Plugins that provide their own scheduled report format should use + * this event to make their format available. + * + * @param array &$reportFormats An array mapping string IDs for each available + * scheduled report format with icon paths for those + * formats. Add your new format's ID to this array. + * @param string $reportType A string ID describing how the report is sent, eg, + * `'sms'` or `'email'`. + */ + Piwik::postEvent( + self::GET_REPORT_FORMATS_EVENT, + array(&$reportFormats, $reportType) + ); + + return $reportFormats; + } + + /** + * @ignore + */ + static public function getReportRecipients($report) + { + $recipients = array(); + + /** + * Triggered when getting the list of recipients of a scheduled report. + * + * Plugins that provide their own scheduled report transport medium should use this event + * to extract the list of recipients their backend's specific scheduled report + * format. + * + * @param array &$recipients An array of strings describing each of the scheduled + * reports recipients. Can be, for example, a list of email + * addresses or phone numbers or whatever else your plugin + * uses. + * @param string $reportType A string ID describing how the report is sent, eg, + * `'sms'` or `'email'`. + * @param array $report An array describing the scheduled report that is being + * generated. + */ + Piwik::postEvent(self::GET_REPORT_RECIPIENTS_EVENT, array(&$recipients, $report['type'], $report)); + + return $recipients; + } + + /** + * @ignore + */ + static public function getSegment($idSegment) + { + if (self::isSegmentEditorActivated() && !empty($idSegment)) { + + $segment = APISegmentEditor::getInstance()->get($idSegment); + + if ($segment) { + return $segment; + } + } + + return null; + } + + /** + * @ignore + */ + public static function isSegmentEditorActivated() + { + return \Piwik\Plugin\Manager::getInstance()->isPluginActivated('SegmentEditor'); + } + + private function getAttachments($reportRenderer, $report, $processedReports, $prettyDate) + { + $additionalFiles = array(); + + if ($reportRenderer instanceof Html) { + + foreach ($processedReports as $processedReport) { + + if ($processedReport['displayGraph']) { + + $additionalFiles[] = $this->createAttachment($report, $processedReport, $prettyDate); + } + } + } + + return $additionalFiles; + } + + private function createAttachment($report, $processedReport, $prettyDate) + { + $additionalFile = array(); + + $segment = self::getSegment($report['idsegment']); + + $segmentName = $segment != null ? sprintf(' (%s)', $segment['name']) : ''; + + $processedReportMetadata = $processedReport['metadata']; + + $additionalFile['filename'] = + sprintf( + '%s - %s - %s %d - %s %d%s.png', + $processedReportMetadata['name'], + $prettyDate, + Piwik::translate('General_Website'), + $report['idsite'], + Piwik::translate('General_Report'), + $report['idreport'], + $segmentName + ); + + $additionalFile['cid'] = $processedReportMetadata['uniqueId']; + + $additionalFile['content'] = + ReportRenderer::getStaticGraph( + $processedReportMetadata, + Html::IMAGE_GRAPH_WIDTH, + Html::IMAGE_GRAPH_HEIGHT, + $processedReport['evolutionGraph'], + $segment + ); + + $additionalFile['mimeType'] = 'image/png'; + + $additionalFile['encoding'] = Zend_Mime::ENCODING_BASE64; + + return $additionalFile; + } + + private function checkUserHasViewPermission($login, $idSite) + { + if (empty($idSite)) { + return; + } + + $idSitesUserHasAccess = SitesManagerApi::getInstance()->getSitesIdWithAtLeastViewAccess($login); + + if (empty($idSitesUserHasAccess) + || !in_array($idSite, $idSitesUserHasAccess) + ) { + throw new NoAccessException(Piwik::translate('General_ExceptionPrivilege', array("'view'"))); + } + } +} diff --git a/www/analytics/plugins/ScheduledReports/Controller.php b/www/analytics/plugins/ScheduledReports/Controller.php new file mode 100644 index 00000000..db617419 --- /dev/null +++ b/www/analytics/plugins/ScheduledReports/Controller.php @@ -0,0 +1,92 @@ +setGeneralVariablesView($view); + + $view->countWebsites = count(APISitesManager::getInstance()->getSitesIdWithAtLeastViewAccess()); + + // get report types + $reportTypes = API::getReportTypes(); + $view->reportTypes = $reportTypes; + $view->defaultReportType = self::DEFAULT_REPORT_TYPE; + $view->defaultReportFormat = ScheduledReports::DEFAULT_REPORT_FORMAT; + $view->displayFormats = ScheduledReports::getDisplayFormats(); + + $reportsByCategoryByType = array(); + $reportFormatsByReportType = array(); + $allowMultipleReportsByReportType = array(); + foreach ($reportTypes as $reportType => $reportTypeIcon) { + // get report formats + $reportFormatsByReportType[$reportType] = API::getReportFormats($reportType); + $allowMultipleReportsByReportType[$reportType] = API::allowMultipleReports($reportType); + + // get report metadata + $reportsByCategory = array(); + $availableReportMetadata = API::getReportMetadata($this->idSite, $reportType); + foreach ($availableReportMetadata as $reportMetadata) { + $reportsByCategory[$reportMetadata['category']][] = $reportMetadata; + } + $reportsByCategoryByType[$reportType] = $reportsByCategory; + } + $view->reportsByCategoryByReportType = $reportsByCategoryByType; + $view->reportFormatsByReportType = $reportFormatsByReportType; + $view->allowMultipleReportsByReportType = $allowMultipleReportsByReportType; + + $reports = array(); + $reportsById = array(); + if (!Piwik::isUserIsAnonymous()) { + $reports = API::getInstance()->getReports($this->idSite, $period = false, $idReport = false, $ifSuperUserReturnOnlySuperUserReports = true); + foreach ($reports as &$report) { + $report['recipients'] = API::getReportRecipients($report); + $reportsById[$report['idreport']] = $report; + } + } + $view->reports = $reports; + $view->reportsJSON = Common::json_encode($reportsById); + + $view->downloadOutputType = API::OUTPUT_INLINE; + + $view->periods = ScheduledReports::getPeriodToFrequency(); + $view->defaultPeriod = ScheduledReports::DEFAULT_PERIOD; + $view->defaultHour = ScheduledReports::DEFAULT_HOUR; + + $view->language = LanguagesManager::getLanguageCodeForCurrentUser(); + + $view->segmentEditorActivated = false; + if (API::isSegmentEditorActivated()) { + + $savedSegmentsById = array(); + foreach (APISegmentEditor::getInstance()->getAll($this->idSite) as $savedSegment) { + $savedSegmentsById[$savedSegment['idsegment']] = $savedSegment['name']; + } + $view->savedSegmentsById = $savedSegmentsById; + $view->segmentEditorActivated = true; + } + + return $view->render(); + } +} diff --git a/www/analytics/plugins/ScheduledReports/ScheduledReports.php b/www/analytics/plugins/ScheduledReports/ScheduledReports.php new file mode 100644 index 00000000..0cf52001 --- /dev/null +++ b/www/analytics/plugins/ScheduledReports/ScheduledReports.php @@ -0,0 +1,642 @@ + false, + self::EVOLUTION_GRAPH_PARAMETER => false, + self::ADDITIONAL_EMAILS_PARAMETER => false, + self::DISPLAY_FORMAT_PARAMETER => true, + ); + + static private $managedReportTypes = array( + self::EMAIL_TYPE => 'plugins/Zeitgeist/images/email.png' + ); + + static private $managedReportFormats = array( + ReportRenderer::HTML_FORMAT => 'plugins/Zeitgeist/images/html_icon.png', + ReportRenderer::PDF_FORMAT => 'plugins/UserSettings/images/plugins/pdf.gif', + ReportRenderer::CSV_FORMAT => 'plugins/Morpheus/images/export.png', + ); + + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'Menu.Top.addItems' => 'addTopMenu', + 'TaskScheduler.getScheduledTasks' => 'getScheduledTasks', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'MobileMessaging.deletePhoneNumber' => 'deletePhoneNumber', + 'ScheduledReports.getReportParameters' => 'getReportParameters', + 'ScheduledReports.validateReportParameters' => 'validateReportParameters', + 'ScheduledReports.getReportMetadata' => 'getReportMetadata', + 'ScheduledReports.getReportTypes' => 'getReportTypes', + 'ScheduledReports.getReportFormats' => 'getReportFormats', + 'ScheduledReports.getRendererInstance' => 'getRendererInstance', + 'ScheduledReports.getReportRecipients' => 'getReportRecipients', + 'ScheduledReports.processReports' => 'processReports', + 'ScheduledReports.allowMultipleReports' => 'allowMultipleReports', + 'ScheduledReports.sendReport' => 'sendReport', + 'Template.reportParametersScheduledReports' => 'template_reportParametersScheduledReports', + 'UsersManager.deleteUser' => 'deleteUserReport', + 'SitesManager.deleteSite.end' => 'deleteSiteReport', + APISegmentEditor::DEACTIVATE_SEGMENT_EVENT => 'segmentDeactivation', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + ); + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = "ScheduledReports_ReportSent"; + $translationKeys[] = "ScheduledReports_ReportUpdated"; + } + + /** + * Delete reports for the website + */ + public function deleteSiteReport($idSite) + { + $idReports = API::getInstance()->getReports($idSite); + + foreach ($idReports as $report) { + $idReport = $report['idreport']; + API::getInstance()->deleteReport($idReport); + } + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/ScheduledReports/javascripts/pdf.js"; + } + + public function validateReportParameters(&$parameters, $reportType) + { + if (self::manageEvent($reportType)) { + $reportFormat = $parameters[self::DISPLAY_FORMAT_PARAMETER]; + $availableDisplayFormats = array_keys(self::getDisplayFormats()); + if (!in_array($reportFormat, $availableDisplayFormats)) { + throw new Exception( + Piwik::translate( + // General_ExceptionInvalidAggregateReportsFormat should be named General_ExceptionInvalidDisplayFormat + 'General_ExceptionInvalidAggregateReportsFormat', + array($reportFormat, implode(', ', $availableDisplayFormats)) + ) + ); + } + + // emailMe is an optional parameter + if (!isset($parameters[self::EMAIL_ME_PARAMETER])) { + $parameters[self::EMAIL_ME_PARAMETER] = self::EMAIL_ME_PARAMETER_DEFAULT_VALUE; + } else { + $parameters[self::EMAIL_ME_PARAMETER] = self::valueIsTrue($parameters[self::EMAIL_ME_PARAMETER]); + } + + // evolutionGraph is an optional parameter + if (!isset($parameters[self::EVOLUTION_GRAPH_PARAMETER])) { + $parameters[self::EVOLUTION_GRAPH_PARAMETER] = self::EVOLUTION_GRAPH_PARAMETER_DEFAULT_VALUE; + } else { + $parameters[self::EVOLUTION_GRAPH_PARAMETER] = self::valueIsTrue($parameters[self::EVOLUTION_GRAPH_PARAMETER]); + } + + // additionalEmails is an optional parameter + if (isset($parameters[self::ADDITIONAL_EMAILS_PARAMETER])) { + $parameters[self::ADDITIONAL_EMAILS_PARAMETER] = self::checkAdditionalEmails($parameters[self::ADDITIONAL_EMAILS_PARAMETER]); + } + } + } + + // based on http://www.php.net/manual/en/filter.filters.validate.php -> FILTER_VALIDATE_BOOLEAN + static private function valueIsTrue($value) + { + return $value == 'true' || $value == 1 || $value == '1' || $value === true; + } + + public function getReportMetadata(&$reportMetadata, $reportType, $idSite) + { + if (self::manageEvent($reportType)) { + $availableReportMetadata = \Piwik\Plugins\API\API::getInstance()->getReportMetadata($idSite); + + $filteredReportMetadata = array(); + foreach ($availableReportMetadata as $reportMetadata) { + // removing reports from the API category and MultiSites.getOne + if ( + $reportMetadata['category'] == 'API' || + $reportMetadata['category'] == Piwik::translate('General_MultiSitesSummary') && $reportMetadata['name'] == Piwik::translate('General_SingleWebsitesDashboard') + ) continue; + + $filteredReportMetadata[] = $reportMetadata; + } + + $reportMetadata = $filteredReportMetadata; + } + } + + public function getReportTypes(&$reportTypes) + { + $reportTypes = array_merge($reportTypes, self::$managedReportTypes); + } + + public function getReportFormats(&$reportFormats, $reportType) + { + if (self::manageEvent($reportType)) { + $reportFormats = self::$managedReportFormats; + } + } + + public function getReportParameters(&$availableParameters, $reportType) + { + if (self::manageEvent($reportType)) { + $availableParameters = self::$availableParameters; + } + } + + public function processReports(&$processedReports, $reportType, $outputType, $report) + { + if (self::manageEvent($reportType)) { + $displayFormat = $report['parameters'][self::DISPLAY_FORMAT_PARAMETER]; + $evolutionGraph = $report['parameters'][self::EVOLUTION_GRAPH_PARAMETER]; + + foreach ($processedReports as &$processedReport) { + $metadata = $processedReport['metadata']; + + $isAggregateReport = !empty($metadata['dimension']); + + $processedReport['displayTable'] = $displayFormat != self::DISPLAY_FORMAT_GRAPHS_ONLY; + + $processedReport['displayGraph'] = + ($isAggregateReport ? + $displayFormat == self::DISPLAY_FORMAT_GRAPHS_ONLY || $displayFormat == self::DISPLAY_FORMAT_TABLES_AND_GRAPHS + : + $displayFormat != self::DISPLAY_FORMAT_TABLES_ONLY) + && \Piwik\SettingsServer::isGdExtensionEnabled() + && \Piwik\Plugin\Manager::getInstance()->isPluginActivated('ImageGraph') + && !empty($metadata['imageGraphUrl']); + + $processedReport['evolutionGraph'] = $evolutionGraph; + + // remove evolution metrics from MultiSites.getAll + if ($metadata['module'] == 'MultiSites') { + $columns = $processedReport['columns']; + + foreach (\Piwik\Plugins\MultiSites\API::getApiMetrics($enhanced = true) as $metricSettings) { + unset($columns[$metricSettings[\Piwik\Plugins\MultiSites\API::METRIC_EVOLUTION_COL_NAME_KEY]]); + } + + $processedReport['metadata'] = $metadata; + $processedReport['columns'] = $columns; + } + } + } + } + + public function getRendererInstance(&$reportRenderer, $reportType, $outputType, $report) + { + if (self::manageEvent($reportType)) { + $reportFormat = $report['format']; + + $reportRenderer = ReportRenderer::factory($reportFormat); + + if ($reportFormat == ReportRenderer::HTML_FORMAT) { + $reportRenderer->setRenderImageInline($outputType != API::OUTPUT_SAVE_ON_DISK); + } + } + } + + public function allowMultipleReports(&$allowMultipleReports, $reportType) + { + if (self::manageEvent($reportType)) { + $allowMultipleReports = true; + } + } + + public function sendReport($reportType, $report, $contents, $filename, $prettyDate, $reportSubject, $reportTitle, + $additionalFiles) + { + if (self::manageEvent($reportType)) { + $periods = self::getPeriodToFrequencyAsAdjective(); + $message = Piwik::translate('ScheduledReports_EmailHello'); + $subject = Piwik::translate('General_Report') . ' ' . $reportTitle . " - " . $prettyDate; + + $mail = new Mail(); + $mail->setDefaultFromPiwik(); + $mail->setSubject($subject); + $attachmentName = $subject; + + $displaySegmentInfo = false; + $segmentInfo = null; + $segment = API::getSegment($report['idsegment']); + if ($segment != null) { + $displaySegmentInfo = true; + $segmentInfo = Piwik::translate('ScheduledReports_SegmentAppliedToReports', $segment['name']); + } + + switch ($report['format']) { + case 'html': + + // Needed when using images as attachment with cid + $mail->setType(Zend_Mime::MULTIPART_RELATED); + $message .= "
            " . Piwik::translate('ScheduledReports_PleaseFindBelow', array($periods[$report['period']], $reportTitle)); + + if ($displaySegmentInfo) { + $message .= " " . $segmentInfo; + } + + $mail->setBodyHtml($message . "

            " . $contents); + break; + + + case 'csv': + $message .= "\n" . Piwik::translate('ScheduledReports_PleaseFindAttachedFile', array($periods[$report['period']], $reportTitle)); + + if ($displaySegmentInfo) { + $message .= " " . $segmentInfo; + } + + $mail->setBodyText($message); + $mail->createAttachment( + $contents, + 'application/csv', + Zend_Mime::DISPOSITION_INLINE, + Zend_Mime::ENCODING_BASE64, + $attachmentName . '.csv' + ); + break; + + default: + case 'pdf': + $message .= "\n" . Piwik::translate('ScheduledReports_PleaseFindAttachedFile', array($periods[$report['period']], $reportTitle)); + + if ($displaySegmentInfo) { + $message .= " " . $segmentInfo; + } + + $mail->setBodyText($message); + $mail->createAttachment( + $contents, + 'application/pdf', + Zend_Mime::DISPOSITION_INLINE, + Zend_Mime::ENCODING_BASE64, + $attachmentName . '.pdf' + ); + break; + } + + foreach ($additionalFiles as $additionalFile) { + $fileContent = $additionalFile['content']; + $at = $mail->createAttachment( + $fileContent, + $additionalFile['mimeType'], + Zend_Mime::DISPOSITION_INLINE, + $additionalFile['encoding'], + $additionalFile['filename'] + ); + $at->id = $additionalFile['cid']; + + unset($fileContent); + } + + // Get user emails and languages + $reportParameters = $report['parameters']; + $emails = array(); + + if (isset($reportParameters[self::ADDITIONAL_EMAILS_PARAMETER])) { + $emails = $reportParameters[self::ADDITIONAL_EMAILS_PARAMETER]; + } + + if ($reportParameters[self::EMAIL_ME_PARAMETER] == 1) { + if (Piwik::getCurrentUserLogin() == $report['login']) { + $emails[] = Piwik::getCurrentUserEmail(); + } else { + try { + $user = APIUsersManager::getInstance()->getUser($report['login']); + } catch (Exception $e) { + return; + } + $emails[] = $user['email']; + } + } + + foreach ($emails as $email) { + if (empty($email)) { + continue; + } + $mail->addTo($email); + + try { + $mail->send(); + } catch (Exception $e) { + + // If running from piwik.php with debug, we ignore the 'email not sent' error + if (!isset($GLOBALS['PIWIK_TRACKER_DEBUG']) || !$GLOBALS['PIWIK_TRACKER_DEBUG']) { + throw new Exception("An error occured while sending '$filename' " . + " to " . implode(', ', $mail->getRecipients()) . + ". Error was '" . $e->getMessage() . "'"); + } + } + $mail->clearRecipients(); + } + } + } + + public function deletePhoneNumber($phoneNumber) + { + $api = API::getInstance(); + + $reports = $api->getReports( + $idSite = false, + $period = false, + $idReport = false, + $ifSuperUserReturnOnlySuperUserReports = false + ); + + foreach ($reports as $report) { + if ($report['type'] == MobileMessaging::MOBILE_TYPE) { + $reportParameters = $report['parameters']; + $reportPhoneNumbers = $reportParameters[MobileMessaging::PHONE_NUMBERS_PARAMETER]; + $updatedPhoneNumbers = array(); + foreach ($reportPhoneNumbers as $reportPhoneNumber) { + if ($reportPhoneNumber != $phoneNumber) { + $updatedPhoneNumbers[] = $reportPhoneNumber; + } + } + + if (count($updatedPhoneNumbers) != count($reportPhoneNumbers)) { + $reportParameters[MobileMessaging::PHONE_NUMBERS_PARAMETER] = $updatedPhoneNumbers; + + // note: reports can end up without any recipients + $api->updateReport( + $report['idreport'], + $report['idsite'], + $report['description'], + $report['period'], + $report['hour'], + $report['type'], + $report['format'], + $report['reports'], + $reportParameters + ); + } + } + } + } + + public function getReportRecipients(&$recipients, $reportType, $report) + { + if (self::manageEvent($reportType)) { + $parameters = $report['parameters']; + $eMailMe = $parameters[self::EMAIL_ME_PARAMETER]; + + if ($eMailMe) { + $recipients[] = Piwik::getCurrentUserEmail(); + } + + if (isset($parameters[self::ADDITIONAL_EMAILS_PARAMETER])) { + $additionalEMails = $parameters[self::ADDITIONAL_EMAILS_PARAMETER]; + $recipients = array_merge($recipients, $additionalEMails); + } + $recipients = array_filter($recipients); + } + } + + static public function template_reportParametersScheduledReports(&$out) + { + $view = new View('@ScheduledReports/reportParametersScheduledReports'); + $view->currentUserEmail = Piwik::getCurrentUserEmail(); + $view->reportType = self::EMAIL_TYPE; + $view->defaultDisplayFormat = self::DEFAULT_DISPLAY_FORMAT; + $view->defaultEmailMe = self::EMAIL_ME_PARAMETER_DEFAULT_VALUE ? 'true' : 'false'; + $view->defaultEvolutionGraph = self::EVOLUTION_GRAPH_PARAMETER_DEFAULT_VALUE ? 'true' : 'false'; + $out .= $view->render(); + } + + private static function manageEvent($reportType) + { + return in_array($reportType, array_keys(self::$managedReportTypes)); + } + + public function getScheduledTasks(&$tasks) + { + foreach (API::getInstance()->getReports() as $report) { + if (!$report['deleted'] && $report['period'] != ScheduledTime::PERIOD_NEVER) { + + $timezone = Site::getTimezoneFor($report['idsite']); + + $schedule = ScheduledTime::getScheduledTimeForPeriod($report['period']); + $schedule->setHour($report['hour']); + $schedule->setTimezone($timezone); + $tasks[] = new ScheduledTask ( + API::getInstance(), + 'sendReport', + $report['idreport'], $schedule + ); + } + } + } + + public function segmentDeactivation($idSegment) + { + $reportsUsingSegment = API::getInstance()->getReports(false, false, false, false, $idSegment); + + if (count($reportsUsingSegment) > 0) { + + $reportList = ''; + $reportNameJoinText = ' ' . Piwik::translate('General_And') . ' '; + foreach ($reportsUsingSegment as $report) { + $reportList .= '\'' . $report['description'] . '\'' . $reportNameJoinText; + } + $reportList = rtrim($reportList, $reportNameJoinText); + + $errorMessage = Piwik::translate('ScheduledReports_Segment_Deletion_Error', $reportList); + throw new Exception($errorMessage); + } + } + + function addTopMenu() + { + MenuTop::addEntry( + $this->getTopMenuTranslationKey(), + array('module' => 'ScheduledReports', 'action' => 'index', 'segment' => false), + true, + 13, + $isHTML = false, + $tooltip = Piwik::translate( + \Piwik\Plugin\Manager::getInstance()->isPluginActivated('MobileMessaging') + ? 'MobileMessaging_TopLinkTooltip' : 'ScheduledReports_TopLinkTooltip' + ) + ); + } + + function getTopMenuTranslationKey() + { + // if MobileMessaging is not activated, display 'Email reports' + if (!\Piwik\Plugin\Manager::getInstance()->isPluginActivated('MobileMessaging')) + return self::PDF_REPORTS_TOP_MENU_TRANSLATION_KEY; + + if (Piwik::isUserIsAnonymous()) { + return self::MOBILE_MESSAGING_TOP_MENU_TRANSLATION_KEY; + } + + $reports = API::getInstance()->getReports(); + $reportCount = count($reports); + + // if there are no reports and the mobile account is + // not configured, display 'Email reports' + // configured, display 'Email & SMS reports' + if ($reportCount == 0) + return APIMobileMessaging::getInstance()->areSMSAPICredentialProvided() ? + self::MOBILE_MESSAGING_TOP_MENU_TRANSLATION_KEY : self::PDF_REPORTS_TOP_MENU_TRANSLATION_KEY; + + $anyMobileReport = false; + foreach ($reports as $report) { + if ($report['type'] == MobileMessaging::MOBILE_TYPE) { + $anyMobileReport = true; + break; + } + } + + // if there is at least one sms report, display 'Email & SMS reports' + if ($anyMobileReport) { + return self::MOBILE_MESSAGING_TOP_MENU_TRANSLATION_KEY; + } + + return self::PDF_REPORTS_TOP_MENU_TRANSLATION_KEY; + } + + public function deleteUserReport($userLogin) + { + Db::query('DELETE FROM ' . Common::prefixTable('report') . ' WHERE login = ?', $userLogin); + } + + public function install() + { + $reportTable = "`idreport` INT(11) NOT NULL AUTO_INCREMENT, + `idsite` INTEGER(11) NOT NULL, + `login` VARCHAR(100) NOT NULL, + `description` VARCHAR(255) NOT NULL, + `idsegment` INT(11), + `period` VARCHAR(10) NOT NULL, + `hour` tinyint NOT NULL default 0, + `type` VARCHAR(10) NOT NULL, + `format` VARCHAR(10) NOT NULL, + `reports` TEXT NOT NULL, + `parameters` TEXT NULL, + `ts_created` TIMESTAMP NULL, + `ts_last_sent` TIMESTAMP NULL, + `deleted` tinyint(4) NOT NULL default 0, + PRIMARY KEY (`idreport`)"; + + DbHelper::createTable('report', $reportTable); + } + + private static function checkAdditionalEmails($additionalEmails) + { + foreach ($additionalEmails as &$email) { + $email = trim($email); + if (empty($email)) { + $email = false; + } elseif (!Piwik::isValidEmailString($email)) { + throw new Exception(Piwik::translate('UsersManager_ExceptionInvalidEmail') . ' (' . $email . ')'); + } + } + $additionalEmails = array_filter($additionalEmails); + return $additionalEmails; + } + + public static function getDisplayFormats() + { + $displayFormats = array( + // ScheduledReports_AggregateReportsFormat_TablesOnly should be named ScheduledReports_DisplayFormat_GraphsOnlyForKeyMetrics + self::DISPLAY_FORMAT_GRAPHS_ONLY_FOR_KEY_METRICS => Piwik::translate('ScheduledReports_AggregateReportsFormat_TablesOnly'), + // ScheduledReports_AggregateReportsFormat_GraphsOnly should be named ScheduledReports_DisplayFormat_GraphsOnly + self::DISPLAY_FORMAT_GRAPHS_ONLY => Piwik::translate('ScheduledReports_AggregateReportsFormat_GraphsOnly'), + // ScheduledReports_AggregateReportsFormat_TablesAndGraphs should be named ScheduledReports_DisplayFormat_TablesAndGraphs + self::DISPLAY_FORMAT_TABLES_AND_GRAPHS => Piwik::translate('ScheduledReports_AggregateReportsFormat_TablesAndGraphs'), + self::DISPLAY_FORMAT_TABLES_ONLY => Piwik::translate('ScheduledReports_DisplayFormat_TablesOnly'), + ); + return $displayFormats; + } + + /** + * Used in the Report Listing + * @ignore + */ + static public function getPeriodToFrequency() + { + return array( + ScheduledTime::PERIOD_NEVER => Piwik::translate('General_Never'), + ScheduledTime::PERIOD_DAY => Piwik::translate('General_Daily'), + ScheduledTime::PERIOD_WEEK => Piwik::translate('General_Weekly'), + ScheduledTime::PERIOD_MONTH => Piwik::translate('General_Monthly'), + ); + } + + /** + * Used in the Report's email content, ie "monthly report" + * @ignore + */ + static public function getPeriodToFrequencyAsAdjective() + { + return array( + ScheduledTime::PERIOD_DAY => Piwik::translate('General_DailyReport'), + ScheduledTime::PERIOD_WEEK => Piwik::translate('General_WeeklyReport'), + ScheduledTime::PERIOD_MONTH => Piwik::translate('General_MonthlyReport'), + ScheduledTime::PERIOD_YEAR => Piwik::translate('General_YearlyReport'), + ScheduledTime::PERIOD_RANGE => Piwik::translate('General_RangeReports'), + ); + } +} diff --git a/www/analytics/plugins/ScheduledReports/config/tcpdf_config.php b/www/analytics/plugins/ScheduledReports/config/tcpdf_config.php new file mode 100644 index 00000000..9d531715 --- /dev/null +++ b/www/analytics/plugins/ScheduledReports/config/tcpdf_config.php @@ -0,0 +1,245 @@ + 0) { + report = ReportPlugin.reportList[idReport]; + $('#report_submit').val(ReportPlugin.updateReportString); + } + else { + $('#report_submit').val(ReportPlugin.createReportString); + } + + toggleReportType(report.type); + + $('#report_description').html(report.description); + $('#report_segment').find('option[value=' + report.idsegment + ']').prop('selected', 'selected'); + $('#report_type').find('option[value=' + report.type + ']').prop('selected', 'selected'); + $('#report_period').find('option[value=' + report.period + ']').prop('selected', 'selected'); + $('#report_hour').val(report.hour); + $('[name=report_format].' + report.type + ' option[value=' + report.format + ']').prop('selected', 'selected'); + + var selectorReportFormat = 'select[name=report_format].' + $('#report_type').val(); + $(selectorReportFormat).change( toggleDisplayOptionsByFormat ); + + // When CSV is selected, hide "Display options" + toggleDisplayOptionsByFormat(); + + function toggleDisplayOptionsByFormat() { + var format = $(selectorReportFormat).val(); + var displayOptionsSelector = $('#row_report_display_options'); + if (format == 'csv') { + displayOptionsSelector.hide(); + } else { + displayOptionsSelector.show(); + } + } + + + $('[name=reportsList] input').prop('checked', false); + + var key; + for (key in report.reports) { + $('.' + report.type + ' [report-unique-id=' + report.reports[key] + ']').prop('checked', 'checked'); + } + + updateReportParametersFunctions[report.type](report.parameters); + + $('#report_idreport').val(idReport); +} + +function getReportAjaxRequest(idReport, defaultApiMethod) { + var parameters = {}; + piwikHelper.lazyScrollTo(".centerLargeDiv>h2", 400); + parameters.module = 'API'; + parameters.method = defaultApiMethod; + if (idReport == 0) { + parameters.method = 'ScheduledReports.addReport'; + } + parameters.format = 'json'; + return parameters; +} + +function toggleReportType(reportType) { + resetReportParametersFunctions[reportType](); + $('#report_type').find('option').each(function (index, type) { + $('.' + $(type).val()).hide(); + }); + $('.' + reportType).show(); +} + +function fadeInOutSuccessMessage(selector, message) { + + var UI = require('piwik/UI'); + var notification = new UI.Notification(); + notification.show(message, { + placeat: selector, + context: 'success', + noclear: true, + type: 'toast', + style: {display: 'inline-block', marginTop: '10px'}, + id: 'usersManagerAccessUpdated' + }); + + piwikHelper.refreshAfter(2); +} + +function initManagePdf() { + // Click Add/Update Submit + $('#addEditReport').submit(function () { + var idReport = $('#report_idreport').val(); + var apiParameters = getReportAjaxRequest(idReport, 'ScheduledReports.updateReport'); + apiParameters.idReport = idReport; + apiParameters.description = $('#report_description').val(); + apiParameters.idSegment = $('#report_segment').find('option:selected').val(); + apiParameters.reportType = $('#report_type').find('option:selected').val(); + apiParameters.reportFormat = $('[name=report_format].' + apiParameters.reportType + ' option:selected').val(); + + var reports = []; + $('[name=reportsList].' + apiParameters.reportType + ' input:checked').each(function () { + reports.push($(this).attr('report-unique-id')); + }); + if (reports.length > 0) { + apiParameters.reports = reports; + } + + apiParameters.parameters = getReportParametersFunctions[apiParameters.reportType](); + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams(apiParameters, 'POST'); + ajaxHandler.addParams({period: $('#report_period').find('option:selected').val()}, 'GET'); + ajaxHandler.addParams({hour: $('#report_hour').val()}, 'GET'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(); + if (idReport) { + ajaxHandler.setCallback(function (response) { + + fadeInOutSuccessMessage('#reportUpdatedSuccess', _pk_translate('ScheduledReports_ReportUpdated')); + }); + } + ajaxHandler.send(true); + return false; + }); + + // Email now + $('a[name=linkSendNow]').click(function () { + var idReport = $(this).attr('idreport'); + var parameters = getReportAjaxRequest(idReport, 'ScheduledReports.sendReport'); + parameters.idReport = idReport; + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams(parameters, 'POST'); + ajaxHandler.setLoadingElement(); + ajaxHandler.setCallback(function (response) { + fadeInOutSuccessMessage('#reportSentSuccess', _pk_translate('ScheduledReports_ReportSent')); + }); + ajaxHandler.send(true); + }); + + // Delete Report + $('a[name=linkDeleteReport]').click(function () { + var idReport = $(this).attr('id'); + + function onDelete() { + var parameters = getReportAjaxRequest(idReport, 'ScheduledReports.deleteReport'); + parameters.idReport = idReport; + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams(parameters, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(); + ajaxHandler.send(true); + } + + piwikHelper.modalConfirm('#confirm', {yes: onDelete}); + }); + + // Edit Report click + $('a[name=linkEditReport]').click(function () { + var idReport = $(this).attr('id'); + formSetEditReport(idReport); + $('.entityAddContainer').show(); + $('#entityEditContainer').hide(); + $(document).trigger('ScheduledReport.edit', {}); + }); + + // Switch Report Type + $('#report_type').change(function () { + var reportType = $(this).val(); + toggleReportType(reportType); + }); + + // Add a Report click + $('#linkAddReport').click(function () { + $('.entityAddContainer').show(); + $('#entityEditContainer').hide(); + formSetEditReport(/*idReport = */0); + }); + + // Cancel click + $('.entityCancelLink').click(function () { + $('.entityAddContainer').hide(); + $('#entityEditContainer').show(); + piwikHelper.hideAjaxError(); + }).click(); +} diff --git a/www/analytics/plugins/ScheduledReports/templates/_addReport.twig b/www/analytics/plugins/ScheduledReports/templates/_addReport.twig new file mode 100644 index 00000000..cabb1703 --- /dev/null +++ b/www/analytics/plugins/ScheduledReports/templates/_addReport.twig @@ -0,0 +1,181 @@ + diff --git a/www/analytics/plugins/ScheduledReports/templates/_listReports.twig b/www/analytics/plugins/ScheduledReports/templates/_listReports.twig new file mode 100644 index 00000000..3ca01e9c --- /dev/null +++ b/www/analytics/plugins/ScheduledReports/templates/_listReports.twig @@ -0,0 +1,103 @@ +
            + + + + + + + + + + + + + + {% if userLogin == 'anonymous' %} + + + +
            {{ 'General_Description'|translate }}{{ 'ScheduledReports_EmailSchedule'|translate }}{{ 'ScheduledReports_ReportFormat'|translate }}{{ 'ScheduledReports_SendReportTo'|translate }}{{ 'General_Download'|translate }}{{ 'General_Edit'|translate }}{{ 'General_Delete'|translate }}
            +
            + {{ 'ScheduledReports_MustBeLoggedIn'|translate }} +
            {{ 'Login_LogIn'|translate }} +

            +
            + {% elseif reports is empty %} + + +
            + {{ 'ScheduledReports_ThereIsNoReportToManage'|translate(siteName)|raw }}. +

            + › {{ 'ScheduledReports_CreateAndScheduleReport'|translate }} +

            + + + + {% else %} + {% for report in reports %} + + + {{ report.description | raw }} + {% if segmentEditorActivated and report.idsegment %} +
            + {{ savedSegmentsById[report.idsegment] }} +
            + {% endif %} + + {{ periods[report.period] }} + + + + {% if report.format is not empty %} + {{ report.format|upper }} + {% endif %} + + + {# report recipients #} + {% if report.recipients|length == 0 %} + {{ 'ScheduledReports_NoRecipients'|translate }} + {% else %} + {% for recipient in report.recipients %} + {{ recipient }} +
            + {% endfor %} + {# send now link #} + + + {{ 'ScheduledReports_SendReportNow'|translate }} + + {% endif %} + + + {# download link #} + + + {{ 'General_Download'|translate }} + + + + {# edit link #} + + + {{ 'General_Edit'|translate }} + + + + {# delete link #} + + + {{ 'General_Delete'|translate }} + + + + {% endfor %} + + {% if userLogin != 'anonymous' %} +
            + › {{ 'ScheduledReports_CreateAndScheduleReport'|translate }} +
            +
            + {% endif %} + {% endif %} +
            diff --git a/www/analytics/plugins/ScheduledReports/templates/index.twig b/www/analytics/plugins/ScheduledReports/templates/index.twig new file mode 100644 index 00000000..b8ccecf5 --- /dev/null +++ b/www/analytics/plugins/ScheduledReports/templates/index.twig @@ -0,0 +1,62 @@ +{% extends 'dashboard.twig' %} + +{% block content %} + +{% include "@CoreHome/_siteSelectHeader.twig" %} + +
            + {% include "@CoreHome/_periodSelect.twig" %} +
            + +
            +

            {{ 'ScheduledReports_ManageEmailReports'|translate }}

            + + + +
            + {% import 'ajaxMacros.twig' as ajax %} + {{ ajax.errorDiv() }} + {{ ajax.loadingDiv() }} + {% include "@ScheduledReports/_listReports.twig" %} + {% include "@ScheduledReports/_addReport.twig" %} + +
            +
            + +
            +

            {{ 'ScheduledReports_AreYouSureDeleteReport'|translate }}

            + + +
            + + + +{% endblock %} diff --git a/www/analytics/plugins/ScheduledReports/templates/reportParametersScheduledReports.twig b/www/analytics/plugins/ScheduledReports/templates/reportParametersScheduledReports.twig new file mode 100644 index 00000000..95a787ec --- /dev/null +++ b/www/analytics/plugins/ScheduledReports/templates/reportParametersScheduledReports.twig @@ -0,0 +1,78 @@ + + {{ 'ScheduledReports_SendReportTo'|translate }} + + + + +

            + {{ 'ScheduledReports_AlsoSendReportToTheseEmails'|translate }}
            + + + + \ No newline at end of file diff --git a/www/analytics/plugins/SegmentEditor/API.php b/www/analytics/plugins/SegmentEditor/API.php new file mode 100644 index 00000000..37e2d880 --- /dev/null +++ b/www/analytics/plugins/SegmentEditor/API.php @@ -0,0 +1,329 @@ +getHash(); + } catch (Exception $e) { + throw new Exception("The specified segment is invalid: " . $e->getMessage()); + } + return $definition; + } + + protected function checkSegmentName($name) + { + if (empty($name)) { + throw new Exception("Invalid name for this custom segment."); + } + } + + protected function checkEnabledAllUsers($enabledAllUsers) + { + $enabledAllUsers = (int)$enabledAllUsers; + if ($enabledAllUsers + && !Piwik::hasUserSuperUserAccess() + ) { + throw new Exception("enabledAllUsers=1 requires Super User access"); + } + return $enabledAllUsers; + } + + protected function checkIdSite($idSite) + { + if (empty($idSite)) { + if (!Piwik::hasUserSuperUserAccess()) { + throw new Exception($this->getMessageCannotEditSegmentCreatedBySuperUser()); + } + } else { + if (!is_numeric($idSite)) { + throw new Exception("idSite should be a numeric value"); + } + Piwik::checkUserHasViewAccess($idSite); + } + $idSite = (int)$idSite; + return $idSite; + } + + protected function checkAutoArchive($autoArchive, $idSite) + { + $autoArchive = (int)$autoArchive; + if ($autoArchive) { + $exception = new Exception("To prevent abuse, autoArchive=1 requires Super User or ControllerAdmin access."); + if (empty($idSite)) { + if (!Piwik::hasUserSuperUserAccess()) { + throw $exception; + } + } else { + if (!Piwik::isUserHasAdminAccess($idSite)) { + throw $exception; + } + } + } + return $autoArchive; + } + + protected function getSegmentOrFail($idSegment) + { + $segment = $this->get($idSegment); + + if (empty($segment)) { + throw new Exception("Requested segment not found"); + } + return $segment; + } + + protected function checkUserIsNotAnonymous() + { + if (Piwik::isUserIsAnonymous()) { + throw new Exception("To create, edit or delete Custom Segments, please sign in first."); + } + } + + protected function checkUserCanModifySegment($segment) + { + if(Piwik::hasUserSuperUserAccess()) { + return; + } + if($segment['login'] != Piwik::getCurrentUserLogin()) { + throw new Exception($this->getMessageCannotEditSegmentCreatedBySuperUser()); + } + } + + /** + * Deletes a stored segment. + * + * @param $idSegment + * @return bool + */ + public function delete($idSegment) + { + $this->checkUserIsNotAnonymous(); + + $segment = $this->getSegmentOrFail($idSegment); + + $this->checkUserCanModifySegment($segment); + + $this->sendSegmentDeactivationEvent($idSegment); + + $db = Db::get(); + $db->delete(Common::prefixTable('segment'), 'idsegment = ' . $idSegment); + return true; + } + + /** + * Modifies an existing stored segment. + * + * @param int $idSegment The ID of the stored segment to modify. + * @param string $name The new name of the segment. + * @param string $definition The new definition of the segment. + * @param bool $idSite If supplied, associates the stored segment with as single site. + * @param bool $autoArchive Whether to automatically archive data with the segment or not. + * @param bool $enabledAllUsers Whether the stored segment is viewable by all users or just the one that created it. + * + * @return bool + */ + public function update($idSegment, $name, $definition, $idSite = false, $autoArchive = false, $enabledAllUsers = false) + { + $this->checkUserIsNotAnonymous(); + $segment = $this->getSegmentOrFail($idSegment); + + $this->checkUserCanModifySegment($segment); + + $idSite = $this->checkIdSite($idSite); + $this->checkSegmentName($name); + $definition = $this->checkSegmentValue($definition, $idSite); + $enabledAllUsers = $this->checkEnabledAllUsers($enabledAllUsers); + $autoArchive = $this->checkAutoArchive($autoArchive, $idSite); + + if ($this->segmentVisibilityIsReduced($idSite, $enabledAllUsers, $segment)) { + $this->sendSegmentDeactivationEvent($idSegment); + } + + $bind = array( + 'name' => $name, + 'definition' => $definition, + 'enable_all_users' => $enabledAllUsers, + 'enable_only_idsite' => $idSite, + 'auto_archive' => $autoArchive, + 'ts_last_edit' => Date::now()->getDatetime(), + ); + + $db = Db::get(); + $db->update(Common::prefixTable("segment"), + $bind, + "idsegment = $idSegment" + ); + return true; + } + + /** + * Adds a new stored segment. + * + * @param string $name The new name of the segment. + * @param string $definition The new definition of the segment. + * @param bool $idSite If supplied, associates the stored segment with as single site. + * @param bool $autoArchive Whether to automatically archive data with the segment or not. + * @param bool $enabledAllUsers Whether the stored segment is viewable by all users or just the one that created it. + * + * @return int The newly created segment Id + */ + public function add($name, $definition, $idSite = false, $autoArchive = false, $enabledAllUsers = false) + { + $this->checkUserIsNotAnonymous(); + $idSite = $this->checkIdSite($idSite); + $this->checkSegmentName($name); + $definition = $this->checkSegmentValue($definition, $idSite); + $enabledAllUsers = $this->checkEnabledAllUsers($enabledAllUsers); + $autoArchive = $this->checkAutoArchive($autoArchive, $idSite); + + $db = Db::get(); + $bind = array( + 'name' => $name, + 'definition' => $definition, + 'login' => Piwik::getCurrentUserLogin(), + 'enable_all_users' => $enabledAllUsers, + 'enable_only_idsite' => $idSite, + 'auto_archive' => $autoArchive, + 'ts_created' => Date::now()->getDatetime(), + 'deleted' => 0, + ); + $db->insert(Common::prefixTable("segment"), $bind); + return $db->lastInsertId(); + } + + /** + * Returns a stored segment by ID + * + * @param $idSegment + * @throws Exception + * @return bool + */ + public function get($idSegment) + { + Piwik::checkUserHasSomeViewAccess(); + if (!is_numeric($idSegment)) { + throw new Exception("idSegment should be numeric."); + } + $segment = Db::get()->fetchRow("SELECT * " . + " FROM " . Common::prefixTable("segment") . + " WHERE idsegment = ?", $idSegment); + + if (empty($segment)) { + return false; + } + try { + + if (!$segment['enable_all_users']) { + Piwik::checkUserHasSuperUserAccessOrIsTheUser($segment['login']); + } + + } catch (Exception $e) { + throw new Exception($this->getMessageCannotEditSegmentCreatedBySuperUser()); + } + + if ($segment['deleted']) { + throw new Exception("This segment is marked as deleted. "); + } + return $segment; + } + + /** + * Returns all stored segments. + * + * @param bool|int $idSite Whether to return stored segments for a specific idSite, or all of them. If supplied, must be a valid site ID. + * @return array + */ + public function getAll($idSite = false) + { + if (!empty($idSite)) { + Piwik::checkUserHasViewAccess($idSite); + } else { + Piwik::checkUserHasSomeViewAccess(); + } + + $userLogin = Piwik::getCurrentUserLogin(); + + $model = new Model(); + if (empty($idSite)) { + $segments = $model->getAllSegments($userLogin); + } else { + $segments = $model->getAllSegmentsForSite($idSite, $userLogin); + } + + return $segments; + } + + /** + * When deleting or making a segment invisible, allow plugins to throw an exception or propagate the action + * + * @param $idSegment + */ + private function sendSegmentDeactivationEvent($idSegment) + { + /** + * Triggered before a segment is deleted or made invisible. + * + * This event can be used by plugins to throw an exception + * or do something else. + * + * @param int $idSegment The ID of the segment being deleted. + */ + Piwik::postEvent(self::DEACTIVATE_SEGMENT_EVENT, array($idSegment)); + } + + /** + * @param $idSiteNewValue + * @param $enableAllUserNewValue + * @param $segment + * @return bool + */ + private function segmentVisibilityIsReduced($idSiteNewValue, $enableAllUserNewValue, $segment) + { + $allUserVisibilityIsDropped = $segment['enable_all_users'] && !$enableAllUserNewValue; + $allWebsiteVisibilityIsDropped = !isset($segment['idSite']) && $idSiteNewValue; + + return $allUserVisibilityIsDropped || $allWebsiteVisibilityIsDropped; + } + + /** + * @return string + */ + private function getMessageCannotEditSegmentCreatedBySuperUser() + { + $message = "You can only edit and delete custom segments that you have created yourself. This segment was created and 'shared with you' by the Super User. " . + "To modify this segment, you can first create a new one by clicking on 'Add new segment'. Then you can customize the segment's definition."; + + return $message; + } +} diff --git a/www/analytics/plugins/SegmentEditor/Controller.php b/www/analytics/plugins/SegmentEditor/Controller.php new file mode 100644 index 00000000..03138ced --- /dev/null +++ b/www/analytics/plugins/SegmentEditor/Controller.php @@ -0,0 +1,20 @@ +render(); + } +} diff --git a/www/analytics/plugins/SegmentEditor/Model.php b/www/analytics/plugins/SegmentEditor/Model.php new file mode 100644 index 00000000..a58f2538 --- /dev/null +++ b/www/analytics/plugins/SegmentEditor/Model.php @@ -0,0 +1,89 @@ +buildQuerySortedByName("($whereIdSite enable_only_idsite = 0) + AND deleted = 0 AND auto_archive = 1"); + + $segments = Db::get()->fetchAll($sql, $bind); + + return $segments; + } + + /** + * Returns all stored segments that are available to the given login. + * + * @param string $userLogin + * @return array + */ + public function getAllSegments($userLogin) + { + $bind = array($userLogin); + $sql = $this->buildQuerySortedByName('deleted = 0 AND (enable_all_users = 1 OR login = ?)'); + + $segments = Db::get()->fetchAll($sql, $bind); + + return $segments; + } + + /** + * Returns all stored segments that are available for the given site and login. + * + * @param string $userLogin + * @param int $idSite Whether to return stored segments for a specific idSite, or all of them. If supplied, must be a valid site ID. + * @return array + */ + public function getAllSegmentsForSite($idSite, $userLogin) + { + $bind = array($idSite, $userLogin); + $sql = $this->buildQuerySortedByName('(enable_only_idsite = ? OR enable_only_idsite = 0) + AND deleted = 0 + AND (enable_all_users = 1 OR login = ?)'); + $segments = Db::get()->fetchAll($sql, $bind); + + return $segments; + } + + private function buildQuerySortedByName($where) + { + $sql = "SELECT * FROM " . Common::prefixTable("segment") . + " WHERE $where ORDER BY name ASC"; + + return $sql; + } +} diff --git a/www/analytics/plugins/SegmentEditor/SegmentEditor.php b/www/analytics/plugins/SegmentEditor/SegmentEditor.php new file mode 100644 index 00000000..c7dd7662 --- /dev/null +++ b/www/analytics/plugins/SegmentEditor/SegmentEditor.php @@ -0,0 +1,104 @@ + 'Create and reuse custom visitor Segments with the Segment Editor.', + 'authors' => array(array('name' => 'Piwik', 'homepage' => 'http://piwik.org/')), + 'version' => Version::VERSION, + 'license' => 'GPL v3+', + 'license_homepage' => 'http://www.gnu.org/licenses/gpl.html' + ); + } + + /** + * @see Piwik\Plugin::getListHooksRegistered + */ + public function getListHooksRegistered() + { + return array( + 'Segments.getKnownSegmentsToArchiveForSite' => 'getKnownSegmentsToArchiveForSite', + 'Segments.getKnownSegmentsToArchiveAllSites' => 'getKnownSegmentsToArchiveAllSites', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'Template.nextToCalendar' => 'getSegmentEditorHtml', + ); + } + + function getSegmentEditorHtml(&$out) + { + $controller = new Controller(); + $out .= $controller->getSelector(); + } + + public function getKnownSegmentsToArchiveAllSites(&$segments) + { + $this->getKnownSegmentsToArchiveForSite($segments, $idSite = false); + } + + /** + * Adds the pre-processed segments to the list of Segments. + * Used by CronArchive, ArchiveProcessor\Rules, etc. + * + * @param $segments + * @param $idSite + */ + public function getKnownSegmentsToArchiveForSite(&$segments, $idSite) + { + $model = new Model(); + $segmentToAutoArchive = $model->getSegmentsToAutoArchive($idSite); + foreach ($segmentToAutoArchive as $segmentInfo) { + $segments[] = $segmentInfo['definition']; + } + $segments = array_unique($segments); + } + + public function install() + { + $segmentTable = "`idsegment` INT(11) NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `definition` TEXT NOT NULL, + `login` VARCHAR(100) NOT NULL, + `enable_all_users` tinyint(4) NOT NULL default 0, + `enable_only_idsite` INTEGER(11) NULL, + `auto_archive` tinyint(4) NOT NULL default 0, + `ts_created` TIMESTAMP NULL, + `ts_last_edit` TIMESTAMP NULL, + `deleted` tinyint(4) NOT NULL default 0, + PRIMARY KEY (`idsegment`)"; + + DbHelper::createTable('segment', $segmentTable); + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/SegmentEditor/javascripts/Segmentation.js"; + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/SegmentEditor/stylesheets/segmentation.less"; + } +} diff --git a/www/analytics/plugins/SegmentEditor/SegmentSelectorControl.php b/www/analytics/plugins/SegmentEditor/SegmentSelectorControl.php new file mode 100644 index 00000000..4db714fe --- /dev/null +++ b/www/analytics/plugins/SegmentEditor/SegmentSelectorControl.php @@ -0,0 +1,130 @@ +jsClass = "SegmentSelectorControl"; + $this->cssIdentifier = "segmentEditorPanel"; + $this->cssClass = "piwikTopControl"; + + $this->idSite = $idSite ?: Common::getRequestVar('idSite', false, 'int'); + + $this->selectedSegment = Common::getRequestVar('segment', false, 'string'); + + $segments = APIMetadata::getInstance()->getSegmentsMetadata($this->idSite); + + $segmentsByCategory = $customVariablesSegments = array(); + foreach ($segments as $segment) { + if ($segment['category'] == Piwik::translate('General_Visit') + && ($segment['type'] == 'metric' && $segment['segment'] != 'visitIp') + ) { + $metricsLabel = Piwik::translate('General_Metrics'); + $metricsLabel[0] = strtolower($metricsLabel[0]); + $segment['category'] .= ' (' . $metricsLabel . ')'; + } + $segmentsByCategory[$segment['category']][] = $segment; + } + uksort($segmentsByCategory, array($this, 'sortSegmentCategories')); + + $this->createRealTimeSegmentsIsEnabled = Config::getInstance()->General['enable_create_realtime_segments']; + $this->segmentsByCategory = $segmentsByCategory; + $this->nameOfCurrentSegment = ''; + $this->isSegmentNotAppliedBecauseBrowserArchivingIsDisabled = 0; + + $this->availableSegments = API::getInstance()->getAll($this->idSite); + foreach ($this->availableSegments as &$savedSegment) { + $savedSegment['name'] = Common::sanitizeInputValue($savedSegment['name']); + + if (!empty($this->selectedSegment) && $this->selectedSegment == $savedSegment['definition']) { + $this->nameOfCurrentSegment = $savedSegment['name']; + $this->isSegmentNotAppliedBecauseBrowserArchivingIsDisabled = + $this->wouldApplySegment($savedSegment) ? 0 : 1; + } + } + + $this->authorizedToCreateSegments = !Piwik::isUserIsAnonymous(); + $this->segmentTranslations = $this->getTranslations(); + } + + public function getClientSideProperties() + { + return array('availableSegments', + 'segmentTranslations', + 'isSegmentNotAppliedBecauseBrowserArchivingIsDisabled', + 'selectedSegment'); + } + + private function wouldApplySegment($savedSegment) + { + $isBrowserArchivingDisabled = Config::getInstance()->General['browser_archiving_disabled_enforce']; + + if (!$isBrowserArchivingDisabled) { + return true; + } + + return (bool) $savedSegment['auto_archive']; + } + + public function sortSegmentCategories($a, $b) + { + // Custom Variables last + if ($a == Piwik::translate('CustomVariables_CustomVariables')) { + return 1; + } + return 0; + } + + private function getTranslations() + { + $translationKeys = array( + 'General_OperationEquals', + 'General_OperationNotEquals', + 'General_OperationAtMost', + 'General_OperationAtLeast', + 'General_OperationLessThan', + 'General_OperationGreaterThan', + 'General_OperationContains', + 'General_OperationDoesNotContain', + 'General_OperationIs', + 'General_OperationIsNot', + 'General_OperationContains', + 'General_OperationDoesNotContain', + 'SegmentEditor_DefaultAllVisits', + 'General_DefaultAppended', + 'SegmentEditor_AddNewSegment', + 'General_Edit', + 'General_Search', + 'General_SearchNoResults', + ); + $translations = array(); + foreach ($translationKeys as $key) { + $translations[$key] = Piwik::translate($key); + } + return $translations; + } +} diff --git a/www/analytics/plugins/SegmentEditor/images/ajax-loader.gif b/www/analytics/plugins/SegmentEditor/images/ajax-loader.gif new file mode 100644 index 00000000..bc545850 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/ajax-loader.gif differ diff --git a/www/analytics/plugins/SegmentEditor/images/bg-inverted-corners.png b/www/analytics/plugins/SegmentEditor/images/bg-inverted-corners.png new file mode 100644 index 00000000..b4602e23 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/bg-inverted-corners.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/bg-segment-search.png b/www/analytics/plugins/SegmentEditor/images/bg-segment-search.png new file mode 100644 index 00000000..3a9a257e Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/bg-segment-search.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/bg-select.png b/www/analytics/plugins/SegmentEditor/images/bg-select.png new file mode 100644 index 00000000..a69f9a69 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/bg-select.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/close.png b/www/analytics/plugins/SegmentEditor/images/close.png new file mode 100644 index 00000000..f2a1380b Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/close.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/close_btn.png b/www/analytics/plugins/SegmentEditor/images/close_btn.png new file mode 100644 index 00000000..6cf122e2 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/close_btn.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/dashboard_h_bg_hover.png b/www/analytics/plugins/SegmentEditor/images/dashboard_h_bg_hover.png new file mode 100644 index 00000000..e46d8cc6 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/dashboard_h_bg_hover.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/icon-users.png b/www/analytics/plugins/SegmentEditor/images/icon-users.png new file mode 100644 index 00000000..d17d4501 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/icon-users.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/reset_search.png b/www/analytics/plugins/SegmentEditor/images/reset_search.png new file mode 100644 index 00000000..cb1d9e80 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/reset_search.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/search_btn.png b/www/analytics/plugins/SegmentEditor/images/search_btn.png new file mode 100644 index 00000000..34abc413 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/search_btn.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/segment-close.png b/www/analytics/plugins/SegmentEditor/images/segment-close.png new file mode 100644 index 00000000..266e0595 Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/segment-close.png differ diff --git a/www/analytics/plugins/SegmentEditor/images/segment-move.png b/www/analytics/plugins/SegmentEditor/images/segment-move.png new file mode 100644 index 00000000..25ee071d Binary files /dev/null and b/www/analytics/plugins/SegmentEditor/images/segment-move.png differ diff --git a/www/analytics/plugins/SegmentEditor/javascripts/Segmentation.js b/www/analytics/plugins/SegmentEditor/javascripts/Segmentation.js new file mode 100644 index 00000000..d7d9f605 --- /dev/null +++ b/www/analytics/plugins/SegmentEditor/javascripts/Segmentation.js @@ -0,0 +1,1216 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +Segmentation = (function($) { + + var segmentation = function segmentation(config) { + if (!config.target) { + throw new Error("target property must be set in config to segment editor control element"); + } + + var self = this; + + self.currentSegmentStr = ""; + self.segmentAccess = "read"; + self.availableSegments = []; + + for (var item in config) { + self[item] = config[item]; + } + + self.editorTemplate = self.editorTemplate.detach(); + + self.timer = ""; // variable for further use in timing events + self.searchAllowed = true; + + self.availableMatches = []; + self.availableMatches["metric"] = []; + self.availableMatches["metric"]["=="] = self.translations['General_OperationEquals']; + self.availableMatches["metric"]["!="] = self.translations['General_OperationNotEquals']; + self.availableMatches["metric"]["<="] = self.translations['General_OperationAtMost']; + self.availableMatches["metric"][">="] = self.translations['General_OperationAtLeast']; + self.availableMatches["metric"]["<"] = self.translations['General_OperationLessThan']; + self.availableMatches["metric"][">"] = self.translations['General_OperationGreaterThan']; + + self.availableMatches["dimension"] = []; + self.availableMatches["dimension"]["=="] = self.translations['General_OperationIs']; + self.availableMatches["dimension"]["!="] = self.translations['General_OperationIsNot']; + self.availableMatches["dimension"]["=@"] = self.translations['General_OperationContains']; + self.availableMatches["dimension"]["!@"] = self.translations['General_OperationDoesNotContain']; + + segmentation.prototype.setAvailableSegments = function (segments) { + this.availableSegments = segments; + }; + + segmentation.prototype.getSegment = function(){ + var self = this; + if($.browser.mozilla) { + return self.currentSegmentStr; + } + return decodeURIComponent(self.currentSegmentStr); + }; + + segmentation.prototype.setSegment = function(segmentStr){ + if(!$.browser.mozilla) { + segmentStr = encodeURIComponent(segmentStr); + } + this.currentSegmentStr = segmentStr; + }; + + segmentation.prototype.shortenSegmentName = function(name, length){ + + if(typeof length === "undefined") length = 16; + if(typeof name === "undefined") name = ""; + var i; + + if(name.length > length) + { + for(i = length; i > 0; i--){ + if(name[i] === " "){ + break; + } + } + if(i == 0){ + i = length-3; + } + + return name.slice(0,i)+"..."; + } + return name; + }; + + segmentation.prototype.markCurrentSegment = function(){ + var current = this.getSegment(); + + var segmentationTitle = $(this.content).find(".segmentationTitle"); + if( current != "") + { + var selector = 'div.segmentList ul li[data-definition="'+current+'"]'; + var foundItems = $(selector, this.target); + var title = $(''); + if( foundItems.length > 0) { + var name = $(foundItems).first().find("span.segname").text(); + title.text(name); + } else { + title.text("Custom Segment"); + } + segmentationTitle.html(title); + } + else { + $(this.content).find(".segmentationTitle").text(this.translations['SegmentEditor_DefaultAllVisits']); + } + }; + + var getAndDiv = function(){ + if(typeof andDiv === "undefined"){ + var andDiv = self.editorTemplate.find("> div.segment-and").clone(); + } + return andDiv.clone(); + }; + + var getOrDiv = function(){ + if(typeof orDiv === "undefined"){ + var orDiv = self.editorTemplate.find("> div.segment-or").clone(); + } + return orDiv.clone(); + }; + + var getMockedInputSet = function(){ + if(typeof mockedInputSet === "undefined"){ + var mockedInputSet = self.editorTemplate.find("div.segment-row-inputs").clone(); + } + return mockedInputSet.clone(); + }; + + var getMockedInputRowHtml = function(){ + if(typeof mockedInputRow === "undefined"){ + var mockedInputRow = '
            '+getMockedInputSet().html()+'
            '; + } + return mockedInputRow; + }; + + var getMockedFormRow = function(){ + if(typeof mockedFormRow === "undefined") + { + var mockedFormRow = self.editorTemplate.find("div.segment-rows").clone(); + $(mockedFormRow).find(".segment-row").append(getMockedInputSet()).after(getAddOrBlockButtonHtml).after(getOrDiv()); + } + return mockedFormRow.clone(); + }; + + var getInitialStateRowsHtml = function(){ + if(typeof initialStateRows === "undefined"){ + var content = self.editorTemplate.find("div.initial-state-rows").html(); + var initialStateRows = $(content).clone(); + } + return initialStateRows; + }; + + var revokeInitialStateRows = function(){ + $(self.form).find(".segment-add-row").remove(); + $(self.form).find(".segment-and").remove(); + }; + + var appendSpecifiedRowHtml= function(metric) { + $(self.form).find(".segment-content > h3").after(getMockedFormRow()); + $(self.form).find(".segment-content").append(getAndDiv()); + $(self.form).find(".segment-content").append(getAddNewBlockButtonHtml()); + doDragDropBindings(); + $(self.form).find(".metricList").val(metric).trigger("change"); + }; + + var appendComplexRowHtml = function(block){ + var key; + var newRow = getMockedFormRow(); + + var x = $(newRow).find(".metricMatchBlock select"); + $(newRow).find(".metricListBlock select").val(block[0].metric); + $(newRow).find(".metricMatchBlock select").val(block[0].match); + $(newRow).find(".metricValueBlock input").val(block[0].value); + + if(block.length > 1) { + $(newRow).find(".segment-add-or").remove(); + for(key = 1; key < block.length;key++) { + var newSubRow = $(getMockedInputRowHtml()).clone(); + $(newSubRow).find(".metricListBlock select").val(block[key].metric); + $(newSubRow).find(".metricMatchBlock select").val(block[key].match); + $(newSubRow).find(".metricValueBlock input").val(block[key].value); + $(newRow).append(newSubRow).append(getOrDiv()); + } + $(newRow).append(getAddOrBlockButtonHtml()); + } + $(self.form).find(".segment-content").append(newRow).append(getAndDiv()); + }; + + var applyInitialStateModification = function(){ + $(self.form).find(".segment-add-row").remove(); + $(self.form).find(".segment-content").append(getInitialStateRowsHtml()); + doDragDropBindings(); + }; + + var getSegmentFromId = function (id) { + if(self.availableSegments.length > 0) { + for(var i = 0; i < self.availableSegments.length; i++) + { + segment = self.availableSegments[i]; + if(segment.idsegment == id) { + return segment; + } + } + } + return false; + }; + + var getListHtml = function() { + var html = self.editorTemplate.find("> .listHtml").clone(); + var segment, injClass; + + var listHtml = '
          • ' + self.translations['SegmentEditor_DefaultAllVisits'] + + ' ' + self.translations['General_DefaultAppended'] + + '
          • '; + if(self.availableSegments.length > 0) { + for(var i = 0; i < self.availableSegments.length; i++) + { + segment = self.availableSegments[i]; + injClass = ""; + if( segment.definition == self.currentSegmentStr){ + injClass = 'class="segmentSelected"'; + } + listHtml += '
          • ' + + self.shortenSegmentName(segment.name)+''; + if(self.segmentAccess == "write") { + listHtml += '['+ self.translations['General_Edit'].toLocaleLowerCase() +']'; + } + listHtml += '
          • '; + } + $(html).find(".segmentList > ul").append(listHtml); + if(self.segmentAccess === "write"){ + $(html).find(".add_new_segment").html(self.translations['SegmentEditor_AddNewSegment']); + } + else { + $(html).find(".add_new_segment").hide(); + } + } + else + { + $(html).find(".segmentList > ul").append(listHtml); + } + return html; + }; + + var getFormHtml = function() { + var html = self.editorTemplate.find("> .segment-element").clone(); + // set left margin to center form + var segmentsDropdown = $(html).find(".available_segments_select"); + var segment, newOption; + newOption = ''; + segmentsDropdown.append(newOption); + for(var i = 0; i < self.availableSegments.length; i++) + { + segment = self.availableSegments[i]; + newOption = ''; + segmentsDropdown.append(newOption); + } + $(html).find(".segment-content > h3").after(getInitialStateRowsHtml()).show(); + return html; + }; + + var closeAllOpenLists = function() { + $(".segmentationContainer", self.target).each(function() { + if($(this).closest('.segmentEditorPanel').hasClass("visible")) + $(this).trigger("click"); + }); + }; + + + var findAndExplodeByMatch = function(metric){ + var matches = ["==" , "!=" , "<=", ">=", "=@" , "!@","<",">"]; + var newMetric = {}; + var minPos = metric.length; + var match, index; + var singleChar = false; + + for(var key=0; key < matches.length; key++) + { + match = matches[key]; + index = metric.indexOf(match); + if( index != -1){ + if(index < minPos){ + minPos = index; + if(match.length == 1){ + singleChar = true; + } + } + } + } + + if(minPos < metric.length){ + // sth found - explode + if(singleChar == true){ + newMetric.metric = metric.substr(0,minPos); + newMetric.match = metric.substr(minPos,1); + newMetric.value = metric.substr(minPos+1); + } else { + newMetric.metric = metric.substr(0,minPos); + newMetric.match = metric.substr(minPos,2); + newMetric.value = metric.substr(minPos+2); + } + // if value is only "" -> change to empty string + if(newMetric.value == '""') + { + newMetric.value = ""; + } + } + + newMetric.value = decodeURIComponent(newMetric.value); + return newMetric; + }; + + var parseSegmentStr = function(segmentStr) + { + var blocks; + blocks = segmentStr.split(";"); + for(var key in blocks){ + blocks[key] = blocks[key].split(","); + for(var innerkey = 0; innerkey < blocks[key].length; innerkey++){ + blocks[key][innerkey] = findAndExplodeByMatch(blocks[key][innerkey]); + } + } + return blocks; + }; + + var openEditForm = function(segment){ + addForm("edit", segment); + + $(self.form).find(".segment-content > h3 > span").text(segment.name); + $(self.form).find('.available_segments_select > option[data-idsegment="'+segment.idsegment+'"]').prop("selected",true); + + $(self.form).find('.available_segments a.dropList').text(self.shortenSegmentName(segment.name, 16)); + + if(segment.definition != ""){ + revokeInitialStateRows(); + var blocks = parseSegmentStr(segment.definition); + for(var key in blocks){ + appendComplexRowHtml(blocks[key]); + } + $(self.form).find(".segment-content").append(getAddNewBlockButtonHtml()); + } + $(self.form).find(".metricList").each( function(){ + $(this).trigger("change", true); + }); + doDragDropBindings(); + }; + + var bindEvents = function() { + self.target.on('click', '.segmentationContainer', function (e) { + // hide all other modals connected with this widget + if (self.content.closest('.segmentEditorPanel').hasClass("visible")) { + if ($(e.target).hasClass("jspDrag") === true) { + e.stopPropagation(); + } else { + self.jscroll.destroy(); + self.target.closest('.segmentEditorPanel').removeClass('visible'); + } + } else { + // for each visible segmentationContainer -> trigger click event to close and kill scrollpane - very important ! + closeAllOpenLists(); + self.target.closest('.segmentEditorPanel').addClass('visible'); + + self.jscroll = self.target.find(".segmentList").jScrollPane({ + autoReinitialise: true, + showArrows:true + }).data().jsp; + } + }); + + self.target.on('click', '.editSegment', function(e) { + $(this).closest(".segmentationContainer").trigger("click"); + var target = $(this).parent("li"); + + openEditFormGivenSegment(target); + e.stopPropagation(); + e.preventDefault(); + }); + + self.target.on("click", ".segmentList li", function (e) { + if ($(e.currentTarget).hasClass("grayed") !== true) { + var segment = {}; + segment.idsegment = $(this).attr("data-idsegment"); + segment.definition = $(this).data("definition"); + segment.name = $(this).attr("title"); + + self.setSegment(segment.definition); + self.markCurrentSegment(); + self.segmentSelectMethod( segment.definition ); + toggleLoadingMessage(segment.definition.length); + } + }); + + self.target.on('click', '.add_new_segment', function (e) { + e.stopPropagation(); + closeAllOpenLists(); + addForm("new"); + doDragDropBindings(); + }); + + self.target.on('change', "select.metricList", function (e, persist) { + if (typeof persist === "undefined") { + persist = false; + } + alterMatchesList(this, persist); + + doDragDropBindings(); + + autoSuggestValues(this, persist); + }); + + // + // segment editor form events + // + + self.target.on('click', ".segment-element a:not(.crowdfundingLink)", function (e) { + e.preventDefault(); + }); + + self.target.on('click', "a.editSegmentName", function (e) { + var oldName = $(e.currentTarget).parents("h3").find("span").text(); + $(e.currentTarget).parents("h3").find("span").hide(); + $(e.currentTarget).hide(); + $(e.currentTarget).before(''); + $(e.currentTarget).siblings(".edit_segment_name").focus().val(oldName); + }); + + self.target.on("click", ".segmentName", function(e) { + $(self.form).find("a.editSegmentName").trigger('click'); + }); + + self.target.on('blur', "input.edit_segment_name", function (e) { + var newName = $(this).val(); + if(newName.trim() != '') { + $(e.currentTarget).parents("h3").find("span").text(newName).show(); + $(self.form).find("a.editSegmentName").show(); + $(this).remove(); + } + }); + + self.target.on('click', '.segment-element', function (e) { + e.stopPropagation(); + e.preventDefault(); + }); + + self.target.on('change', '.available_segments_select', function (e) { + var option = $(e.currentTarget).find('option:selected'); + openEditFormGivenSegment(option); + }); + + // attach event that shows/hides child elements of each metric category + self.target.on('click', '.segment-nav a.metric_category', function (e) { + $(e.currentTarget).siblings("ul").toggle(); + }); + + self.target.on('click', ".custom_select_search a", function (e) { + $(self.form).find(".segmentSearch").val("").trigger("keyup").val(self.translations['General_Search']); + }); + + // attach event that will clear search input upon focus if its content is default + self.target.on('focus', '.segmentSearch', function (e) { + var search = $(e.currentTarget).val(); + if(search == self.translations['General_Search']) + $(e.currentTarget).val(""); + }); + + // attach event that will set search input value upon blur if its content is not null + self.target.on('blur', '.segmentSearch', function (e) { + var search = $(e.currentTarget).val(); + if(search == ""){ + clearSearchMetricHighlight(); + $(e.currentTarget).val(self.translations['General_Search']); + } + }); + + // bind search action triggering - only when input text is longer than 2 chars + self.target.on('keyup', '.segmentSearch', function (e) { + var search = $(e.currentTarget).val(); + if( search.length >= 2) + { + clearTimeout(self.timer); + self.searchAllowed = true; + self.timer = setTimeout(function(){ + searchSegments(search); + }, 500); + } + else{ + self.searchAllowed = false; + clearSearchMetricHighlight(); + } + }); + + self.target.on('click', ".delete", function() { + var segmentName = $(self.form).find(".segment-content > h3 > span").text(); + var segmentId = $(self.form).find(".available_segments_select option:selected").attr("data-idsegment"); + var params = { + "idsegment" : segmentId + }; + $('.segment-delete-confirm', self.target).find('#name').text( segmentName ); + if(segmentId != ""){ + piwikHelper.modalConfirm(self.target.find('.segment-delete-confirm'), { + yes: function(){ + self.deleteMethod(params); + } + }); + } + }); + + self.target.on("click", "a.close", function (e) { + $(".segmentListContainer", self.target).show(); + closeForm(); + }); + + $("body").on("keyup", function (e) { + if(e.keyCode == "27"){ + $(".segmentListContainer", self.target).show(); + closeForm(); + } + }); + + // + // segment manipulation events + // + + // upon clicking - add new segment block, then bind 'x' action to newly added row + self.target.on('click', ".segment-add-row a", function(event, data){ + $(self.form).find(".segment-and:last").after(getAndDiv()).after(getMockedFormRow()); + if(typeof data !== "undefined"){ + $(self.form).find(".metricList:last").val(data); + } + $(self.form).find(".metricList:last").trigger('change'); + doDragDropBindings(); + }); + + self.target.on("click", ".segment-add-row span", function(event, data){ + if(typeof data !== "undefined") { + $(self.form).find(".segment-and:last").after(getAndDiv()).after(getMockedFormRow()); + $(self.form).find(".metricList:last").val(data).trigger('change'); + doDragDropBindings(); + } + }); + + // add new OR block + self.target.on("click", ".segment-add-or a", function(event, data){ + $(event.currentTarget).parents(".segment-rows").find(".segment-or:last").after(getOrDiv()).after(getMockedInputRowHtml()); + if(typeof data !== "undefined"){ + $(event.currentTarget).parents(".segment-rows").find(".metricList:last").val(data); + } + $(event.currentTarget).parents(".segment-rows").find(".metricList:last").trigger('change'); + doDragDropBindings(); + }); + + self.target.on("click", ".segment-close", function (e) { + var target = e.currentTarget; + var rowCnt = $(target).parents(".segment-rows").find(".segment-row").length; + var globalRowCnt = $(self.form).find(".segment-close").length; + if(rowCnt > 1){ + $(target).parents(".segment-row").next().remove(); + $(target).parents(".segment-row").remove(); + } + else if(rowCnt == 1){ + $(target).parents(".segment-rows").next().remove(); + $(target).parents(".segment-rows").remove(); + if(globalRowCnt == 1){ + applyInitialStateModification(); + } + } + }); + }; + + // Request auto-suggest values + var autoSuggestValues = function(select, persist) { + var type = $(select).find("option:selected").attr("value"); + if(!persist) { + var parents = $(select).parents('.segment-row'); + var loadingElement = parents.find(".segment-loading"); + loadingElement.show(); + var inputElement = parents.find(".metricValueBlock input"); + var segmentName = $('option:selected',select).attr('value'); + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'API.getSuggestedValuesForSegment', + segmentName: segmentName + }, 'GET'); + ajaxHandler.useRegularCallbackInCaseOfError = true; + ajaxHandler.setCallback(function(response) { + loadingElement.hide(); + + if (response && response.result != 'error') { + + inputElement.autocomplete({ + source: response, + minLength: 0, + select: function(event, ui){ + event.preventDefault(); + $(inputElement).val(ui.item.value); + } + }); + } + + inputElement.click(function (e) { + $(inputElement).autocomplete('search', $(inputElement).val()); + }); + }); + ajaxHandler.send(); + } + }; + + var alterMatchesList = function(select, persist){ + var oldMatch; + var type = $(select).find("option:selected").attr("data-type"); + var matchSelector = $(select).parents(".segment-input").siblings(".metricMatchBlock").find("select"); + if(persist === true){ + oldMatch = matchSelector.find("option:selected").val(); + } else { + oldMatch = ""; + } + + if(type === "dimension" || type === "metric"){ + matchSelector.empty(); + var optionsHtml = ""; + for(var key in self.availableMatches[type]){ + optionsHtml += ''; + } + } + + matchSelector.append(optionsHtml); + matchSelector.val(oldMatch); + }; + + var getAddNewBlockButtonHtml = function() + { + if(typeof addNewBlockButton === "undefined") + { + var addNewBlockButton = self.editorTemplate.find("> div.segment-add-row").clone(); + } + return addNewBlockButton.clone(); + + }; + + var getAddOrBlockButtonHtml = function(){ + if(typeof addOrBlockButton === "undefined") { + var addOrBlockButton = self.editorTemplate.find("div.segment-add-or").clone(); + } + return addOrBlockButton.clone(); + }; + + var placeSegmentationFormControls = function(){ + doDragDropBindings(); + $(self.form).find(".scrollable").jScrollPane({ + showArrows: true, + autoReinitialise: true, + verticalArrowPositions: 'os', + horizontalArrowPositions: 'os' + }); + }; + + function openEditFormGivenSegment(option) { + var segment = {}; + segment.idsegment = option.attr("data-idsegment"); + + var segmentExtra = getSegmentFromId(segment.idsegment); + for(var item in segmentExtra) + { + segment[item] = segmentExtra[item]; + } + + segment.name = option.attr("title"); + + segment.definition = option.data("definition"); + + openEditForm(segment); + } + + var doDragDropBindings = function(){ + $(self.form).find(".segment-nav div > ul > li > ul > li").sortable({ + cursor: 'move', + revert: 10, + revertDuration: 0, + snap: false, + helper: 'clone', + appendTo: 'body' + }); + + $(self.form).find(".metricListBlock").droppable({ + hoverClass: "hovered", + drop: function( event, ui ) { + $(this).find("select").val(ui.draggable.parent().attr("data-metric")).trigger("change"); + } + }); + + $(self.form).find(".segment-add-row > div").droppable({ + hoverClass: "hovered", + drop: function( event, ui ) { + $(this).find("a").trigger("click", [ui.draggable.parent().attr("data-metric")]); + if($(this).find("a > span").length == 0){ + revokeInitialStateRows(); + appendSpecifiedRowHtml([ui.draggable.parent().attr("data-metric")]); + } + } + }); + + $(self.form).find(".segment-add-or > div").droppable({ + hoverClass: "hovered", + drop: function( event, ui ) { + $(this).find("a").trigger("click", [ui.draggable.parent().attr("data-metric")]); + } + }); + }; + + var searchSegments = function(search){ + // pre-process search string to normalized form + search = normalizeSearchString(search); + + // --- + // clear all previous search highlights and hide all categories + // to allow further showing only matching ones, while others remain invisible + clearSearchMetricHighlight(); + $(self.form).find('.segment-nav div > ul > li').hide(); + var curStr = ""; + var curMetric = ""; + + // 1 - do most obvious selection -> mark whole categories matching search string + // also expand whole category + $(self.form).find('.segment-nav div > ul > li').each( function(){ + curStr = normalizeSearchString($(this).find("a.metric_category").text()); + if(curStr.indexOf(search) > -1) { + $(this).addClass("searchFound"); + $(this).find("ul").show(); + $(this).find("li").show(); + $(this).show(); + } + } + ); + + // 2 - among all unselected categories find metrics which match and mark parent as search result + $(self.form).find(".segment-nav div > ul > li:not(.searchFound)").each(function(){ + var parent = this; + $(this).find("li").each( function(){ + var curStr = normalizeSearchString($(this).text()); + var curMetric = normalizeSearchString($(this).attr("data-metric")); + $(this).hide(); + if(curStr.indexOf(search) > -1 || curMetric.indexOf(search) > -1){ + $(this).show(); + $(parent).find("ul").show(); + $(parent).addClass("searchFound").show(); + } + }); + }); + + if( $(self.form).find("li.searchFound").length == 0) + { + $(self.form).find("div > ul").prepend('
          • '+self.translations['General_SearchNoResults']+'
          • ').show(); + } + // check if search allow flag was revoked - then clear all search results + if(self.searchAllowed == false) + { + clearSearchMetricHighlight(); + self.searchAllowed = true; + } + + }; + + var clearSearchMetricHighlight = function(){ + $(self.form).find('.no_results').remove(); + $(self.form).find('.segment-nav div > ul > li').removeClass("searchFound").show(); + $(self.form).find('.segment-nav div > ul > li').removeClass("others").show(); + $(self.form).find('.segment-nav div > ul > li > ul > li').show(); + $(self.form).find('.segment-nav div > ul > li > ul').hide(); + }; + + var normalizeSearchString = function(search){ + search = search.replace(/^\s+|\s+$/g, ''); // trim + search = search.toLowerCase(); + // remove accents, swap ñ for n, etc + var from = "àáäâèéëêìíïîòóöôùúüûñç·/_,:;"; + var to = "aaaaeeeeiiiioooouuuunc------"; + for (var i=0, l=from.length ; i $(window).width() + && self.form.width() < self.target.offset().left + self.target.width() + ) { + self.form.addClass('anchorRight'); + } + + placeSegmentationFormControls(); + + if(mode == "edit") { + $(self.form).find('.enable_all_users_select > option[value="'+segment.enable_all_users+'"]').prop("selected",true); + $(self.form).find('.visible_to_website_select > option[value="'+segment.enable_only_idsite+'"]').prop("selected",true); + $(self.form).find('.auto_archive_select > option[value="'+segment.auto_archive+'"]').prop("selected",true); + + } + + makeDropList(".enable_all_users" , ".enable_all_users_select"); + makeDropList(".visible_to_website" , ".visible_to_website_select"); + makeDropList(".auto_archive" , ".auto_archive_select"); + makeDropList(".available_segments" , ".available_segments_select"); + $(self.form).find(".saveAndApply").bind("click", function (e) { + e.preventDefault(); + parseFormAndSave(); + }); + + $(self.form).find('.segment-footer').hover( function() { + $('.segmentFooterNote', self.target).fadeIn(); + }, function() { + $('.segmentFooterNote', self.target).fadeOut(); + }); + + if(typeof mode !== "undefined" && mode == "new") + { + $(self.form).find(".editSegmentName").trigger('click'); + } + $(".segmentListContainer", self.target).hide(); + + self.target.closest('.segmentEditorPanel').addClass('editing'); + + piwikHelper.compileAngularComponents(self.target); + }; + + var closeForm = function () { + self.form.unbind().remove(); + self.target.closest('.segmentEditorPanel').removeClass('editing'); + }; + + var parseForm = function(){ + var segmentStr = ""; + $(self.form).find(".segment-rows").each( function(){ + var subSegmentStr = ""; + + $(this).find(".segment-row").each( function(){ + if(subSegmentStr != ""){ + subSegmentStr += ","; // OR operator + } + $(this).find(".segment-row-inputs").each( function(){ + var metric = $(this).find(".metricList option:selected").val(); + var match = $(this).find(".metricMatchBlock > select option:selected").val(); + var value = $(this).find(".segment-input input").val(); + subSegmentStr += metric + match + encodeURIComponent(value); + }); + }); + if(segmentStr != "") + { + segmentStr += ";"; // add AND operator between segment blocks + } + segmentStr += subSegmentStr; + }); + return segmentStr + }; + + var parseFormAndSave = function(){ + var segmentName = $(self.form).find(".segment-content > h3 >span").text(); + var segmentStr = parseForm(); + var segmentId = $(self.form).find('.available_segments_select > option:selected').attr("data-idsegment"); + var user = $(self.form).find(".enable_all_users_select option:selected").val(); + var autoArchive = $(self.form).find(".auto_archive_select option:selected").val() || 0; + var params = { + "name": segmentName, + "definition": segmentStr, + "enabledAllUsers": user, + "autoArchive": autoArchive, + "idSite": $(self.form).find(".visible_to_website_select option:selected").val() + }; + + // determine if save or update should be performed + if(segmentId === ""){ + self.addMethod(params); + } + else{ + jQuery.extend(params, { + "idSegment": segmentId + }); + self.updateMethod(params); + } + }; + + var makeDropList = function(spanId, selectId){ + var select = $(self.form).find(selectId).hide(); + var dropList = $( '' ) + .insertAfter( select ) + .text( select.children(':selected').text() ) + .autocomplete({ + delay: 0, + minLength: 0, + appendTo: "body", + source: function( request, response ) { + response( select.children( "option" ).map(function() { + var text = $( this ).text(); + return { + label: text, + value: this.value, + option: this + }; + }) ); + }, + select: function( event, ui ) { + event.preventDefault(); + ui.item.option.selected = true; + // Mark original select>option + $(spanId + ' option[value="' + ui.item.value + '"]', self.editorTemplate).prop('selected', true); + dropList.text(ui.item.label); + $(self.form).find(selectId).trigger("change"); + } + }) + .click(function() { + // close all other droplists made by this form + $("a.dropList").autocomplete("close"); + // close if already visible + if ( $(this).autocomplete( "widget" ).is(":visible") ) { + $(this).autocomplete("close"); + return; + } + // pass empty string as value to search for, displaying all results + $(this).autocomplete( "search", "" ); + + }); + $('body').on('mouseup',function (e) { + if (!$(e.target).parents(spanId).length + && !$(e.target).is(spanId) + && !$(e.target).parents(spanId).length + && !$(e.target).parents(".ui-autocomplete").length + && !$(e.target).is(".ui-autocomplete") + && !$(e.target).parents(".ui-autocomplete").length + ) { + dropList.autocomplete().autocomplete("close"); + } + }); + }; + + function toggleLoadingMessage(segmentIsSet) { + if (segmentIsSet) { + $('#ajaxLoadingDiv').find('.loadingSegment').show(); + } else { + $('#ajaxLoadingDiv').find('.loadingSegment').hide(); + } + } + + this.initHtml = function() { + var html = getListHtml(); + + if(typeof self.content !== "undefined"){ + this.content.html($(html).html()); + } else { + this.target.append(html); + this.content = this.target.find(".segmentationContainer"); + } + + // assign content to object attribute to make it easil accesible through all widget methods + this.markCurrentSegment(); + + // Loading message + var segmentIsSet = this.getSegment().length; + toggleLoadingMessage(segmentIsSet); + }; + + this.initHtml(); + bindEvents(); + }; + + return segmentation; +})(jQuery); + + +$(document).ready(function() { + var exports = require('piwik/UI'); + var UIControl = exports.UIControl; + + /** + * Sets up and handles events for the segment selector & editor control. + * + * @param {Element} element The HTML element generated by the SegmentSelectorControl PHP class. Should + * have the CSS class 'segmentEditorPanel'. + * @constructor + */ + var SegmentSelectorControl = function (element) { + UIControl.call(this, element); + + if ((typeof this.props.isSegmentNotAppliedBecauseBrowserArchivingIsDisabled != "undefined") + && this.props.isSegmentNotAppliedBecauseBrowserArchivingIsDisabled + ) { + piwikHelper.modalConfirm($('.pleaseChangeBrowserAchivingDisabledSetting', this.$element), { + yes: function () {} + }); + } + + var self = this; + this.changeSegment = function(segmentDefinition) { + segmentDefinition = cleanupSegmentDefinition(segmentDefinition); + segmentDefinition = encodeURIComponent(segmentDefinition); + return broadcast.propagateNewPage('segment=' + segmentDefinition, true); + }; + + this.changeSegmentList = function () {}; + + var cleanupSegmentDefinition = function(definition) { + definition = definition.replace("'", "%29"); + definition = definition.replace("&", "%26"); + return definition; + }; + + var addSegment = function(params){ + var ajaxHandler = new ajaxHelper(); + ajaxHandler.setLoadingElement(); + params.definition = cleanupSegmentDefinition(params.definition); + + ajaxHandler.addParams($.extend({}, params, { + "module": 'API', + "format": 'json', + "method": 'SegmentEditor.add' + }), 'GET'); + ajaxHandler.useCallbackInCaseOfError(); + ajaxHandler.setCallback(function (response) { + if (response && response.result == 'error') { + alert(response.message); + } else { + params.idsegment = response.value; + self.props.availableSegments.push(params); + self.rebuild(); + + self.impl.setSegment(params.definition); + self.impl.markCurrentSegment(); + + self.$element.find('a.close').click(); + self.changeSegment(params.definition); + + self.changeSegmentList(self.props.availableSegments); + } + }); + ajaxHandler.send(true); + }; + + var updateSegment = function(params){ + var ajaxHandler = new ajaxHelper(); + ajaxHandler.setLoadingElement(); + params.definition = cleanupSegmentDefinition(params.definition); + + ajaxHandler.addParams($.extend({}, params, { + "module": 'API', + "format": 'json', + "method": 'SegmentEditor.update' + }), 'GET'); + ajaxHandler.useCallbackInCaseOfError(); + ajaxHandler.setCallback(function (response) { + if (response && response.result == 'error') { + alert(response.message); + } else { + params.idsegment = params.idSegment; + + var idx = null; + for (idx in self.props.availableSegments) { + if (self.props.availableSegments[idx].idsegment == params.idSegment) { + break; + } + } + + self.props.availableSegments[idx] = params; + self.rebuild(); + + self.impl.setSegment(params.definition); + self.impl.markCurrentSegment(); + + self.$element.find('a.close').click(); + self.changeSegment(params.definition); + + self.changeSegmentList(self.props.availableSegments); + } + }); + ajaxHandler.send(true); + }; + + + var deleteSegment = function(params){ + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'SegmentEditor.delete' + }, 'GET'); + ajaxHandler.addParams({ + idSegment: params.idsegment + }, 'POST'); + ajaxHandler.setLoadingElement(); + ajaxHandler.useCallbackInCaseOfError(); + ajaxHandler.setCallback(function (response) { + if (response && response.result == 'error') { + alert(response.message); + } else { + self.impl.setSegment(''); + self.impl.markCurrentSegment(); + + var idx = null; + for (idx in self.props.availableSegments) { + if (self.props.availableSegments[idx].idsegment == params.idsegment) { + break; + } + } + + self.props.availableSegments.splice(idx, 1); + self.rebuild(); + + self.$element.find('a.close').click(); + self.changeSegment(''); + $('.ui-dialog-content').dialog('close'); + + self.changeSegmentList(self.props.availableSegments); + } + }); + + ajaxHandler.send(true); + }; + + var segmentFromRequest = encodeURIComponent(self.props.selectedSegment) + || broadcast.getValueFromHash('segment') + || broadcast.getValueFromUrl('segment'); + if($.browser.mozilla) { + segmentFromRequest = decodeURIComponent(segmentFromRequest); + } + + this.impl = new Segmentation({ + "target" : this.$element.find(".segmentListContainer"), + "editorTemplate": $('.SegmentEditor', self.$element), + "segmentAccess" : "write", + "availableSegments" : this.props.availableSegments, + "addMethod": addSegment, + "updateMethod": updateSegment, + "deleteMethod": deleteSegment, + "segmentSelectMethod": function () { self.changeSegment.apply(this, arguments); }, + "currentSegmentStr": segmentFromRequest, + "translations": this.props.segmentTranslations + }); + + this.onMouseUp = function(e) { + if ($(e.target).closest('.segment-element').length === 0 + && !$(e.target).is('.segment-element') + && $(e.target).hasClass("ui-corner-all") == false + && $(e.target).hasClass("ddmetric") == false + && $(".segment-element:visible", self.$element).length == 1 + ) { + $(".segment-element:visible a.close", self.$element).click(); + } + + if ($(e.target).closest('.segmentListContainer').length === 0 + && self.$element.hasClass("visible") + ) { + $(".segmentationContainer", self.$element).trigger("click"); + } + }; + + $('body').on('mouseup', this.onMouseUp); + + // re-initialize top controls since the size of the control is not the same after it's + // initialized. + initTopControls(); + }; + + /** + * Initializes all elements w/ the .segmentEditorPanel CSS class as SegmentSelectorControls, + * if the element has not already been initialized. + */ + SegmentSelectorControl.initElements = function () { + UIControl.initElements(this, '.segmentEditorPanel'); + }; + + $.extend(SegmentSelectorControl.prototype, UIControl.prototype, { + getSegment: function () { + return this.impl.getSegment(); + }, + + setSegment: function (segment) { + return this.impl.setSegment(segment); + }, + + rebuild: function () { + this.impl.setAvailableSegments(this.props.availableSegments); + this.impl.initHtml(); + }, + + _destroy: function () { + UIControl.prototype._destroy.call(this); + + $('body').off('mouseup', null, this.onMouseUp); + } + }); + + exports.SegmentSelectorControl = SegmentSelectorControl; +}); \ No newline at end of file diff --git a/www/analytics/plugins/SegmentEditor/stylesheets/segmentation.less b/www/analytics/plugins/SegmentEditor/stylesheets/segmentation.less new file mode 100644 index 00000000..7ccbe266 --- /dev/null +++ b/www/analytics/plugins/SegmentEditor/stylesheets/segmentation.less @@ -0,0 +1,705 @@ +/* ADDITIONAL STYLES*/ +.youMustBeLoggedIn { + font-size: 8pt; + font-style: italic; + +} + +.segment-footer .segmentFooterNote { + display: none; + float: left; + padding-top: 9px; +} + +.segment-footer .segmentFooterNote, .segment-element .segment-footer .segmentFooterNote a { + font-size: 8pt; + color: #888172; +} + +.segment-element .segment-footer .segmentFooterNote a { + padding: 0; + margin: 0; + text-decoration: underline; +} + +.searchFound { + border: 0px solid red; +} + +.others { + border: 0px solid green; +} + +.clear { + clear: both; +} + +.segment-row-inputs { + margin-bottom: 5px; +} + +.hovered { + border-radius: 4px; + border: 2px dashed #000 !important; + padding: 0px; +} + +.metricListBlock { + border-radius: 4px; + width: 292px; + margin-right: 11px; + border: 2px dashed #EFEFEB; +} + +.metricListBlock > select { + margin: 0 !important; + width: 98% !important; + margin-left: 2px !important; +} + +.metricMatchBlock { + width: 120px; + margin-right: 11px; +} + +.metricValueBlock { + width: 352px; +} + +div.scrollable { + height: 100%; + overflow: hidden; + overflow-y: auto; +} + +.no_results { + position: absolute; + margin: -225px 0 0 10px; +} + +.segment-element { + border: 1px solid #a9a399; + background-color: #f1f0eb; + padding: 6px 4px; + border-radius: 3px; + position: absolute; + left: -1px; + top: -1px; +} + +.segment-element .custom_select_search { + width: 146px; + height: 21px; + background: url(plugins/SegmentEditor/images/bg-segment-search.png) 0 10px no-repeat; + padding: 10px 0 0 0; + margin: 10px 0 10px 15px; + border-top: 1px solid #dcdacf; + position: relative; +} + +.segment-element .custom_select_search input[type="text"] { + font: 11px Arial; + color: #454545; + width: 125px; + padding: 4px 0 3px 7px; + border: none; + background: none; +} + +.segment-element .custom_select_search a { + position: absolute; + width: 13px; + height: 13px; + right: 5px; + top: 14px; + background: url(plugins/SegmentEditor/images/reset_search.png); +} + +.segment-element .segment-nav { + position: absolute; + top: 7px; + left: 5px; + bottom: 135px; + float: left; + width: 170px; +} + +.segment-element .segment-nav h4 { + font: bold 14px Arial; + padding: 0 0 8px 0; +} + +.segment-element .segment-nav h4.visits { + padding-left: 28px; + background: url(plugins/SegmentEditor/images/icon-users.png) 0 0 no-repeat; +} + +.segment-element .segment-nav h4 a { + color: #255792; + text-decoration: none; +} + +.segment-element .segment-nav div > ul { + padding: 0 0 0 15px; +} + +.segment-element .segment-nav div > ul > li { + padding: 2px 0; + line-height: 14px; +} + +.segment-element .segment-nav div > ul > li li { + padding: 1px; + border-radius: 3px 3px 3px 3px; +} + +.segment-element .segment-nav div > ul > li li:hover { + padding: 0; + border: 1px solid #cfccbd; + border-bottom: 1px solid #7c7a72; +} + +.segment-element .segment-nav div > ul > li li:hover a { + cursor: move; + padding: 1px 0 2px 8px; + border-top: 1px solid #fff; + background: #eae8e3 url(plugins/SegmentEditor/images/segment-move.png) 100% 50% no-repeat; +} + +.segment-element .segment-nav div > ul > li li a { + padding: 2px 0 2px 8px; + font-weight: normal; + display: block; +} + +.segment-element .segment-nav div > ul > li ul { + margin: 2px 0 -3px 10px; +} + +.segment-element .segment-nav div > ul > li a { + color: #5d5342; + font: bold 11px Arial; + text-decoration: none; + text-shadow: 0 1px 0 #fff; +} + +.segment-element .segment-content { + min-height: 300px; + padding: 0 0 20px 203px; +} + +.segment-element .segment-content h3 { + font: bold 16px Arial; + color: #505050; + margin: 11px 0 0 0; + text-shadow: 0 1px 0 #fff; +} + +.segment-element .segment-content h3 a { + font-size: 11px; + text-decoration: none; + margin: -1px 0 0 0; +} + +.segment-element .segment-content .segment-rows { + padding: 4px; + margin: 0 3px 0 0; + background: #fff; + border: 1px solid #a9a399; + border-radius: 3px 3px 3px 3px; + position: relative; + box-shadow: 0 12px 6px -10px rgba(0, 0, 0, 0.42); +} + +.segment-element .segment-content .segment-add-row, +.segment-element .segment-content .segment-add-or { + font: bold 14px Arial; + background: #fff; + color: #b9b9b9; + text-align: center; + position: relative; +} + +.segment-element .segment-content .segment-add-row > div, +.segment-element .segment-content .segment-add-or > div { + border-radius: 4px; + border: 2px dashed #fff; + padding: 10px 0; +} + +.segment-element .segment-content .segment-add-row > div a, +.segment-element .segment-content .segment-add-or > div a { + color: #b9b9b9; + text-decoration: none; +} + +.segment-element .segment-content .segment-add-row > div a span, +.segment-element .segment-content .segment-add-or > div a span { + color: #255792; +} + +.segment-element .segment-content .segment-add-row { + margin: 0 3px 0 0; + padding: 0 12px; + border: 1px solid #a9a399; + border-radius: 3px 3px 3px 3px; + box-shadow: 0 12px 6px -10px rgba(0, 0, 0, 0.42); +} + +.segment-element .segment-content .segment-add-or { + text-shadow: 0 1px 0 #fff; + display: inline-block; + width: 98%; + padding: 0 1%; + background: #efefeb; + border-radius: 3px 3px 3px 3px; +} + +.segment-element .segment-content .segment-add-or > div { + border: 2px dashed #EFEFEB; +} + +.segment-element .segment-content .segment-row { + border-radius: 3px; + display: inline-block; + position: relative; + width: 811px; + padding: 12px 1%; + background: #efefeb; + padding: 7px 5px 0 5px; +} + +.segment-element .segment-content .segment-row .segment-close { + top: 15px; + right: 6px; + position: absolute; + width: 15px; + height: 15px; + background: url(plugins/SegmentEditor/images/segment-close.png) 0 0 no-repeat; +} + +.segment-element .segment-content .segment-row .segment-loading { + display: none; + top: 25px; + right: 30px; + position: absolute; + width: 15px; + height: 15px; + background: url(plugins/MultiSites/images/loading-blue.gif) 0 0 no-repeat; +} + +.segment-element .segment-content .segment-or { + display: inline-block; + margin: 0 0 0 6%; + position: relative; + background: #efefeb; + padding: 5px 28px; + color: #4f4f4f; + font: bold 14px Arial; + text-shadow: 0 1px 0 #fff; +} + +.segment-element .segment-content .segment-or:before, +.segment-element .segment-content .segment-or:after { + content: ''; + position: absolute; + background: #fff; + border: 1px solid #efefeb; + width: 10px; + top: -1px; + bottom: -1px; +} + +.segment-element .segment-content .segment-or:before { + border-left: none; + left: 0px; + border-radius: 0 5px 5px 0; +} + +.segment-element .segment-content .segment-or:after { + border-right: none; + right: 0px; + border-radius: 5px 0 0 5px; +} + +.segment-element .segment-content .segment-and { + display: inline-block; + margin: -1px 0 -1px 6%; + z-index: 1; + position: relative; + background: #fff; + padding: 5px 35px; + color: #4f4f4f; + font: bold 14px Arial; + text-shadow: 0 1px 0 #fff; +} + +.segment-element .segment-content .segment-and:before, +.segment-element .segment-content .segment-and:after { + content: ''; + position: absolute; + background: url(plugins/SegmentEditor/images/bg-inverted-corners.png); + border: 1px solid #a9a399; + width: 10px; + top: 0px; + bottom: 0px; +} + +.segment-element .segment-content .segment-and:before { + border-left: none; + left: 0px; + border-radius: 0 5px 5px 0; +} + +.segment-element .segment-content .segment-and:after { + border-right: none; + right: 0px; + border-radius: 5px 0 0 5px; +} + +.segment-element .segment-content .segment-input { + float: left; + padding: 6px 0 5px 3px; + border: 2px dashed #EFEFEB; + margin-right: 3px; +} + +.segment-element .segment-content .segment-input label { + display: block; + margin: 0 0 5px 0; + font: 11px Arial; + color: #505050; +} + +.segment-element .segment-content .segment-input select, +.segment-element .segment-content .segment-input input { + display: block; + font: 16px Arial; + color: #255792; + width: 96%; + padding: 7px 2%; + border-radius: 2px 2px 2px 2px; +} + +.segment-element .segment-content .segment-input input { + padding: 8px 2%; +} + +.segment-element .segment-top { + font: 11px Arial; + color: #505050; + text-align: right; + padding: 3px 7px 0 0; +} + +.segment-element .segment-top a { + text-decoration: none; +} + +.segment-element .segment-top a.dropdown { + padding: 0 17px 0 0; + background: url(plugins/Zeitgeist/images/sort_subtable_desc.png) 100% -2px no-repeat; +} + +.segment-element .segment-footer { + background: #eae8e3; + border-top: 1px solid #a9a399; + text-align: right; + padding: 7px 10px; + margin: 0 -4px -6px -4px; +} + +.segment-element .segment-footer a.delete { + color: red; +} + +.segment-element .segment-footer a { + font: 14px Arial; + color: #255792; + margin: 0 5px; + text-decoration: none; +} + +.segment-element .segment-footer button { + min-width: 178px; + height: 30px; + background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiA/Pgo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDE3OCAzMCIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSI+PGxpbmVhckdyYWRpZW50IGlkPSJoYXQwIiBncmFkaWVudFVuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgeDE9IjUwJSIgeTE9IjEwMCUiIHgyPSI1MCUiIHkyPSItMS40MjEwODU0NzE1MjAyZS0xNCUiPgo8c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjODM3OTZiIiBzdG9wLW9wYWNpdHk9IjEiLz4KPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjYWJhMzkzIiBzdG9wLW9wYWNpdHk9IjEiLz4KICAgPC9saW5lYXJHcmFkaWVudD4KCjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxNzgiIGhlaWdodD0iMzAiIGZpbGw9InVybCgjaGF0MCkiIC8+Cjwvc3ZnPg==); + background-image: -moz-linear-gradient(bottom, #83796b 0%, #aba393 100%); + background-image: -o-linear-gradient(bottom, #83796b 0%, #aba393 100%); + background-image: -webkit-linear-gradient(bottom, #83796b 0%, #aba393 100%); + background-image: linear-gradient(bottom, #83796b 0%, #aba393 100%); + color: #fff; + font-family: "Arial"; + font-size: 16px; + font-weight: bold; + border-radius: 4px 4px 4px 4px; + border: none; + margin: 0 0 0 15px; +} + +.segmentEditorPanel { + display:inline-block; + position:relative; + z-index: 121; /* Should be bigger than 'Dashboard widget selector' (z-index: 120) */ + background: #f7f7f7; + border: 1px solid #e4e5e4; + padding: 5px 10px 6px 10px; + margin-right: 10px; + border-radius: 4px; + color: #444; + font-size: 14px; +} + +.top_controls .segmentEditorPanel { + position:absolute; +} + +.segmentEditorPanel:hover { + background: #f1f0eb; + border-color: #a9a399; +} + +.segmentationContainer > span > strong { + color: #255792; +} + +.segmentationContainer .submenu { + font-size: 13px; + min-width: 200px; +} + +.segmentationContainer .submenu ul { + color: #5D5342; + float: none; + font-size: 11px; + font-weight: normal; + line-height: 20px; + list-style: none outside none; + margin-left: 5px; + margin-right: 0; + padding-top: 10px; +} + +.segmentationContainer .submenu ul li { + padding: 2px 0px 1px 6px; + margin: 3px 0 0 0; + cursor: pointer; +} + +.segmentationContainer .submenu ul li:hover { + color: #255792; + margin: 0; + margin-left: -3px; + border: 1px solid #d5d2c6; + border-bottom: 2px solid #918f88; + border-radius: 4px; + background: #eae8e3; +} + +.segmentationContainer ul.submenu { + padding-top: 5px; + display: none; + float: left; +} + +.segmentationContainer ul.submenu > li span.editSegment { + display: block; + float: right; + text-align: center; + margin-right:4px; + font-weight: normal; +} + +.segmentEditorPanel.visible .segmentationContainer { + width: 200px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.segmentEditorPanel.visible ul.submenu { + display: block; + list-style: none; +} + +.segmentEditorPanel.visible .add_new_segment { + display: block; + background: url("plugins/SegmentEditor/images/dashboard_h_bg_hover.png") repeat-x scroll 0 0 #847b6d; + border: 0 none; + border-radius: 4px 4px 4px 4px; + clear: both; + color: #FFFFFF; + float: right; + margin: 12px 0 10px; + padding: 3px 10px; + text-decoration: none; + width: 130px; +} + +.segmentationContainer > ul.submenu > li { + padding: 5px 0; + clear: both; + cursor: pointer; +} + +span.segmentationTitle { + background: url(plugins/Zeitgeist/images/sort_subtable_desc.png) no-repeat right 0; + padding-right: 20px; + width: 160px; + display: block; + cursor: pointer; +} + +.add_new_segment { + display: none; +} + +.segmentListContainer { + overflow: hidden; /* Create a BFC */ +} + +.jspVerticalBar { + background: transparent !important; +} + +/* ADDITIONAL STYLES*/ +body > a.ddmetric { + display: block; + cursor: move; + padding: 1px 0 2px 18px; + background: #eae8e3 url(plugins/SegmentEditor/images/segment-move.png) 100% 50% no-repeat; + color: #5d5342; + font: normal 11px Arial; + text-decoration: none; + text-shadow: 0 1px 0 #fff; + border: 1px solid #cfccbd; + border-top: 1px solid #fff; + border-bottom: 1px solid #7c7a72; +} + +.segment-element .segment-nav div > ul > li ul { + margin-left: 0; +} + +.segment-element .segment-nav div > ul > li li a, +.segment-element .segment-nav div > ul > li li a:hover { + padding-right: 18px; +} + +.hovered { + border-color: #a0a0a0 !important; +} + +a.metric_category { + display: block; + width: 100%; +} + +.segment-content > h3 { + padding-bottom: 7px; +} + +.no_results { + margin: 0; + position: relative; +} + +.no_results a { + cursor: default; +} + +.ui-widget-segmentation { + border: 1px solid #D4D4D4 !important; +} + +.clearfix { + zoom: 1; +} + +.clearfix:after { + display: block; + visibility: hidden; + height: 0; + clear: both; + content: "."; +} + +.available_segments a.dropdown { + background: url("plugins/Zeitgeist/images/sort_subtable_desc.png") no-repeat scroll 100% -2px transparent !important; + padding: 0 17px 0 0 !important; +} + +.notification { + float: left; +} + +.metricValueBlock input { + padding: 8px !important; +} + +.segmentationContainer { + z-index: 120; +} + +.segment-element { + z-index: 999; + width: 1040px; +} + +.segmentationSelectorContainer { + margin: 8px; +} + +.segmentSelected, .segmentSelected:hover { + font-weight: bold; +} + +.ui-autocomplete { + position: absolute; + cursor: default; + z-index: 1000 !important; +} + +@media all and (max-width: 749px) { + span.segmentationTitle, + .segmentEditorPanel.visible .segmentationContainer { + width: auto; + } + + .segmentEditorPanel.visible .add_new_segment { + float: left; + } +} + +.dropdown-body { + position:absolute; + top:100%; + left:-1px; + background-color:#f7f7f7; + padding:0px 10px; + border: 1px solid #e4e5e4; + border-top-width:0px; + display:none; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +.segmentEditorPanel.visible .dropdown-body { + display:block; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.segmentEditorPanel:hover .dropdown-body { + border-color: #a9a399; + background: #f1f0eb +} + +.segmentEditorPanel.visible { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.segment-element.anchorRight { + right:-1px; + left:auto; +} \ No newline at end of file diff --git a/www/analytics/plugins/SegmentEditor/templates/_segmentSelector.twig b/www/analytics/plugins/SegmentEditor/templates/_segmentSelector.twig new file mode 100644 index 00000000..04981e52 --- /dev/null +++ b/www/analytics/plugins/SegmentEditor/templates/_segmentSelector.twig @@ -0,0 +1,161 @@ + +
            +
            +

            {{ 'SegmentEditor_AreYouSureDeleteSegment'|translate }}

            + + +
            +
            +

            {{ 'SegmentEditor_SegmentNotApplied'|translate(nameOfCurrentSegment)|raw }}

            + {% set segmentSetting %}{{ 'SegmentEditor_AutoArchivePreProcessed'|translate }}{% endset %} + +

            + {{ 'SegmentEditor_SegmentNotAppliedMessage'|translate(nameOfCurrentSegment)|raw }} +
            + {{ 'SegmentEditor_DataAvailableAtLaterDate'|translate }} + {% if isSuperUser %} +

            {{ 'SegmentEditor_YouMayChangeSetting'|translate('browser_archiving_disabled_enforce', segmentSetting) }} + {% endif %} +

            +
            +
            \ No newline at end of file diff --git a/www/analytics/plugins/SitesManager/API.php b/www/analytics/plugins/SitesManager/API.php new file mode 100644 index 00000000..3baf4267 --- /dev/null +++ b/www/analytics/plugins/SitesManager/API.php @@ -0,0 +1,1555 @@ +Managing Websites in Piwik. + * @method static \Piwik\Plugins\SitesManager\API getInstance() + */ +class API extends \Piwik\Plugin\API +{ + const DEFAULT_SEARCH_KEYWORD_PARAMETERS = 'q,query,s,search,searchword,k,keyword'; + const OPTION_EXCLUDED_IPS_GLOBAL = 'SitesManager_ExcludedIpsGlobal'; + const OPTION_DEFAULT_TIMEZONE = 'SitesManager_DefaultTimezone'; + const OPTION_DEFAULT_CURRENCY = 'SitesManager_DefaultCurrency'; + const OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL = 'SitesManager_ExcludedQueryParameters'; + const OPTION_SEARCH_KEYWORD_QUERY_PARAMETERS_GLOBAL = 'SitesManager_SearchKeywordParameters'; + const OPTION_SEARCH_CATEGORY_QUERY_PARAMETERS_GLOBAL = 'SitesManager_SearchCategoryParameters'; + const OPTION_EXCLUDED_USER_AGENTS_GLOBAL = 'SitesManager_ExcludedUserAgentsGlobal'; + const OPTION_SITE_SPECIFIC_USER_AGENT_EXCLUDE_ENABLE = 'SitesManager_EnableSiteSpecificUserAgentExclude'; + const OPTION_KEEP_URL_FRAGMENTS_GLOBAL = 'SitesManager_KeepURLFragmentsGlobal'; + + /** + * Returns the javascript tag for the given idSite. + * This tag must be included on every page to be tracked by Piwik + * + * @param int $idSite + * @param string $piwikUrl + * @param bool $mergeSubdomains + * @param bool $groupPageTitlesByDomain + * @param bool $mergeAliasUrls + * @param bool $visitorCustomVariables + * @param bool $pageCustomVariables + * @param bool $customCampaignNameQueryParam + * @param bool $customCampaignKeywordParam + * @param bool $doNotTrack + * @internal param $ + * @return string The Javascript tag ready to be included on the HTML pages + */ + public function getJavascriptTag($idSite, $piwikUrl = '', $mergeSubdomains = false, $groupPageTitlesByDomain = false, + $mergeAliasUrls = false, $visitorCustomVariables = false, $pageCustomVariables = false, + $customCampaignNameQueryParam = false, $customCampaignKeywordParam = false, + $doNotTrack = false) + { + Piwik::checkUserHasViewAccess($idSite); + + if (empty($piwikUrl)) { + $piwikUrl = SettingsPiwik::getPiwikUrl(); + } + $piwikUrl = Common::sanitizeInputValues($piwikUrl); + + $htmlEncoded = Piwik::getJavascriptCode($idSite, $piwikUrl, $mergeSubdomains, $groupPageTitlesByDomain, + $mergeAliasUrls, $visitorCustomVariables, $pageCustomVariables, + $customCampaignNameQueryParam, $customCampaignKeywordParam, + $doNotTrack); + $htmlEncoded = str_replace(array('
            ', '
            ', '
            '), '', $htmlEncoded); + return $htmlEncoded; + } + + /** + * Returns image link tracking code for a given site with specified options. + * + * @param int $idSite The ID to generate tracking code for. + * @param string $piwikUrl The domain and URL path to the Piwik installation. + * @param int $idGoal An ID for a goal to trigger a conversion for. + * @param int $revenue The revenue of the goal conversion. Only used if $idGoal is supplied. + * @return string The HTML tracking code. + */ + public function getImageTrackingCode($idSite, $piwikUrl = '', $actionName = false, $idGoal = false, $revenue = false) + { + $urlParams = array('idSite' => $idSite, 'rec' => 1); + + if ($actionName !== false) { + $urlParams['action_name'] = urlencode(Common::unsanitizeInputValue($actionName)); + } + + if ($idGoal !== false) { + $urlParams['idGoal'] = $idGoal; + if ($revenue !== false) { + $urlParams['revenue'] = $revenue; + } + } + + /** + * Triggered when generating image link tracking code server side. Plugins can use + * this event to customise the image tracking code that is displayed to the + * user. + * + * @param string &$piwikHost The domain and URL path to the Piwik installation, eg, + * `'examplepiwik.com/path/to/piwik'`. + * @param array &$urlParams The query parameters used in the element's src + * URL. See Piwik's image tracking docs for more info. + */ + Piwik::postEvent('SitesManager.getImageTrackingCode', array(&$piwikUrl, &$urlParams)); + + $piwikUrl = (ProxyHttp::isHttps() ? "https://" : "http://") . $piwikUrl . '/piwik.php'; + return " +\"\" +"; + } + + /** + * Returns all websites belonging to the specified group + * @param string $group Group name + * @return array of sites + */ + public function getSitesFromGroup($group) + { + Piwik::checkUserHasSuperUserAccess(); + $group = trim($group); + + $sites = Db::get()->fetchAll("SELECT * + FROM " . Common::prefixTable("site") . " + WHERE `group` = ?", $group); + + Site::setSitesFromArray($sites); + return $sites; + } + + /** + * Returns the list of website groups, including the empty group + * if no group were specified for some websites + * + * @return array of group names strings + */ + public function getSitesGroups() + { + Piwik::checkUserHasSuperUserAccess(); + $groups = Db::get()->fetchAll("SELECT DISTINCT `group` FROM " . Common::prefixTable("site")); + $cleanedGroups = array(); + foreach ($groups as $group) { + $cleanedGroups[] = $group['group']; + } + $cleanedGroups = array_map('trim', $cleanedGroups); + return $cleanedGroups; + } + + /** + * Returns the website information : name, main_url + * + * @throws Exception if the site ID doesn't exist or the user doesn't have access to it + * @param int $idSite + * @return array + */ + public function getSiteFromId($idSite) + { + Piwik::checkUserHasViewAccess($idSite); + $site = Db::get()->fetchRow("SELECT * + FROM " . Common::prefixTable("site") . " + WHERE idsite = ?", $idSite); + + Site::setSitesFromArray(array($site)); + return $site; + } + + /** + * Returns the list of alias URLs registered for the given idSite. + * The website ID must be valid when calling this method! + * + * @param int $idSite + * @return array list of alias URLs + */ + private function getAliasSiteUrlsFromId($idSite) + { + $db = Db::get(); + $result = $db->fetchAll("SELECT url + FROM " . Common::prefixTable("site_url") . " + WHERE idsite = ?", $idSite); + $urls = array(); + foreach ($result as $url) { + $urls[] = $url['url']; + } + return $urls; + } + + /** + * Returns the list of all URLs registered for the given idSite (main_url + alias URLs). + * + * @throws Exception if the website ID doesn't exist or the user doesn't have access to it + * @param int $idSite + * @return array list of URLs + */ + public function getSiteUrlsFromId($idSite) + { + Piwik::checkUserHasViewAccess($idSite); + $site = new Site($idSite); + $urls = $this->getAliasSiteUrlsFromId($idSite); + return array_merge(array($site->getMainUrl()), $urls); + } + + /** + * Returns the list of all the website IDs registered. + * Caller must check access. + * + * @return array The list of website IDs + */ + private function getSitesId() + { + $result = Db::fetchAll("SELECT idsite FROM " . Common::prefixTable('site')); + $idSites = array(); + foreach ($result as $idSite) { + $idSites[] = $idSite['idsite']; + } + return $idSites; + } + + /** + * Returns all websites, requires Super User access + * + * @return array The list of websites, indexed by idsite + */ + public function getAllSites() + { + Piwik::checkUserHasSuperUserAccess(); + $sites = Db::get()->fetchAll("SELECT * FROM " . Common::prefixTable("site") . " ORDER BY idsite ASC"); + $return = array(); + foreach ($sites as $site) { + $return[$site['idsite']] = $site; + } + Site::setSitesFromArray($return); + return $return; + } + + /** + * Returns the list of all the website IDs registered. + * Requires Super User access. + * + * @return array The list of website IDs + */ + public function getAllSitesId() + { + Piwik::checkUserHasSuperUserAccess(); + try { + return API::getInstance()->getSitesId(); + } catch (Exception $e) { + // can be called before Piwik tables are created so return empty + return array(); + } + } + + /** + * Returns the list of the website IDs that received some visits since the specified timestamp. + * Requires Super User access. + * + * @param bool|int $timestamp + * @return array The list of website IDs + */ + public function getSitesIdWithVisits($timestamp = false) + { + Piwik::checkUserHasSuperUserAccess(); + + if (empty($timestamp)) $timestamp = time(); + + $time = Date::factory((int)$timestamp)->getDatetime(); + $result = Db::fetchAll(" + SELECT + idsite + FROM + " . Common::prefixTable('site') . " s + WHERE EXISTS ( + SELECT 1 + FROM " . Common::prefixTable('log_visit') . " v + WHERE v.idsite = s.idsite + AND visit_last_action_time > ? + AND visit_last_action_time <= ? + LIMIT 1) + ", array($time, $now = Date::now()->addHour(1)->getDatetime())); + $idSites = array(); + foreach ($result as $idSite) { + $idSites[] = $idSite['idsite']; + } + return $idSites; + } + + /** + * Returns the list of websites with the 'admin' access for the current user. + * For the superUser it returns all the websites in the database. + * + * @return array for each site, an array of information (idsite, name, main_url, etc.) + */ + public function getSitesWithAdminAccess() + { + $sitesId = $this->getSitesIdWithAdminAccess(); + return $this->getSitesFromIds($sitesId); + } + + /** + * Returns the list of websites with the 'view' access for the current user. + * For the superUser it doesn't return any result because the superUser has admin access on all the websites (use getSitesWithAtLeastViewAccess() instead). + * + * @return array for each site, an array of information (idsite, name, main_url, etc.) + */ + public function getSitesWithViewAccess() + { + $sitesId = $this->getSitesIdWithViewAccess(); + return $this->getSitesFromIds($sitesId); + } + + /** + * Returns the list of websites with the 'view' or 'admin' access for the current user. + * For the superUser it returns all the websites in the database. + * + * @param bool|int $limit Specify max number of sites to return + * @param bool $_restrictSitesToLogin Hack necessary when runnning scheduled tasks, where "Super User" is forced, but sometimes not desired, see #3017 + * @return array array for each site, an array of information (idsite, name, main_url, etc.) + */ + public function getSitesWithAtLeastViewAccess($limit = false, $_restrictSitesToLogin = false) + { + $sitesId = $this->getSitesIdWithAtLeastViewAccess($_restrictSitesToLogin); + return $this->getSitesFromIds($sitesId, $limit); + } + + /** + * Returns the list of websites ID with the 'admin' access for the current user. + * For the superUser it returns all the websites in the database. + * + * @return array list of websites ID + */ + public function getSitesIdWithAdminAccess() + { + $sitesId = Access::getInstance()->getSitesIdWithAdminAccess(); + return $sitesId; + } + + /** + * Returns the list of websites ID with the 'view' access for the current user. + * For the superUser it doesn't return any result because the superUser has admin access on all the websites (use getSitesIdWithAtLeastViewAccess() instead). + * + * @return array list of websites ID + */ + public function getSitesIdWithViewAccess() + { + return Access::getInstance()->getSitesIdWithViewAccess(); + } + + /** + * Returns the list of websites ID with the 'view' or 'admin' access for the current user. + * For the superUser it returns all the websites in the database. + * + * @param bool $_restrictSitesToLogin + * @return array list of websites ID + */ + public function getSitesIdWithAtLeastViewAccess($_restrictSitesToLogin = false) + { + if (Piwik::hasUserSuperUserAccess() && !TaskScheduler::isTaskBeingExecuted()) { + return Access::getInstance()->getSitesIdWithAtLeastViewAccess(); + } + + if (!empty($_restrictSitesToLogin) + // Only Super User or logged in user can see viewable sites for a specific login, + // but during scheduled task execution, we sometimes want to restrict sites to + // a different login than the superuser. + && (Piwik::hasUserSuperUserAccessOrIsTheUser($_restrictSitesToLogin) + || TaskScheduler::isTaskBeingExecuted()) + ) { + + if (Piwik::hasTheUserSuperUserAccess($_restrictSitesToLogin)) { + return Access::getInstance()->getSitesIdWithAtLeastViewAccess(); + } + + $accessRaw = Access::getInstance()->getRawSitesWithSomeViewAccess($_restrictSitesToLogin); + $sitesId = array(); + foreach ($accessRaw as $access) { + $sitesId[] = $access['idsite']; + } + return $sitesId; + } else { + return Access::getInstance()->getSitesIdWithAtLeastViewAccess(); + } + } + + /** + * Returns the list of websites from the ID array in parameters. + * The user access is not checked in this method so the ID have to be accessible by the user! + * + * @param array $idSites list of website ID + * @param bool $limit + * @return array + */ + private function getSitesFromIds($idSites, $limit = false) + { + if (count($idSites) === 0) { + return array(); + } + + if ($limit) { + $limit = "LIMIT " . (int)$limit; + } + + $db = Db::get(); + $sites = $db->fetchAll("SELECT * + FROM " . Common::prefixTable("site") . " + WHERE idsite IN (" . implode(", ", $idSites) . ") + ORDER BY idsite ASC $limit"); + + Site::setSitesFromArray($sites); + return $sites; + } + + protected function getNormalizedUrls($url) + { + if (strpos($url, 'www.') !== false) { + $urlBis = str_replace('www.', '', $url); + } else { + $urlBis = str_replace('://', '://www.', $url); + } + return array($url, $urlBis); + } + + /** + * Returns the list of websites ID associated with a URL. + * + * @param string $url + * @return array list of websites ID + */ + public function getSitesIdFromSiteUrl($url) + { + $url = $this->removeTrailingSlash($url); + list($url, $urlBis) = $this->getNormalizedUrls($url); + if (Piwik::hasUserSuperUserAccess()) { + $ids = Db::get()->fetchAll( + 'SELECT idsite + FROM ' . Common::prefixTable('site') . ' + WHERE (main_url = ? OR main_url = ?) ' . + 'UNION + SELECT idsite + FROM ' . Common::prefixTable('site_url') . ' + WHERE (url = ? OR url = ?) ', array($url, $urlBis, $url, $urlBis)); + } else { + $login = Piwik::getCurrentUserLogin(); + $ids = Db::get()->fetchAll( + 'SELECT idsite + FROM ' . Common::prefixTable('site') . ' + WHERE (main_url = ? OR main_url = ?)' . + 'AND idsite IN (' . Access::getSqlAccessSite('idsite') . ') ' . + 'UNION + SELECT idsite + FROM ' . Common::prefixTable('site_url') . ' + WHERE (url = ? OR url = ?)' . + 'AND idsite IN (' . Access::getSqlAccessSite('idsite') . ')', + array($url, $urlBis, $login, $url, $urlBis, $login)); + } + + return $ids; + } + + /** + * Returns all websites with a timezone matching one the specified timezones + * + * @param array $timezones + * @return array + * @ignore + */ + public function getSitesIdFromTimezones($timezones) + { + Piwik::checkUserHasSuperUserAccess(); + $timezones = Piwik::getArrayFromApiParameter($timezones); + $timezones = array_unique($timezones); + $ids = Db::get()->fetchAll( + 'SELECT idsite + FROM ' . Common::prefixTable('site') . ' + WHERE timezone IN (' . Common::getSqlStringFieldsArray($timezones) . ') + ORDER BY idsite ASC', + $timezones); + $return = array(); + foreach ($ids as $id) { + $return[] = $id['idsite']; + } + return $return; + } + + /** + * Add a website. + * Requires Super User access. + * + * The website is defined by a name and an array of URLs. + * @param string $siteName Site name + * @param array|string $urls The URLs array must contain at least one URL called the 'main_url' ; + * if several URLs are provided in the array, they will be recorded + * as Alias URLs for this website. + * @param int $ecommerce Is Ecommerce Reporting enabled for this website? + * @param null $siteSearch + * @param string $searchKeywordParameters Comma separated list of search keyword parameter names + * @param string $searchCategoryParameters Comma separated list of search category parameter names + * @param string $excludedIps Comma separated list of IPs to exclude from the reports (allows wildcards) + * @param null $excludedQueryParameters + * @param string $timezone Timezone string, eg. 'Europe/London' + * @param string $currency Currency, eg. 'EUR' + * @param string $group Website group identifier + * @param string $startDate Date at which the statistics for this website will start. Defaults to today's date in YYYY-MM-DD format + * @param null|string $excludedUserAgents + * @param int $keepURLFragments If 1, URL fragments will be kept when tracking. If 2, they + * will be removed. If 0, the default global behavior will be used. + * @see getKeepURLFragmentsGlobal. + * @param string $type The website type, defaults to "website" if not set. + * + * @return int the website ID created + */ + public function addSite($siteName, + $urls, + $ecommerce = null, + $siteSearch = null, + $searchKeywordParameters = null, + $searchCategoryParameters = null, + $excludedIps = null, + $excludedQueryParameters = null, + $timezone = null, + $currency = null, + $group = null, + $startDate = null, + $excludedUserAgents = null, + $keepURLFragments = null, + $type = null) + { + Piwik::checkUserHasSuperUserAccess(); + + $this->checkName($siteName); + $urls = $this->cleanParameterUrls($urls); + $this->checkUrls($urls); + $this->checkAtLeastOneUrl($urls); + $siteSearch = $this->checkSiteSearch($siteSearch); + list($searchKeywordParameters, $searchCategoryParameters) = $this->checkSiteSearchParameters($searchKeywordParameters, $searchCategoryParameters); + + $keepURLFragments = (int)$keepURLFragments; + self::checkKeepURLFragmentsValue($keepURLFragments); + + $timezone = trim($timezone); + if (empty($timezone)) { + $timezone = $this->getDefaultTimezone(); + } + $this->checkValidTimezone($timezone); + + if (empty($currency)) { + $currency = $this->getDefaultCurrency(); + } + $this->checkValidCurrency($currency); + + $db = Db::get(); + + $url = $urls[0]; + $urls = array_slice($urls, 1); + + $bind = array('name' => $siteName, + 'main_url' => $url, + + ); + + $bind['excluded_ips'] = $this->checkAndReturnExcludedIps($excludedIps); + $bind['excluded_parameters'] = $this->checkAndReturnCommaSeparatedStringList($excludedQueryParameters); + $bind['excluded_user_agents'] = $this->checkAndReturnCommaSeparatedStringList($excludedUserAgents); + $bind['keep_url_fragment'] = $keepURLFragments; + $bind['timezone'] = $timezone; + $bind['currency'] = $currency; + $bind['ecommerce'] = (int)$ecommerce; + $bind['sitesearch'] = $siteSearch; + $bind['sitesearch_keyword_parameters'] = $searchKeywordParameters; + $bind['sitesearch_category_parameters'] = $searchCategoryParameters; + $bind['ts_created'] = !is_null($startDate) + ? Date::factory($startDate)->getDatetime() + : Date::now()->getDatetime(); + $bind['type'] = $this->checkAndReturnType($type); + + if (!empty($group) + && Piwik::hasUserSuperUserAccess() + ) { + $bind['group'] = trim($group); + } else { + $bind['group'] = ""; + } + + $db->insert(Common::prefixTable("site"), $bind); + + $idSite = $db->lastInsertId(); + + $this->insertSiteUrls($idSite, $urls); + + // we reload the access list which doesn't yet take in consideration this new website + Access::getInstance()->reloadAccess(); + $this->postUpdateWebsite($idSite); + + /** + * Triggered after a site has been added. + * + * @param int $idSite The ID of the site that was added. + */ + Piwik::postEvent('SitesManager.addSite.end', array($idSite)); + + return (int)$idSite; + } + + private function postUpdateWebsite($idSite) + { + Site::clearCache(); + Cache::regenerateCacheWebsiteAttributes($idSite); + } + + /** + * Delete a website from the database, given its Id. + * + * Requires Super User access. + * + * @param int $idSite + * @throws Exception + */ + public function deleteSite($idSite) + { + Piwik::checkUserHasSuperUserAccess(); + + $idSites = API::getInstance()->getSitesId(); + if (!in_array($idSite, $idSites)) { + throw new Exception("website id = $idSite not found"); + } + $nbSites = count($idSites); + if ($nbSites == 1) { + throw new Exception(Piwik::translate("SitesManager_ExceptionDeleteSite")); + } + + $db = Db::get(); + + $db->query("DELETE FROM " . Common::prefixTable("site") . " + WHERE idsite = ?", $idSite); + + $db->query("DELETE FROM " . Common::prefixTable("site_url") . " + WHERE idsite = ?", $idSite); + + $db->query("DELETE FROM " . Common::prefixTable("access") . " + WHERE idsite = ?", $idSite); + + // we do not delete logs here on purpose (you can run these queries on the log_ tables to delete all data) + Cache::deleteCacheWebsiteAttributes($idSite); + + /** + * Triggered after a site has been deleted. + * + * Plugins can use this event to remove site specific values or settings, such as removing all + * goals that belong to a specific website. If you store any data related to a website you + * should clean up that information here. + * + * @param int $idSite The ID of the site being deleted. + */ + Piwik::postEvent('SitesManager.deleteSite.end', array($idSite)); + } + + /** + * Checks that the array has at least one element + * + * @param array $urls + * @throws Exception + */ + private function checkAtLeastOneUrl($urls) + { + if (!is_array($urls) + || count($urls) == 0 + ) { + throw new Exception(Piwik::translate("SitesManager_ExceptionNoUrl")); + } + } + + private function checkValidTimezone($timezone) + { + $timezones = $this->getTimezonesList(); + foreach (array_values($timezones) as $cities) { + foreach ($cities as $timezoneId => $city) { + if ($timezoneId == $timezone) { + return true; + } + } + } + throw new Exception(Piwik::translate('SitesManager_ExceptionInvalidTimezone', array($timezone))); + } + + private function checkValidCurrency($currency) + { + if (!in_array($currency, array_keys($this->getCurrencyList()))) { + throw new Exception(Piwik::translate('SitesManager_ExceptionInvalidCurrency', array($currency, "USD, EUR, etc."))); + } + } + + private function checkAndReturnType($type) + { + if(empty($type)) { + $type = Site::DEFAULT_SITE_TYPE; + } + if(!is_string($type)) { + throw new Exception("Invalid website type $type"); + } + return $type; + } + + /** + * Checks that the submitted IPs (comma separated list) are valid + * Returns the cleaned up IPs + * + * @param string $excludedIps Comma separated list of IP addresses + * @throws Exception + * @return array of IPs + */ + private function checkAndReturnExcludedIps($excludedIps) + { + if (empty($excludedIps)) { + return ''; + } + $ips = explode(',', $excludedIps); + $ips = array_map('trim', $ips); + $ips = array_filter($ips, 'strlen'); + foreach ($ips as $ip) { + if (!$this->isValidIp($ip)) { + throw new Exception(Piwik::translate('SitesManager_ExceptionInvalidIPFormat', array($ip, "1.2.3.4, 1.2.3.*, or 1.2.3.4/5"))); + } + } + $ips = implode(',', $ips); + return $ips; + } + + /** + * Add a list of alias Urls to the given idSite + * + * If some URLs given in parameter are already recorded as alias URLs for this website, + * they won't be duplicated. The 'main_url' of the website won't be affected by this method. + * + * @param int $idSite + * @param array|string $urls + * @return int the number of inserted URLs + */ + public function addSiteAliasUrls($idSite, $urls) + { + Piwik::checkUserHasAdminAccess($idSite); + + $urls = $this->cleanParameterUrls($urls); + $this->checkUrls($urls); + + $urlsInit = $this->getSiteUrlsFromId($idSite); + $toInsert = array_diff($urls, $urlsInit); + $this->insertSiteUrls($idSite, $toInsert); + $this->postUpdateWebsite($idSite); + + return count($toInsert); + } + + /** + * Set the list of alias Urls for the given idSite + * + * Completely overwrites the current list of URLs with the provided list. + * The 'main_url' of the website won't be affected by this method. + * + * @return int the number of inserted URLs + */ + public function setSiteAliasUrls($idSite, $urls = array()) + { + Piwik::checkUserHasAdminAccess($idSite); + + $urls = $this->cleanParameterUrls($urls); + $this->checkUrls($urls); + + $this->deleteSiteAliasUrls($idSite); + $this->insertSiteUrls($idSite, $urls); + $this->postUpdateWebsite($idSite); + + return count($urls); + } + + /** + * Get the start and end IP addresses for an IP address range + * + * @param string $ipRange IP address range in presentation format + * @return array|false Array( low, high ) IP addresses in presentation format; or false if error + */ + public function getIpsForRange($ipRange) + { + $range = IP::getIpsForRange($ipRange); + if ($range === false) { + return false; + } + + return array(IP::N2P($range[0]), IP::N2P($range[1])); + } + + /** + * Sets IPs to be excluded from all websites. IPs can contain wildcards. + * Will also apply to websites created in the future. + * + * @param string $excludedIps Comma separated list of IPs to exclude from being tracked (allows wildcards) + * @return bool + */ + public function setGlobalExcludedIps($excludedIps) + { + Piwik::checkUserHasSuperUserAccess(); + $excludedIps = $this->checkAndReturnExcludedIps($excludedIps); + Option::set(self::OPTION_EXCLUDED_IPS_GLOBAL, $excludedIps); + Cache::deleteTrackerCache(); + return true; + } + + /** + * Sets Site Search keyword/category parameter names, to be used on websites which have not specified these values + * Expects Comma separated list of query params names + * + * @param string + * @param string + * @return bool + */ + public function setGlobalSearchParameters($searchKeywordParameters, $searchCategoryParameters) + { + Piwik::checkUserHasSuperUserAccess(); + Option::set(self::OPTION_SEARCH_KEYWORD_QUERY_PARAMETERS_GLOBAL, $searchKeywordParameters); + Option::set(self::OPTION_SEARCH_CATEGORY_QUERY_PARAMETERS_GLOBAL, $searchCategoryParameters); + Cache::deleteTrackerCache(); + return true; + } + + /** + * @return string Comma separated list of URL parameters + */ + public function getSearchKeywordParametersGlobal() + { + Piwik::checkUserHasSomeAdminAccess(); + $names = Option::get(self::OPTION_SEARCH_KEYWORD_QUERY_PARAMETERS_GLOBAL); + if ($names === false) { + $names = self::DEFAULT_SEARCH_KEYWORD_PARAMETERS; + } + if (empty($names)) { + $names = ''; + } + return $names; + } + + /** + * @return string Comma separated list of URL parameters + */ + public function getSearchCategoryParametersGlobal() + { + Piwik::checkUserHasSomeAdminAccess(); + return Option::get(self::OPTION_SEARCH_CATEGORY_QUERY_PARAMETERS_GLOBAL); + } + + /** + * Returns the list of URL query parameters that are excluded from all websites + * + * @return string Comma separated list of URL parameters + */ + public function getExcludedQueryParametersGlobal() + { + Piwik::checkUserHasSomeViewAccess(); + return Option::get(self::OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL); + } + + /** + * Returns the list of user agent substrings to look for when excluding visits for + * all websites. If a visitor's user agent string contains one of these substrings, + * their visits will not be included. + * + * @return string Comma separated list of strings. + */ + public function getExcludedUserAgentsGlobal() + { + Piwik::checkUserHasSomeAdminAccess(); + return Option::get(self::OPTION_EXCLUDED_USER_AGENTS_GLOBAL); + } + + /** + * Sets list of user agent substrings to look for when excluding visits. For more info, + * @see getExcludedUserAgentsGlobal. + * + * @param string $excludedUserAgents Comma separated list of strings. Each element is trimmed, + * and empty strings are removed. + */ + public function setGlobalExcludedUserAgents($excludedUserAgents) + { + Piwik::checkUserHasSuperUserAccess(); + + // update option + $excludedUserAgents = $this->checkAndReturnCommaSeparatedStringList($excludedUserAgents); + Option::set(self::OPTION_EXCLUDED_USER_AGENTS_GLOBAL, $excludedUserAgents); + + // make sure tracker cache will reflect change + Cache::deleteTrackerCache(); + } + + /** + * Returns true if site-specific user agent exclusion has been enabled. If it hasn't, + * only the global user agent substrings (see @setGlobalExcludedUserAgents) will be used. + * + * @return bool + */ + public function isSiteSpecificUserAgentExcludeEnabled() + { + Piwik::checkUserHasSomeAdminAccess(); + return (bool)Option::get(self::OPTION_SITE_SPECIFIC_USER_AGENT_EXCLUDE_ENABLE); + } + + /** + * Sets whether it should be allowed to exclude different user agents for different + * websites. + * + * @param bool $enabled + */ + public function setSiteSpecificUserAgentExcludeEnabled($enabled) + { + Piwik::checkUserHasSuperUserAccess(); + + // update option + Option::set(self::OPTION_SITE_SPECIFIC_USER_AGENT_EXCLUDE_ENABLE, $enabled); + + // make sure tracker cache will reflect change + Cache::deleteTrackerCache(); + } + + /** + * Returns true if the default behavior is to keep URL fragments when tracking, + * false if otherwise. + * + * @return bool + */ + public function getKeepURLFragmentsGlobal() + { + Piwik::checkUserHasSomeViewAccess(); + return (bool)Option::get(self::OPTION_KEEP_URL_FRAGMENTS_GLOBAL); + } + + /** + * Sets whether the default behavior should be to keep URL fragments when + * tracking or not. + * + * @param $enabled bool If true, the default behavior will be to keep URL + * fragments when tracking. If false, the default + * behavior will be to remove them. + */ + public function setKeepURLFragmentsGlobal($enabled) + { + Piwik::checkUserHasSuperUserAccess(); + + // update option + Option::set(self::OPTION_KEEP_URL_FRAGMENTS_GLOBAL, $enabled); + + // make sure tracker cache will reflect change + Cache::deleteTrackerCache(); + } + + /** + * Sets list of URL query parameters to be excluded on all websites. + * Will also apply to websites created in the future. + * + * @param string $excludedQueryParameters Comma separated list of URL query parameters to exclude from URLs + * @return bool + */ + public function setGlobalExcludedQueryParameters($excludedQueryParameters) + { + Piwik::checkUserHasSuperUserAccess(); + $excludedQueryParameters = $this->checkAndReturnCommaSeparatedStringList($excludedQueryParameters); + Option::set(self::OPTION_EXCLUDED_QUERY_PARAMETERS_GLOBAL, $excludedQueryParameters); + Cache::deleteTrackerCache(); + return true; + } + + /** + * Returns the list of IPs that are excluded from all websites + * + * @return string Comma separated list of IPs + */ + public function getExcludedIpsGlobal() + { + Piwik::checkUserHasSomeAdminAccess(); + return Option::get(self::OPTION_EXCLUDED_IPS_GLOBAL); + } + + /** + * Returns the default currency that will be set when creating a website through the API. + * + * @return string Currency ID eg. 'USD' + */ + public function getDefaultCurrency() + { + Piwik::checkUserHasSomeAdminAccess(); + $defaultCurrency = Option::get(self::OPTION_DEFAULT_CURRENCY); + if ($defaultCurrency) { + return $defaultCurrency; + } + return 'USD'; + } + + /** + * Sets the default currency that will be used when creating websites + * + * @param string $defaultCurrency Currency code, eg. 'USD' + * @return bool + */ + public function setDefaultCurrency($defaultCurrency) + { + Piwik::checkUserHasSuperUserAccess(); + $this->checkValidCurrency($defaultCurrency); + Option::set(self::OPTION_DEFAULT_CURRENCY, $defaultCurrency); + return true; + } + + /** + * Returns the default timezone that will be set when creating a website through the API. + * Via the UI, if the default timezone is not UTC, it will be pre-selected in the drop down + * + * @return string Timezone eg. UTC+7 or Europe/Paris + */ + public function getDefaultTimezone() + { + $defaultTimezone = Option::get(self::OPTION_DEFAULT_TIMEZONE); + if ($defaultTimezone) { + return $defaultTimezone; + } + return 'UTC'; + } + + /** + * Sets the default timezone that will be used when creating websites + * + * @param string $defaultTimezone Timezone string eg. Europe/Paris or UTC+8 + * @return bool + */ + public function setDefaultTimezone($defaultTimezone) + { + Piwik::checkUserHasSuperUserAccess(); + $this->checkValidTimezone($defaultTimezone); + Option::set(self::OPTION_DEFAULT_TIMEZONE, $defaultTimezone); + return true; + } + + /** + * Update an existing website. + * If only one URL is specified then only the main url will be updated. + * If several URLs are specified, both the main URL and the alias URLs will be updated. + * + * @param int $idSite website ID defining the website to edit + * @param string $siteName website name + * @param string|array $urls the website URLs + * @param int $ecommerce Whether Ecommerce is enabled, 0 or 1 + * @param null|int $siteSearch Whether site search is enabled, 0 or 1 + * @param string $searchKeywordParameters Comma separated list of search keyword parameter names + * @param string $searchCategoryParameters Comma separated list of search category parameter names + * @param string $excludedIps Comma separated list of IPs to exclude from being tracked (allows wildcards) + * @param null|string $excludedQueryParameters + * @param string $timezone Timezone + * @param string $currency Currency code + * @param string $group Group name where this website belongs + * @param string $startDate Date at which the statistics for this website will start. Defaults to today's date in YYYY-MM-DD format + * @param null|string $excludedUserAgents + * @param int|null $keepURLFragments If 1, URL fragments will be kept when tracking. If 2, they + * will be removed. If 0, the default global behavior will be used. + * @param string $type The Website type, default value is "website" + * @throws Exception + * @see getKeepURLFragmentsGlobal. If null, the existing value will + * not be modified. + * + * @return bool true on success + */ + public function updateSite($idSite, + $siteName = null, + $urls = null, + $ecommerce = null, + $siteSearch = null, + $searchKeywordParameters = null, + $searchCategoryParameters = null, + $excludedIps = null, + $excludedQueryParameters = null, + $timezone = null, + $currency = null, + $group = null, + $startDate = null, + $excludedUserAgents = null, + $keepURLFragments = null, + $type = null) + { + Piwik::checkUserHasAdminAccess($idSite); + + $idSites = API::getInstance()->getSitesId(); + if (!in_array($idSite, $idSites)) { + throw new Exception("website id = $idSite not found"); + } + + // Build the SQL UPDATE based on specified updates to perform + $bind = array(); + + if (!is_null($siteName)) { + $this->checkName($siteName); + $bind['name'] = $siteName; + } + + if (!is_null($urls)) { + $urls = $this->cleanParameterUrls($urls); + $this->checkUrls($urls); + $this->checkAtLeastOneUrl($urls); + $url = $urls[0]; + $bind['main_url'] = $url; + } + + if (!is_null($currency)) { + $currency = trim($currency); + $this->checkValidCurrency($currency); + $bind['currency'] = $currency; + } + if (!is_null($timezone)) { + $timezone = trim($timezone); + $this->checkValidTimezone($timezone); + $bind['timezone'] = $timezone; + } + if (!is_null($group) + && Piwik::hasUserSuperUserAccess() + ) { + $bind['group'] = trim($group); + } + if (!is_null($ecommerce)) { + $bind['ecommerce'] = (int)(bool)$ecommerce; + } + if (!is_null($startDate)) { + $bind['ts_created'] = Date::factory($startDate)->getDatetime(); + } + $bind['excluded_ips'] = $this->checkAndReturnExcludedIps($excludedIps); + $bind['excluded_parameters'] = $this->checkAndReturnCommaSeparatedStringList($excludedQueryParameters); + $bind['excluded_user_agents'] = $this->checkAndReturnCommaSeparatedStringList($excludedUserAgents); + + if (!is_null($keepURLFragments)) { + $keepURLFragments = (int)$keepURLFragments; + self::checkKeepURLFragmentsValue($keepURLFragments); + + $bind['keep_url_fragment'] = $keepURLFragments; + } + + $bind['sitesearch'] = $this->checkSiteSearch($siteSearch); + list($searchKeywordParameters, $searchCategoryParameters) = $this->checkSiteSearchParameters($searchKeywordParameters, $searchCategoryParameters); + $bind['sitesearch_keyword_parameters'] = $searchKeywordParameters; + $bind['sitesearch_category_parameters'] = $searchCategoryParameters; + $bind['type'] = $this->checkAndReturnType($type); + + $db = Db::get(); + $db->update(Common::prefixTable("site"), + $bind, + "idsite = $idSite" + ); + + + // we now update the main + alias URLs + $this->deleteSiteAliasUrls($idSite); + if (count($urls) > 1) { + $this->addSiteAliasUrls($idSite, array_slice($urls, 1)); + } + $this->postUpdateWebsite($idSite); + } + + /** + * Updates the field ts_created for the specified websites. + * + * @param $idSites int Id Site to update ts_created + * @param $minDate Date to set as creation date. To play it safe it will substract one more day. + * + * @ignore + */ + public function updateSiteCreatedTime($idSites, $minDate) + { + $idSites = Site::getIdSitesFromIdSitesString($idSites); + Piwik::checkUserHasAdminAccess($idSites); + + // Update piwik_site.ts_created + $query = "UPDATE " . Common::prefixTable("site") . + " SET ts_created = ?" . + " WHERE idsite IN ( " . implode(",", $idSites) . " ) + AND ts_created > ?"; + $minDateSql = $minDate->subDay(1)->getDatetime(); + $bind = array($minDateSql, $minDateSql); + Db::query($query, $bind); + } + + private function checkAndReturnCommaSeparatedStringList($parameters) + { + $parameters = trim($parameters); + if (empty($parameters)) { + return ''; + } + + $parameters = explode(',', $parameters); + $parameters = array_map('trim', $parameters); + $parameters = array_filter($parameters, 'strlen'); + $parameters = array_unique($parameters); + return implode(',', $parameters); + } + + /** + * Returns the list of supported currencies + * @see getCurrencySymbols() + * @return array ( currencyId => currencyName) + */ + public function getCurrencyList() + { + $currencies = MetricsFormatter::getCurrencyList(); + return array_map(function ($a) { + return $a[1] . " (" . $a[0] . ")"; + }, $currencies); + } + + /** + * Returns the list of currency symbols + * @see getCurrencyList() + * @return array( currencyId => currencySymbol ) + */ + public function getCurrencySymbols() + { + $currencies = MetricsFormatter::getCurrencyList(); + return array_map(function ($a) { + return $a[0]; + }, $currencies); + } + + /** + * Returns the list of timezones supported. + * Used for addSite and updateSite + * + * @return array of timezone strings + */ + public function getTimezonesList() + { + if (!SettingsServer::isTimezoneSupportEnabled()) { + return array('UTC' => $this->getTimezonesListUTCOffsets()); + } + + $continents = array('Africa', 'America', 'Antarctica', 'Arctic', 'Asia', 'Atlantic', 'Australia', 'Europe', 'Indian', 'Pacific'); + $timezones = timezone_identifiers_list(); + + $return = array(); + foreach ($timezones as $timezone) { + // filter out timezones not recognized by strtotime() + // @see http://bugs.php.net/46111 + $testDate = '2008-09-18 13:00:00 ' . $timezone; + if (!strtotime($testDate)) { + continue; + } + + $timezoneExploded = explode('/', $timezone); + $continent = $timezoneExploded[0]; + + // only display timezones that are grouped by continent + if (!in_array($continent, $continents)) { + continue; + } + $city = $timezoneExploded[1]; + if (!empty($timezoneExploded[2])) { + $city .= ' - ' . $timezoneExploded[2]; + } + $city = str_replace('_', ' ', $city); + $return[$continent][$timezone] = $city; + } + + foreach ($continents as $continent) { + if (!empty($return[$continent])) { + ksort($return[$continent]); + } + } + + $return['UTC'] = $this->getTimezonesListUTCOffsets(); + return $return; + } + + private function getTimezonesListUTCOffsets() + { + // manually add the UTC offsets + $GmtOffsets = array(-12, -11.5, -11, -10.5, -10, -9.5, -9, -8.5, -8, -7.5, -7, -6.5, -6, -5.5, -5, -4.5, -4, -3.5, -3, -2.5, -2, -1.5, -1, -0.5, + 0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 5.75, 6, 6.5, 7, 7.5, 8, 8.5, 8.75, 9, 9.5, 10, 10.5, 11, 11.5, 12, 12.75, 13, 13.75, 14); + + $return = array(); + foreach ($GmtOffsets as $offset) { + if ($offset > 0) { + $offset = '+' . $offset; + } elseif ($offset == 0) { + $offset = ''; + } + $offset = 'UTC' . $offset; + $offsetName = str_replace(array('.25', '.5', '.75'), array(':15', ':30', ':45'), $offset); + $return[$offset] = $offsetName; + } + return $return; + } + + /** + * Returns the list of unique timezones from all configured sites. + * + * @return array ( string ) + */ + public function getUniqueSiteTimezones() + { + Piwik::checkUserHasSuperUserAccess(); + $results = Db::fetchAll("SELECT distinct timezone FROM " . Common::prefixTable('site')); + $timezones = array(); + foreach ($results as $result) { + $timezones[] = $result['timezone']; + } + return $timezones; + } + + /** + * Insert the list of alias URLs for the website. + * The URLs must not exist already for this website! + */ + private function insertSiteUrls($idSite, $urls) + { + if (count($urls) != 0) { + $db = Db::get(); + foreach ($urls as $url) { + try { + $db->insert(Common::prefixTable("site_url"), array( + 'idsite' => $idSite, + 'url' => $url + ) + ); + } catch(Exception $e) { + // See bug #4149 + } + } + } + } + + /** + * Delete all the alias URLs for the given idSite. + */ + private function deleteSiteAliasUrls($idsite) + { + $db = Db::get(); + $db->query("DELETE FROM " . Common::prefixTable("site_url") . " + WHERE idsite = ?", $idsite); + } + + /** + * Remove the final slash in the URLs if found + * + * @param string $url + * @return string the URL without the trailing slash + */ + private function removeTrailingSlash($url) + { + // if there is a final slash, we take the URL without this slash (expected URL format) + if (strlen($url) > 5 + && $url[strlen($url) - 1] == '/' + ) { + $url = substr($url, 0, strlen($url) - 1); + } + return $url; + } + + /** + * Tests if the URL is a valid URL + * + * @param string $url + * @return bool + */ + private function isValidUrl($url) + { + return UrlHelper::isLookLikeUrl($url); + } + + /** + * Tests if the IP is a valid IP, allowing wildcards, except in the first octet. + * Wildcards can only be used from right to left, ie. 1.1.*.* is allowed, but 1.1.*.1 is not. + * + * @param string $ip IP address + * @return bool + */ + private function isValidIp($ip) + { + return IP::getIpsForRange($ip) !== false; + } + + /** + * Check that the website name has a correct format. + * + * @param $siteName + * @throws Exception + */ + private function checkName($siteName) + { + if (empty($siteName)) { + throw new Exception(Piwik::translate("SitesManager_ExceptionEmptyName")); + } + } + + private function checkSiteSearch($siteSearch) + { + if ($siteSearch === null) { + return "1"; + } + return $siteSearch == 1 ? "1" : "0"; + } + + private function checkSiteSearchParameters($searchKeywordParameters, $searchCategoryParameters) + { + $searchKeywordParameters = trim($searchKeywordParameters); + $searchCategoryParameters = trim($searchCategoryParameters); + if (empty($searchKeywordParameters)) { + $searchKeywordParameters = ''; + } + if (empty($searchCategoryParameters)) { + $searchCategoryParameters = ''; + } + + return array($searchKeywordParameters, $searchCategoryParameters); + } + + /** + * Check that the array of URLs are valid URLs + * + * @param array $urls + * @throws Exception if any of the urls is not valid + */ + private function checkUrls($urls) + { + foreach ($urls as $url) { + if (!$this->isValidUrl($url)) { + throw new Exception(sprintf(Piwik::translate("SitesManager_ExceptionInvalidUrl"), $url)); + } + } + } + + /** + * Clean the parameter URLs: + * - if the parameter is a string make it an array + * - remove the trailing slashes if found + * + * @param string|array urls + * @return array the array of cleaned URLs + */ + private function cleanParameterUrls($urls) + { + if (!is_array($urls)) { + $urls = array($urls); + } + $urls = array_filter($urls); + + $urls = array_map('urldecode', $urls); + foreach ($urls as &$url) { + $url = $this->removeTrailingSlash($url); + if (strpos($url, 'http') !== 0) { + $url = 'http://' . $url; + } + $url = trim($url); + $url = Common::sanitizeInputValue($url); + } + $urls = array_unique($urls); + return $urls; + } + + public function renameGroup($oldGroupName, $newGroupName) + { + Piwik::checkUserHasSuperUserAccess(); + + if ($oldGroupName == $newGroupName) { + return true; + } + + $sitesHavingOldGroup = $this->getSitesFromGroup($oldGroupName); + + foreach ($sitesHavingOldGroup as $site) { + $this->updateSite($site['idsite'], + $siteName = null, + $urls = null, + $ecommerce = null, + $siteSearch = null, + $searchKeywordParameters = null, + $searchCategoryParameters = null, + $excludedIps = null, + $excludedQueryParameters = null, + $timezone = null, + $currency = null, + $newGroupName); + } + + return true; + } + + public function getPatternMatchSites($pattern) + { + $ids = $this->getSitesIdWithAtLeastViewAccess(); + if (empty($ids)) { + return array(); + } + + $ids_str = ''; + foreach ($ids as $id_val) { + $ids_str .= $id_val . ' , '; + } + $ids_str .= $id_val; + + $db = Db::get(); + $bind = array('%' . $pattern . '%', 'http%' . $pattern . '%', '%' . $pattern . '%'); + + // Also match the idsite + $where = ''; + if (is_numeric($pattern)) { + $bind[] = $pattern; + $where = 'OR s.idsite = ?'; + } + $sites = $db->fetchAll("SELECT idsite, name, main_url, `group` + FROM " . Common::prefixTable('site') . " s + WHERE ( s.name like ? + OR s.main_url like ? + OR s.`group` like ? + $where ) + AND idsite in ($ids_str) + LIMIT " . SettingsPiwik::getWebsitesCountToDisplay(), + $bind); + return $sites; + } + + /** + * Utility function that throws if a value is not valid for the 'keep_url_fragment' + * column of the piwik_site table. + * + * @param int $keepURLFragments + * @throws Exception + */ + private static function checkKeepURLFragmentsValue($keepURLFragments) + { + // make sure value is between 0 & 2 + if (!in_array($keepURLFragments, array(0, 1, 2))) { + throw new Exception("Error in SitesManager.updateSite: keepURLFragments must be between 0 & 2" . + " (actual value: $keepURLFragments)."); + } + } +} diff --git a/www/analytics/plugins/SitesManager/Controller.php b/www/analytics/plugins/SitesManager/Controller.php new file mode 100644 index 00000000..585829c0 --- /dev/null +++ b/www/analytics/plugins/SitesManager/Controller.php @@ -0,0 +1,158 @@ +getAllSites(); + } else { + $sitesRaw = API::getInstance()->getSitesWithAdminAccess(); + } + // Gets sites after Site.setSite hook was called + $sites = array_values( Site::getSites() ); + if(count($sites) != count($sitesRaw)) { + throw new Exception("One or more website are missing or invalid."); + } + + foreach ($sites as &$site) { + $site['alias_urls'] = API::getInstance()->getSiteUrlsFromId($site['idsite']); + $site['excluded_ips'] = explode(',', $site['excluded_ips']); + $site['excluded_parameters'] = explode(',', $site['excluded_parameters']); + $site['excluded_user_agents'] = explode(',', $site['excluded_user_agents']); + } + $view->adminSites = $sites; + $view->adminSitesCount = count($sites); + + $timezones = API::getInstance()->getTimezonesList(); + $view->timezoneSupported = SettingsServer::isTimezoneSupportEnabled(); + $view->timezones = Common::json_encode($timezones); + $view->defaultTimezone = API::getInstance()->getDefaultTimezone(); + + $view->currencies = Common::json_encode(API::getInstance()->getCurrencyList()); + $view->defaultCurrency = API::getInstance()->getDefaultCurrency(); + + $view->utcTime = Date::now()->getDatetime(); + $excludedIpsGlobal = API::getInstance()->getExcludedIpsGlobal(); + $view->globalExcludedIps = str_replace(',', "\n", $excludedIpsGlobal); + $excludedQueryParametersGlobal = API::getInstance()->getExcludedQueryParametersGlobal(); + $view->globalExcludedQueryParameters = str_replace(',', "\n", $excludedQueryParametersGlobal); + + $globalExcludedUserAgents = API::getInstance()->getExcludedUserAgentsGlobal(); + $view->globalExcludedUserAgents = str_replace(',', "\n", $globalExcludedUserAgents); + + $view->globalSearchKeywordParameters = API::getInstance()->getSearchKeywordParametersGlobal(); + $view->globalSearchCategoryParameters = API::getInstance()->getSearchCategoryParametersGlobal(); + $view->isSearchCategoryTrackingEnabled = \Piwik\Plugin\Manager::getInstance()->isPluginActivated('CustomVariables'); + $view->allowSiteSpecificUserAgentExclude = + API::getInstance()->isSiteSpecificUserAgentExcludeEnabled(); + + $view->globalKeepURLFragments = API::getInstance()->getKeepURLFragmentsGlobal(); + + $view->currentIpAddress = IP::getIpFromHeader(); + + $view->showAddSite = (boolean)Common::getRequestVar('showaddsite', false); + + $this->setBasicVariablesView($view); + return $view->render(); + } + + /** + * Records Global settings when user submit changes + */ + public function setGlobalSettings() + { + $response = new ResponseBuilder(Common::getRequestVar('format')); + + try { + $this->checkTokenInUrl(); + $timezone = Common::getRequestVar('timezone', false); + $excludedIps = Common::getRequestVar('excludedIps', false); + $excludedQueryParameters = Common::getRequestVar('excludedQueryParameters', false); + $excludedUserAgents = Common::getRequestVar('excludedUserAgents', false); + $currency = Common::getRequestVar('currency', false); + $searchKeywordParameters = Common::getRequestVar('searchKeywordParameters', $default = ""); + $searchCategoryParameters = Common::getRequestVar('searchCategoryParameters', $default = ""); + $enableSiteUserAgentExclude = Common::getRequestVar('enableSiteUserAgentExclude', $default = 0); + $keepURLFragments = Common::getRequestVar('keepURLFragments', $default = 0); + + $api = API::getInstance(); + $api->setDefaultTimezone($timezone); + $api->setDefaultCurrency($currency); + $api->setGlobalExcludedQueryParameters($excludedQueryParameters); + $api->setGlobalExcludedIps($excludedIps); + $api->setGlobalExcludedUserAgents($excludedUserAgents); + $api->setGlobalSearchParameters($searchKeywordParameters, $searchCategoryParameters); + $api->setSiteSpecificUserAgentExcludeEnabled($enableSiteUserAgentExclude == 1); + $api->setKeepURLFragmentsGlobal($keepURLFragments); + + $toReturn = $response->getResponse(); + } catch (Exception $e) { + $toReturn = $response->getResponseException($e); + } + + return $toReturn; + } + + /** + * Displays the admin UI page showing all tracking tags + * @return string + */ + function displayJavascriptCode() + { + $idSite = Common::getRequestVar('idSite'); + Piwik::checkUserHasViewAccess($idSite); + $jsTag = Piwik::getJavascriptCode($idSite, SettingsPiwik::getPiwikUrl()); + $view = new View('@SitesManager/displayJavascriptCode'); + $this->setBasicVariablesView($view); + $view->idSite = $idSite; + $site = new Site($idSite); + $view->displaySiteName = $site->getName(); + $view->jsTag = $jsTag; + + return $view->render(); + } + + /** + * User will download a file called PiwikTracker.php that is the content of the actual script + */ + function downloadPiwikTracker() + { + $path = PIWIK_INCLUDE_PATH . '/libs/PiwikTracker/'; + $filename = 'PiwikTracker.php'; + header('Content-type: text/php'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + return file_get_contents($path . $filename); + } +} diff --git a/www/analytics/plugins/SitesManager/SitesManager.php b/www/analytics/plugins/SitesManager/SitesManager.php new file mode 100644 index 00000000..6c649c15 --- /dev/null +++ b/www/analytics/plugins/SitesManager/SitesManager.php @@ -0,0 +1,216 @@ + 'getJsFiles', + 'AssetManager.getStylesheetFiles' => 'getStylesheetFiles', + 'Menu.Admin.addItems' => 'addMenu', + 'Tracker.Cache.getSiteAttributes' => 'recordWebsiteDataInCache', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys', + ); + } + + function addMenu() + { + MenuAdmin::getInstance()->add('CoreAdminHome_MenuManage', 'SitesManager_Sites', + array('module' => 'SitesManager', 'action' => 'index'), + Piwik::isUserHasSomeAdminAccess(), + $order = 1); + } + + /** + * Get CSS files + */ + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = "plugins/SitesManager/stylesheets/SitesManager.less"; + $stylesheets[] = "plugins/Zeitgeist/stylesheets/base.less"; + } + + /** + * Get JavaScript files + */ + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = "plugins/SitesManager/javascripts/SitesManager.js"; + } + + /** + * Hooks when a website tracker cache is flushed (website updated, cache deleted, or empty cache) + * Will record in the tracker config file all data needed for this website in Tracker. + * + * @param array $array + * @param int $idSite + * @return void + */ + public function recordWebsiteDataInCache(&$array, $idSite) + { + $idSite = (int)$idSite; + + // add the 'hosts' entry in the website array + $array['hosts'] = $this->getTrackerHosts($idSite); + + $website = API::getInstance()->getSiteFromId($idSite); + $array['excluded_ips'] = $this->getTrackerExcludedIps($website); + $array['excluded_parameters'] = self::getTrackerExcludedQueryParameters($website); + $array['excluded_user_agents'] = self::getExcludedUserAgents($website); + $array['keep_url_fragment'] = self::shouldKeepURLFragmentsFor($website); + $array['sitesearch'] = $website['sitesearch']; + $array['sitesearch_keyword_parameters'] = $this->getTrackerSearchKeywordParameters($website); + $array['sitesearch_category_parameters'] = $this->getTrackerSearchCategoryParameters($website); + } + + /** + * Returns whether we should keep URL fragments for a specific site. + * + * @param array $site DB data for the site. + * @return bool + */ + private static function shouldKeepURLFragmentsFor($site) + { + if ($site['keep_url_fragment'] == self::KEEP_URL_FRAGMENT_YES) { + return true; + } else if ($site['keep_url_fragment'] == self::KEEP_URL_FRAGMENT_NO) { + return false; + } + + return API::getInstance()->getKeepURLFragmentsGlobal(); + } + + private function getTrackerSearchKeywordParameters($website) + { + $searchParameters = $website['sitesearch_keyword_parameters']; + if (empty($searchParameters)) { + $searchParameters = API::getInstance()->getSearchKeywordParametersGlobal(); + } + return explode(",", $searchParameters); + } + + private function getTrackerSearchCategoryParameters($website) + { + $searchParameters = $website['sitesearch_category_parameters']; + if (empty($searchParameters)) { + $searchParameters = API::getInstance()->getSearchCategoryParametersGlobal(); + } + return explode(",", $searchParameters); + } + + /** + * Returns the array of excluded IPs to save in the config file + * + * @param array $website + * @return array + */ + private function getTrackerExcludedIps($website) + { + $excludedIps = $website['excluded_ips']; + $globalExcludedIps = API::getInstance()->getExcludedIpsGlobal(); + + $excludedIps .= ',' . $globalExcludedIps; + + $ipRanges = array(); + foreach (explode(',', $excludedIps) as $ip) { + $ipRange = API::getInstance()->getIpsForRange($ip); + if ($ipRange !== false) { + $ipRanges[] = $ipRange; + } + } + return $ipRanges; + } + + /** + * Returns the array of excluded user agent substrings for a site. Filters out + * any garbage data & trims each entry. + * + * @param array $website The full set of information for a site. + * @return array + */ + private static function getExcludedUserAgents($website) + { + $excludedUserAgents = API::getInstance()->getExcludedUserAgentsGlobal(); + if (API::getInstance()->isSiteSpecificUserAgentExcludeEnabled()) { + $excludedUserAgents .= ',' . $website['excluded_user_agents']; + } + return self::filterBlankFromCommaSepList($excludedUserAgents); + } + + /** + * Returns the array of URL query parameters to exclude from URLs + * + * @param array $website + * @return array + */ + public static function getTrackerExcludedQueryParameters($website) + { + $excludedQueryParameters = $website['excluded_parameters']; + $globalExcludedQueryParameters = API::getInstance()->getExcludedQueryParametersGlobal(); + + $excludedQueryParameters .= ',' . $globalExcludedQueryParameters; + return self::filterBlankFromCommaSepList($excludedQueryParameters); + } + + /** + * Trims each element of a comma-separated list of strings, removes empty elements and + * returns the result (as an array). + * + * @param string $parameters The unfiltered list. + * @return array The filtered list of strings as an array. + */ + static private function filterBlankFromCommaSepList($parameters) + { + $parameters = explode(',', $parameters); + $parameters = array_filter($parameters, 'strlen'); + $parameters = array_unique($parameters); + return $parameters; + } + + /** + * Returns the hosts alias URLs + * @param int $idSite + * @return array + */ + private function getTrackerHosts($idSite) + { + $urls = API::getInstance()->getSiteUrlsFromId($idSite); + $hosts = array(); + foreach ($urls as $url) { + $url = parse_url($url); + if (isset($url['host'])) { + $hosts[] = $url['host']; + } + } + return $hosts; + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = "General_Save"; + $translationKeys[] = "General_OrCancel"; + $translationKeys[] = "SitesManager_OnlyOneSiteAtTime"; + $translationKeys[] = "SitesManager_DeleteConfirm"; + } +} diff --git a/www/analytics/plugins/SitesManager/javascripts/SitesManager.js b/www/analytics/plugins/SitesManager/javascripts/SitesManager.js new file mode 100644 index 00000000..7290e1ed --- /dev/null +++ b/www/analytics/plugins/SitesManager/javascripts/SitesManager.js @@ -0,0 +1,473 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +// NOTE: if you cannot find the definition of a variable here, look in index.twig +function SitesManager(_timezones, _currencies, _defaultTimezone, _defaultCurrency) { + + var timezones = _timezones; + var currencies = _currencies; + var defaultTimezone = _defaultTimezone; + var defaultCurrency = _defaultCurrency; + var siteBeingEdited = false; + var siteBeingEditedName = ''; + + function sendDeleteSiteAJAX(idSite) { + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + idSite: idSite, + module: 'API', + format: 'json', + method: 'SitesManager.deleteSite' + }, 'GET'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(); + ajaxHandler.send(true); + } + + function sendAddSiteAJAX(row) { + var siteName = $(row).find('input#name').val(); + var urls = $(row).find('textarea#urls').val(); + urls = urls.trim().split("\n"); + var excludedIps = $(row).find('textarea#excludedIps').val(); + excludedIps = piwikHelper.getApiFormatTextarea(excludedIps); + var timezone = $(row).find('#timezones option:selected').val(); + var currency = $(row).find('#currencies option:selected').val(); + var excludedQueryParameters = $(row).find('textarea#excludedQueryParameters').val(); + excludedQueryParameters = piwikHelper.getApiFormatTextarea(excludedQueryParameters); + var excludedUserAgents = $(row).find('textarea#excludedUserAgents').val(); + excludedUserAgents = piwikHelper.getApiFormatTextarea(excludedUserAgents); + var keepURLFragments = $('#keepURLFragmentSelect', row).val(); + var ecommerce = $(row).find('#ecommerce option:selected').val(); + var sitesearch = $(row).find('#sitesearch option:selected').val(); + var searchKeywordParameters = $('input#searchKeywordParameters').val(); + var searchCategoryParameters = $('input#searchCategoryParameters').val(); + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'SitesManager.addSite' + }, 'GET'); + ajaxHandler.addParams({ + siteName: siteName, + timezone: timezone, + currency: currency, + ecommerce: ecommerce, + excludedIps: excludedIps, + excludedQueryParameters: excludedQueryParameters, + excludedUserAgents: excludedUserAgents, + keepURLFragments: keepURLFragments, + siteSearch: sitesearch, + searchKeywordParameters: searchKeywordParameters, + searchCategoryParameters: searchCategoryParameters, + urls: urls + }, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(); + ajaxHandler.send(true); + } + + function sendUpdateSiteAJAX(row) { + var siteName = $(row).find('input#siteName').val(); + var idSite = $(row).children('#idSite').html(); + var urls = $(row).find('textarea#urls').val(); + urls = urls.trim().split("\n"); + var excludedIps = $(row).find('textarea#excludedIps').val(); + excludedIps = piwikHelper.getApiFormatTextarea(excludedIps); + + var excludedQueryParameters = $(row).find('textarea#excludedQueryParameters').val(); + excludedQueryParameters = piwikHelper.getApiFormatTextarea(excludedQueryParameters); + var excludedUserAgents = $(row).find('textarea#excludedUserAgents').val(); + excludedUserAgents = piwikHelper.getApiFormatTextarea(excludedUserAgents); + var keepURLFragments = $('#keepURLFragmentSelect', row).val(); + var timezone = $(row).find('#timezones option:selected').val(); + var currency = $(row).find('#currencies option:selected').val(); + var ecommerce = $(row).find('#ecommerce option:selected').val(); + var sitesearch = $(row).find('#sitesearch option:selected').val(); + var searchKeywordParameters = $('input#searchKeywordParameters').val(); + var searchCategoryParameters = $('input#searchCategoryParameters').val(); + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'API', + format: 'json', + method: 'SitesManager.updateSite', + idSite: idSite + }, 'GET'); + ajaxHandler.addParams({ + siteName: siteName, + timezone: timezone, + currency: currency, + ecommerce: ecommerce, + excludedIps: excludedIps, + excludedQueryParameters: excludedQueryParameters, + excludedUserAgents: excludedUserAgents, + keepURLFragments: keepURLFragments, + siteSearch: sitesearch, + searchKeywordParameters: searchKeywordParameters, + searchCategoryParameters: searchCategoryParameters, + urls: urls + }, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement(); + ajaxHandler.send(true); + } + + function sendGlobalSettingsAJAX() { + var timezone = $('#defaultTimezone').find('option:selected').val(); + var currency = $('#defaultCurrency').find('option:selected').val(); + var excludedIps = $('textarea#globalExcludedIps').val(); + excludedIps = piwikHelper.getApiFormatTextarea(excludedIps); + var excludedQueryParameters = $('textarea#globalExcludedQueryParameters').val(); + excludedQueryParameters = piwikHelper.getApiFormatTextarea(excludedQueryParameters); + var globalExcludedUserAgents = $('textarea#globalExcludedUserAgents').val(); + globalExcludedUserAgents = piwikHelper.getApiFormatTextarea(globalExcludedUserAgents); + var globalKeepURLFragments = $('#globalKeepURLFragments').is(':checked') ? 1 : 0; + var searchKeywordParameters = $('input#globalSearchKeywordParameters').val(); + var searchCategoryParameters = $('input#globalSearchCategoryParameters').val(); + var enableSiteUserAgentExclude = $('input#enableSiteUserAgentExclude').is(':checked') ? 1 : 0; + + var ajaxHandler = new ajaxHelper(); + ajaxHandler.addParams({ + module: 'SitesManager', + format: 'json', + action: 'setGlobalSettings' + }, 'GET'); + ajaxHandler.addParams({ + timezone: timezone, + currency: currency, + excludedIps: excludedIps, + excludedQueryParameters: excludedQueryParameters, + excludedUserAgents: globalExcludedUserAgents, + keepURLFragments: globalKeepURLFragments, + enableSiteUserAgentExclude: enableSiteUserAgentExclude, + searchKeywordParameters: searchKeywordParameters, + searchCategoryParameters: searchCategoryParameters + }, 'POST'); + ajaxHandler.redirectOnSuccess(); + ajaxHandler.setLoadingElement('#ajaxLoadingGlobalSettings'); + ajaxHandler.setErrorElement('#ajaxErrorGlobalSettings'); + ajaxHandler.send(true); + } + + this.init = function () { + $('.addRowSite').click(function () { + piwikHelper.hideAjaxError(); + $('.addRowSite').toggle(); + + var excludedUserAgentCell = ''; + if ($('#exclude-user-agent-header').is(':visible')) { + excludedUserAgentCell = '
            ' + excludedUserAgentsHelp + ''; + } + + var numberOfRows = $('table#editSites')[0].rows.length; + var newRowId = 'rowNew' + numberOfRows; + var submitButtonHtml = ''; + $($.parseHTML(' \ +  \ +


            ' + submitButtonHtml + '\ +
            ' + aliasUrlsHelp + keepURLFragmentSelectHTML + '\ +
            ' + excludedIpHelp + '\ +
            ' + excludedQueryParametersHelp + '' + + excludedUserAgentCell + + '' + getSitesearchSelector(false) + '\ + ' + getTimezoneSelector(defaultTimezone) + '
            ' + timezoneHelp + '\ + ' + getCurrencySelector(defaultCurrency) + '
            ' + currencyHelp + '\ + ' + getEcommerceSelector(0) + '
            ' + ecommerceHelp + '\ + ' + submitButtonHtml + '\ + ' + sprintf(_pk_translate('General_OrCancel'), "", "") + '\ + ')) + .appendTo('#editSites') + ; + + piwikHelper.lazyScrollTo('#' + newRowId); + + $('.addsite').click(function () { + sendAddSiteAJAX($('tr#' + newRowId)); + }); + + $('.cancel').click(function () { + piwikHelper.hideAjaxError(); + $(this).parents('tr').remove(); + $('.addRowSite').toggle(); + }); + return false; + }); + + // when click on deleteuser, the we ask for confirmation and then delete the user + $('.deleteSite').click(function () { + piwikHelper.hideAjaxError(); + var idRow = $(this).attr('id'); + var nameToDelete = $(this).parent().parent().find('input#siteName').val() || $(this).parent().parent().find('td#siteName').html(); + var idsiteToDelete = $(this).parent().parent().find('#idSite').html(); + + $('#confirm').find('h2').text(sprintf(_pk_translate('SitesManager_DeleteConfirm'), '"' + nameToDelete + '" (idSite = ' + idsiteToDelete + ')')); + piwikHelper.modalConfirm('#confirm', { yes: function () { + + sendDeleteSiteAJAX(idsiteToDelete); + }}); + } + ); + + var alreadyEdited = []; + $('.editSite') + .click(function () { + piwikHelper.hideAjaxError(); + var idRow = $(this).attr('id'); + if (alreadyEdited[idRow] == 1) return; + if (siteBeingEdited) { + $('#alert').find('h2').text(sprintf(_pk_translate('SitesManager_OnlyOneSiteAtTime'), '"' + $("
            ").html(siteBeingEditedName).text() + '"')); + piwikHelper.modalConfirm('#alert', {}); + return; + } + siteBeingEdited = true; + + alreadyEdited[idRow] = 1; + $('tr#' + idRow + ' .editableSite').each( + // make the fields editable + // change the EDIT button to VALID button + function (i, n) { + var contentBefore = $(n).html(); + + var idName = $(n).attr('id'); + if (idName == 'siteName') { + siteBeingEditedName = contentBefore; + var contentAfter = ''; + + var inputSave = $('
            ') + .click(function () { submitUpdateSite($(this).parent()); }); + var spanCancel = $('

            ' + sprintf(_pk_translate('General_OrCancel'), "", "") + '
            ') + .click(function () { piwikHelper.refreshAfter(0); }); + $(n) + .html(contentAfter) + .keypress(submitSiteOnEnter) + .append(inputSave) + .append(spanCancel); + } + else if (idName == 'urls') { + var keepURLFragmentsForSite = $(this).closest('tr').attr('data-keep-url-fragments'); + var contentAfter = ''; + contentAfter += '
            ' + aliasUrlsHelp + keepURLFragmentSelectHTML; + $(n).html(contentAfter).find('select').val(keepURLFragmentsForSite); + } + else if (idName == 'excludedIps') { + var contentAfter = ''; + contentAfter += '
            ' + excludedIpHelp; + $(n).html(contentAfter); + } + else if (idName == 'excludedQueryParameters') { + var contentAfter = ''; + contentAfter += '
            ' + excludedQueryParametersHelp; + $(n).html(contentAfter); + } + else if (idName == 'excludedUserAgents') { + var contentAfter = '
            ' + excludedUserAgentsHelp; + $(n).html(contentAfter); + } + else if (idName == 'timezone') { + var contentAfter = getTimezoneSelector(contentBefore); + contentAfter += '
            ' + timezoneHelp; + $(n).html(contentAfter); + } + else if (idName == 'currency') { + var contentAfter = getCurrencySelector(contentBefore); + contentAfter += '
            ' + currencyHelp; + $(n).html(contentAfter); + } + else if (idName == 'ecommerce') { + var ecommerceActive = contentBefore.indexOf("ecommerceActive") > 0 ? 1 : 0; + contentAfter = getEcommerceSelector(ecommerceActive) + '
            ' + ecommerceHelp; + $(n).html(contentAfter); + } + else if (idName == 'sitesearch') { + contentAfter = getSitesearchSelector(contentBefore); + $(n).html(contentAfter); + onClickSiteSearchUseDefault(); + } + } + ); + $(this) + .toggle() + .parent() + .prepend($('') + .click(function () { sendUpdateSiteAJAX($('tr#' + idRow)); }) + ); + }); + + $('#globalSettingsSubmit').click(function () { + sendGlobalSettingsAJAX(); + }); + + $('#defaultTimezone').html(getTimezoneSelector(defaultTimezone)); + $('#defaultCurrency').html(getCurrencySelector(defaultCurrency)); + + $('td.editableSite').click(function (event) { + $(this).parent().find('.editSite').click(); + }); + }; + + function getSitesearchSelector(contentBefore) { + var globalKeywordParameters = $('input#globalSearchKeywordParameters').val().trim(); + var globalCategoryParameters = $('input#globalSearchCategoryParameters').val().trim(); + if (contentBefore) { + var enabled = contentBefore.indexOf("sitesearchActive") > 0 ? 1 : 0; + var spanSearch = $(contentBefore).filter('.sskp'); + var searchKeywordParameters = spanSearch.attr('sitesearch_keyword_parameters').trim(); + var searchCategoryParameters = spanSearch.attr('sitesearch_category_parameters').trim(); + var checked = globalKeywordParameters.length && !searchKeywordParameters.trim().length; + } else { + var searchKeywordParameters = globalKeywordParameters; + var searchCategoryParameters = globalCategoryParameters; + var enabled = searchKeywordParameters.length || searchCategoryParameters.length; // default is enabled + var checked = enabled; + } + + var searchGlobalHasValues = globalKeywordParameters.trim().length; + var html = ''; + html += '
            '; + + if (searchGlobalHasValues) { + var checkedStr = checked ? ' checked ' : ''; + html += ''; + + ''; + + html += '
            ' + + searchKeywordLabel + ' (' + strDefault + ') ' + ': ' + + piwikHelper.htmlEntities( globalKeywordParameters ) + + (globalCategoryParameters.length ? ', ' + searchCategoryLabel + ': ' + piwikHelper.htmlEntities(globalCategoryParameters) : '') + + '
            '; + } + html += '
            ' + sitesearchIntro + '
            '; + + html += '
            '; + html += '
            '; + + // if custom var plugin is disabled, category tracking not supported + if (globalCategoryParameters != 'globalSearchCategoryParametersIsDisabled') { + html += '
            '; + } + html += '
            '; + + return html; + } + + function getEcommerceSelector(enabled) { + var html = ''; + return html; + } + + function getTimezoneSelector(selectedTimezone) { + var html = ''; + return html; + } + + + function getCurrencySelector(selectedCurrency) { + var html = ''; + return html; + } + + function submitSiteOnEnter(e) { + var key = e.keyCode || e.which; + if (key == 13) { + submitUpdateSite(this); + $(this).find('.addsite').click(); + } + } + + function submitUpdateSite(self) { + $(self).parent().find('.updateSite').click(); + } +} + +function onClickSiteSearchUseDefault() { + // Site Search enabled + if ($('select#sitesearch').val() == "1") { + $('#sitesearchUseDefault').show(); + + // Use default is checked + if ($('#sitesearchUseDefaultCheck').is(':checked')) { + $('#searchSiteParameters').hide(); + $('#sitesearchIntro').show(); + $('#searchKeywordParameters,#searchCategoryParameters').val(''); + $('.searchDisplayParams').show(); + // Use default is unchecked + + } else { + $('#sitesearchIntro').hide(); + $('.searchDisplayParams').hide(); + $('#searchSiteParameters').show(); + } + } else { + $('.searchDisplayParams').hide(); + $('#sitesearchUseDefault').hide(); + $('#searchSiteParameters').hide(); + $('#sitesearchIntro').show(); + } +} + +$(function () { + + // when code element is clicked, select the text + $('.trackingHelp code').click(function() { + // credit where credit is due: + // http://stackoverflow.com/questions/1173194/select-all-div-text-with-single-mouse-click + var range; + if (document.body.createTextRange) // MSIE + { + range = document.body.createTextRange(); + range.moveToElementText(this); + range.select(); + } + else if (window.getSelection) // others + { + range = document.createRange(); + range.selectNodeContents(this); + + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + }) + .click(); +}); diff --git a/www/analytics/plugins/SitesManager/stylesheets/SitesManager.less b/www/analytics/plugins/SitesManager/stylesheets/SitesManager.less new file mode 100644 index 00000000..fca6f5d8 --- /dev/null +++ b/www/analytics/plugins/SitesManager/stylesheets/SitesManager.less @@ -0,0 +1,69 @@ +.trackingHelp ul { + padding-left: 40px; + list-style-type: square; +} + +.trackingHelp ul li { + margin-bottom: 10px; +} + +.trackingHelp h2 { + margin-top: 20px; +} + +.trackingHelp p { + text-align: justify; +} + +.addRowSite { + text-decoration: none; +} + +.addRowSite:before { + display: inline-block; + content: url(plugins/UsersManager/images/add.png); + vertical-align: middle; +} + +#editSites { + vertical-align: top; +} + +#editSites h4 { + font-size: .8em; + margin: 1em 0 1em 0; + font-weight: bold; +} + +#editSites .entityTable tr td { + vertical-align: top; + padding-top: 7px; +} + +#editSites .addRowSite:hover, +#editSites .editableSite:hover, +#editSites .addsite:hover, +#editSites .cancel:hover, +#editSites .deleteSite:hover, +#editSites .editSite:hover, +#editSites .updateSite:hover { + cursor: pointer; +} + +#editSites .addRowSite a { + text-decoration: none; +} + +#editSites .addRowSite { + padding: 1em; + font-weight: bold; +} + +.ecommerceInactive, +.sitesearchInactive { + color: #666666; +} + +#searchSiteParameters { + display: none; +} \ No newline at end of file diff --git a/www/analytics/plugins/SitesManager/templates/_displayJavascriptCode.twig b/www/analytics/plugins/SitesManager/templates/_displayJavascriptCode.twig new file mode 100644 index 00000000..9684358a --- /dev/null +++ b/www/analytics/plugins/SitesManager/templates/_displayJavascriptCode.twig @@ -0,0 +1,18 @@ +

            {{ 'SitesManager_TrackingTags'|translate(displaySiteName) }}

            + +
            +

            {{ 'Installation_JSTracking_Intro'|translate }}

            + +

            {{ 'CoreAdminHome_JSTrackingIntro3'|translate('','')|raw }}

            + +

            {{ 'General_JsTrackingTag'|translate }}

            + +

            {{ 'CoreAdminHome_JSTracking_CodeNote'|translate("</body>")|raw }}

            + +
            {{ jsTag|raw }}
            + +
            +

            {{ 'CoreAdminHome_JSTrackingIntro5'|translate('','')|raw }}

            + +

            {{ 'Installation_JSTracking_EndNote'|translate('','')|raw }}

            +
            \ No newline at end of file diff --git a/www/analytics/plugins/SitesManager/templates/displayJavascriptCode.twig b/www/analytics/plugins/SitesManager/templates/displayJavascriptCode.twig new file mode 100644 index 00000000..9b514b69 --- /dev/null +++ b/www/analytics/plugins/SitesManager/templates/displayJavascriptCode.twig @@ -0,0 +1,6 @@ +{% extends 'admin.twig' %} + +{% block content %} + +{% include "@SitesManager/_displayJavascriptCode.twig" %} +{% endblock %} \ No newline at end of file diff --git a/www/analytics/plugins/SitesManager/templates/index.twig b/www/analytics/plugins/SitesManager/templates/index.twig new file mode 100644 index 00000000..19f788b3 --- /dev/null +++ b/www/analytics/plugins/SitesManager/templates/index.twig @@ -0,0 +1,426 @@ +{% extends 'admin.twig' %} + +{% block content %} + {% import 'macros.twig' as piwik %} + {% import 'ajaxMacros.twig' as ajax %} + + {% set excludedIpHelpPlain %} + {{ 'SitesManager_HelpExcludedIps'|translate("1.2.3.*","1.2.*.*") }} +

            + {{ 'SitesManager_YourCurrentIpAddressIs'|translate("" ~ currentIpAddress ~ "")|raw }} + {% endset %} + + {% set excludedIpHelp=piwik.inlineHelp(excludedIpHelpPlain) %} + + {% set defaultTimezoneHelpPlain %} + {% if timezoneSupported %} + {{ 'SitesManager_ChooseCityInSameTimezoneAsYou'|translate }} + {% else %} + {{ 'SitesManager_AdvancedTimezoneSupportNotFound'|translate }} + {% endif %} +

            + {{ 'SitesManager_UTCTimeIs'|translate(utcTime) }} + {% endset %} + + {% set timezoneHelpPlain %} + {{ defaultTimezoneHelpPlain }} +

            + {{ 'SitesManager_ChangingYourTimezoneWillOnlyAffectDataForward'|translate }} + {% endset %} + + {% set currencyHelpPlain %} + {{ piwik.inlineHelp('SitesManager_CurrencySymbolWillBeUsedForGoals'|translate) }} + {% endset %} + + {% set ecommerceHelpPlain %} + {{ 'SitesManager_EcommerceHelp'|translate }} +
            + {{ 'SitesManager_PiwikOffersEcommerceAnalytics'|translate("","")|raw }} + {% endset %} + + {% set excludedQueryParametersHelp %} + {{ 'SitesManager_ListOfQueryParametersToExclude'|translate }} +

            + {{ 'SitesManager_PiwikWillAutomaticallyExcludeCommonSessionParameters'|translate("phpsessid, sessionid, ...") }} + {% endset %} + + {% set excludedQueryParametersHelp=piwik.inlineHelp(excludedQueryParametersHelp) %} + + {% set excludedUserAgentsHelp %} + {{ 'SitesManager_GlobalExcludedUserAgentHelp1'|translate }} +

            + {{ 'SitesManager_GlobalListExcludedUserAgents_Desc'|translate }} {{ 'SitesManager_GlobalExcludedUserAgentHelp2'|translate }} + {% endset %} + + {% set excludedUserAgentsHelp=piwik.inlineHelp(excludedUserAgentsHelp) %} + + {% set keepURLFragmentSelectHTML %} +

            {{ 'SitesManager_KeepURLFragmentsLong'|translate }}

            + + + {% endset %} + + + +

            {{ 'SitesManager_WebsitesManagement'|translate }}

            +

            {{ 'SitesManager_MainDescription'|translate }} + {{ 'SitesManager_YouCurrentlyHaveAccessToNWebsites'|translate("" ~ adminSitesCount ~ "")|raw }} + {% if isSuperUser %} +
            + {{ 'SitesManager_SuperUserAccessCan'|translate("","")|raw }} + {% endif %} +

            + {{ ajax.errorDiv() }} + {{ ajax.loadingDiv() }} + + {% set createNewWebsite %} + + {{ 'SitesManager_AddSite'|translate }} + + {% endset %} + + {% if adminSites|length == 0 %} + {{ 'SitesManager_NoWebsites'|translate }} + {% else %} + +
            +

            + + +
            +
            + {% if isSuperUser %} + {{ createNewWebsite }} + {% endif %} + + + + + + + + + + + + + + + + + + + + {% for i,site in adminSites %} + + + + + + + + + + + + + + + + {% endfor %} + +
            {{ 'General_Id'|translate }}{{ 'General_Name'|translate }}{{ 'SitesManager_Urls'|translate }}{{ 'SitesManager_ExcludedIps'|translate }}{{ 'SitesManager_ExcludedParameters'|translate|replace({" ":"
            "})|raw }}
            {{ 'SitesManager_ExcludedUserAgents'|translate }}{{ 'Actions_SubmenuSitesearch'|translate }}{{ 'SitesManager_Timezone'|translate }}{{ 'SitesManager_Currency'|translate }}{{ 'Goals_Ecommerce'|translate }}{{ 'General_JsTrackingTag'|translate }}
            {{ site.idsite }} + {{- site.name|raw -}} + + {%- for url in site.alias_urls -%} + {{- url|trim|replace({'http://': ''})|raw -}}
            + {%- endfor -%} +
            + {%- for ip in site.excluded_ips -%} + {{- ip -}}
            + {%- endfor -%} +
            + {%- for parameter in site.excluded_parameters -%} + {{- parameter|raw -}}
            + {%- endfor -%} +
            + {%- for ua in site.excluded_user_agents -%} + {{- ua|raw -}}
            + {%- endfor -%} +
            + {% if site.sitesearch %} + {{ 'General_Yes'|translate }} + {% else %} + - + {% endif %} + + + {{ site.timezone }}{{ site.currency }} + {% if site.ecommerce %} + {{ 'General_Yes'|translate }} + {% else %} + - + {% endif %} + + + + {{ 'General_Edit'|translate }} + + + + + {{ 'General_Delete'|translate }} + + + + {{ 'SitesManager_ShowTrackingTag'|translate }} + +
            + {% if isSuperUser %} + {{ createNewWebsite }} + {% endif %} +
            + {% endif %} + + {# Admin users use these values for Site Search column, when editing websites #} + {% if not isSuperUser %} + + + {% endif %} + + {% if isSuperUser %} +
            + +

            {{ 'SitesManager_GlobalWebsitesSettings'|translate }}

            +
            + + + + + + + + + + + + + + + + + + + + {# global excluded user agents #} + + + + + + + + + + + + + + + {# global keep URL fragments #} + + + + + {# global site search #} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
            + {{ 'SitesManager_GlobalListExcludedIps'|translate }} + +

            {{ 'SitesManager_ListOfIpsToBeExcludedOnAllWebsites'|translate }}

            +
            + + + +
            + {{ 'SitesManager_GlobalListExcludedQueryParameters'|translate }} + +

            {{ 'SitesManager_ListOfQueryParametersToBeExcludedOnAllWebsites'|translate }}

            +
            + + + +
            + {{ 'SitesManager_GlobalListExcludedUserAgents'|translate }} + +

            {{ 'SitesManager_GlobalListExcludedUserAgents_Desc'|translate }}

            +
            + + +
            + + + + + {{ piwik.inlineHelp('SitesManager_EnableSiteSpecificUserAgentExclude_Help'|translate('',''))|raw }} +
            + {{ 'SitesManager_KeepURLFragments'|translate }} + +

            {{ 'SitesManager_KeepURLFragmentsHelp'|translate("#","example.org/index.html#first_section","example.org/index.html")|raw }} +

            + + + +

            {{ 'SitesManager_KeepURLFragmentsHelp2'|translate }}

            +
            + {{ 'SitesManager_TrackingSiteSearch'|translate }} + +

            {{ sitesearchIntro }}

            + + {{ 'SitesManager_SearchParametersNote'|translate }} {{ 'SitesManager_SearchParametersNote2'|translate }} + +
            + +
            + {% if not isSearchCategoryTrackingEnabled %} + + Note: you could also track your Internal Search Engine Categories, but the plugin Custom Variables is required. Please enable the plugin CustomVariables (or ask your Piwik admin). + {% else %} + {{ 'Goals_Optional'|translate }} {{ 'SitesManager_SearchCategoryDesc'|translate }}
            +
            + + {% endif %} +
            + {{ 'SitesManager_DefaultTimezoneForNewWebsites'|translate }} + +

            {{ 'SitesManager_SelectDefaultTimezone'|translate }}

            +
            +
            +
            + {{ defaultTimezoneHelp }} +
            + {{ 'SitesManager_DefaultCurrencyForNewWebsites'|translate }} + +

            {{ 'SitesManager_SelectDefaultCurrency'|translate }}

            +
            +
            +
            + {{ currencyHelpPlain }} +
            + + + + {{ ajax.errorDiv('ajaxErrorGlobalSettings') }} + {{ ajax.loadingDiv('ajaxLoadingGlobalSettings') }} + {% endif %} + {% if showAddSite %} + + {% endif %} + +



            +{% endblock %} diff --git a/www/analytics/plugins/Transitions/API.php b/www/analytics/plugins/Transitions/API.php new file mode 100644 index 00000000..382801d1 --- /dev/null +++ b/www/analytics/plugins/Transitions/API.php @@ -0,0 +1,605 @@ +getTransitionsForAction($pageTitle, 'title', $idSite, $period, $date, $segment, $limitBeforeGrouping); + } + + public function getTransitionsForPageUrl($pageUrl, $idSite, $period, $date, $segment = false, $limitBeforeGrouping = false) + { + return $this->getTransitionsForAction($pageUrl, 'url', $idSite, $period, $date, $segment, $limitBeforeGrouping); + } + + /** + * General method to get transitions for an action + * + * @param $actionName + * @param $actionType "url"|"title" + * @param $idSite + * @param $period + * @param $date + * @param bool $segment + * @param bool $limitBeforeGrouping + * @param string $parts + * @return array + * @throws Exception + */ + public function getTransitionsForAction($actionName, $actionType, $idSite, $period, $date, + $segment = false, $limitBeforeGrouping = false, $parts = 'all') + { + Piwik::checkUserHasViewAccess($idSite); + + // get idaction of the requested action + $idaction = $this->deriveIdAction($actionName, $actionType); + if ($idaction < 0) { + throw new Exception('NoDataForAction'); + } + + // prepare log aggregator + $segment = new Segment($segment, $idSite); + $site = new Site($idSite); + $period = Period::factory($period, $date); + $params = new ArchiveProcessor\Parameters($site, $period, $segment); + $logAggregator = new LogAggregator($params); + + // prepare the report + $report = array( + 'date' => Period::factory($period->getLabel(), $date)->getLocalizedShortString() + ); + + $partsArray = explode(',', $parts); + if ($parts == 'all' || in_array('internalReferrers', $partsArray)) { + $this->addInternalReferrers($logAggregator, $report, $idaction, $actionType, $limitBeforeGrouping); + } + if ($parts == 'all' || in_array('followingActions', $partsArray)) { + $includeLoops = $parts != 'all' && !in_array('internalReferrers', $partsArray); + $this->addFollowingActions($logAggregator, $report, $idaction, $actionType, $limitBeforeGrouping, $includeLoops); + } + if ($parts == 'all' || in_array('externalReferrers', $partsArray)) { + $this->addExternalReferrers($logAggregator, $report, $idaction, $actionType, $limitBeforeGrouping); + } + + // derive the number of exits from the other metrics + if ($parts == 'all') { + $report['pageMetrics']['exits'] = $report['pageMetrics']['pageviews'] + - $this->getTotalTransitionsToFollowingActions() + - $report['pageMetrics']['loops']; + } + + // replace column names in the data tables + $reportNames = array( + 'previousPages' => true, + 'previousSiteSearches' => false, + 'followingPages' => true, + 'followingSiteSearches' => false, + 'outlinks' => true, + 'downloads' => true + ); + foreach ($reportNames as $reportName => $replaceLabel) { + if (isset($report[$reportName])) { + $columnNames = array(Metrics::INDEX_NB_ACTIONS => 'referrals'); + if ($replaceLabel) { + $columnNames[Metrics::INDEX_NB_ACTIONS] = 'referrals'; + } + $report[$reportName]->filter('ReplaceColumnNames', array($columnNames)); + } + } + + return $report; + } + + /** + * Derive the action ID from the request action name and type. + */ + private function deriveIdAction($actionName, $actionType) + { + $actionsPlugin = new Actions; + switch ($actionType) { + case 'url': + $originalActionName = $actionName; + $actionName = Common::unsanitizeInputValue($actionName); + $id = TableLogAction::getIdActionFromSegment($actionName, 'idaction_url', SegmentExpression::MATCH_EQUAL, 'pageUrl'); + + if ($id < 0) { + // an example where this is needed is urls containing < or > + $actionName = $originalActionName; + $id = TableLogAction::getIdActionFromSegment($actionName, 'idaction_url', SegmentExpression::MATCH_EQUAL, 'pageUrl'); + } + + return $id; + + case 'title': + $id = TableLogAction::getIdActionFromSegment($actionName, 'idaction_name', SegmentExpression::MATCH_EQUAL, 'pageTitle'); + + if ($id < 0) { + $unknown = ArchivingHelper::getUnknownActionName(Action::TYPE_PAGE_TITLE); + if (trim($actionName) == trim($unknown)) { + $id = TableLogAction::getIdActionFromSegment('', 'idaction_name', SegmentExpression::MATCH_EQUAL, 'pageTitle'); + } + } + + return $id; + + default: + throw new Exception('Unknown action type'); + } + } + + /** + * Add the internal referrers to the report: + * previous pages and previous site searches + * + * @param LogAggregator $logAggregator + * @param $report + * @param $idaction + * @param string $actionType + * @param $limitBeforeGrouping + * @throws Exception + */ + private function addInternalReferrers($logAggregator, &$report, $idaction, $actionType, $limitBeforeGrouping) + { + + $data = $this->queryInternalReferrers($idaction, $actionType, $logAggregator, $limitBeforeGrouping); + + if ($data['pageviews'] == 0) { + throw new Exception('NoDataForAction'); + } + + $report['previousPages'] = & $data['previousPages']; + $report['previousSiteSearches'] = & $data['previousSiteSearches']; + $report['pageMetrics']['loops'] = $data['loops']; + $report['pageMetrics']['pageviews'] = $data['pageviews']; + } + + /** + * Add the following actions to the report: + * following pages, downloads, outlinks + * + * @param LogAggregator $logAggregator + * @param $report + * @param $idaction + * @param string $actionType + * @param $limitBeforeGrouping + * @param boolean $includeLoops + */ + private function addFollowingActions($logAggregator, &$report, $idaction, $actionType, $limitBeforeGrouping, $includeLoops = false) + { + + $data = $this->queryFollowingActions( + $idaction, $actionType, $logAggregator, $limitBeforeGrouping, $includeLoops); + + foreach ($data as $tableName => $table) { + $report[$tableName] = $table; + } + } + + /** + * Get information about the following actions (following pages, site searches, outlinks, downloads) + * + * @param $idaction + * @param $actionType + * @param LogAggregator $logAggregator + * @param $limitBeforeGrouping + * @param $includeLoops + * @return array(followingPages:DataTable, outlinks:DataTable, downloads:DataTable) + */ + protected function queryFollowingActions($idaction, $actionType, LogAggregator $logAggregator, + $limitBeforeGrouping = false, $includeLoops = false) + { + $types = array(); + + if ($actionType != 'title') { + // specific setup for page urls + $types[Action::TYPE_PAGE_URL] = 'followingPages'; + $dimension = 'IF( idaction_url IS NULL, idaction_name, idaction_url )'; + // site search referrers are logged with url=NULL + // when we find one, we have to join on name + $joinLogActionColumn = $dimension; + $selects = array('log_action.name', 'log_action.url_prefix', 'log_action.type'); + } else { + // specific setup for page titles: + $types[Action::TYPE_PAGE_TITLE] = 'followingPages'; + // join log_action on name and url and pick depending on url type + // the table joined on url is log_action1 + $joinLogActionColumn = array('idaction_url', 'idaction_name'); + $dimension = ' + CASE + ' /* following site search */ . ' + WHEN log_link_visit_action.idaction_url IS NULL THEN log_action2.idaction + ' /* following page view: use page title */ . ' + WHEN log_action1.type = ' . Action::TYPE_PAGE_URL . ' THEN log_action2.idaction + ' /* following download or outlink: use url */ . ' + ELSE log_action1.idaction + END + '; + $selects = array( + 'CASE + ' /* following site search */ . ' + WHEN log_link_visit_action.idaction_url IS NULL THEN log_action2.name + ' /* following page view: use page title */ . ' + WHEN log_action1.type = ' . Action::TYPE_PAGE_URL . ' THEN log_action2.name + ' /* following download or outlink: use url */ . ' + ELSE log_action1.name + END AS `name`', + 'CASE + ' /* following site search */ . ' + WHEN log_link_visit_action.idaction_url IS NULL THEN log_action2.type + ' /* following page view: use page title */ . ' + WHEN log_action1.type = ' . Action::TYPE_PAGE_URL . ' THEN log_action2.type + ' /* following download or outlink: use url */ . ' + ELSE log_action1.type + END AS `type`', + 'NULL AS `url_prefix`' + ); + } + + // these types are available for both titles and urls + $types[Action::TYPE_SITE_SEARCH] = 'followingSiteSearches'; + $types[Action::TYPE_OUTLINK] = 'outlinks'; + $types[Action::TYPE_DOWNLOAD] = 'downloads'; + + $rankingQuery = new RankingQuery($limitBeforeGrouping ? $limitBeforeGrouping : $this->limitBeforeGrouping); + $rankingQuery->addLabelColumn(array('name', 'url_prefix')); + $rankingQuery->partitionResultIntoMultipleGroups('type', array_keys($types)); + + $type = $this->getColumnTypeSuffix($actionType); + $where = 'log_link_visit_action.idaction_' . $type . '_ref = ' . intval($idaction); + if (!$includeLoops) { + $where .= ' AND (log_link_visit_action.idaction_' . $type . ' IS NULL OR ' + . 'log_link_visit_action.idaction_' . $type . ' != ' . intval($idaction) . ')'; + } + + $metrics = array(Metrics::INDEX_NB_ACTIONS); + $data = $logAggregator->queryActionsByDimension(array($dimension), $where, $selects, $metrics, $rankingQuery, $joinLogActionColumn); + + $dataTables = $this->makeDataTablesFollowingActions($types, $data); + + return $dataTables; + } + + /** + * Get information about external referrers (i.e. search engines, websites & campaigns) + * + * @param $idaction + * @param $actionType + * @param Logaggregator $logAggregator + * @param $limitBeforeGrouping + * @return DataTable + */ + protected function queryExternalReferrers($idaction, $actionType, $logAggregator, $limitBeforeGrouping = false) + { + $rankingQuery = new RankingQuery($limitBeforeGrouping ? $limitBeforeGrouping : $this->limitBeforeGrouping); + + // we generate a single column that contains the interesting data for each referrer. + // the reason we cannot group by referer_* becomes clear when we look at search engine keywords. + // referer_url contains the url from the search engine, referer_keyword the keyword we want to + // group by. when we group by both, we don't get a single column for the keyword but instead + // one column per keyword + search engine url. this way, we could not get the top keywords using + // the ranking query. + $dimensions = array('referrer_data', 'referer_type'); + $rankingQuery->addLabelColumn('referrer_data'); + $selects = array( + 'CASE log_visit.referer_type + WHEN ' . Common::REFERRER_TYPE_DIRECT_ENTRY . ' THEN \'\' + WHEN ' . Common::REFERRER_TYPE_SEARCH_ENGINE . ' THEN log_visit.referer_keyword + WHEN ' . Common::REFERRER_TYPE_WEBSITE . ' THEN log_visit.referer_url + WHEN ' . Common::REFERRER_TYPE_CAMPAIGN . ' THEN CONCAT(log_visit.referer_name, \' \', log_visit.referer_keyword) + END AS `referrer_data`'); + + // get one limited group per referrer type + $rankingQuery->partitionResultIntoMultipleGroups('referer_type', array( + Common::REFERRER_TYPE_DIRECT_ENTRY, + Common::REFERRER_TYPE_SEARCH_ENGINE, + Common::REFERRER_TYPE_WEBSITE, + Common::REFERRER_TYPE_CAMPAIGN + )); + + $type = $this->getColumnTypeSuffix($actionType); + $where = 'visit_entry_idaction_' . $type . ' = ' . intval($idaction); + + $metrics = array(Metrics::INDEX_NB_VISITS); + $data = $logAggregator->queryVisitsByDimension($dimensions, $where, $selects, $metrics, $rankingQuery); + + $referrerData = array(); + $referrerSubData = array(); + + foreach ($data as $referrerType => &$subData) { + $referrerData[$referrerType] = array(Metrics::INDEX_NB_VISITS => 0); + if ($referrerType != Common::REFERRER_TYPE_DIRECT_ENTRY) { + $referrerSubData[$referrerType] = array(); + } + + foreach ($subData as &$row) { + if ($referrerType == Common::REFERRER_TYPE_SEARCH_ENGINE && empty($row['referrer_data'])) { + $row['referrer_data'] = \Piwik\Plugins\Referrers\API::LABEL_KEYWORD_NOT_DEFINED; + } + + $referrerData[$referrerType][Metrics::INDEX_NB_VISITS] += $row[Metrics::INDEX_NB_VISITS]; + + $label = $row['referrer_data']; + if ($label) { + $referrerSubData[$referrerType][$label] = array( + Metrics::INDEX_NB_VISITS => $row[Metrics::INDEX_NB_VISITS] + ); + } + } + } + + $array = new DataArray($referrerData, $referrerSubData); + return $array->asDataTable(); + } + + /** + * Get information about internal referrers (previous pages & loops, i.e. page refreshes) + * + * @param $idaction + * @param $actionType + * @param LogAggregator $logAggregator + * @param $limitBeforeGrouping + * @return array(previousPages:DataTable, loops:integer) + */ + protected function queryInternalReferrers($idaction, $actionType, $logAggregator, $limitBeforeGrouping = false) + { + $keyIsOther = 0; + $keyIsPageUrlAction = 1; + $keyIsSiteSearchAction = 2; + + $rankingQuery = new RankingQuery($limitBeforeGrouping ? $limitBeforeGrouping : $this->limitBeforeGrouping); + $rankingQuery->addLabelColumn(array('name', 'url_prefix')); + $rankingQuery->setColumnToMarkExcludedRows('is_self'); + $rankingQuery->partitionResultIntoMultipleGroups('action_partition', array($keyIsOther, $keyIsPageUrlAction, $keyIsSiteSearchAction)); + + $type = $this->getColumnTypeSuffix($actionType); + $mainActionType = Action::TYPE_PAGE_URL; + $dimension = 'idaction_url_ref'; + + if ($actionType == 'title') { + $mainActionType = Action::TYPE_PAGE_TITLE; + $dimension = 'idaction_name_ref'; + } + + $selects = array( + 'log_action.name', + 'log_action.url_prefix', + 'CASE WHEN log_link_visit_action.idaction_' . $type . '_ref = ' . intval($idaction) . ' THEN 1 ELSE 0 END AS `is_self`', + 'CASE + WHEN log_action.type = ' . $mainActionType . ' THEN ' . $keyIsPageUrlAction . ' + WHEN log_action.type = ' . Action::TYPE_SITE_SEARCH . ' THEN ' . $keyIsSiteSearchAction .' + ELSE ' . $keyIsOther . ' + END AS `action_partition`' + ); + + $where = ' log_link_visit_action.idaction_' . $type . ' = ' . intval($idaction); + + if ($dimension == 'idaction_url_ref') { + // site search referrers are logged with url_ref=NULL + // when we find one, we have to join on name_ref + $dimension = 'IF( idaction_url_ref IS NULL, idaction_name_ref, idaction_url_ref )'; + $joinLogActionOn = $dimension; + } else { + $joinLogActionOn = $dimension; + } + $metrics = array(Metrics::INDEX_NB_ACTIONS); + $data = $logAggregator->queryActionsByDimension(array($dimension), $where, $selects, $metrics, $rankingQuery, $joinLogActionOn); + + $loops = 0; + $nbPageviews = 0; + $previousPagesDataTable = new DataTable; + if (isset($data['result'][$keyIsPageUrlAction])) { + foreach ($data['result'][$keyIsPageUrlAction] as &$page) { + $nbActions = intval($page[Metrics::INDEX_NB_ACTIONS]); + $previousPagesDataTable->addRow(new Row(array( + Row::COLUMNS => array( + 'label' => $this->getPageLabel($page, Action::TYPE_PAGE_URL), + Metrics::INDEX_NB_ACTIONS => $nbActions + ) + ))); + $nbPageviews += $nbActions; + } + } + + $previousSearchesDataTable = new DataTable; + if (isset($data['result'][$keyIsSiteSearchAction])) { + foreach ($data['result'][$keyIsSiteSearchAction] as &$search) { + $nbActions = intval($search[Metrics::INDEX_NB_ACTIONS]); + $previousSearchesDataTable->addRow(new Row(array( + Row::COLUMNS => array( + 'label' => $search['name'], + Metrics::INDEX_NB_ACTIONS => $nbActions + ) + ))); + $nbPageviews += $nbActions; + } + } + + if (isset($data['result'][0])) { + foreach ($data['result'][0] as &$referrer) { + $nbPageviews += intval($referrer[Metrics::INDEX_NB_ACTIONS]); + } + } + + if (count($data['excludedFromLimit'])) { + $loops += intval($data['excludedFromLimit'][0][Metrics::INDEX_NB_ACTIONS]); + $nbPageviews += $loops; + } + + return array( + 'pageviews' => $nbPageviews, + 'previousPages' => $previousPagesDataTable, + 'previousSiteSearches' => $previousSearchesDataTable, + 'loops' => $loops + ); + } + + private function getPageLabel(&$pageRecord, $type) + { + if ($type == Action::TYPE_PAGE_TITLE) { + $label = $pageRecord['name']; + if (empty($label)) { + $label = ArchivingHelper::getUnknownActionName(Action::TYPE_PAGE_TITLE); + } + return $label; + } + + if ($type == Action::TYPE_OUTLINK || $type == Action::TYPE_DOWNLOAD) { + return PageUrl::reconstructNormalizedUrl($pageRecord['name'], $pageRecord['url_prefix']); + } + + return $pageRecord['name']; + } + + private function getColumnTypeSuffix($actionType) + { + if ($actionType == 'title') { + return 'name'; + } + return 'url'; + } + + private $limitBeforeGrouping = 5; + private $totalTransitionsToFollowingActions = 0; + + /** + * Get the sum of all transitions to following actions (pages, outlinks, downloads). + * Only works if queryFollowingActions() has been used directly before. + */ + protected function getTotalTransitionsToFollowingActions() + { + return $this->totalTransitionsToFollowingActions; + } + + /** + * Add the external referrers to the report: + * direct entries, websites, campaigns, search engines + * + * @param LogAggregator $logAggregator + * @param $report + * @param $idaction + * @param string $actionType + * @param $limitBeforeGrouping + */ + private function addExternalReferrers($logAggregator, &$report, $idaction, $actionType, $limitBeforeGrouping) + { + + $data = $this->queryExternalReferrers( + $idaction, $actionType, $logAggregator, $limitBeforeGrouping); + + $report['pageMetrics']['entries'] = 0; + $report['referrers'] = array(); + foreach ($data->getRows() as $row) { + $referrerId = $row->getColumn('label'); + $visits = $row->getColumn(Metrics::INDEX_NB_VISITS); + if ($visits) { + // load details (i.e. subtables) + $details = array(); + if ($idSubTable = $row->getIdSubDataTable()) { + $subTable = Manager::getInstance()->getTable($idSubTable); + foreach ($subTable->getRows() as $subRow) { + $details[] = array( + 'label' => $subRow->getColumn('label'), + 'referrals' => $subRow->getColumn(Metrics::INDEX_NB_VISITS) + ); + } + } + $report['referrers'][] = array( + 'label' => $this->getReferrerLabel($referrerId), + 'shortName' => \Piwik\Plugins\Referrers\getReferrerTypeFromShortName($referrerId), + 'visits' => $visits, + 'details' => $details + ); + $report['pageMetrics']['entries'] += $visits; + } + } + + // if there's no data for referrers, ResponseBuilder::handleMultiDimensionalArray + // does not detect the multi dimensional array and the data is rendered differently, which + // causes an exception. + if (count($report['referrers']) == 0) { + $report['referrers'][] = array( + 'label' => $this->getReferrerLabel(Common::REFERRER_TYPE_DIRECT_ENTRY), + 'shortName' => \Piwik\Plugins\Referrers\getReferrerTypeLabel(Common::REFERRER_TYPE_DIRECT_ENTRY), + 'visits' => 0 + ); + } + } + + private function getReferrerLabel($referrerId) + { + switch ($referrerId) { + case Common::REFERRER_TYPE_DIRECT_ENTRY: + return Controller::getTranslation('directEntries'); + case Common::REFERRER_TYPE_SEARCH_ENGINE: + return Controller::getTranslation('fromSearchEngines'); + case Common::REFERRER_TYPE_WEBSITE: + return Controller::getTranslation('fromWebsites'); + case Common::REFERRER_TYPE_CAMPAIGN: + return Controller::getTranslation('fromCampaigns'); + default: + return Piwik::translate('General_Others'); + } + } + + public function getTranslations() + { + $controller = new Controller(); + return $controller->getTranslations(); + } + + protected function makeDataTablesFollowingActions($types, $data) + { + $this->totalTransitionsToFollowingActions = 0; + $dataTables = array(); + foreach ($types as $type => $recordName) { + $dataTable = new DataTable; + if (isset($data[$type])) { + foreach ($data[$type] as &$record) { + $actions = intval($record[Metrics::INDEX_NB_ACTIONS]); + $dataTable->addRow(new Row(array( + Row::COLUMNS => array( + 'label' => $this->getPageLabel($record, $type), + Metrics::INDEX_NB_ACTIONS => $actions + ) + ))); + $this->totalTransitionsToFollowingActions += $actions; + } + } + $dataTables[$recordName] = $dataTable; + } + return $dataTables; + } +} diff --git a/www/analytics/plugins/Transitions/Controller.php b/www/analytics/plugins/Transitions/Controller.php new file mode 100644 index 00000000..f36c110a --- /dev/null +++ b/www/analytics/plugins/Transitions/Controller.php @@ -0,0 +1,89 @@ + 'VisitsSummary_NbPageviewsDescription', + 'loopsInline' => 'Transitions_LoopsInline', + 'fromPreviousPages' => 'Transitions_FromPreviousPages', + 'fromPreviousPagesInline' => 'Transitions_FromPreviousPagesInline', + 'fromPreviousSiteSearches' => 'Transitions_FromPreviousSiteSearches', + 'fromPreviousSiteSearchesInline' => 'Transitions_FromPreviousSiteSearchesInline', + 'fromSearchEngines' => 'Transitions_FromSearchEngines', + 'fromSearchEnginesInline' => 'Referrers_TypeSearchEngines', + 'fromWebsites' => 'Transitions_FromWebsites', + 'fromWebsitesInline' => 'Referrers_TypeWebsites', + 'fromCampaigns' => 'Transitions_FromCampaigns', + 'fromCampaignsInline' => 'Referrers_TypeCampaigns', + 'directEntries' => 'Transitions_DirectEntries', + 'directEntriesInline' => 'Referrers_TypeDirectEntries', + 'toFollowingPages' => 'Transitions_ToFollowingPages', + 'toFollowingPagesInline' => 'Transitions_ToFollowingPagesInline', + 'toFollowingSiteSearches' => 'Transitions_ToFollowingSiteSearches', + 'toFollowingSiteSearchesInline' => 'Transitions_ToFollowingSiteSearchesInline', + 'downloads' => 'General_Downloads', + 'downloadsInline' => 'VisitsSummary_NbDownloadsDescription', + 'outlinks' => 'General_Outlinks', + 'outlinksInline' => 'VisitsSummary_NbOutlinksDescription', + 'exits' => 'General_ColumnExits', + 'exitsInline' => 'Transitions_ExitsInline', + 'bouncesInline' => 'Transitions_BouncesInline' + ); + + /** + * Translations that are added to JS + */ + private static $jsTranslations = array( + 'XOfY' => 'Transitions_XOutOfYVisits', + 'XOfAllPageviews' => 'Transitions_XOfAllPageviews', + 'NoDataForAction' => 'Transitions_NoDataForAction', + 'NoDataForActionDetails' => 'Transitions_NoDataForActionDetails', + 'NoDataForActionBack' => 'Transitions_ErrorBack', + 'ShareOfAllPageviews' => 'Transitions_ShareOfAllPageviews', + 'DateRange' => 'General_DateRange' + ); + + public static function getTranslation($key) + { + return Piwik::translate(self::$metricTranslations[$key]); + } + + /** + * The main method of the plugin. + * It is triggered from the Transitions data table action. + */ + public function renderPopover() + { + $view = new View('@Transitions/renderPopover'); + $view->translations = $this->getTranslations(); + return $view->render(); + } + + public function getTranslations() + { + $translations = self::$metricTranslations + self::$jsTranslations; + foreach ($translations as &$message) { + $message = Piwik::translate($message); + } + return $translations; + } +} diff --git a/www/analytics/plugins/Transitions/Transitions.php b/www/analytics/plugins/Transitions/Transitions.php new file mode 100644 index 00000000..0ab191bd --- /dev/null +++ b/www/analytics/plugins/Transitions/Transitions.php @@ -0,0 +1,43 @@ + 'getStylesheetFiles', + 'AssetManager.getJavaScriptFiles' => 'getJsFiles', + 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys' + ); + } + + public function getStylesheetFiles(&$stylesheets) + { + $stylesheets[] = 'plugins/Transitions/stylesheets/transitions.less'; + } + + public function getJsFiles(&$jsFiles) + { + $jsFiles[] = 'plugins/Transitions/javascripts/transitions.js'; + } + + public function getClientSideTranslationKeys(&$translationKeys) + { + $translationKeys[] = 'General_TransitionsRowActionTooltipTitle'; + $translationKeys[] = 'General_TransitionsRowActionTooltip'; + } +} diff --git a/www/analytics/plugins/Transitions/images/transitions_icon.png b/www/analytics/plugins/Transitions/images/transitions_icon.png new file mode 100755 index 00000000..b7f79961 Binary files /dev/null and b/www/analytics/plugins/Transitions/images/transitions_icon.png differ diff --git a/www/analytics/plugins/Transitions/images/transitions_icon_hover.png b/www/analytics/plugins/Transitions/images/transitions_icon_hover.png new file mode 100755 index 00000000..380915b9 Binary files /dev/null and b/www/analytics/plugins/Transitions/images/transitions_icon_hover.png differ diff --git a/www/analytics/plugins/Transitions/javascripts/transitions.js b/www/analytics/plugins/Transitions/javascripts/transitions.js new file mode 100644 index 00000000..30521aca --- /dev/null +++ b/www/analytics/plugins/Transitions/javascripts/transitions.js @@ -0,0 +1,1553 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + + +// +// TRANSITIONS ROW ACTION FOR DATA TABLES +// + +function DataTable_RowActions_Transitions(dataTable) { + this.dataTable = dataTable; + this.transitions = null; +} + +DataTable_RowActions_Transitions.prototype = new DataTable_RowAction; + +/** Static helper method to launch transitions from anywhere */ +DataTable_RowActions_Transitions.launchForUrl = function (url) { + broadcast.propagateNewPopoverParameter('RowAction', 'Transitions:url:' + url); +}; + +DataTable_RowActions_Transitions.isPageUrlReport = function (module, action) { + return module == 'Actions' && + (action == 'getPageUrls' || action == 'getEntryPageUrls' || action == 'getExitPageUrls' || action == 'getPageUrlsFollowingSiteSearch'); +}; + +DataTable_RowActions_Transitions.isPageTitleReport = function (module, action) { + return module == 'Actions' && (action == 'getPageTitles' || action == 'getPageTitlesFollowingSiteSearch'); +}; + +DataTable_RowActions_Transitions.prototype.trigger = function (tr, e, subTableLabel) { + var link = tr.find('> td:first > a').attr('href'); + link = $('