diff --git a/plugins/DeviceDetectorCache/CHANGELOG.md b/plugins/DeviceDetectorCache/CHANGELOG.md
new file mode 100644
index 0000000000000000000000000000000000000000..a45c0b9c198e65345a0635ca3f3ff99ac0183abc
--- /dev/null
+++ b/plugins/DeviceDetectorCache/CHANGELOG.md
@@ -0,0 +1,49 @@
+## Changelog
+
+5.0.3
+- Added plugin category for Marketplace
+
+5.0.2
+- Fixes errors due to incorrect default values
+
+5.0.1
+- Fixes errors from undefined indexes
+
+5.0.0
+- Compatibility with Matomo 5.X
+
+4.3.0
+- Compatibility with Matomo 4.12, and Adjustments for client hints support
+
+4.2.3
+- Fixed parsers not being set when cache is present
+
+4.2.2
+- Compatibility with Matomo 4
+
+4.2.1
+- Compatibility with PHP DI 6
+
+4.2.0
+- Performance improvements
+
+4.1.0
+- Changed how this command works. It now requires a configuration.
+
+4.0.0
+- Compatibility with Matomo 4.X
+
+3.1.0
+- Changed how this command works. It now requires a configuration.
+
+3.0.3
+- Add more cache entries
+
+3.0.2
+- Add more cache entries
+
+3.0.1
+- Add more cache entries
+
+3.0.0
+- Initial version
diff --git a/plugins/DeviceDetectorCache/CachedEntry.php b/plugins/DeviceDetectorCache/CachedEntry.php
new file mode 100644
index 0000000000000000000000000000000000000000..5291e71af161df3c36ac2ef82a106c0659fe4918
--- /dev/null
+++ b/plugins/DeviceDetectorCache/CachedEntry.php
@@ -0,0 +1,192 @@
+bot = $values['bot'] ?? null;
+ $this->brand = $values['brand'] ?? '';
+ $this->client = $values['client'] ?? null;
+ $this->device = $values['device'] ?? null;
+ $this->model = $values['model'] ?? '';
+ $this->os = $values['os'] ?? null;
+
+ // Or cached entries only use the useragents, so if we have some client hints provided,
+ // We use some special parsers, which use the cached user agent result and parses it again using client hints
+ if (!empty($clientHints) && !empty($values['client']['type']) && $values['client']['type'] === 'browser') {
+ $browserParser = new CachedBrowserParser($userAgent, $clientHints);
+ $browserParser->setCachedResult($this->client);
+ $this->client = $browserParser->parse();
+ }
+
+ if (!empty($clientHints)) {
+ $osParser = new CachedOperatingSystemParser($userAgent, $clientHints);
+ $osParser->setCachedResult($this->os);
+ $this->os = $osParser->parse();
+ }
+ }
+
+ public static function getCached(string $userAgent): ?array
+ {
+ // we check if file exists and include the file here directly as it needs to be kind of atomic...
+ // if we only checked if file exists, and then choose to use cached entry which would then include the file,
+ // then there's a risk that between the file_exists and the include the cache file was removed
+ $path = self::getCachePath($userAgent);
+ $exists = file_exists($path);
+ if ($exists) {
+ $values = @include($path);
+ if (!empty($values) && is_array($values) && isset($values['os'])) {
+ return $values;
+ }
+ }
+
+ return null;
+ }
+
+ public static function writeToCache(string $userAgent): ?string
+ {
+ if (self::getCached($userAgent)) {
+ return null; // already cached
+ }
+
+ if (empty(self::$customCache)) {
+ self::$customCache = StaticContainer::get('DeviceDetector\Cache\Cache');
+ }
+
+ // we don't use device detector factory because this way we can cache the cache instance and
+ // lower memory since the factory would store an instance of every user agent in a static variable
+ $deviceDetector = new DeviceDetector($userAgent);
+ $deviceDetector->discardBotInformation();
+ $deviceDetector->setCache(self::$customCache);
+ $deviceDetector->parse();
+
+ $outputArray = [
+ 'bot' => $deviceDetector->getBot(),
+ 'brand' => $deviceDetector->getBrandName(),
+ 'client' => $deviceDetector->getClient(),
+ 'device' => $deviceDetector->getDevice(),
+ 'model' => $deviceDetector->getModel(),
+ 'os' => $deviceDetector->getOs()
+ ];
+ $outputPath = self::getCachePath($userAgent, true);
+ $content = " $time) {
+ if ($numFilesDeleted > $numFilesToDelete) {
+ break;
+ } else {
+ Filesystem::deleteFileIfExists($file);
+ $numFilesDeleted++;
+ }
+ }
+ }
+}
diff --git a/plugins/DeviceDetectorCache/Commands/WarmDeviceDetectorCache.php b/plugins/DeviceDetectorCache/Commands/WarmDeviceDetectorCache.php
new file mode 100644
index 0000000000000000000000000000000000000000..336d9c4972db34ff43999ca238900fb359a164f5
--- /dev/null
+++ b/plugins/DeviceDetectorCache/Commands/WarmDeviceDetectorCache.php
@@ -0,0 +1,189 @@
+config = StaticContainer::get(Configuration::class);
+ }
+
+ protected function configure()
+ {
+ $this->setName(self::COMMAND_NAME);
+ $this->setDescription('Cached device detector information based on access log');
+ }
+
+ private function printupdate($count)
+ {
+ if ($this->getOutput()->isVerbose()) {
+ $mem = round(memory_get_peak_usage() / 1024 / 1024);
+ $now = Date::now()->getDatetime();
+ $this->getOutput()->writeln("Count: " . $count . ' Mem:' . $mem . 'MB Date: ' . $now);
+ }
+ }
+
+ private function log($message)
+ {
+ if ($this->getOutput()->isVerbose()) {
+ $mem = round(memory_get_peak_usage() / 1024 / 1024);
+ $now = Date::now()->getDatetime();
+ $this->getOutput()->writeln($message . ' Mem:' . $mem . 'MB Date: ' . $now);
+ }
+ }
+
+ protected function doExecute(): int
+ {
+ $userAgents = [];
+
+ $output = $this->getOutput();
+ $regex = $this->config->getAccessLogRegex();
+ $numEntriesToCache = $this->config->getNumEntriesToCache();
+ $matchEntry = $this->config->getRegexMatchEntry();
+ $path = $this->config->getAccessLogPath();
+ $path = trim($path);
+
+ $this->log('caching up to ' . $numEntriesToCache . ' entries');
+ $this->log('reading from file ' . $path);
+ $this->log('used regex ' . $regex . ' with index ' . $matchEntry);
+
+ if (empty($numEntriesToCache)) {
+ $output->writeln('No entries are supposed to be cached. Stopping command');
+ return self::SUCCESS;
+ }
+
+ if (!file_exists($path)) {
+ throw new \Exception('Configured access log path does not exist: "' . $path . '"');
+ }
+
+ $count = 0;
+ $numLinesToProcess = StaticContainer::get('DeviceDetectorCacheNumLinesToScan');
+ $numLinesProcessed = 0;
+ $handle = fopen($path, "r");
+ if ($handle) {
+ while (($line = fgets($handle)) !== false) {
+ $numLinesProcessed++;
+ if ($numLinesProcessed >= $numLinesToProcess) {
+ break;// we read max 5M lines to prevent in running for too long time
+ }
+ if (empty($line)) {
+ continue;
+ }
+ preg_match($regex, $line, $matches);
+ if (
+ !empty($matches[$matchEntry])
+ && strlen($matches[$matchEntry]) > 5
+ && strlen($matches[$matchEntry]) < 700
+ ) {
+ $useragent = $matches[$matchEntry];
+ if (!isset($userAgents[$useragent])) {
+ $userAgents[$useragent] = 1;
+ $count = count($userAgents);
+ if ($count % 10000 === 0) {
+ $this->printupdate($count);
+ }
+ } else {
+ $userAgents[$useragent] = $userAgents[$useragent] + 1;
+ }
+ }
+ $line = null;
+ unset($line);
+ $matches = null;
+ unset($matches);
+ if ($numLinesProcessed % 10 === 0) {
+ usleep(300); // slightly slow down disk usage to avoid running eg into some EBS limit
+ }
+
+ if ($numLinesProcessed % 1000 === 0) {
+ usleep(10000); // every 10K lines sleep for a 10ms to not max out CPU as much
+ }
+ }
+
+ fclose($handle);
+ } else {
+ throw new \Exception('Error opening file. Maybe no read permission? Path: ' . $path);
+ }
+
+ $this->log("parsed file: " . $numLinesProcessed . " lines");
+ $this->printupdate($count);
+
+ arsort($userAgents, SORT_NATURAL);
+
+ if (empty($userAgents)) {
+ $output->writeln('No user agents found');
+ return self::SUCCESS;
+ }
+
+ $this->log($count . ' user agents found');
+ $this->log("writing files");
+
+ $i = 0;
+ $numRequestsDetected = 0;
+ $ignoreUserAgentsWithLessRequestsThan = StaticContainer::get('DeviceDetectorCacheIgnoreUserAgentsWithLessThanXRequests');
+
+ foreach ($userAgents as $agent => $val) {
+ if ($i >= $numEntriesToCache) {
+ $output->writeln('stopping because number of configured entries were cached');
+ break;
+ }
+ if ($val < $ignoreUserAgentsWithLessRequestsThan) {
+ $output->writeln('stopping because remaining user agents have only few requests');
+ // we don't cache user agents that happened less than 9 times or less as it's so rare it's not really worth caching it and we rather do it on demand
+ break;
+ }
+ $i++;
+ $numRequestsDetected += $val; // useful to detect hit ratio
+
+ if ($i % 5000 === 0) {
+ $this->printupdate(
+ 'written files so far: ' . $i . ' detecting that many requests: ' . $numRequestsDetected
+ );
+ }
+ if ($i <= 10) {
+ $this->log('Found user agent ' . $agent . ' count: ' . $val);
+ }
+ CachedEntry::writeToCache($agent, []);
+ // sleep 2ms to let CPU do something else
+ // this will make things about 10m slower for 200K entries but at least sudden CPU increase for instance
+ // can be prevented when there are only few CPUs available
+ // note: roughly per minute we write around 5K entries
+ usleep(2000);
+ }
+ $output->writeln('Written ' . $i . ' cache entries to file.');
+ $output->writeln('The hit ratio will be roughly ' . Piwik::getPercentageSafe($numRequestsDetected, $numLinesToProcess) . '%');
+
+ $numCacheFilesExist = CachedEntry::getNumEntriesInCacheDir();
+ $output->writeln($numCacheFilesExist . ' cached files exist');
+
+ if ($numCacheFilesExist > $numEntriesToCache) {
+ $numEntriesToDelete = $numCacheFilesExist - $numEntriesToCache;
+ $output->writeln('Need to delete ' . $numEntriesToDelete . ' files');
+ CachedEntry::deleteLeastAccessedFiles($numEntriesToDelete);
+ $output->writeln('done deleting files');
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/plugins/DeviceDetectorCache/Configuration.php b/plugins/DeviceDetectorCache/Configuration.php
new file mode 100644
index 0000000000000000000000000000000000000000..c95cb1a56ffaaf36b7f1e53b28d4d63f09d96907
--- /dev/null
+++ b/plugins/DeviceDetectorCache/Configuration.php
@@ -0,0 +1,110 @@
+getConfig();
+
+ if (empty($config->DeviceDetectorCache)) {
+ $config->DeviceDetectorCache = [];
+ }
+
+ $cache = $config->DeviceDetectorCache;
+
+ // we make sure to set a value only if none has been configured yet, eg in common config.
+ if (empty($cache[self::KEY_NUM_ENTRIES_TO_CACHE])) {
+ $cache[self::KEY_NUM_ENTRIES_TO_CACHE] = self::DEFAULT_NUM_ENTRIES_TO_CACHE;
+ }
+ if (empty($cache[self::KEY_ACCESS_LOG_PATH])) {
+ $cache[self::KEY_ACCESS_LOG_PATH] = self::DEFAULT_ACCESS_LOG_PATH;
+ }
+ if (empty($cache[self::KEY_ACCESS_LOG_REGEX])) {
+ $cache[self::KEY_ACCESS_LOG_REGEX] = self::DEFAULT_ACCESS_LOG_REGEX;
+ }
+ if (empty($cache[self::KEY_ACCESS_LOG_REGEX_MATCH_ENTRY])) {
+ $cache[self::KEY_ACCESS_LOG_REGEX_MATCH_ENTRY] = self::DEFAULT_ACCESS_LOG_REGEX_MATCH_ENTRY;
+ }
+
+ $config->DeviceDetectorCache = $cache;
+
+ $config->forceSave();
+ }
+
+ public function uninstall()
+ {
+ $config = $this->getConfig();
+ $config->DeviceDetectorCache = [];
+ $config->forceSave();
+ }
+
+ /**
+ * @return string
+ */
+ public function getAccessLogPath()
+ {
+ return $this->getConfigValue(self::KEY_ACCESS_LOG_PATH, self::DEFAULT_ACCESS_LOG_PATH);
+ }
+
+ /**
+ * @return string
+ */
+ public function getAccessLogRegex()
+ {
+ return $this->getConfigValue(self::KEY_ACCESS_LOG_REGEX, self::DEFAULT_ACCESS_LOG_REGEX);
+ }
+
+ /**
+ * @return string
+ */
+ public function getRegexMatchEntry()
+ {
+ return (int)$this->getConfigValue(self::KEY_ACCESS_LOG_REGEX_MATCH_ENTRY, self::DEFAULT_ACCESS_LOG_REGEX_MATCH_ENTRY);
+ }
+
+ /**
+ * @return string
+ */
+ public function getNumEntriesToCache()
+ {
+ return (int)$this->getConfigValue(self::KEY_NUM_ENTRIES_TO_CACHE, self::DEFAULT_NUM_ENTRIES_TO_CACHE);
+ }
+
+ private function getConfig()
+ {
+ return Config::getInstance();
+ }
+
+ private function getConfigValue($name, $default)
+ {
+ $config = $this->getConfig();
+ $attribution = $config->DeviceDetectorCache;
+ if (isset($attribution[$name])) {
+ return $attribution[$name];
+ }
+ return $default;
+ }
+}
diff --git a/plugins/DeviceDetectorCache/DeviceDetector/CachedBrowserParser.php b/plugins/DeviceDetectorCache/DeviceDetector/CachedBrowserParser.php
new file mode 100644
index 0000000000000000000000000000000000000000..d4e92417102ec9b79368ba9164eae6b4431915d5
--- /dev/null
+++ b/plugins/DeviceDetectorCache/DeviceDetector/CachedBrowserParser.php
@@ -0,0 +1,32 @@
+cachedUaResult = $result;
+ }
+
+ protected function parseBrowserFromUserAgent(): array
+ {
+ if (!empty($this->cachedUaResult)) {
+ return $this->cachedUaResult;
+ }
+
+ return parent::parseBrowserFromUserAgent();
+ }
+}
diff --git a/plugins/DeviceDetectorCache/DeviceDetector/CachedOperatingSystemParser.php b/plugins/DeviceDetectorCache/DeviceDetector/CachedOperatingSystemParser.php
new file mode 100644
index 0000000000000000000000000000000000000000..6c4c0d990a928362acee55a00ad6f5d5d785485b
--- /dev/null
+++ b/plugins/DeviceDetectorCache/DeviceDetector/CachedOperatingSystemParser.php
@@ -0,0 +1,32 @@
+cachedUaResult = $result;
+ }
+
+ protected function parseOsFromUserAgent(): array
+ {
+ if (!empty($this->cachedUaResult)) {
+ return $this->cachedUaResult;
+ }
+
+ return parent::parseOsFromUserAgent();
+ }
+}
diff --git a/plugins/DeviceDetectorCache/DeviceDetectorCache.php b/plugins/DeviceDetectorCache/DeviceDetectorCache.php
new file mode 100644
index 0000000000000000000000000000000000000000..ed23fb6af0ed8b97d5b0e02420af19305889005f
--- /dev/null
+++ b/plugins/DeviceDetectorCache/DeviceDetectorCache.php
@@ -0,0 +1,24 @@
+install();
+ }
+}
diff --git a/plugins/DeviceDetectorCache/Factory.php b/plugins/DeviceDetectorCache/Factory.php
new file mode 100644
index 0000000000000000000000000000000000000000..6d1eadb3816e74eb9b7b9aa499a792121400ab30
--- /dev/null
+++ b/plugins/DeviceDetectorCache/Factory.php
@@ -0,0 +1,25 @@
+
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/plugins/DeviceDetectorCache/README.md b/plugins/DeviceDetectorCache/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..dd3b04b05f582ea5f81bad81745613edc5068fb8
--- /dev/null
+++ b/plugins/DeviceDetectorCache/README.md
@@ -0,0 +1,58 @@
+# Matomo DeviceDetectorCache Plugin
+
+[](https://github.com/matomo-org/plugin-DeviceDetectorCache/actions/workflows/matomo-tests.yml)
+
+## Description
+
+Makes device detection in Matomo faster by having cached entries for many commonly used user agents.
+
+By default, Matomo runs thousands of regular expressions for each tracking request to detect what Browser, Device, Operating system, ... is being used and to detect if a user agent is a bot or not.
+
+This plugin changes this by first looking if a cached result exists for the particular user agent and if so, directly loads the result from the file system.
+
+We recommend this plugin only if you have a very high traffic website (> 200M requests a month). Depending on your server it may save you a few ms per tracking request (say 5ms which may be say 10% of the total tracking request time).
+
+If you have not that much traffic, the overhead might not be worth it.
+
+### How to set it up
+
+#### Config setup
+
+Configure these values in your `config/config.ini.php`
+
+```
+[DeviceDetectorCache]
+access_log_path = "/var/log/httpd/access_log" # The path to your access log file. This command needs to have read permission for this file
+access_log_regex = "/^(\S+) (\S+) (\S+) \[([^:]+):(\d+:\d+:\d+) ([^\]]+)\] \"(\S+) (.*?) (\S+)\" (\S+) (\S+) \"([^\"]*)\" \"([^\"]*)\"$/" # the regex used to extract the user agent
+regex_match_entry = 14 # defines which subpattern of the abovce regex matches the user agent
+num_cache_entries = 200000 # how many user agents should be cached. This value basically depends on your memory and disk space. Likely there is no need to change this
+```
+
+#### Testing if it works
+
+Run this command to see if it works:
+
+```
+php /path/to/matomo/console device-detector-cache:warm-cache -vvv
+```
+
+It should show how many user agents were detected and should print the top 10 most commonly found user agents if things are configured correctly.
+
+Cached files will be stored in `/tmp/devicecache/`. Make sure there is write access for this folder. Every time this command runs previously created cache entries will be deleted.
+
+#### Set up a cronjob
+
+If above test goes well you need to set up a cronjob that runs regularly (eg every few hours or days) to update the cached entries based on the access log.
+
+The cronjob needs to look like for example like this:
+
+```
+0 8 * * * php /path/to/matomo/console device-detector-cache:warm-cache
+```
+
+If you have multiple servers, you need to set up the command on every server that processes tracking requests.
+
+## Credits
+
+* [PHP Device Detector library](https://github.com/matomo-org/device-detector/)
+* [Matomo - Open Source Web Analytics](https://matomo.org)
diff --git a/plugins/DeviceDetectorCache/config/config.php b/plugins/DeviceDetectorCache/config/config.php
new file mode 100644
index 0000000000000000000000000000000000000000..bc83b0329b7702e2680e1d527c12daa0863d1aa8
--- /dev/null
+++ b/plugins/DeviceDetectorCache/config/config.php
@@ -0,0 +1,8 @@
+ 9,
+ 'DeviceDetectorCacheNumLinesToScan' => 5000000,
+ \Piwik\DeviceDetector\DeviceDetectorFactory::class
+ => Piwik\DI::autowire(\Piwik\Plugins\DeviceDetectorCache\Factory::class),
+];
diff --git a/plugins/DeviceDetectorCache/config/test.php b/plugins/DeviceDetectorCache/config/test.php
new file mode 100644
index 0000000000000000000000000000000000000000..a812bf567780a714b1d3fad3d8c61bc006ff5bfc
--- /dev/null
+++ b/plugins/DeviceDetectorCache/config/test.php
@@ -0,0 +1,12 @@
+ 0,
+ \Piwik\DeviceDetector\DeviceDetectorFactory::class => Piwik\DI::decorate(function ($previous) {
+ CachedEntry::setCacheDir(PIWIK_DOCUMENT_ROOT . '/tmp/devicecachetests/');
+
+ return $previous;
+ })
+];
diff --git a/plugins/DeviceDetectorCache/config/tracker.php b/plugins/DeviceDetectorCache/config/tracker.php
new file mode 100644
index 0000000000000000000000000000000000000000..0b67a5fe4742a02757d939368cc05d4c246502f8
--- /dev/null
+++ b/plugins/DeviceDetectorCache/config/tracker.php
@@ -0,0 +1,3 @@
+=5.0.0-b1,<6.0.0-b1"
+ },
+ "authors": [
+ {
+ "name": "Matomo",
+ "email": "hello@matomo.org",
+ "homepage": "https://matomo.org"
+ }
+ ],
+ "support": {
+ "issues": "https://github.com/matomo-org/plugin-DeviceDetectorCache/issues"
+ },
+ "homepage": "https://matomo.org",
+ "license": "GPL v3+",
+ "keywords": ["device", "detector", "tracking", "performance"],
+ "category": "database"
+}
diff --git a/plugins/DeviceDetectorCache/pull_request_template.md b/plugins/DeviceDetectorCache/pull_request_template.md
new file mode 100644
index 0000000000000000000000000000000000000000..211089bb4ec77dcc05db341c8d1be45bae811eea
--- /dev/null
+++ b/plugins/DeviceDetectorCache/pull_request_template.md
@@ -0,0 +1,29 @@
+## Description
+
+
+## Issue No
+
+
+## Steps to Replicate the Issue
+1.
+2.
+3.
+
+
+
+## Checklist
+- [✔/✖] Tested locally or on demo2/demo3?
+- [✔/✖/NA] New test case added/updated?
+- [✔/✖/NA] Are all newly added texts included via translation?
+- [✔/✖/NA] Are text sanitized properly? (Eg use of v-text v/s v-html for vue)
+- [✔/✖/NA] Version bumped?
+- [✔/✖/NA] I have understood, reviewed, and tested all AI outputs before use
+- [✔/✖/NA] All AI instructions respect security, IP, and privacy rules
+- [✔/✖/NA] Documentation updated?
diff --git a/plugins/ExamplePlugin/API.php b/plugins/ExamplePlugin/API.php
new file mode 100644
index 0000000000000000000000000000000000000000..5cbf5b9a291b4a3811d87d3474e585350be180c7
--- /dev/null
+++ b/plugins/ExamplePlugin/API.php
@@ -0,0 +1,76 @@
+ 'My Label 1', 'nb_visits' => '1'),
+ array('label' => 'My Label 2', 'nb_visits' => '5'),
+ ));
+
+ return $table;
+ }
+
+ /**
+ * Returns the example metric we archive in Archiver.php.
+ * @param string $idSite (might be a number, or the string all)
+ */
+ public function getExampleArchivedMetric(string $idSite, string $period, string $date, ?string $segment = null): DataTable\DataTableInterface
+ {
+ Piwik::checkUserHasViewAccess($idSite);
+
+ $archive = Archive::build($idSite, $period, $date, $segment);
+ return $archive->getDataTableFromNumeric([ExampleMetric::EXAMPLEPLUGIN_METRIC_NAME, ExampleMetric2::EXAMPLEPLUGIN_CONST_METRIC_NAME]);
+ }
+
+ public function getSegmentHash(string $idSite, string $segment)
+ {
+ Piwik::checkUserHasViewAccess($idSite);
+
+ $segment = new Segment($segment, [$idSite]);
+ return $segment->getHash();
+ }
+}
diff --git a/plugins/ExamplePlugin/CHANGELOG.md b/plugins/ExamplePlugin/CHANGELOG.md
new file mode 100644
index 0000000000000000000000000000000000000000..854631896a5fae960eaa2c270e0ebcd1de7e32c2
--- /dev/null
+++ b/plugins/ExamplePlugin/CHANGELOG.md
@@ -0,0 +1,3 @@
+## Changelog
+
+Here goes the changelog text.
diff --git a/plugins/ExamplePlugin/Controller.php b/plugins/ExamplePlugin/Controller.php
new file mode 100644
index 0000000000000000000000000000000000000000..95692e00397eb5374f39097413aa1ee9849444b0
--- /dev/null
+++ b/plugins/ExamplePlugin/Controller.php
@@ -0,0 +1,27 @@
+renderTemplate('index', array(
+ 'answerToLife' => 42,
+ ));
+ }
+}
diff --git a/plugins/ExamplePlugin/Diagnostic/ExampleCheck.php b/plugins/ExamplePlugin/Diagnostic/ExampleCheck.php
new file mode 100644
index 0000000000000000000000000000000000000000..f2a5f278eaefb3d512022f75cbd98087df686348
--- /dev/null
+++ b/plugins/ExamplePlugin/Diagnostic/ExampleCheck.php
@@ -0,0 +1,32 @@
+ 'getArchivingAPIMethodForPlugin',
+ ];
+ }
+
+ // support archiving just this plugin via core:archive
+ public function getArchivingAPIMethodForPlugin(&$method, $plugin)
+ {
+ if ($plugin == 'ExamplePlugin') {
+ $method = 'ExamplePlugin.getExampleArchivedMetric';
+ }
+ }
+}
diff --git a/plugins/ExamplePlugin/Menu.php b/plugins/ExamplePlugin/Menu.php
new file mode 100644
index 0000000000000000000000000000000000000000..c7f76c35794fffa5c66895674c9e5f22d4bc20cd
--- /dev/null
+++ b/plugins/ExamplePlugin/Menu.php
@@ -0,0 +1,36 @@
+addItem('ExamplePlugin_MyTopItem', null, $this->urlForDefaultAction(), $orderId = 30);
+ }
+
+ public function configureAdminMenu(MenuAdmin $menu)
+ {
+ // reuse an existing category. Execute the showList() method within the controller when menu item was clicked
+ // $menu->addManageItem('ExamplePlugin_MyUserItem', $this->urlForAction('showList'), $orderId = 30);
+ // $menu->addPlatformItem('ExamplePlugin_MyUserItem', $this->urlForDefaultAction(), $orderId = 30);
+
+ // or create a custom category
+ // $menu->addItem('CoreAdminHome_MenuManage', 'ExamplePlugin_MyUserItem', $this->urlForDefaultAction(), $orderId = 30);
+ }
+}
diff --git a/plugins/ExamplePlugin/README.md b/plugins/ExamplePlugin/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f39a5b49fc013d36f280c1576f87e7420f6e23e9
--- /dev/null
+++ b/plugins/ExamplePlugin/README.md
@@ -0,0 +1,6 @@
+# Matomo ExamplePlugin Plugin
+
+## Description
+
+Add your plugin description here.
+
diff --git a/plugins/ExamplePlugin/RecordBuilders/ExampleBlob.php b/plugins/ExamplePlugin/RecordBuilders/ExampleBlob.php
new file mode 100644
index 0000000000000000000000000000000000000000..e2956be57ef5ec15008eee8237b9d8a37a2b9cf7
--- /dev/null
+++ b/plugins/ExamplePlugin/RecordBuilders/ExampleBlob.php
@@ -0,0 +1,76 @@
+getLogAggregator()->queryVisitsByDimension(['idvisitor']);
+ * while ($row = $query->fetch()) {
+ * $label = $row['idvisitor'];
+ * unset($row['idvisitor']);
+ * $record->sumRowWithLabel($label, $row);
+ * }
+ *
+ * return [self::EXAMPLEPLUGIN_ARCHIVE_RECORD => $record];
+ * ```
+ *
+ * non-day periods will automatically be aggregated together
+ */
+ protected function aggregate(ArchiveProcessor $archiveProcessor): array
+ {
+ $record = new DataTable();
+
+ $query = $archiveProcessor->getLogAggregator()->queryVisitsByDimension(['idvisitor']);
+ while ($row = $query->fetch()) {
+ $label = $row['idvisitor'];
+ unset($row['idvisitor']);
+ $record->sumRowWithLabel($label, $row);
+ }
+
+ return [self::EXAMPLEPLUGIN_BLOB_RECORD_NAME => $record];
+ }
+}
diff --git a/plugins/ExamplePlugin/RecordBuilders/ExampleMetric.php b/plugins/ExamplePlugin/RecordBuilders/ExampleMetric.php
new file mode 100644
index 0000000000000000000000000000000000000000..1edbffb96ee925beb49bca84ca684c8f1a229bd1
--- /dev/null
+++ b/plugins/ExamplePlugin/RecordBuilders/ExampleMetric.php
@@ -0,0 +1,93 @@
+getLogAggregator()->queryVisitsByDimension(['idvisitor']);
+ * while ($row = $query->fetch()) {
+ * $label = $row['idvisitor'];
+ * unset($row['idvisitor']);
+ * $record->sumRowWithLabel($label, $row);
+ * }
+ *
+ * return [self::EXAMPLEPLUGIN_ARCHIVE_RECORD => $record];
+ * ```
+ *
+ * non-day periods will automatically be aggregated together
+ */
+ protected function aggregate(ArchiveProcessor $archiveProcessor): array
+ {
+ $params = $archiveProcessor->getParams();
+
+ $records = [];
+
+ // insert a test numeric metric that is the difference in days between the day we're archiving and
+ // $this->daysFrom.
+ $daysFrom = Date::factory($this->daysFrom);
+ $date = $params->getPeriod()->getDateStart();
+
+ $differenceInSeconds = $daysFrom->getTimestamp() - $date->getTimestamp();
+ $differenceInDays = round($differenceInSeconds / 86400);
+
+ $records[self::EXAMPLEPLUGIN_METRIC_NAME] = $differenceInDays;
+
+ return $records;
+ }
+}
diff --git a/plugins/ExamplePlugin/RecordBuilders/ExampleMetric2.php b/plugins/ExamplePlugin/RecordBuilders/ExampleMetric2.php
new file mode 100644
index 0000000000000000000000000000000000000000..8d482a0335bbe5613d3170b2e9721d53d3a5c620
--- /dev/null
+++ b/plugins/ExamplePlugin/RecordBuilders/ExampleMetric2.php
@@ -0,0 +1,84 @@
+getLogAggregator()->queryVisitsByDimension(['idvisitor']);
+ * while ($row = $query->fetch()) {
+ * $label = $row['idvisitor'];
+ * unset($row['idvisitor']);
+ * $record->sumRowWithLabel($label, $row);
+ * }
+ *
+ * return [self::EXAMPLEPLUGIN_ARCHIVE_RECORD => $record];
+ * ```
+ *
+ * non-day periods will automatically be aggregated together
+ */
+ protected function aggregate(ArchiveProcessor $archiveProcessor): array
+ {
+ $records = [];
+
+ $callCount = $this->getAndIncrementArchiveCallCount($archiveProcessor);
+ $metricValue = $callCount > 0 ? 1 : 0;
+
+ $records[self::EXAMPLEPLUGIN_CONST_METRIC_NAME] = $metricValue;
+
+ return $records;
+ }
+
+ private function getAndIncrementArchiveCallCount(ArchiveProcessor $archiveProcessor)
+ {
+ $params = $archiveProcessor->getParams();
+ $optionName = 'ExamplePlugin.metricValue.' . md5($params->getSite()->getId() . '.' . $params->getPeriod()->getRangeString()
+ . '.' . $params->getPeriod()->getLabel() . '.' . $params->getSegment()->getHash());
+ $value = (int) Option::get($optionName);
+ Option::set($optionName, $value + 1);
+ return $value;
+ }
+}
diff --git a/plugins/ExamplePlugin/Tasks.php b/plugins/ExamplePlugin/Tasks.php
new file mode 100644
index 0000000000000000000000000000000000000000..e0ad572e2b054ce71942a46aef514349d6979d9d
--- /dev/null
+++ b/plugins/ExamplePlugin/Tasks.php
@@ -0,0 +1,38 @@
+hourly('myTask'); // method will be executed once every hour
+ $this->daily('myTask'); // method will be executed once every day
+ $this->weekly('myTask'); // method will be executed once every week
+ $this->monthly('myTask'); // method will be executed once every month
+
+ // pass a parameter to the task
+ $this->weekly('myTaskWithParam', 'anystring');
+
+ // specify a different priority
+ $this->monthly('myTask', null, self::LOWEST_PRIORITY);
+ $this->monthly('myTaskWithParam', 'anystring', self::HIGH_PRIORITY);
+ }
+
+ public function myTask()
+ {
+ // do something
+ }
+
+ public function myTaskWithParam($param)
+ {
+ // do something
+ }
+}
diff --git a/plugins/ExamplePlugin/Updates/0.0.2.php b/plugins/ExamplePlugin/Updates/0.0.2.php
new file mode 100644
index 0000000000000000000000000000000000000000..ef3aa293a8a93d41f4f502a6744d2c511c3f0658
--- /dev/null
+++ b/plugins/ExamplePlugin/Updates/0.0.2.php
@@ -0,0 +1,65 @@
+migration = $factory;
+ }
+
+ /**
+ * Return database migrations to be executed in this update.
+ *
+ * Database migrations should be defined here, instead of in `doUpdate()`, since this method is used
+ * in the `core:update` command when displaying the queries an update will run. If you execute
+ * migrations directly in `doUpdate()`, they won't be displayed to the user. Migrations will be executed in the
+ * order as positioned in the returned array.
+ *
+ * @return Migration\Db[]
+ */
+ public function getMigrations(Updater $updater)
+ {
+ // many different migrations are available to be used via $this->migration factory
+ $migration1 = $this->migration->db->changeColumnType('log_visit', 'example', 'BOOLEAN NOT NULL');
+ // you can also define custom SQL migrations. If you need to bind parameters, use `->boundSql()`
+ $migration2 = $this->migration->db->sql($sqlQuery = 'SELECT 1');
+
+ return array(
+ // $migration1,
+ // $migration2
+ );
+ }
+
+ /**
+ * Perform the incremental version update.
+ *
+ * This method should perform all updating logic. If you define queries in the `getMigrations()` method,
+ * you must call {@link Updater::executeMigrations()} here.
+ */
+ public function doUpdate(Updater $updater)
+ {
+ $updater->executeMigrations(__FILE__, $this->getMigrations($updater));
+ }
+}
diff --git a/plugins/ExamplePlugin/Widgets/MyExampleWidget.php b/plugins/ExamplePlugin/Widgets/MyExampleWidget.php
new file mode 100644
index 0000000000000000000000000000000000000000..a907a5811566d34629a23241e65824ec72666abc
--- /dev/null
+++ b/plugins/ExamplePlugin/Widgets/MyExampleWidget.php
@@ -0,0 +1,78 @@
+setCategoryId('About Matomo');
+
+ /**
+ * Set the subcategory the widget belongs to. If a subcategory is set, the widget will be shown in the UI.
+ */
+ // $config->setSubcategoryId('General_Overview');
+
+ /**
+ * Set the name of the widget belongs to.
+ */
+ $config->setName('Example Widget Name');
+
+ /**
+ * Set the order of the widget. The lower the number, the earlier the widget will be listed within a category.
+ */
+ $config->setOrder(99);
+
+ /**
+ * Optionally set URL parameters that will be used when this widget is requested.
+ * $config->setParameters(array('myparam' => 'myvalue'));
+ */
+
+ /**
+ * Define whether a widget is enabled or not. For instance some widgets might not be available to every user or
+ * might depend on a setting (such as Ecommerce) of a site. In such a case you can perform any checks and then
+ * set `true` or `false`. If your widget is only available to users having super user access you can do the
+ * following:
+ *
+ * $config->setIsEnabled(\Piwik\Piwik::hasUserSuperUserAccess());
+ * or
+ * if (!\Piwik\Piwik::hasUserSuperUserAccess())
+ * $config->disable();
+ */
+ }
+
+ /**
+ * This method renders the widget. It's on you how to generate the content of the widget.
+ * As long as you return a string everything is fine. You can use for instance a "Piwik\View" to render a
+ * twig template. In such a case don't forget to create a twig template (eg. myViewTemplate.twig) in the
+ * "templates" directory of your plugin.
+ *
+ * @return string
+ */
+ public function render()
+ {
+ // or: return $this->renderTemplate('myViewTemplate', array(...view variables...));
+
+ return 'My Widget Text
';
+ }
+}
diff --git a/plugins/ExamplePlugin/changes.json b/plugins/ExamplePlugin/changes.json
new file mode 100644
index 0000000000000000000000000000000000000000..a1a4e4d85e3649f0576bb917926c2b360c85dbc2
--- /dev/null
+++ b/plugins/ExamplePlugin/changes.json
@@ -0,0 +1,14 @@
+[
+ {
+ "title": "New feature x added",
+ "description": "Now you can do a with b like this",
+ "link_name": "For more information go here",
+ "link": "https://www.matomo.org",
+ "version": "4.0.2"
+ },
+ {
+ "title": "New feature y added",
+ "description": "Now you can do c with d like this.",
+ "version": "4.0.1"
+ }
+]
diff --git a/plugins/ExamplePlugin/config/config.php b/plugins/ExamplePlugin/config/config.php
new file mode 100644
index 0000000000000000000000000000000000000000..d5be5de26f92a935f318ed4c0a04fe01434bfe66
--- /dev/null
+++ b/plugins/ExamplePlugin/config/config.php
@@ -0,0 +1,3 @@
+=5.0.0-b1,<6.0.0-b1"
+ },
+ "authors": [
+ {
+ "name": "Matomo",
+ "email": "",
+ "homepage": ""
+ }
+ ],
+ "support": {
+ "email": "",
+ "issues": "",
+ "forum": "",
+ "irc": "",
+ "wiki": "",
+ "source": "",
+ "docs": "",
+ "rss": ""
+ },
+ "homepage": "",
+ "license": "GPL v3+",
+ "keywords": []
+}
diff --git a/plugins/ExamplePlugin/screenshots/.gitkeep b/plugins/ExamplePlugin/screenshots/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/plugins/ExamplePlugin/templates/index.twig b/plugins/ExamplePlugin/templates/index.twig
new file mode 100644
index 0000000000000000000000000000000000000000..c322e32785c692b3ec289926b533b44220ca1528
--- /dev/null
+++ b/plugins/ExamplePlugin/templates/index.twig
@@ -0,0 +1,8 @@
+{% extends 'dashboard.twig' %}
+
+{% block content %}
+ Hello world!
+
+
+ The answer to life is {{ answerToLife }}
+{% endblock %}
\ No newline at end of file
diff --git a/plugins/ImageGraph/API.php b/plugins/ImageGraph/API.php
new file mode 100644
index 0000000000000000000000000000000000000000..3c46caf17b03163265c5c5f79e29bb4ddf86a329
--- /dev/null
+++ b/plugins/ImageGraph/API.php
@@ -0,0 +1,631 @@
+
+ * - $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 Matomo 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
+{
+ public const FILENAME_KEY = 'filename';
+ public const TRUNCATE_KEY = 'truncate';
+ public const WIDTH_KEY = 'width';
+ public const HEIGHT_KEY = 'height';
+ public const MAX_WIDTH = 2048;
+ public const MAX_HEIGHT = 2048;
+
+ /**
+ * @var array>
+ */
+ private static $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,
+ ),
+ );
+
+ /**
+ * @var array[]
+ */
+ private static $DEFAULT_GRAPH_TYPE_OVERRIDE = array(
+ 'Referrers_getReferrerType' => array(
+ false // override if !$isMultiplePeriod
+ => StaticGraph::GRAPH_TYPE_HORIZONTAL_BAR,
+ ),
+ );
+
+ public const GRAPH_OUTPUT_INLINE = 0;
+ public const GRAPH_OUTPUT_FILE = 1;
+ public const GRAPH_OUTPUT_PHP = 2;
+
+ public const DEFAULT_ORDINATE_METRIC = 'nb_visits';
+ public const FONT_DIR = '/plugins/ImageGraph/fonts/';
+ public const DEFAULT_FONT = 'tahoma.ttf';
+ public const UNICODE_FONT = 'unifont.ttf';
+ public const DEFAULT_FONT_SIZE = 9;
+ public const DEFAULT_LEGEND_FONT_SIZE_OFFSET = 2;
+ public const DEFAULT_TEXT_COLOR = '222222';
+ public const DEFAULT_BACKGROUND_COLOR = 'FFFFFF';
+ public const DEFAULT_GRID_COLOR = 'CCCCCC';
+
+ // number of row evolutions to plot when no labels are specified, can be overridden using &filter_limit
+ public const DEFAULT_NB_ROW_EVOLUTIONS = 5;
+ public const MAX_NB_ROW_LABELS = 10;
+
+ /**
+ * Generates a static graph image for a Matomo report.
+ *
+ * @param int $idSite The numeric ID of the website to query.
+ * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the period
+ * containing the specified date.
+ * @param string $date The date or date range to process.
+ * 'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
+ * or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
+ * @param string $apiModule API module name of the source report.
+ * @param string $apiAction API method name of the source report.
+ * @param 'evolution'|'verticalBar'|'horizontalBar'|'pie'|'3dPie'|false $graphType Optional graph type.
+ * @param int $outputType Output mode: inline image, saved file, or raw PHP graph object.
+ * @param string|false $columns Optional comma-separated metric names to plot.
+ * @param string|false $labels Optional comma-separated row labels to include for evolution graphs.
+ * @param bool $showLegend Whether the graph legend should be displayed.
+ * @param int|false $width Optional graph width in pixels.
+ * @param int|false $height Optional graph height in pixels.
+ * @param int $fontSize Base font size used in the graph.
+ * @param int|false $legendFontSize Optional legend font size.
+ * @param bool $aliasedGraph Whether anti-aliasing should be enabled.
+ * @param int|string|false $idGoal Optional goal ID for report methods that support goals.
+ * @param string|false $colors Optional comma-separated list of hex colors to use in the graph.
+ * @param string $textColor Hex color used for text.
+ * @param string $backgroundColor Hex color used for the background.
+ * @param string $gridColor Hex color used for grid lines.
+ * @param int|false $idSubtable Optional subtable ID for source reports that use subtables.
+ * @param bool $legendAppendMetric Whether the metric name should be appended to legend labels.
+ * @param string|null|false $segment Custom segment to filter the report.
+ * Example: "referrerName==example.com"
+ * Supports AND (;) and OR (,) operators.
+ * @param int|string|false $idDimension Optional dimension ID for reports that support dimensions.
+ * @return mixed Generated graph output, depending on the selected output type.
+ */
+ public function get(
+ int $idSite,
+ string $period,
+ string $date,
+ string $apiModule,
+ string $apiAction,
+ $graphType = false,
+ int $outputType = API::GRAPH_OUTPUT_INLINE,
+ $columns = false,
+ $labels = false,
+ bool $showLegend = true,
+ $width = false,
+ $height = false,
+ int $fontSize = API::DEFAULT_FONT_SIZE,
+ $legendFontSize = false,
+ bool $aliasedGraph = true,
+ $idGoal = false,
+ $colors = false,
+ string $textColor = API::DEFAULT_TEXT_COLOR,
+ string $backgroundColor = API::DEFAULT_BACKGROUND_COLOR,
+ string $gridColor = API::DEFAULT_GRID_COLOR,
+ $idSubtable = false,
+ bool $legendAppendMetric = true,
+ $segment = false,
+ $idDimension = false
+ ) {
+ Piwik::checkUserHasViewAccess($idSite);
+
+ // Health check - should we also test for GD2 only?
+ if (!SettingsServer::isGdExtensionEnabled()) {
+ throw new Exception('Error: To create graphs in Matomo, please enable GD php extension (with Freetype support) in php.ini,
+ and restart your web server.');
+ }
+
+ $useUnicodeFont = [
+ 'am', 'ar', 'el', 'fa', 'fi', 'he', 'ja', 'ka', 'ko', 'te', 'th', 'zh-cn', 'zh-tw',
+ ];
+ $languageLoaded = StaticContainer::get('Piwik\Translation\Translator')->getCurrentLanguage();
+ $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 = [];
+ if (!empty($idGoal)) {
+ $apiParameters = ['idGoal' => $idGoal];
+ }
+ if (!empty($idDimension)) {
+ $apiParameters = ['idDimension' => $idDimension];
+ }
+ // Fetch the metadata for given api-action
+ $parameters = [
+ 'idSite' => $idSite,
+ 'apiModule' => $apiModule,
+ 'apiAction' => $apiAction,
+ 'apiParameters' => $apiParameters,
+ 'language' => $languageLoaded,
+ 'period' => $period,
+ 'date' => $date,
+ 'hideMetricsDoc' => false,
+ 'showSubtableReports' => true,
+ ];
+
+ /** @var array|false $metadata */
+ $metadata = Request::processRequest('API.getMetadata', $parameters);
+ 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 = $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)) {
+ if (!empty($reportColumns[self::DEFAULT_ORDINATE_METRIC])) {
+ $ordinateColumns[] = self::DEFAULT_ORDINATE_METRIC;
+ } elseif (!empty($metadata['metrics'])) {
+ $ordinateColumns[] = key($metadata['metrics']);
+ } else {
+ throw new Exception(
+ Piwik::translate(
+ 'ImageGraph_ColumnOrdinateMissing',
+ array(self::DEFAULT_ORDINATE_METRIC, implode(',', array_keys($reportColumns)))
+ )
+ );
+ }
+ } 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) {
+ // CpChart 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 = [];
+
+ // 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 = PiwikRequest::fromRequest()->getStringParameter('filter_sort_column', '');
+ $_GET['filter_sort_column'] = $plottedMetric;
+
+ $savedFilterLimitValue = PiwikRequest::fromRequest()->getIntegerParameter('filter_limit', -1);
+ if ($savedFilterLimitValue == -1 || $savedFilterLimitValue > self::MAX_NB_ROW_LABELS) {
+ $_GET['filter_limit'] = self::DEFAULT_NB_ROW_EVOLUTIONS;
+ }
+ }
+
+ $parameters = [
+ 'idSite' => $idSite,
+ 'period' => $period,
+ 'date' => $date,
+ 'apiModule' => $apiModule,
+ 'apiAction' => $apiAction,
+ 'label' => $labels,
+ 'segment' => $segment,
+ 'column' => $plottedMetric,
+ 'language' => $languageLoaded,
+ 'idGoal' => $idGoal,
+ 'idDimension' => $idDimension,
+ 'legendAppendMetric' => $legendAppendMetric,
+ 'labelUseAbsoluteUrl' => false,
+ ];
+ /** @var array $processedReport */
+ $processedReport = Request::processRequest('API.getRowEvolution', $parameters);
+
+ //@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 = [];
+
+ // getRowEvolution returned more than one label
+ if (!array_key_exists($plottedMetric, $metrics)) {
+ $ordinateColumns = [];
+ $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 {
+ $parameters = [
+ 'idSite' => $idSite,
+ 'period' => $period,
+ 'date' => $date,
+ 'apiModule' => $apiModule,
+ 'apiAction' => $apiAction,
+ 'segment' => $segment,
+ 'apiParameters' => false,
+ 'idGoal' => $idGoal,
+ 'idDimension' => $idDimension,
+ 'language' => $languageLoaded,
+ 'showTimer' => true,
+ 'hideMetricsDoc' => false,
+ 'idSubtable' => $idSubtable,
+ 'showRawMetrics' => false,
+ 'format_metrics' => 0,
+ ];
+ /** @var array $processedReport */
+ $processedReport = Request::processRequest('API.getProcessedReport', $parameters);
+ }
+
+ // prepare abscissa and ordinate series
+ $abscissaSeries = [];
+ $abscissaLogos = [];
+ $ordinateSeries = [];
+ /** @var DataTable|DataTable\Map $reportData */
+ $reportData = $processedReport['reportData'];
+ $hasData = false;
+ $hasNonZeroValue = false;
+
+ if (!$isMultiplePeriod || !($reportData instanceof Map)) {
+ /** @var DataTable $reportData */
+ $reportMetadata = $processedReport['reportMetadata']->getRows();
+
+ $i = 0;
+ foreach ($reportData->getRows() as $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
+ /** @var DataTable[] $periodsData */
+ $periodsData = array_values($reportData->getDataTables());
+ $periodsCount = count($periodsData);
+
+ for ($i = 0; $i < $periodsCount; $i++) {
+ 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) {
+ if (!isset($rowData[$column])) {
+ continue;
+ }
+ $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;
+ }
+ }
+
+ /** @var Period $periodObj */
+ $periodObj = $periodsData[$i]->getMetadata(DataTableFactory::TABLE_METADATA_PERIOD_INDEX);
+ $rowId = $periodObj->getLocalizedShortString();
+ $abscissaSeries[] = Common::unsanitizeInputValue($rowId);
+ }
+ }
+
+ if (!$hasData || !$hasNonZeroValue) {
+ throw new Exception(Piwik::translate('General_NoDataForGraph'));
+ }
+
+ /** @var StaticGraph $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);
+ }
+
+ $graph->renderGraph();
+ } catch (InvalidDimensionException $e) {
+ throw $e;
+ } 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;
+ }
+ if ($idDimension != '') {
+ $idDimension = '__' . $idDimension;
+ }
+ $fileName = self::$DEFAULT_PARAMETERS[$graphType][self::FILENAME_KEY] . '_' . $apiModule . '_' . $apiAction . $idGoal . $idDimension . ' ' . 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(int $default): void
+ {
+ $_GET['filter_truncate'] = PiwikRequest::fromRequest()->getIntegerParameter('filter_truncate', $default);
+ }
+
+ private static function parseOrdinateValue($ordinateValue): float
+ {
+ if (is_numeric($ordinateValue)) {
+ return (float) $ordinateValue;
+ }
+
+ $ordinateValue = str_replace(',', '.', (string) $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}(\.[0-9]{2})?)/', $ordinateValue, $matches)) {
+ $hour = (int)$matches[1];
+ $min = (int)$matches[2];
+ $sec = (float)$matches[3];
+
+ return ($hour * 3600) + ($min * 60) + $sec;
+ }
+
+ // OK, only numbers from here please (strip out currency sign)
+ $ordinateValue = preg_replace('/[^0-9.]/', '', $ordinateValue);
+
+ if (empty($ordinateValue) || $ordinateValue === '.') {
+ return 0.0;
+ }
+
+ return (float) $ordinateValue;
+ }
+
+ private static function getFontPath(string $font): string
+ {
+ return PIWIK_INCLUDE_PATH . self::FONT_DIR . $font;
+ }
+
+ protected static function getAbsoluteLogoPath(string $relativeLogoPath): string
+ {
+ return PIWIK_INCLUDE_PATH . '/' . $relativeLogoPath;
+ }
+}
diff --git a/plugins/ImageGraph/Controller.php b/plugins/ImageGraph/Controller.php
new file mode 100644
index 0000000000000000000000000000000000000000..971c39c6946623b7e1201c7f9a94d2d3e04ae4f9
--- /dev/null
+++ b/plugins/ImageGraph/Controller.php
@@ -0,0 +1,80 @@
+idSite ?: 1;
+ $period = Common::getRequestVar('period', 'day', 'string');
+ $date = Common::getRequestVar('date', 'today', 'string');
+ $_GET['token_auth'] = Piwik::getCurrentUserTokenAuth();
+ $reports = APIPlugins::getInstance()->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/plugins/ImageGraph/ImageGraph.php b/plugins/ImageGraph/ImageGraph.php
new file mode 100644
index 0000000000000000000000000000000000000000..58716ff0797ab9cff04643150e870fe1a11148c0
--- /dev/null
+++ b/plugins/ImageGraph/ImageGraph.php
@@ -0,0 +1,182 @@
+ array('function' => 'getReportMetadata',
+ 'after' => true),
+ );
+ return $hooks;
+ }
+
+ // Number of periods to plot on an evolution graph
+ public const GRAPH_EVOLUTION_LAST_PERIODS = 30;
+
+ /**
+ * @param array $reports
+ * @param array $info
+ * @return mixed
+ */
+ public function getReportMetadata(&$reports, $info)
+ {
+ $idSite = $info['idSite'];
+
+ // If only one website is selected, we add the Graph URL
+ if (empty($idSite) || !is_numeric($idSite)) {
+ return;
+ }
+
+ // 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') {
+ // for period=range, show the configured sub-periods
+ $periodForMultiplePeriodGraph = Config::getInstance()->General['graphs_default_period_to_plot_when_period_range'];
+ $dateForMultiplePeriodGraph = $dateForSinglePeriodGraph;
+ } elseif ($info['period'] == 'day' || !Config::getInstance()->General['graphs_show_evolution_within_selected_period']) {
+ // for period=day, always show the last n days
+ // if graphs_show_evolution_within_selected_period=false, show the last n periods
+ $periodForMultiplePeriodGraph = $periodForSinglePeriodGraph;
+ $dateForMultiplePeriodGraph = Range::getRelativeToEndDate(
+ $periodForSinglePeriodGraph,
+ 'last' . self::getDefaultGraphEvolutionLastPeriods(),
+ $dateForSinglePeriodGraph,
+ $piwikSite
+ );
+ } else {
+ // if graphs_show_evolution_within_selected_period=true, show the days within the period
+ // (except if the period is day, see above)
+ $periodForMultiplePeriodGraph = 'day';
+ $period = PeriodFactory::build($info['period'], $info['date']);
+ $start = $period->getDateStart()->toString();
+ $end = $period->getDateEnd()->toString();
+ $dateForMultiplePeriodGraph = $start . ',' . $end;
+ }
+ }
+
+ $segment = Request::getRawSegmentFromRequest();
+
+ /** @var Scheduler $scheduler */
+ $scheduler = StaticContainer::getContainer()->get('Piwik\Scheduler\Scheduler');
+ $isRunningTask = $scheduler->isRunningTask();
+
+ // add the idSubtable if it exists
+ $idSubtable = Common::getRequestVar('idSubtable', 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;
+
+ // 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;
+ }
+
+ if ($idSubtable !== false) {
+ $parameters['idSubtable'] = $idSubtable;
+ }
+
+ if (!empty($_GET['_restrictSitesToLogin']) && $isRunningTask) {
+ $parameters['_restrictSitesToLogin'] = $_GET['_restrictSitesToLogin'];
+ }
+
+ if (!empty($segment)) {
+ $parameters['segment'] = $segment;
+ }
+
+ $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 Matomo 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);
+ }
+ }
+ }
+
+ public static function getDefaultGraphEvolutionLastPeriods()
+ {
+ $lastPeriods = (int) Config::getInstance()->General['graphs_default_evolution_graph_last_days_amount'];
+ if ($lastPeriods <= 0) {
+ throw new \Exception("Invalid value '$lastPeriods' supplied for [General] graphs_default_evolution_graph_last_days_amount config.");
+ }
+ return $lastPeriods;
+ }
+}
diff --git a/plugins/ImageGraph/StaticGraph.php b/plugins/ImageGraph/StaticGraph.php
new file mode 100644
index 0000000000000000000000000000000000000000..03c37ba204180fdde33a9ae1b9613c7c2e49ee83
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph.php
@@ -0,0 +1,429 @@
+ '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',
+ );
+
+ public const ABSCISSA_SERIE_NAME = 'ABSCISSA';
+
+ private $aliasedGraph;
+
+ /**
+ * @var Image
+ */
+ protected $pImage;
+ /**
+ * @var Data
+ */
+ 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();
+
+ protected static function getClassNameFromClassId($graphType)
+ {
+ $className = self::$availableStaticGraphTypes[$graphType];
+ $className = __NAMESPACE__ . "\\StaticGraph\\" . $className;
+ return $className;
+ }
+
+ protected static function getInvalidClassIdExceptionMessage($graphType)
+ {
+ return Piwik::translate(
+ 'General_ExceptionInvalidStaticGraphType',
+ array($graphType, implode(', ', self::getAvailableStaticGraphTypes()))
+ );
+ }
+
+ public static function getAvailableStaticGraphTypes()
+ {
+ return array_keys(self::$availableStaticGraphTypes);
+ }
+
+ public static function fixWhitespaceNonUnifont($value)
+ {
+ if (!is_string($value)) {
+ return $value;
+ }
+
+ return strtr(
+ $value,
+ [
+ // thin space
+ "\xE2\x80\x89" => ' ',
+ // narrow non-break-space
+ "\xE2\x80\xAF" => "\xC2\xA0",
+ ]
+ );
+ }
+
+ /**
+ * 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 = StaticContainer::get('path.tmp') . '/assets/' . $filename;
+
+ @chmod($outputFilename, 0600);
+ @unlink($outputFilename);
+ return $outputFilename;
+ }
+
+ protected function initpData()
+ {
+ $this->pData = new Data();
+
+ foreach ($this->ordinateSeries as $column => $data) {
+ $this->pData->addPoints($data, $column);
+ if (isset($this->ordinateLabels[$column])) {
+ $this->pData->setSerieDescription($column, $this->ordinateLabels[$column]);
+ }
+
+ if (isset($this->ordinateLogos[$column])) {
+ $ordinateLogo = $this->createResizedImageCopyIfNeeded($this->ordinateLogos[$column]);
+ $this->pData->setSeriePicture($column, $ordinateLogo);
+ }
+ }
+
+ // Fix whitespace if not using unifont
+ $abscissaSeries = $this->abscissaSeries;
+ $formatMethodName = 'formatYAxis';
+
+ if (false === strpos($this->font, API::UNICODE_FONT)) {
+ $abscissaSeries = array_map([$this, 'fixWhitespaceNonUnifont'], $abscissaSeries);
+ $formatMethodName = 'formatYAxisNonUnifont';
+ }
+
+ $this->pData->setAxisDisplay(0, AXIS_FORMAT_CUSTOM, '\\Piwik\\Plugins\\ImageGraph\\' . $formatMethodName);
+ $this->pData->addPoints($abscissaSeries, self::ABSCISSA_SERIE_NAME);
+ $this->pData->setAbscissa(self::ABSCISSA_SERIE_NAME);
+ }
+
+ protected function createResizedImageCopyIfNeeded($image)
+ {
+ $size = getimagesize($image);
+
+ if ($size[0] <= 16 && $size[1] <= 16) {
+ return $image; // use original image if size fits
+ }
+
+ $ratio = $size[0] / $size[1];
+ if ($ratio > 1) {
+ $width = 16;
+ $height = 16 / $ratio;
+ } else {
+ $width = 16 * $ratio;
+ $height = 16;
+ }
+
+ $newImage = self::getOutputPath(md5($image) . '.png');
+
+ $src = imagecreatefromstring(file_get_contents($image));
+ $dst = imagecreatetruecolor($width, $height);
+ imagesavealpha($dst, true);
+ $color = imagecolorallocatealpha($dst, 0, 0, 0, 127);
+ imagefill($dst, 0, 0, $color);
+ imagecopyresampled($dst, $src, 0, 0, 0, 0, $width, $height, $size[0], $size[1]);
+ imagedestroy($src);
+ imagepng($dst, $newImage);
+ imagedestroy($dst);
+
+ return $newImage;
+ }
+
+ protected function initpImage()
+ {
+ $this->pImage = new Image($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 $data) {
+ foreach ($data as $value) {
+ [$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 [];
+ }
+ }
+}
+
+/**
+ * Global format method - unifont
+ *
+ * required to format y axis values using CpChart internal format callbacks
+ * @param $value
+ * @return mixed
+ */
+function formatYAxis($value)
+{
+ return NumberFormatter::getInstance()->format($value);
+}
+
+/**
+ * Global format method - non-unifont
+ *
+ * required to format y axis values using CpChart internal format callbacks
+ * @param $value
+ * @return mixed
+ */
+function formatYAxisNonUnifont($value)
+{
+ return StaticGraph::fixWhitespaceNonUnifont(
+ NumberFormatter::getInstance()->format($value)
+ );
+}
diff --git a/plugins/ImageGraph/StaticGraph/Evolution.php b/plugins/ImageGraph/StaticGraph/Evolution.php
new file mode 100644
index 0000000000000000000000000000000000000000..12e0968663d9fe7bdc0ef2b5136622b5f65907fa
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph/Evolution.php
@@ -0,0 +1,26 @@
+initGridChart(
+ $displayVerticalGridLines = true,
+ $bulletType = LEGEND_FAMILY_LINE,
+ $horizontalGraph = false,
+ $showTicks = true,
+ $verticalLegend = true
+ );
+
+ $this->pImage->drawLineChart();
+ }
+}
diff --git a/plugins/ImageGraph/StaticGraph/Exception.php b/plugins/ImageGraph/StaticGraph/Exception.php
new file mode 100644
index 0000000000000000000000000000000000000000..dd79f4dc043b34bd3299479928fecd7b10216a60
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph/Exception.php
@@ -0,0 +1,76 @@
+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 Data();
+
+ $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/plugins/ImageGraph/StaticGraph/GridGraph.php b/plugins/ImageGraph/StaticGraph/GridGraph.php
new file mode 100644
index 0000000000000000000000000000000000000000..75813590723911a9c757d23330b3d150de7e28b6
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph/GridGraph.php
@@ -0,0 +1,490 @@
+ '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 = ceil($currentMax);
+ }
+ }
+
+ // rounding top scale value to the next multiple of 10
+ if ($maxOrdinateValue > 10) {
+ $modTen = $maxOrdinateValue % 10;
+ if ($modTen) {
+ $maxOrdinateValue += 10 - $modTen;
+ }
+ }
+
+ if ($ordinateAxisLength <= 0 || $bottomRightYValue - $topLeftYValue <= 0) {
+ throw new InvalidDimensionException('Error: the graph dimension is not valid. Please try larger width and height values or use 0 for default values.');
+ }
+
+ $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 criteria
+ // - 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 parameters :
+ // - 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 + self::TOP_GRID_MARGIN_HORIZONTAL_GRAPH;
+ }
+
+ if ($this->showLegend && !$verticalLegend) {
+ $topMargin += $this->getHorizontalLegendHeight();
+ }
+
+ return $topMargin;
+ }
+
+ private function getHorizontalLegendHeight()
+ {
+ list($maxMetricLegendWidth, $maxMetricLegendHeight) =
+ $this->getMaximumTextWidthHeight(array_values($this->ordinateLabels));
+
+ 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 self::RIGHT_GRID_MARGIN_HORIZONTAL_GRAPH;
+ }
+ }
+
+ 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 https://github.com/piwik/piwik/issues/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/plugins/ImageGraph/StaticGraph/HorizontalBar.php b/plugins/ImageGraph/StaticGraph/HorizontalBar.php
new file mode 100644
index 0000000000000000000000000000000000000000..d6d4d13f593bbb6ec4fdb79b0447ef2a27f7ad2b
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph/HorizontalBar.php
@@ -0,0 +1,186 @@
+abscissaLogos as &$logoPath) {
+ $logoPath = $this->createResizedImageCopyIfNeeded($logoPath);
+ list($logoWidth, $logoHeight) = self::getLogoSize($logoPath);
+ $logoPathToHeight[$logoPath] = $logoHeight;
+ }
+
+ // determine the maximum logo width & height
+ list($maxLogoWidth, $maxLogoHeight) = self::getMaxLogoSize($this->abscissaLogos);
+
+ // 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])) {
+ $currentLogoPath = $this->abscissaLogos[$i];
+
+ if (isset($logoPathToHeight[$currentLogoPath])) {
+ $logoHeight = $logoPathToHeight[$currentLogoPath];
+
+ $pathInfo = pathinfo($currentLogoPath);
+ $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,
+ $currentLogoPath
+ );
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/ImageGraph/StaticGraph/Pie.php b/plugins/ImageGraph/StaticGraph/Pie.php
new file mode 100644
index 0000000000000000000000000000000000000000..ed484dc0a5b90dd0951cfa93a2ee87bb9a976e43
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph/Pie.php
@@ -0,0 +1,29 @@
+initPieGraph(false);
+
+ $this->pieChart->draw2DPie(
+ $this->xPosition,
+ $this->yPosition,
+ $this->pieConfig
+ );
+ }
+
+ public function supportsComparison()
+ {
+ return false;
+ }
+}
diff --git a/plugins/ImageGraph/StaticGraph/Pie3D.php b/plugins/ImageGraph/StaticGraph/Pie3D.php
new file mode 100644
index 0000000000000000000000000000000000000000..b61ef1f4cbe91d79f4b8b31016ec0129e3112b80
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph/Pie3D.php
@@ -0,0 +1,24 @@
+initPieGraph(true);
+
+ $this->pieChart->draw3DPie(
+ $this->xPosition,
+ $this->yPosition,
+ $this->pieConfig
+ );
+ }
+}
diff --git a/plugins/ImageGraph/StaticGraph/PieGraph.php b/plugins/ImageGraph/StaticGraph/PieGraph.php
new file mode 100644
index 0000000000000000000000000000000000000000..94e6355c17d223afb55e879597b30fa6552bc8c3
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph/PieGraph.php
@@ -0,0 +1,123 @@
+ '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 Pie($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, CpChart 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/plugins/ImageGraph/StaticGraph/VerticalBar.php b/plugins/ImageGraph/StaticGraph/VerticalBar.php
new file mode 100644
index 0000000000000000000000000000000000000000..7dd1eaed555d13f54f49ecadc0a2dfee113e7d6c
--- /dev/null
+++ b/plugins/ImageGraph/StaticGraph/VerticalBar.php
@@ -0,0 +1,32 @@
+initGridChart(
+ $displayVerticalGridLines = false,
+ $bulletType = LEGEND_FAMILY_BOX,
+ $horizontalGraph = false,
+ $showTicks = true,
+ $verticalLegend = false
+ );
+
+ $this->pImage->drawBarChart(
+ array(
+ 'Interleave' => self::INTERLEAVE,
+ )
+ );
+ }
+}
diff --git a/plugins/ImageGraph/config/config.php b/plugins/ImageGraph/config/config.php
new file mode 100644
index 0000000000000000000000000000000000000000..d5be5de26f92a935f318ed4c0a04fe01434bfe66
--- /dev/null
+++ b/plugins/ImageGraph/config/config.php
@@ -0,0 +1,3 @@
+{{ plot.0 }}
+
+
+
+{% endfor %}
\ No newline at end of file
diff --git a/plugins/ImageGraph/templates/testAllSizes.twig b/plugins/ImageGraph/templates/testAllSizes.twig
new file mode 100644
index 0000000000000000000000000000000000000000..c4df9672ecb29c119605befb30c17ba9849c908d
--- /dev/null
+++ b/plugins/ImageGraph/templates/testAllSizes.twig
@@ -0,0 +1,55 @@
+{% extends 'dashboard.twig' %}
+
+{% block topcontrols %}
+ {% include '@CoreHome/_periodSelect.twig' %}
+{% endblock %}
+
+{% block content %}
+ {% set showSitesSelection=true %}
+
+
+
{{ 'ImageGraph_ImageGraph'|translate }} ::: {{ siteName }}
+
+
+
+
+
+
+
+
+ | Category |
+ Name |
+
+ {% for type in graphTypes %}
+ {{ type }} |
+ {% endfor %}
+
+ {% for report in availableReports %}
+ {% if report.imageGraphUrl is defined %}
+
+ | {{ report.category }} |
+ {{ report.name }} |
+ {% for type in graphTypes %}
+
+ 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 %}
+ |
+ {% endfor %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/plugins/LanguagesManager/API.php b/plugins/LanguagesManager/API.php
new file mode 100644
index 0000000000000000000000000000000000000000..cd2847f4dc2f19484679ef354a9f3db5b83e0ed4
--- /dev/null
+++ b/plugins/LanguagesManager/API.php
@@ -0,0 +1,427 @@
+40+ translations!).
+ * This is mostly useful to developers who integrate Matomo 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".
+ *
+ * @phpstan-type AvailableLanguage array{code: string, name: string, english_name: string}
+ * @phpstan-type AvailableLanguageInfo array{
+ * code: string,
+ * name: string,
+ * english_name: string,
+ * translators: string,
+ * percentage_complete: string
+ * }
+ * @phpstan-type TranslationEntry array{label: string, value: string}
+ * @phpstan-type Translations array>
+ *
+ * @method static \Piwik\Plugins\LanguagesManager\API getInstance()
+ */
+class API extends \Piwik\Plugin\API
+{
+ /**
+ * @var array>
+ */
+ protected $availableLanguageNames = [];
+
+ /**
+ * @var array>
+ */
+ protected $languageNames = [];
+
+ /**
+ * Returns whether a language code can be used in the current Matomo instance.
+ *
+ * @param string $languageCode The ISO language code to validate.
+ * @param bool $_ignoreConfig Whether to ignore the configured language allowlist.
+ * @return bool True if the language is available, `false` otherwise.
+ */
+ public function isLanguageAvailable(string $languageCode, bool $_ignoreConfig = false)
+ {
+ return $languageCode !== ''
+ && Filesystem::isValidFilename($languageCode)
+ && in_array($languageCode, $this->getAvailableLanguages($_ignoreConfig));
+ }
+
+ /**
+ * Returns the available Matomo language codes.
+ *
+ * @param bool $_ignoreConfig Whether to ignore the configured language allowlist.
+ * @return list Available ISO language codes.
+ */
+ public function getAvailableLanguages(bool $_ignoreConfig = false)
+ {
+ $cacheKey = (int)$_ignoreConfig;
+
+ if (!empty($this->languageNames[$cacheKey])) {
+ return $this->languageNames[$cacheKey];
+ }
+ $path = PIWIK_INCLUDE_PATH . "/lang/";
+ $languagesPath = _glob($path . "*.json");
+
+ $pathLength = strlen($path);
+ $filesystemLanguages = array();
+ if ($languagesPath) {
+ foreach ($languagesPath as $language) {
+ $filesystemLanguages[] = substr($language, $pathLength, -strlen('.json'));
+ }
+ }
+
+ $configLanguages = Config::getInstance()->Languages["Languages"];
+
+ if ($_ignoreConfig) {
+ $languages = $filesystemLanguages;
+ } else {
+ $languages = array_intersect($filesystemLanguages, $configLanguages);
+ }
+
+ $this->enableDevelopmentLanguageInDevEnvironment($languages);
+
+ /**
+ * Hook called after loading available language files.
+ *
+ * Use this hook to customise the list of languagesPath available in Matomo.
+ *
+ * @param array
+ */
+ Piwik::postEvent('LanguagesManager.getAvailableLanguages', array(&$languages));
+
+ $this->languageNames[$cacheKey] = $languages;
+ return $languages;
+ }
+
+ /**
+ * Returns translation coverage information for each available language.
+ *
+ * @param bool $excludeNonCorePlugins Whether to exclude non-core plugins from the translation percentage calculation.
+ * @param bool $_ignoreConfig Whether to ignore the configured language allowlist.
+ * @return list Translation metadata for each available language, including code, names,
+ * translators, and completion percentage.
+ */
+ public function getAvailableLanguagesInfo(bool $excludeNonCorePlugins = true, bool $_ignoreConfig = false)
+ {
+ $data = file_get_contents(PIWIK_INCLUDE_PATH . '/lang/en.json');
+ /** @var Translations $englishTranslation */
+ $englishTranslation = json_decode($data, true);
+
+ $pluginDirectories = Manager::getPluginsDirectories();
+ // merge with plugin translations if any
+
+ $pluginFiles = array();
+ foreach ($pluginDirectories as $pluginsDir) {
+ $pluginFiles = array_merge($pluginFiles, glob(sprintf('%s*/lang/en.json', $pluginsDir)));
+ }
+
+ foreach ($pluginFiles as $file) {
+ $fileWithoutPluginDir = str_replace($pluginDirectories, '', $file);
+
+ preg_match('/([^\/]+)\/lang/i', $fileWithoutPluginDir, $matches);
+ $plugin = $matches[1];
+
+ if (!$excludeNonCorePlugins || Manager::getInstance()->isPluginBundledWithCore($plugin)) {
+ $data = file_get_contents($file);
+ /** @var Translations $pluginTranslations */
+ $pluginTranslations = json_decode($data, true);
+ $englishTranslation = array_merge_recursive($englishTranslation, $pluginTranslations);
+ }
+ }
+
+ $filenames = $this->getAvailableLanguages($_ignoreConfig);
+ $languagesInfo = array();
+ foreach ($filenames as $filename) {
+ $data = file_get_contents(sprintf('%s/lang/%s.json', PIWIK_INCLUDE_PATH, $filename));
+ /** @var Translations $translations */
+ $translations = json_decode($data, true);
+
+ // merge with plugin translations if any
+ $pluginFiles = array();
+ foreach ($pluginDirectories as $pluginsDir) {
+ $pluginFiles = array_merge($pluginFiles, glob(sprintf('%s*/lang/%s.json', $pluginsDir, $filename)));
+ }
+
+ foreach ($pluginFiles as $file) {
+ $fileWithoutPluginDir = str_replace($pluginDirectories, '', $file);
+
+ preg_match('/([^\/]+)\/lang/i', $fileWithoutPluginDir, $matches);
+ $plugin = $matches[1];
+
+ if (!$excludeNonCorePlugins || Manager::getInstance()->isPluginBundledWithCore($plugin)) {
+ $data = file_get_contents($file);
+ /** @var Translations $pluginTranslations */
+ $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;
+ };
+
+ // Skip languages not having Intl translations
+ if (empty($translations['Intl'])) {
+ continue;
+ }
+
+ $translationStringsDone = $intersect($englishTranslation, $translations);
+ $percentageComplete = count($translationStringsDone, COUNT_RECURSIVE) / count($englishTranslation, COUNT_RECURSIVE);
+ $percentageComplete = round(100 * $percentageComplete, 0);
+ $languageInfo = array('code' => $filename,
+ 'name' => $translations['Intl']['OriginalLanguageName'],
+ 'english_name' => $translations['Intl']['EnglishLanguageName'],
+ 'translators' => $translations['General']['TranslatorName'] ?? '-',
+ 'percentage_complete' => $percentageComplete . '%',
+ );
+ $languagesInfo[] = $languageInfo;
+ }
+ return $languagesInfo;
+ }
+
+ /**
+ * Returns the available languages with their localized and English names.
+ *
+ * @param bool $_ignoreConfig Whether to ignore the configured language allowlist.
+ * @return list Available languages with `code`, `name`, and `english_name` fields.
+ */
+ public function getAvailableLanguageNames(bool $_ignoreConfig = false)
+ {
+ $this->loadAvailableLanguages($_ignoreConfig);
+ return $this->availableLanguageNames[(int)$_ignoreConfig];
+ }
+
+ /**
+ * Returns translation strings for a specific language across core and loaded plugins.
+ *
+ * @param string $languageCode The ISO language code to load.
+ * @return list|false Translation entries with `label` and `value` keys, or `false` if the
+ * language is unavailable.
+ */
+ public function getTranslationsForLanguage(string $languageCode)
+ {
+ if (!$this->isLanguageAvailable($languageCode)) {
+ return false;
+ }
+ $data = file_get_contents(PIWIK_INCLUDE_PATH . "/lang/$languageCode.json");
+ /** @var Translations $translations */
+ $translations = json_decode($data, true);
+ $languageInfo = [];
+ foreach ($translations as $module => $keys) {
+ foreach ($keys as $key => $value) {
+ $languageInfo[] = array(
+ 'label' => sprintf('%s_%s', $module, $key),
+ 'value' => $value,
+ );
+ }
+ }
+
+ foreach (PluginManager::getInstance()->getLoadedPluginsName() as $pluginName) {
+ $translations = $this->getPluginTranslationsForLanguage($pluginName, $languageCode);
+
+ if (!empty($translations)) {
+ foreach ($translations as $keys) {
+ $languageInfo[] = $keys;
+ }
+ }
+ }
+
+ return $languageInfo;
+ }
+
+ /**
+ * @return list|false
+ * @ignore
+ */
+ public function getPluginTranslationsForLanguage(string $pluginName, string $languageCode)
+ {
+ if (!$this->isLanguageAvailable($languageCode)) {
+ return false;
+ }
+
+ $languageFile = Manager::getPluginDirectory($pluginName) . "/lang/$languageCode.json";
+
+ if (!file_exists($languageFile)) {
+ return false;
+ }
+
+ $data = file_get_contents($languageFile);
+ /** @var Translations $translations */
+ $translations = json_decode($data, true);
+ $languageInfo = [];
+ foreach ($translations as $module => $keys) {
+ foreach ($keys as $key => $value) {
+ $languageInfo[] = [
+ 'label' => sprintf("%s_%s", $module, $key),
+ 'value' => $value,
+ ];
+ }
+ }
+ return $languageInfo;
+ }
+
+ /**
+ * Returns the saved language preference for a user.
+ *
+ * @param string $login The user login to read the language for.
+ * @return string|false The saved language code, or `false` for the anonymous user.
+ */
+ public function getLanguageForUser(string $login)
+ {
+ if (strtolower($login) === 'anonymous') {
+ return false;
+ }
+
+ Piwik::checkUserHasSuperUserAccessOrIsTheUser($login);
+
+ $lang = $this->getModel()->getLanguageForUser($login);
+
+ return $lang;
+ }
+
+ private function getModel(): Model
+ {
+ return new Model();
+ }
+
+ /**
+ * Stores the language preference for a user.
+ *
+ * @param string $login The user login to update.
+ * @param string $languageCode The ISO language code to store.
+ * @return bool `true` if the language was stored, `false` if the language code is unavailable.
+ */
+ public function setLanguageForUser(string $login, string $languageCode): bool
+ {
+ Piwik::checkUserHasSuperUserAccessOrIsTheUser($login);
+ Piwik::checkUserIsNotAnonymous();
+
+ if (!$this->isLanguageAvailable($languageCode)) {
+ return false;
+ }
+
+ $this->getModel()->setLanguageForUser($login, $languageCode);
+
+ return true;
+ }
+
+ /**
+ * Returns whether a user prefers 12-hour time formatting.
+ *
+ * @param string $login The user login to query.
+ * @return bool `true` if the user uses a 12-hour clock, `false` otherwise or for the anonymous user.
+ */
+ public function uses12HourClockForUser(string $login): bool
+ {
+ if (strtolower($login) === 'anonymous') {
+ return false;
+ }
+
+ Piwik::checkUserHasSuperUserAccessOrIsTheUser($login);
+
+ return $this->getModel()->uses12HourClock($login);
+ }
+
+ /**
+ * Stores whether a user prefers 12-hour time formatting.
+ *
+ * @param string $login The user login to update.
+ * @param bool $use12HourClock Whether to enable 12-hour clock formatting.
+ * @return bool `true` if the preference was stored, `false` for the anonymous user.
+ */
+ public function set12HourClockForUser(string $login, bool $use12HourClock): bool
+ {
+ if (strtolower($login) === 'anonymous') {
+ return false;
+ }
+
+ Piwik::checkUserHasSuperUserAccessOrIsTheUser($login);
+
+ return $this->getModel()->set12HourClock($login, $use12HourClock);
+ }
+
+ private function loadAvailableLanguages(bool $_ignoreConfig = false): void
+ {
+ $cacheKey = (int)$_ignoreConfig;
+
+ if (!empty($this->availableLanguageNames[$cacheKey])) {
+ return;
+ }
+
+ $cacheId = 'availableLanguages' . (int) $_ignoreConfig;
+ $cache = PiwikCache::getEagerCache();
+
+ if ($cache->contains($cacheId)) {
+ /** @var list $languagesInfo */
+ $languagesInfo = $cache->fetch($cacheId);
+ } else {
+ $languages = $this->getAvailableLanguages($_ignoreConfig);
+ $languagesInfo = [];
+ foreach ($languages as $languageCode) {
+ $data = @file_get_contents(PIWIK_INCLUDE_PATH . "/plugins/Intl/lang/$languageCode.json");
+
+ // Skip languages not having Intl translations
+ if (empty($data)) {
+ continue;
+ }
+
+ /** @var Translations $translations */
+ $translations = json_decode($data, true);
+ $languagesInfo[] = [
+ 'code' => $languageCode,
+ 'name' => (string)$translations['Intl']['OriginalLanguageName'],
+ 'english_name' => (string)$translations['Intl']['EnglishLanguageName'],
+ ];
+ }
+
+ $cache->save($cacheId, $languagesInfo);
+ }
+
+ $this->availableLanguageNames[$cacheKey] = $languagesInfo;
+ }
+
+ /**
+ * @param list $languages
+ */
+ private function enableDevelopmentLanguageInDevEnvironment(array &$languages): void
+ {
+ $key = array_search(DevelopmentLoader::LANGUAGE_ID, $languages);
+ if (!Development::isEnabled() && $key) {
+ unset($languages[$key]);
+ }
+ if (Development::isEnabled() && !$key) {
+ $languages[] = DevelopmentLoader::LANGUAGE_ID;
+ }
+ }
+}
diff --git a/plugins/LanguagesManager/Controller.php b/plugins/LanguagesManager/Controller.php
new file mode 100644
index 0000000000000000000000000000000000000000..c07e04f2143ec5c0feb341b9688f6fa16b447745
--- /dev/null
+++ b/plugins/LanguagesManager/Controller.php
@@ -0,0 +1,42 @@
+renderTemplate('searchTranslation');
+ }
+}
diff --git a/plugins/LanguagesManager/LanguagesHelper.php b/plugins/LanguagesManager/LanguagesHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..953cafb85561227610ed1906f72539fcc66a050b
--- /dev/null
+++ b/plugins/LanguagesManager/LanguagesHelper.php
@@ -0,0 +1,64 @@
+getLanguageForUser($user['login']);
+
+ $translator = StaticContainer::get(Translator::class);
+
+ $backupLanguage = $translator->getCurrentLanguage();
+ if (empty($backupLanguage)) {
+ // if no language was set yet, ensure to restore the default language
+ $backupLanguage = $translator->getDefaultLanguage();
+ }
+
+ if (!empty($userLanguage)) {
+ // temporarily overwrite the language to perform the callback action
+ $translator->setCurrentLanguage($userLanguage);
+ }
+
+ try {
+ $result = $callback();
+ } catch (\Throwable $ex) {
+ throw $ex;
+ } finally {
+ $translator->setCurrentLanguage($backupLanguage);
+ }
+
+ return $result;
+ }
+}
diff --git a/plugins/LanguagesManager/LanguagesManager.php b/plugins/LanguagesManager/LanguagesManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..558d314a4f7df09a5a2203aa85b6800ce7834934
--- /dev/null
+++ b/plugins/LanguagesManager/LanguagesManager.php
@@ -0,0 +1,241 @@
+ 'getClientSideTranslationKeys',
+ 'Config.NoConfigurationFile' => 'initLanguage',
+ 'Request.dispatchCoreAndPluginUpdatesScreen' => 'initLanguage',
+ 'Request.dispatch' => 'initLanguage',
+ 'Platform.initialized' => 'initLanguage',
+ 'UsersManager.deleteUser' => 'deleteUserLanguage',
+ 'Template.topBar' => 'addLanguagesManagerToOtherTopBar',
+ 'Template.jsGlobalVariables' => 'jsGlobalVariables',
+ 'Db.getTablesInstalled' => 'getTablesInstalled',
+ );
+ }
+
+ public function getClientSideTranslationKeys(&$translations)
+ {
+ $translations[] = 'LanguagesManager_TranslationSearch';
+ $translations[] = 'LanguagesManager_AboutPiwikTranslations';
+ }
+
+ /**
+ * Register the new tables, so Matomo knows about them.
+ *
+ * @param array $allTablesInstalled
+ */
+ public function getTablesInstalled(&$allTablesInstalled)
+ {
+ $allTablesInstalled[] = Common::prefixTable('user_language');
+ }
+
+ /**
+ * 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 .= "";
+ $file = PluginUmdAssetFetcher::getUmdFileToUseForPlugin('LanguagesManager');
+ $str .= "";
+ $str .= $this->getLanguagesSelector();
+ }
+
+ /**
+ * Adds the current user's language name as a JavaScript global variable (piwik.languageName).
+ */
+ 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
+ */
+ public function getLanguagesSelector()
+ {
+ $view = new View("@LanguagesManager/getLanguagesSelector");
+ $view->languages = API::getInstance()->getAvailableLanguageNames();
+ $view->currentLanguageCode = self::getLanguageCodeForCurrentUser();
+ $view->currentLanguageName = self::getLanguageNameForCurrentUser();
+ $view->nonce = Nonce::getNonce(self::LANGUAGE_SELECTION_NONCE);
+ return $view->render();
+ }
+
+ public function initLanguage()
+ {
+ /** @var Translator $translator */
+ $translator = StaticContainer::get('Piwik\Translation\Translator');
+
+ $language = Common::getRequestVar('language', '', 'string');
+ if (empty($language)) {
+ $userLanguage = self::getLanguageCodeForCurrentUser();
+ if (is_string($userLanguage) && API::getInstance()->isLanguageAvailable($userLanguage)) {
+ $language = $userLanguage;
+ }
+ }
+ if (!empty($language) && API::getInstance()->isLanguageAvailable($language)) {
+ $translator->setCurrentLanguage($language);
+ }
+
+ $locale = $translator->translate('General_Locale');
+ Locale::setLocale($locale);
+ }
+
+ public function deleteUserLanguage($userLogin)
+ {
+ $model = new Model();
+ $model->deleteUserLanguage($userLogin);
+ }
+
+ /**
+ * @throws Exception if non-recoverable error
+ */
+ public function install()
+ {
+ Model::install();
+ }
+
+ /**
+ * @throws Exception if non-recoverable error
+ */
+ public function uninstall()
+ {
+ Model::uninstall();
+ }
+
+ /**
+ * @return boolean
+ */
+ public static function uses12HourClockForCurrentUser()
+ {
+ try {
+ $currentUser = Piwik::getCurrentUserLogin();
+ return Request::processRequest('LanguagesManager.uses12HourClockForUser', array('login' => $currentUser));
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * @return string Two letters language code, eg. "fr"
+ */
+ public static function getLanguageCodeForCurrentUser()
+ {
+ $languageCode = self::getLanguageFromPreferences();
+ if (!is_string($languageCode) || !API::getInstance()->isLanguageAvailable($languageCode)) {
+ $languageCode = Common::extractLanguageAndRegionCodeFromBrowserLanguage(Common::getBrowserLanguage(), API::getInstance()->getAvailableLanguages());
+ }
+ if (!is_string($languageCode) || !API::getInstance()->isLanguageAvailable($languageCode)) {
+ $languageCode = StaticContainer::get('Piwik\Translation\Translator')->getDefaultLanguage();
+ }
+ return $languageCode;
+ }
+
+ /**
+ * @return string|false Full english language string, eg. "French"
+ */
+ public static 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
+ */
+ protected static 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
+ */
+ public static 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
+ */
+ public static function setLanguageForSession($languageCode)
+ {
+ if (!is_string($languageCode) || !API::getInstance()->isLanguageAvailable($languageCode)) {
+ return false;
+ }
+
+ $cookieName = Config::getInstance()->General['language_cookie_name'];
+ $cookie = new Cookie($cookieName, 0);
+ $cookie->set('language', $languageCode);
+ $cookie->setSecure(ProxyHttp::isHttps());
+ $cookie->setHttpOnly(true);
+ $cookie->save('Lax');
+ return true;
+ }
+}
diff --git a/plugins/LanguagesManager/Menu.php b/plugins/LanguagesManager/Menu.php
new file mode 100644
index 0000000000000000000000000000000000000000..22b6b8408c78ca4b3a8f3a9d970c5f43d579de66
--- /dev/null
+++ b/plugins/LanguagesManager/Menu.php
@@ -0,0 +1,37 @@
+addHtml('LanguageSelector', $langManager->getLanguagesSelector(), true, $order = 30, false);
+ }
+ }
+
+ public function configureAdminMenu(MenuAdmin $menu)
+ {
+ if (Development::isEnabled() && Piwik::isUserHasSomeAdminAccess()) {
+ $menu->addDevelopmentItem(
+ 'LanguagesManager_TranslationSearch',
+ $this->urlForAction('searchTranslation')
+ );
+ }
+ }
+}
diff --git a/plugins/LanguagesManager/Model.php b/plugins/LanguagesManager/Model.php
new file mode 100644
index 0000000000000000000000000000000000000000..5b74c7b3a9e75f2a2e337eb77f970b607a1072d1
--- /dev/null
+++ b/plugins/LanguagesManager/Model.php
@@ -0,0 +1,105 @@
+table = Common::prefixTable(self::$rawPrefix);
+ }
+
+ public function deleteUserLanguage($userLogin)
+ {
+ Db::query('DELETE FROM ' . $this->table . ' WHERE login = ?', $userLogin);
+ }
+
+ /**
+ * Returns the language for the user
+ *
+ * @param string $userLogin
+ * @return string
+ */
+ public function getLanguageForUser($userLogin)
+ {
+ return Db::fetchOne('SELECT language FROM ' . $this->table .
+ ' WHERE login = ? ', array($userLogin));
+ }
+
+ /**
+ * Sets the language for the user
+ *
+ * @param string $login
+ * @param string $languageCode
+ * @return bool
+ */
+ public function setLanguageForUser($login, $languageCode)
+ {
+ $query = 'INSERT INTO ' . $this->table .
+ ' (login, language) VALUES (?,?) ON DUPLICATE KEY UPDATE language=?';
+ $bind = array($login, $languageCode, $languageCode);
+ Db::query($query, $bind);
+
+ return true;
+ }
+
+ /**
+ * Returns whether the given user has chosen to use 12-hour clock
+ *
+ * @param $userLogin
+ * @return bool
+ * @throws \Exception
+ */
+ public function uses12HourClock($userLogin)
+ {
+ return (bool) Db::fetchOne('SELECT use_12_hour_clock FROM ' . $this->table .
+ ' WHERE login = ? ', array($userLogin));
+ }
+
+ /**
+ * Sets whether the given user wants to use 12-hour clock
+ *
+ * @param string $login
+ * @param bool $use12HourClock
+ * @return bool
+ */
+ public function set12HourClock($login, $use12HourClock)
+ {
+ $query = 'INSERT INTO ' . $this->table .
+ ' (login, use_12_hour_clock) VALUES (?,?) ON DUPLICATE KEY UPDATE use_12_hour_clock=?';
+ $bind = [$login, (int)$use12HourClock, (int)$use12HourClock];
+ Db::query($query, $bind);
+
+ return true;
+ }
+
+ public static function install()
+ {
+ $userLanguage = "login VARCHAR( 100 ) NOT NULL ,
+ language VARCHAR( 10 ) NOT NULL ,
+ use_12_hour_clock TINYINT(1) NOT NULL DEFAULT 0 ,
+ PRIMARY KEY ( login )";
+ DbHelper::createTable(self::$rawPrefix, $userLanguage);
+ }
+
+ public static function uninstall()
+ {
+ Db::dropTables(Common::prefixTable(self::$rawPrefix));
+ }
+}
diff --git a/plugins/LanguagesManager/templates/getLanguagesSelector.twig b/plugins/LanguagesManager/templates/getLanguagesSelector.twig
new file mode 100644
index 0000000000000000000000000000000000000000..70f96387ab9e89d6b1750f50e3f4a33e17f26e56
--- /dev/null
+++ b/plugins/LanguagesManager/templates/getLanguagesSelector.twig
@@ -0,0 +1,8 @@
+
diff --git a/plugins/LanguagesManager/templates/searchTranslation.twig b/plugins/LanguagesManager/templates/searchTranslation.twig
new file mode 100644
index 0000000000000000000000000000000000000000..fb705b6f7f2d48f571126a2723b697832cb73f74
--- /dev/null
+++ b/plugins/LanguagesManager/templates/searchTranslation.twig
@@ -0,0 +1,9 @@
+{% extends 'admin.twig' %}
+
+{% set title %}{{ 'LanguagesManager_TranslationSearch'|translate }}{% endset %}
+
+{% block content %}
+
+
+
+{% endblock %}
diff --git a/plugins/LanguagesManager/vue/dist/LanguagesManager.umd.js b/plugins/LanguagesManager/vue/dist/LanguagesManager.umd.js
new file mode 100644
index 0000000000000000000000000000000000000000..346fc3db0e7bbc1930512fae923edbd6e2b48a7e
--- /dev/null
+++ b/plugins/LanguagesManager/vue/dist/LanguagesManager.umd.js
@@ -0,0 +1,473 @@
+(function webpackUniversalModuleDefinition(root, factory) {
+ if(typeof exports === 'object' && typeof module === 'object')
+ module.exports = factory(require("CoreHome"), require("vue"));
+ else if(typeof define === 'function' && define.amd)
+ define(["CoreHome", ], factory);
+ else if(typeof exports === 'object')
+ exports["LanguagesManager"] = factory(require("CoreHome"), require("vue"));
+ else
+ root["LanguagesManager"] = factory(root["CoreHome"], root["Vue"]);
+})((typeof self !== 'undefined' ? self : this), function(__WEBPACK_EXTERNAL_MODULE__19dc__, __WEBPACK_EXTERNAL_MODULE__8bbf__) {
+return /******/ (function(modules) { // webpackBootstrap
+/******/ // The module cache
+/******/ var installedModules = {};
+/******/
+/******/ // The require function
+/******/ function __webpack_require__(moduleId) {
+/******/
+/******/ // Check if module is in cache
+/******/ if(installedModules[moduleId]) {
+/******/ return installedModules[moduleId].exports;
+/******/ }
+/******/ // Create a new module (and put it into the cache)
+/******/ var module = installedModules[moduleId] = {
+/******/ i: moduleId,
+/******/ l: false,
+/******/ exports: {}
+/******/ };
+/******/
+/******/ // Execute the module function
+/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
+/******/
+/******/ // Flag the module as loaded
+/******/ module.l = true;
+/******/
+/******/ // Return the exports of the module
+/******/ return module.exports;
+/******/ }
+/******/
+/******/
+/******/ // expose the modules object (__webpack_modules__)
+/******/ __webpack_require__.m = modules;
+/******/
+/******/ // expose the module cache
+/******/ __webpack_require__.c = installedModules;
+/******/
+/******/ // define getter function for harmony exports
+/******/ __webpack_require__.d = function(exports, name, getter) {
+/******/ if(!__webpack_require__.o(exports, name)) {
+/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
+/******/ }
+/******/ };
+/******/
+/******/ // define __esModule on exports
+/******/ __webpack_require__.r = function(exports) {
+/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
+/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
+/******/ }
+/******/ Object.defineProperty(exports, '__esModule', { value: true });
+/******/ };
+/******/
+/******/ // create a fake namespace object
+/******/ // mode & 1: value is a module id, require it
+/******/ // mode & 2: merge all properties of value into the ns
+/******/ // mode & 4: return value when already ns object
+/******/ // mode & 8|1: behave like require
+/******/ __webpack_require__.t = function(value, mode) {
+/******/ if(mode & 1) value = __webpack_require__(value);
+/******/ if(mode & 8) return value;
+/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
+/******/ var ns = Object.create(null);
+/******/ __webpack_require__.r(ns);
+/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
+/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
+/******/ return ns;
+/******/ };
+/******/
+/******/ // getDefaultExport function for compatibility with non-harmony modules
+/******/ __webpack_require__.n = function(module) {
+/******/ var getter = module && module.__esModule ?
+/******/ function getDefault() { return module['default']; } :
+/******/ function getModuleExports() { return module; };
+/******/ __webpack_require__.d(getter, 'a', getter);
+/******/ return getter;
+/******/ };
+/******/
+/******/ // Object.prototype.hasOwnProperty.call
+/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
+/******/
+/******/ // __webpack_public_path__
+/******/ __webpack_require__.p = "plugins/LanguagesManager/vue/dist/";
+/******/
+/******/
+/******/ // Load entry module and return exports
+/******/ return __webpack_require__(__webpack_require__.s = "fae3");
+/******/ })
+/************************************************************************/
+/******/ ({
+
+/***/ "19dc":
+/***/ (function(module, exports) {
+
+module.exports = __WEBPACK_EXTERNAL_MODULE__19dc__;
+
+/***/ }),
+
+/***/ "8bbf":
+/***/ (function(module, exports) {
+
+module.exports = __WEBPACK_EXTERNAL_MODULE__8bbf__;
+
+/***/ }),
+
+/***/ "fae3":
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+// ESM COMPAT FLAG
+__webpack_require__.r(__webpack_exports__);
+
+// EXPORTS
+__webpack_require__.d(__webpack_exports__, "TranslationSearch", function() { return /* reexport */ TranslationSearch; });
+__webpack_require__.d(__webpack_exports__, "TranslationSearchPage", function() { return /* reexport */ TranslationSearchPage; });
+__webpack_require__.d(__webpack_exports__, "LanguageSelector", function() { return /* reexport */ LanguageSelector; });
+__webpack_require__.d(__webpack_exports__, "LanguagesDropdown", function() { return /* reexport */ LanguagesDropdown; });
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-service/lib/commands/build/setPublicPath.js
+// This file is imported into lib/wc client bundles.
+
+if (typeof window !== 'undefined') {
+ var currentScript = window.document.currentScript
+ if (false) { var getCurrentScript; }
+
+ var src = currentScript && currentScript.src.match(/(.+\/)[^/]+\.js(\?.*)?$/)
+ if (src) {
+ __webpack_require__.p = src[1] // eslint-disable-line
+ }
+}
+
+// Indicate to webpack that this file can be concatenated
+/* harmony default export */ var setPublicPath = (null);
+
+// EXTERNAL MODULE: external {"commonjs":"vue","commonjs2":"vue","root":"Vue"}
+var external_commonjs_vue_commonjs2_vue_root_Vue_ = __webpack_require__("8bbf");
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearch.vue?vue&type=template&id=35ed731d
+
+const _hoisted_1 = ["href"];
+const _hoisted_2 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1);
+const _hoisted_3 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("br", null, null, -1);
+const _hoisted_4 = {
+ style: {
+ "word-break": "break-all"
+ }
+};
+const _hoisted_5 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", {
+ style: {
+ "width": "250px"
+ }
+}, "Key", -1);
+const _hoisted_6 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", null, "English translation", -1);
+const _hoisted_7 = {
+ key: 0
+};
+function render(_ctx, _cache, $props, $setup, $data, $options) {
+ const _component_Field = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Field");
+ const _directive_content_table = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("content-table");
+ return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("p", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(" This page helps you to find existing translations that you can reuse in your Plugin. If you want to know more about translations have a look at our "), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", {
+ href: _ctx.externalRawLink('https://developer.matomo.org/guides/internationalization'),
+ rel: "noreferrer noopener",
+ target: "_blank"
+ }, "Internationalization guide", 8, _hoisted_1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(". Enter a search term to find translations and their corresponding keys: ")]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, {
+ uicontrol: "text",
+ name: "alias",
+ "inline-help": "Search for English translation. Max 1000 results will be shown.",
+ placeholder: "Search for English translation",
+ modelValue: _ctx.searchTerm,
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => _ctx.searchTerm = $event)
+ }, null, 8, ["modelValue"])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, {
+ uicontrol: "select",
+ name: "translationSearch.compareLanguage",
+ "inline-help": "Optionally select a language to compare the English language with.",
+ "model-value": _ctx.compareLanguage,
+ "onUpdate:modelValue": _cache[1] || (_cache[1] = $event => {
+ _ctx.compareLanguage = $event;
+ _ctx.doCompareLanguage();
+ }),
+ options: _ctx.languages
+ }, null, 8, ["model-value", "options"])]), _hoisted_2, _hoisted_3, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("table", _hoisted_4, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("thead", null, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tr", null, [_hoisted_5, _hoisted_6, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("th", null, "Compare translation", 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.compareLanguage && _ctx.compareTranslations]])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("tbody", null, [(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.filteredTranslations, translation => {
+ return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("tr", {
+ key: translation.label
+ }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(translation.label), 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("td", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(translation.value), 1), _ctx.compareLanguage && _ctx.compareTranslations ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("td", _hoisted_7, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.compareTranslations[translation.label]), 1)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)]);
+ }), 128))])])), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.searchTerm], [_directive_content_table]])]);
+}
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearch.vue?vue&type=template&id=35ed731d
+
+// EXTERNAL MODULE: external "CoreHome"
+var external_CoreHome_ = __webpack_require__("19dc");
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearch.vue?vue&type=script&lang=ts
+
+
+// loading a component this way since during Installation we don't want to load CorePluginsAdmin
+// just for the language selector directive
+const Field = Object(external_CoreHome_["useExternalPluginComponent"])('CorePluginsAdmin', 'Field');
+/* harmony default export */ var TranslationSearchvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({
+ components: {
+ Field
+ },
+ directives: {
+ ContentTable: external_CoreHome_["ContentTable"]
+ },
+ data() {
+ return {
+ compareTranslations: null,
+ existingTranslations: [],
+ languages: [],
+ compareLanguage: '',
+ searchTerm: ''
+ };
+ },
+ created() {
+ this.fetchTranslations('en');
+ this.fetchLanguages();
+ },
+ methods: {
+ fetchTranslations(languageCode) {
+ external_CoreHome_["AjaxHelper"].fetch({
+ method: 'LanguagesManager.getTranslationsForLanguage',
+ filter_limit: -1,
+ languageCode
+ }).then(response => {
+ if (!response) {
+ return;
+ }
+ if (languageCode === 'en') {
+ this.existingTranslations = response;
+ } else {
+ this.compareTranslations = {};
+ response.forEach(translation => {
+ this.compareTranslations[translation.label] = translation.value;
+ });
+ }
+ });
+ },
+ fetchLanguages() {
+ external_CoreHome_["AjaxHelper"].fetch({
+ method: 'LanguagesManager.getAvailableLanguagesInfo',
+ filter_limit: -1
+ }).then(languages => {
+ this.languages = [{
+ key: '',
+ value: 'None'
+ }];
+ if (languages) {
+ languages.forEach(language => {
+ if (language.code === 'en') {
+ return;
+ }
+ this.languages.push({
+ key: language.code,
+ value: language.name
+ });
+ });
+ }
+ });
+ },
+ doCompareLanguage() {
+ if (this.compareLanguage) {
+ this.compareTranslations = null;
+ this.fetchTranslations(this.compareLanguage);
+ }
+ }
+ },
+ computed: {
+ filteredTranslations() {
+ let filtered = this.existingTranslations.filter(t => t.label.includes(this.searchTerm) || t.value.includes(this.searchTerm));
+ filtered = filtered.slice(0, 1000);
+ return filtered;
+ }
+ }
+}));
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearch.vue?vue&type=script&lang=ts
+
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearch.vue
+
+
+
+TranslationSearchvue_type_script_lang_ts.render = render
+
+/* harmony default export */ var TranslationSearch = (TranslationSearchvue_type_script_lang_ts);
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearchPage.vue?vue&type=template&id=75ade4ac
+
+function TranslationSearchPagevue_type_template_id_75ade4ac_render(_ctx, _cache, $props, $setup, $data, $options) {
+ const _component_TranslationSearch = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("TranslationSearch");
+ const _component_ContentBlock = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("ContentBlock");
+ return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_ContentBlock, {
+ "content-title": _ctx.translate('LanguagesManager_TranslationSearch'),
+ feature: "true"
+ }, {
+ default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_TranslationSearch)]),
+ _: 1
+ }, 8, ["content-title"]);
+}
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearchPage.vue?vue&type=template&id=75ade4ac
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearchPage.vue?vue&type=script&lang=ts
+
+
+
+/* harmony default export */ var TranslationSearchPagevue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({
+ components: {
+ ContentBlock: external_CoreHome_["ContentBlock"],
+ TranslationSearch: TranslationSearch
+ }
+}));
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearchPage.vue?vue&type=script&lang=ts
+
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearchPage.vue
+
+
+
+TranslationSearchPagevue_type_script_lang_ts.render = TranslationSearchPagevue_type_template_id_75ade4ac_render
+
+/* harmony default export */ var TranslationSearchPage = (TranslationSearchPagevue_type_script_lang_ts);
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/LanguageSelector/LanguageSelector.ts
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+const {
+ $
+} = window;
+function postLanguageChange(element, event) {
+ const value = $(event.target).attr('value');
+ if (value) {
+ $(element).find('#language').val(value).parents('form').submit();
+ }
+}
+/* harmony default export */ var LanguageSelector = ({
+ mounted(el, binding) {
+ binding.value.onClick = postLanguageChange.bind(null, el);
+ $(el).on('click', 'a[value]', binding.value.onClick);
+ },
+ unmounted(el, binding) {
+ $(el).off('click', 'a[value]', binding.value.onClick);
+ }
+});
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/LanguagesManager/vue/src/LanguagesDropdown/LanguagesDropdown.vue?vue&type=template&id=040297e6
+
+const LanguagesDropdownvue_type_template_id_040297e6_hoisted_1 = {
+ class: "languageSelection"
+};
+const LanguagesDropdownvue_type_template_id_040297e6_hoisted_2 = ["href"];
+const LanguagesDropdownvue_type_template_id_040297e6_hoisted_3 = ["value", "title"];
+const LanguagesDropdownvue_type_template_id_040297e6_hoisted_4 = {
+ action: "index.php?module=LanguagesManager&action=saveLanguage",
+ method: "post",
+ ref: "form"
+};
+const LanguagesDropdownvue_type_template_id_040297e6_hoisted_5 = ["value"];
+const LanguagesDropdownvue_type_template_id_040297e6_hoisted_6 = ["value"];
+const LanguagesDropdownvue_type_template_id_040297e6_hoisted_7 = ["value"];
+function LanguagesDropdownvue_type_template_id_040297e6_render(_ctx, _cache, $props, $setup, $data, $options) {
+ const _component_MenuItemsDropdown = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("MenuItemsDropdown");
+ return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", LanguagesDropdownvue_type_template_id_040297e6_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_MenuItemsDropdown, {
+ "menu-title": _ctx.currentLanguageName,
+ onAfterSelect: _cache[0] || (_cache[0] = $event => _ctx.onSelect($event))
+ }, {
+ default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", {
+ class: "item",
+ target: "_blank",
+ rel: "noreferrer noopener",
+ href: _ctx.externalRawLink('https://matomo.org/translations/')
+ }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('LanguagesManager_AboutPiwikTranslations')), 9, LanguagesDropdownvue_type_template_id_040297e6_hoisted_2), (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(true), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])(external_commonjs_vue_commonjs2_vue_root_Vue_["Fragment"], null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["renderList"])(_ctx.languages, language => {
+ return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", {
+ key: language.code,
+ class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])(`item ${language.code === _ctx.currentLanguageCode ? 'active' : ''}`),
+ value: language.code,
+ title: `${language.name} (${language.english_name})`
+ }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(language.name), 11, LanguagesDropdownvue_type_template_id_040297e6_hoisted_3);
+ }), 128)), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("form", LanguagesDropdownvue_type_template_id_040297e6_hoisted_4, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", {
+ type: "hidden",
+ name: "language",
+ id: "language",
+ value: _ctx.selectedLanguage
+ }, null, 8, LanguagesDropdownvue_type_template_id_040297e6_hoisted_5), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("input", {
+ type: "hidden",
+ name: "nonce",
+ id: "nonce",
+ value: _ctx.formNonce
+ }, null, 8, LanguagesDropdownvue_type_template_id_040297e6_hoisted_6), _ctx.tokenAuth ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("input", {
+ key: 0,
+ type: "hidden",
+ name: "token_auth",
+ value: _ctx.tokenAuth
+ }, null, 8, LanguagesDropdownvue_type_template_id_040297e6_hoisted_7)) : Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createCommentVNode"])("", true)], 512)]),
+ _: 1
+ }, 8, ["menu-title"])]);
+}
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/LanguagesDropdown/LanguagesDropdown.vue?vue&type=template&id=040297e6
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/LanguagesManager/vue/src/LanguagesDropdown/LanguagesDropdown.vue?vue&type=script&lang=ts
+
+
+/* harmony default export */ var LanguagesDropdownvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({
+ props: {
+ tokenAuth: String,
+ formNonce: {
+ type: String,
+ required: true
+ },
+ languages: {
+ type: Array,
+ required: true
+ },
+ currentLanguageCode: {
+ type: String,
+ required: true
+ },
+ currentLanguageName: {
+ type: String,
+ required: true
+ }
+ },
+ components: {
+ MenuItemsDropdown: external_CoreHome_["MenuItemsDropdown"]
+ },
+ data() {
+ return {
+ selectedLanguage: this.currentLanguageCode
+ };
+ },
+ methods: {
+ onSelect(selected) {
+ this.selectedLanguage = selected.getAttribute('value');
+ Object(external_commonjs_vue_commonjs2_vue_root_Vue_["nextTick"])().then(() => {
+ this.$refs.form.submit();
+ });
+ }
+ }
+}));
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/LanguagesDropdown/LanguagesDropdown.vue?vue&type=script&lang=ts
+
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/LanguagesDropdown/LanguagesDropdown.vue
+
+
+
+LanguagesDropdownvue_type_script_lang_ts.render = LanguagesDropdownvue_type_template_id_040297e6_render
+
+/* harmony default export */ var LanguagesDropdown = (LanguagesDropdownvue_type_script_lang_ts);
+// CONCATENATED MODULE: ./plugins/LanguagesManager/vue/src/index.ts
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+
+
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-service/lib/commands/build/entry-lib-no-default.js
+
+
+
+
+/***/ })
+
+/******/ });
+});
+//# sourceMappingURL=LanguagesManager.umd.js.map
\ No newline at end of file
diff --git a/plugins/LanguagesManager/vue/dist/LanguagesManager.umd.min.js b/plugins/LanguagesManager/vue/dist/LanguagesManager.umd.min.js
new file mode 100644
index 0000000000000000000000000000000000000000..4de28e97d7be08d187fdb86ee23d47b85aebd564
--- /dev/null
+++ b/plugins/LanguagesManager/vue/dist/LanguagesManager.umd.min.js
@@ -0,0 +1,14 @@
+(function(e,t){"object"===typeof exports&&"object"===typeof module?module.exports=t(require("CoreHome"),require("vue")):"function"===typeof define&&define.amd?define(["CoreHome"],t):"object"===typeof exports?exports["LanguagesManager"]=t(require("CoreHome"),require("vue")):e["LanguagesManager"]=t(e["CoreHome"],e["Vue"])})("undefined"!==typeof self?self:this,(function(e,t){return function(e){var t={};function n(a){if(t[a])return t[a].exports;var o=t[a]={i:a,l:!1,exports:{}};return e[a].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,a){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:a})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var a=Object.create(null);if(n.r(a),Object.defineProperty(a,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(a,o,function(t){return e[t]}.bind(null,o));return a},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="plugins/LanguagesManager/vue/dist/",n(n.s="fae3")}({"19dc":function(t,n){t.exports=e},"8bbf":function(e,n){e.exports=t},fae3:function(e,t,n){"use strict";if(n.r(t),n.d(t,"TranslationSearch",(function(){return h})),n.d(t,"TranslationSearchPage",(function(){return v})),n.d(t,"LanguageSelector",(function(){return L})),n.d(t,"LanguagesDropdown",(function(){return M})),"undefined"!==typeof window){var a=window.document.currentScript,o=a&&a.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);o&&(n.p=o[1])}var r=n("8bbf");const l=["href"],c=Object(r["createElementVNode"])("br",null,null,-1),i=Object(r["createElementVNode"])("br",null,null,-1),u={style:{"word-break":"break-all"}},s=Object(r["createElementVNode"])("th",{style:{width:"250px"}},"Key",-1),d=Object(r["createElementVNode"])("th",null,"English translation",-1),g={key:0};function m(e,t,n,a,o,m){const p=Object(r["resolveComponent"])("Field"),b=Object(r["resolveDirective"])("content-table");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",null,[Object(r["createElementVNode"])("p",null,[Object(r["createTextVNode"])(" This page helps you to find existing translations that you can reuse in your Plugin. If you want to know more about translations have a look at our "),Object(r["createElementVNode"])("a",{href:e.externalRawLink("https://developer.matomo.org/guides/internationalization"),rel:"noreferrer noopener",target:"_blank"},"Internationalization guide",8,l),Object(r["createTextVNode"])(". Enter a search term to find translations and their corresponding keys: ")]),Object(r["createElementVNode"])("div",null,[Object(r["createVNode"])(p,{uicontrol:"text",name:"alias","inline-help":"Search for English translation. Max 1000 results will be shown.",placeholder:"Search for English translation",modelValue:e.searchTerm,"onUpdate:modelValue":t[0]||(t[0]=t=>e.searchTerm=t)},null,8,["modelValue"])]),Object(r["createElementVNode"])("div",null,[Object(r["createVNode"])(p,{uicontrol:"select",name:"translationSearch.compareLanguage","inline-help":"Optionally select a language to compare the English language with.","model-value":e.compareLanguage,"onUpdate:modelValue":t[1]||(t[1]=t=>{e.compareLanguage=t,e.doCompareLanguage()}),options:e.languages},null,8,["model-value","options"])]),c,i,Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("table",u,[Object(r["createElementVNode"])("thead",null,[Object(r["createElementVNode"])("tr",null,[s,d,Object(r["withDirectives"])(Object(r["createElementVNode"])("th",null,"Compare translation",512),[[r["vShow"],e.compareLanguage&&e.compareTranslations]])])]),Object(r["createElementVNode"])("tbody",null,[(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.filteredTranslations,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("tr",{key:t.label},[Object(r["createElementVNode"])("td",null,Object(r["toDisplayString"])(t.label),1),Object(r["createElementVNode"])("td",null,Object(r["toDisplayString"])(t.value),1),e.compareLanguage&&e.compareTranslations?(Object(r["openBlock"])(),Object(r["createElementBlock"])("td",g,Object(r["toDisplayString"])(e.compareTranslations[t.label]),1)):Object(r["createCommentVNode"])("",!0)]))),128))])])),[[r["vShow"],e.searchTerm],[b]])])}var p=n("19dc");const b=Object(p["useExternalPluginComponent"])("CorePluginsAdmin","Field");var f=Object(r["defineComponent"])({components:{Field:b},directives:{ContentTable:p["ContentTable"]},data(){return{compareTranslations:null,existingTranslations:[],languages:[],compareLanguage:"",searchTerm:""}},created(){this.fetchTranslations("en"),this.fetchLanguages()},methods:{fetchTranslations(e){p["AjaxHelper"].fetch({method:"LanguagesManager.getTranslationsForLanguage",filter_limit:-1,languageCode:e}).then(t=>{t&&("en"===e?this.existingTranslations=t:(this.compareTranslations={},t.forEach(e=>{this.compareTranslations[e.label]=e.value})))})},fetchLanguages(){p["AjaxHelper"].fetch({method:"LanguagesManager.getAvailableLanguagesInfo",filter_limit:-1}).then(e=>{this.languages=[{key:"",value:"None"}],e&&e.forEach(e=>{"en"!==e.code&&this.languages.push({key:e.code,value:e.name})})})},doCompareLanguage(){this.compareLanguage&&(this.compareTranslations=null,this.fetchTranslations(this.compareLanguage))}},computed:{filteredTranslations(){let e=this.existingTranslations.filter(e=>e.label.includes(this.searchTerm)||e.value.includes(this.searchTerm));return e=e.slice(0,1e3),e}}});f.render=m;var h=f;function j(e,t,n,a,o,l){const c=Object(r["resolveComponent"])("TranslationSearch"),i=Object(r["resolveComponent"])("ContentBlock");return Object(r["openBlock"])(),Object(r["createBlock"])(i,{"content-title":e.translate("LanguagesManager_TranslationSearch"),feature:"true"},{default:Object(r["withCtx"])(()=>[Object(r["createVNode"])(c)]),_:1},8,["content-title"])}var O=Object(r["defineComponent"])({components:{ContentBlock:p["ContentBlock"],TranslationSearch:h}});O.render=j;var v=O;
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */const{$:k}=window;function y(e,t){const n=k(t.target).attr("value");n&&k(e).find("#language").val(n).parents("form").submit()}var L={mounted(e,t){t.value.onClick=y.bind(null,e),k(e).on("click","a[value]",t.value.onClick)},unmounted(e,t){k(e).off("click","a[value]",t.value.onClick)}};const T={class:"languageSelection"},E=["href"],C=["value","title"],N={action:"index.php?module=LanguagesManager&action=saveLanguage",method:"post",ref:"form"},V=["value"],S=["value"],x=["value"];function w(e,t,n,a,o,l){const c=Object(r["resolveComponent"])("MenuItemsDropdown");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",T,[Object(r["createVNode"])(c,{"menu-title":e.currentLanguageName,onAfterSelect:t[0]||(t[0]=t=>e.onSelect(t))},{default:Object(r["withCtx"])(()=>[Object(r["createElementVNode"])("a",{class:"item",target:"_blank",rel:"noreferrer noopener",href:e.externalRawLink("https://matomo.org/translations/")},Object(r["toDisplayString"])(e.translate("LanguagesManager_AboutPiwikTranslations")),9,E),(Object(r["openBlock"])(!0),Object(r["createElementBlock"])(r["Fragment"],null,Object(r["renderList"])(e.languages,t=>(Object(r["openBlock"])(),Object(r["createElementBlock"])("a",{key:t.code,class:Object(r["normalizeClass"])("item "+(t.code===e.currentLanguageCode?"active":"")),value:t.code,title:`${t.name} (${t.english_name})`},Object(r["toDisplayString"])(t.name),11,C))),128)),Object(r["createElementVNode"])("form",N,[Object(r["createElementVNode"])("input",{type:"hidden",name:"language",id:"language",value:e.selectedLanguage},null,8,V),Object(r["createElementVNode"])("input",{type:"hidden",name:"nonce",id:"nonce",value:e.formNonce},null,8,S),e.tokenAuth?(Object(r["openBlock"])(),Object(r["createElementBlock"])("input",{key:0,type:"hidden",name:"token_auth",value:e.tokenAuth},null,8,x)):Object(r["createCommentVNode"])("",!0)],512)]),_:1},8,["menu-title"])])}var B=Object(r["defineComponent"])({props:{tokenAuth:String,formNonce:{type:String,required:!0},languages:{type:Array,required:!0},currentLanguageCode:{type:String,required:!0},currentLanguageName:{type:String,required:!0}},components:{MenuItemsDropdown:p["MenuItemsDropdown"]},data(){return{selectedLanguage:this.currentLanguageCode}},methods:{onSelect(e){this.selectedLanguage=e.getAttribute("value"),Object(r["nextTick"])().then(()=>{this.$refs.form.submit()})}}});B.render=w;var M=B;
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */}})}));
+//# sourceMappingURL=LanguagesManager.umd.min.js.map
\ No newline at end of file
diff --git a/plugins/LanguagesManager/vue/dist/umd.metadata.json b/plugins/LanguagesManager/vue/dist/umd.metadata.json
new file mode 100644
index 0000000000000000000000000000000000000000..9ecfcc0456204cf8e8e80416ebc95ef42d4fbd28
--- /dev/null
+++ b/plugins/LanguagesManager/vue/dist/umd.metadata.json
@@ -0,0 +1,5 @@
+{
+ "dependsOn": [
+ "CoreHome"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/LanguagesManager/vue/src/LanguageSelector/LanguageSelector.ts b/plugins/LanguagesManager/vue/src/LanguageSelector/LanguageSelector.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a7be7e386f3a233e0462c9b300ed14589bd765de
--- /dev/null
+++ b/plugins/LanguagesManager/vue/src/LanguageSelector/LanguageSelector.ts
@@ -0,0 +1,36 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+import { DirectiveBinding } from 'vue';
+import ClickEvent = JQuery.ClickEvent;
+
+const { $ } = window;
+
+interface LanguageSelectorBinding {
+ onClick?: (event: ClickEvent) => void;
+}
+
+function postLanguageChange(element: HTMLElement, event: ClickEvent) {
+ const value = $(event.target).attr('value');
+ if (value) {
+ $(element)
+ .find('#language')
+ .val(value)
+ .parents('form')
+ .submit();
+ }
+}
+
+export default {
+ mounted(el: HTMLElement, binding: DirectiveBinding): void {
+ binding.value.onClick = postLanguageChange.bind(null, el);
+ $(el).on('click', 'a[value]', binding.value.onClick!);
+ },
+ unmounted(el: HTMLElement, binding: DirectiveBinding): void {
+ $(el).off('click', 'a[value]', binding.value.onClick!);
+ },
+};
diff --git a/plugins/LanguagesManager/vue/src/LanguagesDropdown/LanguagesDropdown.vue b/plugins/LanguagesManager/vue/src/LanguagesDropdown/LanguagesDropdown.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3db5f34d47d1cc7fdfe789b6ca7e64a80aac9ee7
--- /dev/null
+++ b/plugins/LanguagesManager/vue/src/LanguagesDropdown/LanguagesDropdown.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
diff --git a/plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearch.vue b/plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearch.vue
new file mode 100644
index 0000000000000000000000000000000000000000..269b70018f9b924904157eb6e3362a771ec13ff5
--- /dev/null
+++ b/plugins/LanguagesManager/vue/src/TranslationSearch/TranslationSearch.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+ This page helps you to find existing translations that you can reuse in your Plugin.
+ If you want to know more about translations have a look at our
+ Internationalization guide.
+ Enter a search term to find translations and their corresponding keys:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Key |
+ English translation |
+ Compare translation |
+
+
+
+
+ | {{ translation.label }} |
+ {{ translation.value }} |
+
+ {{ compareTranslations[translation.label] }}
+ |
+
+
+
+
+
+
+
diff --git a/plugins/LanguagesManager/vue/src/index.ts b/plugins/LanguagesManager/vue/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b8912804691e9ec44a107c47499e3befb043d702
--- /dev/null
+++ b/plugins/LanguagesManager/vue/src/index.ts
@@ -0,0 +1,11 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+export { default as TranslationSearch } from './TranslationSearch/TranslationSearch.vue';
+export { default as TranslationSearchPage } from './TranslationSearch/TranslationSearchPage.vue';
+export { default as LanguageSelector } from './LanguageSelector/LanguageSelector.ts';
+export { default as LanguagesDropdown } from './LanguagesDropdown/LanguagesDropdown.vue';
diff --git a/plugins/Transitions/API.php b/plugins/Transitions/API.php
new file mode 100644
index 0000000000000000000000000000000000000000..48366f1d21b6fe35753c01affb9b0ef3d3138a92
--- /dev/null
+++ b/plugins/Transitions/API.php
@@ -0,0 +1,802 @@
+getTransitionsForAction($pageTitle, 'title', $idSite, $period, $date, $segment, $limitBeforeGrouping);
+ }
+
+ /**
+ * Returns transition data for the specified page URL.
+ *
+ * @param string $pageUrl The page URL to analyze.
+ * @param int $idSite The numeric ID of the website to query.
+ * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the
+ * period containing the specified date.
+ * @param string $date The date or date range to process.
+ * 'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
+ * or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
+ * @param string|null|false $segment Custom segment to filter the report.
+ * Example: "referrerName==example.com"
+ * Supports AND (;) and OR (,) operators.
+ * @param int|string $limitBeforeGrouping Maximum number of transition rows to keep before grouping the
+ * remainder.
+ * @return array Transition metrics and related referrer/action tables for the requested page URL.
+ */
+ public function getTransitionsForPageUrl(string $pageUrl, int $idSite, string $period, string $date, $segment = false, $limitBeforeGrouping = 0)
+ {
+ return $this->getTransitionsForAction($pageUrl, 'url', $idSite, $period, $date, $segment, $limitBeforeGrouping);
+ }
+
+ /**
+ * Returns transition data for the specified page action.
+ *
+ * @param string $actionName The page URL or title to analyze.
+ * @param 'url'|'title' $actionType Whether the action name is a URL or title.
+ * @param int $idSite The numeric ID of the website to query.
+ * @param 'day'|'week'|'month'|'year'|'range' $period The period to process, processes data for the
+ * period containing the specified date.
+ * @param string $date The date or date range to process.
+ * 'YYYY-MM-DD', magic keywords (today, yesterday, lastWeek, lastMonth, lastYear),
+ * or date range (ie, 'YYYY-MM-DD,YYYY-MM-DD', lastX, previousX).
+ * @param string|null|false $segment Custom segment to filter the report.
+ * Example: "referrerName==example.com"
+ * Supports AND (;) and OR (,) operators.
+ * @param int|string $limitBeforeGrouping Maximum number of transition rows to keep before grouping the
+ * remainder.
+ * @param string $parts Comma-separated list of transition report parts to include, or `all`.
+ * @return array Transition metrics and related referrer/action tables for the requested action.
+ */
+ public function getTransitionsForAction(
+ string $actionName,
+ string $actionType,
+ int $idSite,
+ string $period,
+ string $date,
+ $segment = false,
+ $limitBeforeGrouping = 0,
+ $parts = 'all'
+ ) {
+ Piwik::checkUserHasViewAccess($idSite);
+
+ if (!$this->isPeriodAllowed($idSite, $period, $date)) {
+ throw new Exception('PeriodNotAllowed');
+ }
+
+ if ($limitBeforeGrouping && !is_numeric($limitBeforeGrouping)) {
+ throw new Exception('limitBeforeGrouping has to be an integer.');
+ }
+ //convert string to int
+ $limitBeforeGrouping = (int)$limitBeforeGrouping;
+
+ // get idaction of the requested action
+ $idaction = $this->deriveIdAction($actionName, $actionType);
+ if ($idaction === null) {
+ throw new Exception('NoDataForAction');
+ }
+
+ // prepare log aggregator
+ $site = new Site($idSite);
+ $period = Period\Factory::build($period, $date);
+ $segment = new Segment(
+ $segment ?: '',
+ [$idSite],
+ $period->getDateTimeStart()->setTimezone($site->getTimezone()),
+ $period->getDateTimeEnd()->setTimezone($site->getTimezone())
+ );
+ $params = new ArchiveProcessor\Parameters($site, $period, $segment);
+ $logAggregator = new LogAggregator($params);
+
+ // prepare the report
+ $report = [
+ 'date' => Period\Factory::build($period->getLabel(), $date)->getLocalizedShortString(),
+ ];
+
+ try {
+ $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'];
+ }
+ } catch (\Exception $e) {
+ Model::handleMaxExecutionTimeError(
+ Db::getReader(),
+ $e,
+ $segment->getString(),
+ $period->getDateStart(),
+ $period->getDateEnd(),
+ 0,
+ GeneralConfig::getConfigValue('live_query_max_execution_time'),
+ ['method' => 'Transitions.getTransitionsForAction', 'actionName' => $actionName, 'actionType' => $actionType]
+ );
+ throw $e;
+ }
+
+
+ // replace column names in the data tables
+ $reportNames = [
+ 'previousPages' => true,
+ 'previousSiteSearches' => false,
+ 'followingPages' => true,
+ 'followingSiteSearches' => false,
+ 'outlinks' => true,
+ 'downloads' => true,
+ ];
+ foreach ($reportNames as $reportName => $replaceLabel) {
+ if (isset($report[$reportName])) {
+ $columnNames = [Metrics::INDEX_NB_ACTIONS => 'referrals'];
+ if ($replaceLabel) {
+ $columnNames[Metrics::INDEX_NB_ACTIONS] = 'referrals';
+ }
+ $report[$reportName]->filter('ReplaceColumnNames', [$columnNames]);
+ }
+ }
+
+ return $report;
+ }
+
+ /**
+ * Derive the action ID from the request action name and type.
+ */
+ private function deriveIdAction(string $actionName, string $actionType): ?int
+ {
+ switch ($actionType) {
+ case 'url':
+ $originalActionName = $actionName;
+ $actionName = Common::unsanitizeInputValue($actionName);
+
+ $id = TableLogAction::getIdActionFromSegment($actionName, 'idaction_url', SegmentExpression::MATCH_EQUAL, 'pageUrl');
+
+ if (!is_numeric($id) || $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 is_numeric($id) && $id > 0 ? (int)$id : null;
+
+ case 'title':
+ $id = TableLogAction::getIdActionFromSegment($actionName, 'idaction_name', SegmentExpression::MATCH_EQUAL, 'pageTitle');
+
+ if (!is_numeric($id) || $id < 0) {
+ $unknown = ArchivingHelper::getUnknownActionName(Action::TYPE_PAGE_TITLE);
+ if (trim($actionName) == trim($unknown)) {
+ return 0;
+ }
+ }
+
+ return is_numeric($id) && $id > 0 ? (int)$id : null;
+
+ default:
+ throw new Exception('Unknown action type');
+ }
+ }
+
+ /**
+ * Add the internal referrers to the report:
+ * previous pages and previous site searches
+ *
+ * @param array $report
+ * @throws Exception
+ */
+ private function addInternalReferrers(LogAggregator $logAggregator, array &$report, int $idaction, string $actionType, int $limitBeforeGrouping): void
+ {
+ $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 array $report
+ */
+ private function addFollowingActions(LogAggregator $logAggregator, array &$report, int $idaction, string $actionType, int $limitBeforeGrouping = 0, bool $includeLoops = false): void
+ {
+
+ $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)
+ *
+ * @return array
+ */
+ protected function queryFollowingActions(
+ int $idaction,
+ string $actionType,
+ LogAggregator $logAggregator,
+ int $limitBeforeGrouping = 0,
+ bool $includeLoops = false
+ ): array {
+ $types = [];
+
+ if ($actionType != 'title') {
+ // specific setup for page urls
+ $types[Action::TYPE_PAGE_URL] = 'followingPages';
+ $dimension = 'if ( %1$s.idaction_url IS NULL, %1$s.idaction_name, %1$s.idaction_url )';
+ $dimension = sprintf($dimension, 'log_link_visit_action');
+ // site search referrers are logged with url=NULL
+ // when we find one, we have to join on name
+ $joinLogActionColumn = $dimension;
+ $selects = ['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 = ['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 = [
+ '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->setOthersLabel('Others');
+ $rankingQuery->addLabelColumn(['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 = [Metrics::INDEX_NB_ACTIONS];
+ $data = $logAggregator->queryActionsByDimension(
+ [$dimension],
+ $where,
+ $selects,
+ $metrics,
+ $rankingQuery,
+ $joinLogActionColumn,
+ $secondaryOrderBy = "`name`",
+ GeneralConfig::getConfigValue('live_query_max_execution_time')
+ );
+
+ $dataTables = $this->makeDataTablesFollowingActions($types, $data);
+
+ return $dataTables;
+ }
+
+ /**
+ * Get information about external referrers (i.e. search engines, websites & campaigns)
+ *
+ * @throws Exception
+ */
+ protected function queryExternalReferrers(int $idaction, string $actionType, LogAggregator $logAggregator, int $limitBeforeGrouping = 0): DataTable
+ {
+
+ $rankingQuery = new RankingQuery($limitBeforeGrouping ?: $this->limitBeforeGrouping);
+ $rankingQuery->setOthersLabel('Others');
+
+ // 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 = [
+ 'referrer_data' => 'CASE log_visit.referer_type
+ WHEN ' . Common::REFERRER_TYPE_DIRECT_ENTRY . ' THEN \'\'
+ WHEN ' . Common::REFERRER_TYPE_SEARCH_ENGINE . ' THEN log_visit.referer_name
+ WHEN ' . Common::REFERRER_TYPE_SOCIAL_NETWORK . ' THEN log_visit.referer_name
+ WHEN ' . Common::REFERRER_TYPE_AI_ASSISTANT . ' THEN log_visit.referer_name
+ WHEN ' . Common::REFERRER_TYPE_WEBSITE . ' THEN log_visit.referer_url
+ WHEN ' . Common::REFERRER_TYPE_CAMPAIGN . ' THEN CONCAT_WS(\' \', log_visit.referer_name, log_visit.referer_keyword)
+ END', 'referer_type',
+ ];
+ $rankingQuery->addLabelColumn('referrer_data');
+
+ // get one limited group per referrer type
+ $rankingQuery->partitionResultIntoMultipleGroups('referer_type', [
+ Common::REFERRER_TYPE_DIRECT_ENTRY,
+ Common::REFERRER_TYPE_SEARCH_ENGINE,
+ Common::REFERRER_TYPE_SOCIAL_NETWORK,
+ Common::REFERRER_TYPE_AI_ASSISTANT,
+ Common::REFERRER_TYPE_WEBSITE,
+ Common::REFERRER_TYPE_CAMPAIGN,
+ ]);
+
+ $type = $this->getColumnTypeSuffix($actionType);
+ $where = 'visit_entry_idaction_' . $type . ' = ' . intval($idaction);
+ $metrics = [Metrics::INDEX_NB_VISITS];
+ $data = $logAggregator->queryVisitsByDimension($dimensions, $where, [], $metrics, $rankingQuery, false, GeneralConfig::getConfigValue('live_query_max_execution_time'));
+
+ // array is prefilled with available keys and empty values are removed in the end to ensure the order is static
+ $referrerData = [
+ Common::REFERRER_TYPE_DIRECT_ENTRY => [],
+ Common::REFERRER_TYPE_SEARCH_ENGINE => [],
+ Common::REFERRER_TYPE_SOCIAL_NETWORK => [],
+ Common::REFERRER_TYPE_AI_ASSISTANT => [],
+ Common::REFERRER_TYPE_WEBSITE => [],
+ Common::REFERRER_TYPE_CAMPAIGN => [],
+ ];
+ $referrerSubData = [];
+
+ foreach ($data as $referrerType => &$subData) {
+ $referrerData[$referrerType] = [Metrics::INDEX_NB_VISITS => 0];
+ if ($referrerType != Common::REFERRER_TYPE_DIRECT_ENTRY) {
+ $referrerSubData[$referrerType] = [];
+ }
+
+ foreach ($subData as &$row) {
+ if ($referrerType == Common::REFERRER_TYPE_SEARCH_ENGINE && empty($row['referrer_data'])) {
+ $row['referrer_data'] = Piwik::translate('General_Unknown');
+ }
+
+ $referrerData[$referrerType][Metrics::INDEX_NB_VISITS] += $row[Metrics::INDEX_NB_VISITS];
+
+ $label = $row['referrer_data'];
+ if ($label) {
+ $referrerSubData[$referrerType][$label] = [
+ Metrics::INDEX_NB_VISITS => $row[Metrics::INDEX_NB_VISITS],
+ ];
+ }
+ }
+ }
+
+ // remove empty records
+ $referrerData = array_filter($referrerData);
+
+ $array = new DataArray($referrerData, $referrerSubData);
+ return $array->asDataTable();
+ }
+
+ /**
+ * Get information about internal referrers (previous pages & loops, i.e. page refreshes)
+ *
+ * @return array{pageviews: int, previousPages: DataTable, previousSiteSearches: DataTable, loops: int}
+ */
+ protected function queryInternalReferrers(int $idaction, string $actionType, LogAggregator $logAggregator, int $limitBeforeGrouping = 0): array
+ {
+ $keyIsOther = 0;
+ $keyIsPageUrlAction = 1;
+ $keyIsSiteSearchAction = 2;
+
+ $rankingQuery = new RankingQuery($limitBeforeGrouping ? $limitBeforeGrouping : $this->limitBeforeGrouping);
+ $rankingQuery->setOthersLabel('Others');
+ $rankingQuery->addLabelColumn(['name', 'url_prefix']);
+ $rankingQuery->setColumnToMarkExcludedRows('is_self');
+ $rankingQuery->partitionResultIntoMultipleGroups('action_partition', [$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 = [
+ '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 ( %1$s.idaction_url_ref IS NULL, %1$s.idaction_name_ref, %1$s.idaction_url_ref )';
+ $dimension = sprintf($dimension, 'log_link_visit_action');
+ $joinLogActionOn = $dimension;
+ } else {
+ $joinLogActionOn = $dimension;
+ }
+ $metrics = [Metrics::INDEX_NB_ACTIONS];
+ $data = $logAggregator->queryActionsByDimension(
+ [$dimension],
+ $where,
+ $selects,
+ $metrics,
+ $rankingQuery,
+ $joinLogActionOn,
+ $secondaryOrderBy = "`name`",
+ GeneralConfig::getConfigValue('live_query_max_execution_time')
+ );
+
+ $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([
+ Row::COLUMNS => [
+ '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([
+ Row::COLUMNS => [
+ '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 [
+ 'pageviews' => $nbPageviews,
+ 'previousPages' => $previousPagesDataTable,
+ 'previousSiteSearches' => $previousSearchesDataTable,
+ 'loops' => $loops,
+ ];
+ }
+
+ /**
+ * @param array $pageRecord
+ * @param int|string $type
+ * @return string|null
+ */
+ 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'];
+ }
+
+ /**
+ * @param string $actionType
+ */
+ private function getColumnTypeSuffix($actionType): string
+ {
+ if ($actionType == 'title') {
+ return 'name';
+ }
+ return 'url';
+ }
+
+ /**
+ * @var int
+ */
+ private $limitBeforeGrouping = 5;
+
+ /**
+ * @var int
+ */
+ private $totalTransitionsToFollowingPages = 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(): int
+ {
+ return $this->totalTransitionsToFollowingPages;
+ }
+
+ /**
+ * Add the external referrers to the report:
+ * direct entries, websites, campaigns, search engines
+ *
+ * @param array $report
+ */
+ private function addExternalReferrers(LogAggregator $logAggregator, array &$report, int $idaction, string $actionType, int $limitBeforeGrouping): void
+ {
+ $data = $this->queryExternalReferrers(
+ $idaction,
+ $actionType,
+ $logAggregator,
+ $limitBeforeGrouping
+ );
+
+ $report['pageMetrics']['entries'] = 0;
+ $report['referrers'] = [];
+ foreach ($data->getRows() as $row) {
+ /** @var int|string $referrerId */
+ $referrerId = $row->getColumn('label');
+ $visits = $row->getColumn(Metrics::INDEX_NB_VISITS);
+ if ($visits) {
+ // load details (i.e. subtables)
+ $details = [];
+ $subTable = $row->getSubtable();
+ if ($subTable) {
+ foreach ($subTable->getRows() as $subRow) {
+ $details[] = [
+ 'label' => $subRow->getColumn('label'),
+ 'referrals' => $subRow->getColumn(Metrics::INDEX_NB_VISITS),
+ ];
+ }
+ }
+ $report['referrers'][] = [
+ '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'][] = [
+ 'label' => $this->getReferrerLabel(Common::REFERRER_TYPE_DIRECT_ENTRY),
+ 'shortName' => \Piwik\Plugins\Referrers\getReferrerTypeLabel(Common::REFERRER_TYPE_DIRECT_ENTRY),
+ 'visits' => 0,
+ ];
+ }
+ }
+
+ /**
+ * @param int|string $referrerId
+ */
+ private function getReferrerLabel($referrerId): string
+ {
+ 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_SOCIAL_NETWORK:
+ return Controller::getTranslation('fromSocialNetworks');
+ case Common::REFERRER_TYPE_AI_ASSISTANT:
+ return Controller::getTranslation('fromAIAssistants');
+ case Common::REFERRER_TYPE_WEBSITE:
+ return Controller::getTranslation('fromWebsites');
+ case Common::REFERRER_TYPE_CAMPAIGN:
+ return Controller::getTranslation('fromCampaigns');
+ default:
+ return Piwik::translate('General_Others');
+ }
+ }
+
+ /**
+ * @internal
+ */
+ public function getTranslations(): array
+ {
+ $controller = new Controller();
+ return $controller->getTranslations();
+ }
+
+ /**
+ * @param array $types
+ * @param array $data
+ * @return array
+ */
+ protected function makeDataTablesFollowingActions(array $types, array $data): array
+ {
+ $this->totalTransitionsToFollowingPages = 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->processTransitionsToFollowingPages($type, $actions);
+ }
+ }
+ $dataTables[$recordName] = $dataTable;
+ }
+ return $dataTables;
+ }
+
+ /**
+ * @param int $type
+ * @param int $actions
+ */
+ protected function processTransitionsToFollowingPages($type, $actions): void
+ {
+ // Downloads and Outlinks are not included as these actions count towards a Visit Exit
+ $actionTypesNotExitActions = array(
+ Action::TYPE_SITE_SEARCH,
+ Action::TYPE_PAGE_TITLE,
+ Action::TYPE_PAGE_URL,
+ );
+ if (in_array($type, $actionTypesNotExitActions)) {
+ $this->totalTransitionsToFollowingPages += $actions;
+ }
+ }
+
+
+ /**
+ * Check if a period is allowed by config settings
+ *
+ * @param int $idSite
+ * @param string $period
+ * @param string $date
+ */
+ public function isPeriodAllowed($idSite, $period, $date): bool
+ {
+ $maxPeriodAllowed = Transitions::getPeriodAllowedConfig($idSite);
+ if ($maxPeriodAllowed === 'all') {
+ return true;
+ }
+
+ // If the period is a range then the number of days in the range must be less or equal to the max period allowed
+ if ($period === 'range') {
+ $range = new Period\Range($period, $date);
+ $rangeDays = $range->getDayCount();
+
+ switch ($maxPeriodAllowed) {
+ case 'day':
+ return $rangeDays == 1;
+ case 'week':
+ return $rangeDays <= 7;
+ case 'month':
+ return $rangeDays <= 31;
+ case 'year':
+ return $rangeDays <= 365;
+ }
+ }
+
+ switch ($maxPeriodAllowed) {
+ case 'day':
+ return $period === 'day';
+ case 'week':
+ return in_array($period, ['day', 'week']);
+ case 'month':
+ return in_array($period, ['day', 'week', 'month']);
+ case 'year':
+ return in_array($period, ['day', 'week', 'month', 'year']);
+ }
+
+ return false;
+ }
+}
diff --git a/plugins/Transitions/Categories/TransitionsSubcategory.php b/plugins/Transitions/Categories/TransitionsSubcategory.php
new file mode 100644
index 0000000000000000000000000000000000000000..0c1c30ff74070281fe427c3c17d0be033d690f38
--- /dev/null
+++ b/plugins/Transitions/Categories/TransitionsSubcategory.php
@@ -0,0 +1,28 @@
+' . Piwik::translate('Transitions_TransitionsSubcategoryHelp1') . '
'
+ . '' . Url::getExternalLinkTag('https://matomo.org/docs/transitions/', null, null, 'App.Transitions.getTransitions')
+ . Piwik::translate('Transitions_TransitionsSubcategoryHelp2') . '
';
+ }
+}
diff --git a/plugins/Transitions/Controller.php b/plugins/Transitions/Controller.php
new file mode 100644
index 0000000000000000000000000000000000000000..d24612eb2f41ca3f08c2f7418a5250f0b2006765
--- /dev/null
+++ b/plugins/Transitions/Controller.php
@@ -0,0 +1,95 @@
+ 'Transitions_NumPageviews',
+ 'loopsInline' => 'Transitions_LoopsInline',
+ 'fromPreviousPages' => 'Transitions_FromPreviousPages',
+ 'fromPreviousPagesInline' => 'Transitions_FromPreviousPagesInline',
+ 'fromPreviousSiteSearches' => 'Transitions_FromPreviousSiteSearches',
+ 'fromPreviousSiteSearchesInline' => 'Transitions_FromPreviousSiteSearchesInline',
+ 'fromSearchEngines' => 'Transitions_FromSearchEngines',
+ 'fromSearchEnginesInline' => 'Referrers_TypeSearchEngines',
+ 'fromSocialNetworks' => 'Transitions_FromSocialNetworks',
+ 'fromSocialNetworksInline' => 'Referrers_TypeSocialNetworks',
+ 'fromAIAssistants' => 'Transitions_FromAIAssistants',
+ 'fromAIAssistantsInline' => 'Referrers_TypeAIAssistants',
+ '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' => 'Transitions_NumDownloads',
+ 'outlinks' => 'General_Outlinks',
+ 'outlinksInline' => 'Transitions_NumOutlinks',
+ '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',
+ 'PeriodNotAllowed' => 'Transitions_PeriodNotAllowed',
+ 'PeriodNotAllowedDetails' => 'Transitions_PeriodNotAllowedDetails',
+ 'PeriodNotAllowedBack' => '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/plugins/Transitions/Transitions.php b/plugins/Transitions/Transitions.php
new file mode 100644
index 0000000000000000000000000000000000000000..c68c9803d827afafc16bfe5db89ea4015bde491e
--- /dev/null
+++ b/plugins/Transitions/Transitions.php
@@ -0,0 +1,100 @@
+ 'getStylesheetFiles',
+ 'AssetManager.getJavaScriptFiles' => 'getJsFiles',
+ 'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys',
+ 'API.getPagesComparisonsDisabledFor' => 'getPagesComparisonsDisabledFor',
+ 'Template.jsGlobalVariables' => 'addJsGlobalVariables',
+ );
+ }
+
+ public function getPagesComparisonsDisabledFor(&$pages)
+ {
+ $pages[] = "General_Actions.Transitions_Transitions";
+ }
+
+ public function getStylesheetFiles(&$stylesheets)
+ {
+ $stylesheets[] = 'plugins/Transitions/stylesheets/transitions.less';
+ $stylesheets[] = 'plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.less';
+ }
+
+ public function getJsFiles(&$jsFiles)
+ {
+ $jsFiles[] = 'plugins/Transitions/javascripts/transitions.js';
+ }
+
+ public function getClientSideTranslationKeys(&$translationKeys)
+ {
+ $translationKeys[] = 'General_TransitionsRowActionTooltipTitle';
+ $translationKeys[] = 'General_TransitionsRowActionTooltip';
+ $translationKeys[] = 'Actions_PageUrls';
+ $translationKeys[] = 'Actions_WidgetPageTitles';
+ $translationKeys[] = 'Transitions_NumPageviews';
+ $translationKeys[] = 'Transitions_Transitions';
+ $translationKeys[] = 'CoreHome_ThereIsNoDataForThisReport';
+ $translationKeys[] = 'General_Others';
+ $translationKeys[] = 'Actions_ActionType';
+ $translationKeys[] = 'Transitions_TopX';
+ $translationKeys[] = 'Transitions_AvailableInOtherReports';
+ $translationKeys[] = 'Actions_SubmenuPageTitles';
+ $translationKeys[] = 'Actions_SubmenuPagesEntry';
+ $translationKeys[] = 'Actions_SubmenuPagesExit';
+ $translationKeys[] = 'Transitions_AvailableInOtherReports2';
+ $translationKeys[] = 'Transitions_FeatureDescription';
+ }
+
+ public function addJsGlobalVariables(&$out)
+ {
+ $idSite = Common::getRequestVar('idSite', 1, 'int');
+ $maxPeriodAllowed = self::getPeriodAllowedConfig($idSite);
+
+ $out .= ' piwik.transitionsMaxPeriodAllowed = "' . ($maxPeriodAllowed ? $maxPeriodAllowed : 'all') . '"' . "\n";
+ }
+
+ /**
+ * Retrieve the max period allowed config setting for the given site,
+ * falling back to the general config when no site is given.
+ *
+ * @param int|null $idSite
+ */
+ public static function getPeriodAllowedConfig($idSite): string
+ {
+ $transitionsGeneralConfig = Config::getInstance()->Transitions;
+ $generalMaxPeriodAllowed = ($transitionsGeneralConfig && !empty($transitionsGeneralConfig['max_period_allowed']) ? $transitionsGeneralConfig['max_period_allowed'] : null);
+
+ $siteMaxPeriodAllowed = null;
+ if ($idSite) {
+ $sectionName = 'Transitions_' . $idSite;
+ $transitionsSiteConfig = Config::getInstance()->$sectionName;
+ $siteMaxPeriodAllowed = ($transitionsSiteConfig && !empty($transitionsSiteConfig['max_period_allowed']) ? $transitionsSiteConfig['max_period_allowed'] : null);
+ }
+
+ if (!$generalMaxPeriodAllowed && !$siteMaxPeriodAllowed) {
+ return 'all'; // No config setting, so all periods are valid
+ }
+
+ // Site setting overrides general, if it exists
+ return $siteMaxPeriodAllowed ?? $generalMaxPeriodAllowed;
+ }
+}
diff --git a/plugins/Transitions/Widgets/GetTransitions.php b/plugins/Transitions/Widgets/GetTransitions.php
new file mode 100644
index 0000000000000000000000000000000000000000..3f75b53eba354c3fa6d247c93baeb4a0b881bab6
--- /dev/null
+++ b/plugins/Transitions/Widgets/GetTransitions.php
@@ -0,0 +1,36 @@
+setCategoryId('General_Actions');
+ $config->setSubcategoryId('Transitions_Transitions');
+ $config->setName('Transitions_Transitions');
+ $config->setOrder(99);
+ $config->setClientSideComponent('Transitions', 'TransitionsPage');
+ $idSite = self::getIdSite();
+ if (!$idSite || !Piwik::isUserHasViewAccess($idSite)) {
+ $config->disable();
+ }
+ }
+
+ private static function getIdSite()
+ {
+ return Request::fromRequest()->getIntegerParameter('idSite', 0);
+ }
+}
diff --git a/plugins/Transitions/config/config.php b/plugins/Transitions/config/config.php
new file mode 100644
index 0000000000000000000000000000000000000000..d5be5de26f92a935f318ed4c0a04fe01434bfe66
--- /dev/null
+++ b/plugins/Transitions/config/config.php
@@ -0,0 +1,3 @@
+ '; // LabelFilter::SEPARATOR_RECURSIVE_LABEL
+ var labelParts = label.split(separator);
+ for (var i = 0; i < labelParts.length; i++) {
+ var labelPart = labelParts[i].replace('@', '');
+ labelParts[i] = $.trim(decodeURIComponent(labelPart));
+ }
+ var delimiter = piwik.config.action_url_category_delimiter;
+ if(this.dataTable.param.action.indexOf('PageTitles') !== false) {
+ delimiter = piwik.config.action_title_category_delimiter;
+ }
+ label = labelParts.join(delimiter);
+ this.openPopover('title:' + label);
+};
+
+DataTable_RowActions_Transitions.prototype.doOpenPopover = function (link) {
+ var ALLOWED_OVERRIDE_PARAMS = ['segment', 'date', 'period', 'idSite'];
+
+ var parts = link.split(':');
+
+ var overrideParams = {};
+
+ var i = 0;
+ while (i < parts.length) {
+ var paramName = '';
+
+ try {
+ paramName = decodeURIComponent(parts[i]);
+ } catch (e) {
+ // invalid parameter
+ }
+
+ if (ALLOWED_OVERRIDE_PARAMS.indexOf(paramName) === -1) {
+ i += 1;
+ continue;
+ }
+
+ overrideParams[paramName] = decodeURIComponent(parts[i + 1]);
+ parts.splice(i, 2);
+ }
+
+ if (parts.length < 2) {
+ return;
+ }
+
+ var actionType = parts[0];
+ parts.shift();
+ var actionName = parts.join(':');
+
+ if (this.transitions === null) {
+ this.transitions = new Piwik_Transitions(actionType, actionName, this, overrideParams);
+ } else {
+ this.transitions.reset(actionType, actionName, segment);
+ }
+ this.transitions.showPopover();
+};
+
+DataTable_RowActions_Registry.register({
+
+ name: 'Transitions',
+
+ dataTableIcon: 'icon-transition',
+
+ order: 20,
+
+ dataTableIconTooltip: [
+ _pk_translate('General_TransitionsRowActionTooltipTitle'),
+ _pk_translate('General_TransitionsRowActionTooltip')
+ ],
+
+ createInstance: function (dataTable) {
+ return new DataTable_RowActions_Transitions(dataTable);
+ },
+
+ isAvailableOnReport: function (dataTableParams) {
+
+ if (piwik.transitionsMaxPeriodAllowed && dataTableParams['period']) {
+
+ if (dataTableParams['period'] === 'range') {
+
+ var piwikPeriods = window.CoreHome.Periods;
+ if (piwikPeriods) {
+ var range = piwikPeriods.parse(dataTableParams['period'], dataTableParams['date']);
+ if (range) {
+ var rangeDays = range.getDayCount();
+ if ((piwik.transitionsMaxPeriodAllowed === 'day' && rangeDays > 1) ||
+ (piwik.transitionsMaxPeriodAllowed === 'week' && rangeDays > 7) ||
+ (piwik.transitionsMaxPeriodAllowed === 'month' && rangeDays > 31) ||
+ (piwik.transitionsMaxPeriodAllowed === 'year' && rangeDays > 365))
+ {
+ return false;
+ }
+ }
+ }
+ } else {
+ if (piwik.transitionsMaxPeriodAllowed === 'day' && dataTableParams['period'] !== 'day') {
+ return false;
+ }
+ if (piwik.transitionsMaxPeriodAllowed === 'week' && dataTableParams['period'] !== 'day'
+ && dataTableParams['period'] !== 'week') {
+ return false;
+ }
+ if (piwik.transitionsMaxPeriodAllowed === 'month' && dataTableParams['period'] !== 'day'
+ && dataTableParams['period'] !== 'week' && dataTableParams['period'] !== 'month') {
+ return false;
+ }
+ if (piwik.transitionsMaxPeriodAllowed === 'year' && dataTableParams['period'] !== 'day'
+ && dataTableParams['period'] !== 'week' && dataTableParams['period'] !== 'month'
+ && dataTableParams['period'] !== 'year'
+ ) {
+ return false;
+ }
+ }
+ }
+ var i = 0;
+ for (i; i < DataTable_RowActions_Transitions.registeredReports.length; i++) {
+ var report = DataTable_RowActions_Transitions.registeredReports[i];
+ if (report
+ && report.isAvailableOnReport
+ && report.isAvailableOnReport(dataTableParams)) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ isAvailableOnRow: function (dataTableParams, tr) {
+ if (tr.hasClass('subDataTable') || tr.hasClass('totalsRow')) {
+ // not available on groups (i.e. folders)
+ return false;
+ }
+
+ var i = 0;
+ for (i; i < DataTable_RowActions_Transitions.registeredReports.length; i++) {
+ var report = DataTable_RowActions_Transitions.registeredReports[i];
+ if (report
+ && report.isAvailableOnRow
+ && report.isAvailableOnReport
+ && report.isAvailableOnReport(dataTableParams)) {
+ return report.isAvailableOnRow(dataTableParams, tr);
+ }
+ }
+
+ return true;
+ }
+
+});
+
+//
+// TRANSITIONS IMPLEMENTATION
+//
+
+function Piwik_Transitions(actionType, actionName, rowAction, overrideParams) {
+ this.reset(actionType, actionName, overrideParams);
+ this.rowAction = rowAction;
+
+ this.ajax = new Piwik_Transitions_Ajax();
+ this.model = new Piwik_Transitions_Model(this.ajax);
+
+ this.leftGroups = ['previousPages', 'previousSiteSearches', 'searchEngines', 'socialNetworks', 'aiAssistants', 'websites', 'campaigns'];
+ this.rightGroups = ['followingPages', 'followingSiteSearches', 'downloads', 'outlinks'];
+}
+
+Piwik_Transitions.prototype.reset = function (actionType, actionName, overrideParams) {
+ this.actionType = actionType;
+ this.actionName = actionName;
+ this.overrideParams = overrideParams;
+
+ this.popover = null;
+ this.canvas = null;
+ this.centerBox = null;
+
+ this.leftOpenGroup = 'previousPages';
+ this.rightOpenGroup = 'followingPages';
+
+ this.highlightedGroup = false;
+ this.highlightedGroupSide = false;
+ this.highlightedGroupCenterEl = false;
+};
+
+/** Open the popover */
+Piwik_Transitions.prototype.showPopover = function (showEmbeddedInReport) {
+ var self = this;
+ this.showEmbeddedInReport = showEmbeddedInReport;
+ Piwik_Transitions.currentInstance = this;
+
+ $('#transitions_report .popoverContainer').hide();
+
+ if (showEmbeddedInReport) {
+ this.popover = $('#transitions_report');
+ $('#Transitions_Error_Container').hide();
+ $('#transitions_inline_loading').show();
+ } else {
+ this.popover = Piwik_Popover.showLoading('Transitions', self.actionName, 550);
+ Piwik_Popover.addHelpButton(_pk_externalRawLink('https://matomo.org/docs/transitions'));
+ }
+
+ var bothLoaded = function () {
+ if (showEmbeddedInReport) {
+ $('#transitions_inline_loading').hide();
+ }
+
+ self.restorePopoverContent();
+ self.model.htmlLoaded();
+ self.renderPopover();
+ };
+
+ // load the popover HTML (only done once)
+ var callbackForHtml = false;
+ if (typeof Piwik_Transitions.popoverHtml == 'undefined') {
+ this.ajax.callTransitionsController('renderPopover', function (html) {
+ Piwik_Transitions.popoverHtml = html;
+ if (callbackForHtml !== false) {
+ callbackForHtml();
+ }
+ });
+ }
+
+ // load the data
+ self.model.loadData(self.actionType, self.actionName, self.overrideParams, function () {
+ if (typeof Piwik_Transitions.popoverHtml == 'undefined') {
+ // html not there yet
+ callbackForHtml = bothLoaded;
+ } else {
+ // html already loaded
+ bothLoaded();
+ }
+ });
+};
+
+/** Prepare the popover with the basic DOM to add data later. */
+Piwik_Transitions.prototype.preparePopover = function () {
+ var self = this;
+
+ var width = 900;
+ var height = 550;
+
+ var canvasBgLeft = self.prepareCanvas('Background_Left', width, height);
+ var canvasBgRight = self.prepareCanvas('Background_Right', width, height);
+ var canvasLeft = self.prepareCanvas('Left', width, height);
+ var canvasRight = self.prepareCanvas('Right', width, height);
+ var canvasLoops = self.prepareCanvas('Loops', width, height);
+
+ self.canvas = new Piwik_Transitions_Canvas(canvasBgLeft, canvasBgRight, canvasLeft, canvasRight,
+ canvasLoops, width, height);
+
+ self.centerBox = self.popover.find('#Transitions_CenterBox');
+
+ var title = self.actionName;
+ if (self.actionType == 'url') {
+ title = Piwik_Transitions_Util.shortenUrl(title, true);
+ }
+ var h2 = self.centerBox.find('h2');
+ var textContainer = h2;
+ if (self.actionType == 'url') {
+ var a = $(document.createElement('a'));
+ a.attr('href', self.actionName);
+ a.attr('rel', 'noreferrer noopener');
+ a.attr('target', '_blank');
+ h2.append(a);
+ textContainer = a;
+ }
+
+ textContainer.addClass('Transitions_ApplyTextAndTruncate')
+ .data('text', title)
+ .data('maxLines', 3);
+
+ var element = textContainer.add(self.popover.find('p.Transitions_Pageviews'));
+
+ element.tooltip({
+ track: true,
+ content: function () {
+ var totalNbPageviews = self.model.getTotalNbPageviews();
+ if (totalNbPageviews > 0) {
+
+ var share = NumberFormatter.formatPercent(Math.round(self.model.pageviews / totalNbPageviews * 1000) / 10);
+
+ var text = Piwik_Transitions_Translations.ShareOfAllPageviews;
+ text = sprintf(text, NumberFormatter.formatNumber(self.model.pageviews), share);
+ text += '
' + Piwik_Transitions_Translations.DateRange + ' ' + self.model.date + '';
+
+ var title = '' + piwikHelper.addBreakpointsToUrl(self.actionName) + '
';
+
+ return title + text;
+ }
+ return false;
+ },
+ items: '*',
+ tooltipClass: 'Transitions_Tooltip_Small',
+ show: false,
+ hide: false
+ });
+};
+
+Piwik_Transitions.prototype.prepareCanvas = function (canvasId, width, height) {
+ canvasId = 'Transitions_Canvas_' + canvasId;
+ var div = $('#' + canvasId).width(width).height(height);
+ var canvas;
+
+ if (typeof Piwik_Transitions.canvasCache == 'undefined'
+ || typeof window.G_vmlCanvasManager != "undefined") {
+ // no recycling for excanvas because they are disposed properly anyway and
+ // recycling doesn't work this way in IE8
+ Piwik_Transitions.canvasCache = {};
+ }
+
+ if (typeof Piwik_Transitions.canvasCache[canvasId] == 'undefined') {
+ Piwik_Transitions.canvasCache[canvasId] = document.createElement('canvas');
+ canvas = Piwik_Transitions.canvasCache[canvasId];
+ canvas.width = width;
+ canvas.height = height;
+ } else {
+ canvas = Piwik_Transitions.canvasCache[canvasId];
+ canvas.getContext('2d').clearRect(0, 0, width, height);
+ }
+
+ div.append(canvas);
+ return canvas;
+};
+
+/** Render the popover content */
+Piwik_Transitions.prototype.render = function () {
+ this.renderCenterBox();
+
+ this.renderLeftSide();
+ this.renderRightSide();
+
+ this.renderLoops();
+
+ window.CoreHome.Matomo.postEvent('Transitions.dataChanged', {'actionType': this.actionType, 'actionName': this.actionName});
+};
+
+Piwik_Transitions.prototype.restorePopoverContent = function () {
+ if (this.showEmbeddedInReport) {
+ this.popover.find('.popoverContainer').html(Piwik_Transitions.popoverHtml).show();
+ } else {
+ Piwik_Popover.setContent(Piwik_Transitions.popoverHtml);
+ }
+};
+
+Piwik_Transitions.prototype.renderPopover = function () {
+ this.preparePopover();
+
+ if (this.model.searchEnginesNbTransitions > 0 && this.model.websitesNbTransitions > 0 && this.model.socialNetworksNbTransitions > 0
+ && this.model.aiAssistantsNbTransitions > 0 && this.model.campaignsNbTransitions > 0) {
+ this.canvas.narrowMode();
+ }
+
+ this.canvas.truncateVisibleBoxTexts();
+ this.render();
+ this.canvas.truncateVisibleBoxTexts();
+};
+
+Piwik_Transitions.prototype.refreshTheme = function () {
+ if (!this.popover || !this.popover.length
+ || !$.contains(document.documentElement, this.popover[0])
+ || typeof Piwik_Transitions.popoverHtml == 'undefined') {
+ return;
+ }
+
+ this.restorePopoverContent();
+ this.renderPopover();
+};
+
+/** Render left side: referrer groups & direct entries */
+Piwik_Transitions.prototype.renderLeftSide = function (onlyBg) {
+ this.renderGroups(this.leftGroups, this.leftOpenGroup, 'left', onlyBg);
+ this.renderEntries(onlyBg);
+
+ this.reRenderIfNeededToCenter('left', onlyBg);
+};
+
+/** Render right side: following pages & exits */
+Piwik_Transitions.prototype.renderRightSide = function (onlyBg) {
+ this.renderGroups(this.rightGroups, this.rightOpenGroup, 'right', onlyBg);
+ this.renderExits(onlyBg);
+
+ this.reRenderIfNeededToCenter('right', onlyBg);
+};
+
+/** Helper method to render open and closed groups for both sides */
+Piwik_Transitions.prototype.renderGroups = function (groups, openGroup, side, onlyBg) {
+ for (var i = 0; i < groups.length; i++) {
+ var groupName = groups[i];
+ if (groupName == openGroup) {
+ if (i != 0) {
+ var spacing = this.canvas.isNarrowMode() ? 7 : 13;
+ this.canvas.addBoxSpacing(spacing, side);
+ }
+ this.renderOpenGroup(groupName, side, onlyBg);
+ } else {
+ this.renderClosedGroup(groupName, side, onlyBg);
+ }
+ }
+
+ this.canvas.addBoxSpacing(13, side);
+};
+
+/**
+ * If one side doesn't have much information, it doesn't look good to start from y=0.
+ * In this case, add some spacing on top and redraw.
+ */
+Piwik_Transitions.prototype.reRenderIfNeededToCenter = function (side, onlyBg) {
+ var height = (side == 'left' ? this.canvas.leftBoxPositionY : this.canvas.rightBoxPositionY) - 20;
+ if (height < 460 && !this.reRendering) {
+ var yOffset = (460 - height) / 2;
+ this.canvas.clearSide(side, onlyBg);
+ this.canvas.addBoxSpacing(yOffset, side);
+ this.reRendering = true;
+ side == 'left' ? this.renderLeftSide(onlyBg) : this.renderRightSide(onlyBg);
+ this.reRendering = false;
+ }
+};
+
+/** Render the center box with the main metrics */
+Piwik_Transitions.prototype.renderCenterBox = function () {
+ var box = this.centerBox;
+
+ Piwik_Transitions_Util.replacePlaceholderInHtml(
+ box.find('.Transitions_Pageviews'), NumberFormatter.formatNumber(this.model.pageviews));
+
+ var self = this;
+ var showMetric = function (cssClass, modelProperty, highlightCurveOnSide, groupCanBeExpanded) {
+ var el = box.find('.Transitions_' + cssClass);
+ Piwik_Transitions_Util.replacePlaceholderInHtml(el, NumberFormatter.formatNumber(self.model[modelProperty]));
+
+ if (self.model[modelProperty] == 0) {
+ el.addClass('Transitions_Value0');
+ } else {
+ self.addTooltipShowingPercentageOfAllPageviews(el, modelProperty);
+ var groupName = cssClass.charAt(0).toLowerCase() + cssClass.slice(1);
+ el.hover(function () {
+ self.highlightGroup(groupName, highlightCurveOnSide);
+ }, function () {
+ self.unHighlightGroup(groupName, highlightCurveOnSide);
+ });
+ if (groupCanBeExpanded) {
+ el.click(function () {
+ self.openGroup(highlightCurveOnSide, groupName);
+ }).css('cursor', 'pointer');
+ }
+ }
+ };
+
+ showMetric('DirectEntries', 'directEntries', 'left', false);
+ showMetric('PreviousSiteSearches', 'previousSiteSearchesNbTransitions', 'left', true);
+ showMetric('PreviousPages', 'previousPagesNbTransitions', 'left', true);
+ showMetric('SearchEngines', 'searchEnginesNbTransitions', 'left', true);
+ showMetric('SocialNetworks', 'socialNetworksNbTransitions', 'left', true);
+ showMetric('AIAssistants', 'aiAssistantsNbTransitions', 'left', true);
+ showMetric('Websites', 'websitesNbTransitions', 'left', true);
+ showMetric('Campaigns', 'campaignsNbTransitions', 'left', true);
+
+ showMetric('FollowingPages', 'followingPagesNbTransitions', 'right', true);
+ showMetric('FollowingSiteSearches', 'followingSiteSearchesNbTransitions', 'right', true);
+ showMetric('Outlinks', 'outlinksNbTransitions', 'right', true);
+ showMetric('Downloads', 'downloadsNbTransitions', 'right', true);
+ showMetric('Exits', 'exits', 'right', false);
+
+ box.find('.Transitions_CenterBoxMetrics').show();
+};
+
+Piwik_Transitions.prototype.addTooltipShowingPercentageOfAllPageviews = function (element, metric) {
+ var tip = Piwik_Transitions_Translations.XOfAllPageviews;
+ var percentage = this.model.getPercentage(metric, true);
+ tip = sprintf(tip, '' + percentage + '');
+
+ element.tooltip({
+ track: true,
+ content: tip,
+ items: '*',
+ tooltipClass: 'Transitions_Tooltip_Small',
+ show: false,
+ hide: false
+ });
+};
+
+/** Render the loops (i.e. page reloads) */
+Piwik_Transitions.prototype.renderLoops = function () {
+ if (this.model.loops == 0) {
+ return;
+ }
+
+ var loops = this.popover.find('#Transitions_Loops').show();
+ Piwik_Transitions_Util.replacePlaceholderInHtml(loops, NumberFormatter.formatNumber(this.model.loops));
+
+ this.addTooltipShowingPercentageOfAllPageviews(loops, 'loops');
+
+ this.canvas.renderLoops(this.model.getPercentage('loops'));
+ loops.css({marginTop: $('#Transitions_CenterBox').outerHeight() + 45});
+};
+
+Piwik_Transitions.prototype.renderEntries = function (onlyBg) {
+ if (this.model.directEntries > 0) {
+ var self = this;
+
+ var isHighlighted = this.highlightedGroup == 'directEntries';
+ var gradient = this.canvas.createHorizontalGradient('entries', 'left', isHighlighted);
+
+ this.canvas.renderBox({
+ side: 'left',
+ onlyBg: onlyBg,
+ share: this.model.getPercentage('directEntries'),
+ gradient: gradient,
+ boxText: Piwik_Transitions_Translations.directEntries,
+ boxTextNumLines: 1,
+ boxTextCssClass: 'SingleLine',
+ smallBox: true,
+ onMouseOver: function () {
+ self.highlightGroup('directEntries', 'left');
+ },
+ onMouseOut: function () {
+ self.unHighlightGroup('directEntries', 'left');
+ }
+ });
+ this.canvas.addBoxSpacing(20, 'left');
+ }
+};
+
+Piwik_Transitions.prototype.renderExits = function (onlyBg) {
+ if (this.model.exits > 0) {
+ var self = this;
+
+ var isHighlighted = this.highlightedGroup == 'exits';
+ var gradient = this.canvas.createHorizontalGradient('exits', 'right', isHighlighted);
+
+ this.canvas.renderBox({
+ side: 'right',
+ onlyBg: onlyBg,
+ share: this.model.getPercentage('exits'),
+ gradient: gradient,
+ boxText: Piwik_Transitions_Translations.exits,
+ boxTextNumLines: 1,
+ boxTextCssClass: 'SingleLine',
+ smallBox: true,
+ onMouseOver: function () {
+ self.highlightGroup('exits', 'right');
+ },
+ onMouseOut: function () {
+ self.unHighlightGroup('exits', 'right');
+ }
+ });
+ this.canvas.addBoxSpacing(20, 'right');
+ }
+};
+
+/** Render the open group with the detailed data */
+Piwik_Transitions.prototype.renderOpenGroup = function (groupName, side, onlyBg) {
+ var self = this;
+
+ // get data from the model
+ var nbTransitionsVarName = groupName + 'NbTransitions';
+ var nbTransitions = self.model[nbTransitionsVarName];
+ if (nbTransitions == 0) {
+ return;
+ }
+
+ var totalShare = this.model.getPercentage(nbTransitionsVarName);
+ var details = self.model.getDetailsForGroup(groupName);
+
+ // prepare gradients
+ var gradientItems = this.canvas.createHorizontalGradient('items', side);
+ var gradientOthers = this.canvas.createHorizontalGradient('others', side);
+ var gradientBackground =
+ this.canvas.createHorizontalGradient('background', side, groupName == this.highlightedGroup);
+
+ // remember current offsets to reset them later for drawing the background
+ var boxPositionBefore, curvePositionBefore;
+ if (side == 'left') {
+ boxPositionBefore = this.canvas.leftBoxPositionY;
+ curvePositionBefore = this.canvas.leftCurvePositionY;
+ } else {
+ boxPositionBefore = this.canvas.rightBoxPositionY;
+ curvePositionBefore = this.canvas.rightCurvePositionY;
+ }
+
+ // headline of the open group
+ var titleX, titleClass;
+ if (side == 'left') {
+ titleX = this.canvas.leftBoxBeginX + 10;
+ titleClass = 'BoxTextLeft';
+ } else {
+ titleX = this.canvas.rightBoxBeginX - 1;
+ titleClass = 'BoxTextRight';
+ }
+ if (!onlyBg) {
+ var groupTitle = self.model.getGroupTitle(groupName);
+ var titleEl = this.canvas.renderText(groupTitle, titleX, boxPositionBefore + 11,
+ [titleClass, 'TitleOfOpenGroup']);
+ titleEl.hover(function () {
+ self.highlightGroup(groupName, side);
+ }, function () {
+ self.unHighlightGroup(groupName, side);
+ });
+ }
+ this.canvas.addBoxSpacing(34, side);
+
+ // draw detail boxes
+ for (var i = 0; i < details.length; i++) {
+ var data = details[i];
+ var label = (typeof data.url != 'undefined' ? data.url : data.label);
+ label = (typeof label != 'undefined' && label !== null ? label : '');
+ var isOthers = (label == 'Others');
+ var onClick = false;
+ if (!isOthers && (groupName == 'previousPages' || groupName == 'followingPages')) {
+
+ if (this.showEmbeddedInReport) {
+ onClick = (function (url) {
+ return function () {
+ window.CoreHome.Matomo.postEvent('Transitions.switchTransitionsUrl', {
+ url: url,
+ });
+ };
+ })(label);
+ } else {
+ onClick = (function (url) {
+ return function () {
+ if (self.actionType == 'url') {
+ url = url.replace(/^(?!http)/, 'http://');
+ }
+ self.reloadPopover(url);
+ };
+ })(label);
+ }
+
+ } else if (!isOthers && (groupName == 'outlinks' || groupName == 'websites' || groupName == 'downloads')) {
+ onClick = label
+ }
+
+ var tooltip = Piwik_Transitions_Translations.XOfY;
+ tooltip = '' + sprintf(tooltip, data.referrals, nbTransitions) + '';
+ tooltip = this.model.getShareInGroupTooltip(tooltip, groupName);
+
+ var fullLabel = label;
+ var shortened = false;
+ if ((this.actionType == 'url' && (groupName == 'previousPages' || groupName == 'followingPages'))
+ || groupName == 'downloads') {
+ // remove http + www + domain for internal URLs
+ label = Piwik_Transitions_Util.shortenUrl(label, true);
+ shortened = true;
+ } else if (groupName == 'outlinks' || groupName == 'websites') {
+ // remove http + www for external URLs
+ label = Piwik_Transitions_Util.shortenUrl(label);
+ shortened = true;
+ }
+
+ this.canvas.renderBox({
+ side: side,
+ onlyBg: onlyBg,
+ share: data.percentage / 100 * totalShare,
+ gradient: isOthers ? gradientOthers : gradientItems,
+ boxText: label,
+ boxTextTooltip: isOthers || !shortened ? false : fullLabel,
+ boxTextNumLines: 3,
+ curveText: NumberFormatter.formatPercent(data.percentage),
+ curveTextTooltip: tooltip,
+ onClick: onClick
+ });
+ }
+
+ // draw background
+ var boxPositionAfter, curvePositionAfter;
+ if (side == 'left') {
+ boxPositionAfter = this.canvas.leftBoxPositionY;
+ curvePositionAfter = this.canvas.leftCurvePositionY;
+ this.canvas.leftBoxPositionY = boxPositionBefore;
+ this.canvas.leftCurvePositionY = curvePositionBefore;
+ } else {
+ boxPositionAfter = this.canvas.rightBoxPositionY;
+ curvePositionAfter = this.canvas.rightCurvePositionY;
+ this.canvas.rightBoxPositionY = boxPositionBefore;
+ this.canvas.rightCurvePositionY = curvePositionBefore;
+ }
+
+ this.canvas.renderBox({
+ side: side,
+ boxHeight: boxPositionAfter - boxPositionBefore - this.canvas.boxSpacing - 2,
+ curveHeight: curvePositionAfter - curvePositionBefore - this.canvas.curveSpacing,
+ gradient: gradientBackground,
+ bgCanvas: true
+ });
+
+ var spacing = this.canvas.isNarrowMode() ? 8 : 15;
+ this.canvas.addBoxSpacing(spacing, side);
+};
+
+/** Render a closed group without detailed data, only one box for the sum */
+Piwik_Transitions.prototype.renderClosedGroup = function (groupName, side, onlyBg) {
+ var self = this;
+
+ var isHighlighted = groupName == this.highlightedGroup;
+ var gradient = this.canvas.createHorizontalGradient('closed-group', side, isHighlighted);
+
+ var nbTransitionsVarName = groupName + 'NbTransitions';
+
+ if (self.model[nbTransitionsVarName] == 0) {
+ return;
+ }
+
+ self.canvas.renderBox({
+ side: side,
+ onlyBg: onlyBg,
+ share: self.model.getPercentage(nbTransitionsVarName),
+ gradient: gradient,
+ boxText: self.model.getGroupTitle(groupName),
+ boxTextNumLines: 1,
+ boxTextCssClass: 'SingleLine',
+ boxIcon: 'plugins/Morpheus/images/plus_blue.png',
+ smallBox: true,
+ onClick: function () {
+ self.unHighlightGroup(groupName, side);
+ self.openGroup(side, groupName);
+ },
+ onMouseOver: function () {
+ self.highlightGroup(groupName, side);
+ },
+ onMouseOut: function () {
+ self.unHighlightGroup(groupName, side);
+ }
+ });
+};
+
+/** Reload the entire popover for a different URL */
+Piwik_Transitions.prototype.reloadPopover = function (url) {
+ if (this.rowAction) {
+ this.rowAction.openPopover(this.actionType + ':' + url);
+ } else {
+ this.reset(this.actionType, url);
+ this.showPopover();
+ }
+};
+
+/** Redraw the left or right sides with a different group opened */
+Piwik_Transitions.prototype.openGroup = function (side, groupName) {
+
+ this.canvas.clearSide(side);
+
+ if (side == 'left') {
+ this.leftOpenGroup = groupName;
+ this.renderLeftSide();
+ } else {
+ this.rightOpenGroup = groupName;
+ this.renderRightSide();
+ }
+
+ this.renderLoops();
+
+ this.canvas.truncateVisibleBoxTexts();
+};
+
+/** Highlight a group: change curve color and highlight metric in the center box */
+Piwik_Transitions.prototype.highlightGroup = function (groupName, side) {
+ if (this.highlightedGroup == groupName) {
+ return;
+ }
+ if (this.highlightedGroup !== false) {
+ this.unHighlightGroup(this.highlightedGroup, this.highlightedGroupSide);
+ }
+
+ this.highlightedGroup = groupName;
+ this.highlightedGroupSide = side;
+
+ var cssClass = 'Transitions_' + groupName.charAt(0).toUpperCase() + groupName.slice(1);
+ this.highlightedGroupCenterEl = this.canvas.container.find('.' + cssClass);
+ this.highlightedGroupCenterEl.addClass('Transitions_Highlighted');
+
+ this.canvas.clearSide(side, true);
+ if (side == 'left') {
+ this.renderLeftSide(true);
+ } else {
+ this.renderRightSide(true);
+ }
+ this.renderLoops();
+};
+
+/** Remove highlight after using highlightGroup() */
+Piwik_Transitions.prototype.unHighlightGroup = function (groupName, side) {
+ if (this.highlightedGroup === false) {
+ return;
+ }
+
+ this.highlightedGroupCenterEl.removeClass('Transitions_Highlighted');
+
+ this.highlightedGroup = false;
+ this.highlightedGroupSide = false;
+ this.highlightedGroupCenterEl = false;
+
+ this.canvas.clearSide(side, true);
+ if (side == 'left') {
+ this.renderLeftSide(true);
+ } else {
+ this.renderRightSide(true);
+ }
+ this.renderLoops();
+};
+
+// --------------------------------------
+// CANVAS
+// --------------------------------------
+
+function Piwik_Transitions_Canvas(canvasBgLeft, canvasBgRight, canvasLeft, canvasRight, canvasLoops, width, height) {
+
+ if (typeof window.G_vmlCanvasManager != "undefined") {
+ window.G_vmlCanvasManager.initElement(canvasBgLeft);
+ window.G_vmlCanvasManager.initElement(canvasBgRight);
+ window.G_vmlCanvasManager.initElement(canvasLeft);
+ window.G_vmlCanvasManager.initElement(canvasRight);
+ window.G_vmlCanvasManager.initElement(canvasLoops);
+ }
+
+ if (!canvasBgLeft.getContext) {
+ alert('Your browser is not supported.');
+ return;
+ }
+
+ /** DOM element that contains the canvas */
+ this.container = $(canvasBgLeft).parent().parent();
+
+ /** Drawing context of the canvases */
+ this.contextBgLeft = canvasBgLeft.getContext('2d');
+ this.contextBgRight = canvasBgRight.getContext('2d');
+ this.contextLeft = canvasLeft.getContext('2d');
+ this.contextRight = canvasRight.getContext('2d');
+ this.contextLoops = canvasLoops.getContext('2d');
+
+ /** Width of the entire canvas */
+ this.width = width;
+ /** Height of the entire canvas */
+ this.height = height;
+
+ /** Current Y positions */
+ this.leftBoxPositionY = this.originalBoxPositionY = 0;
+ this.leftCurvePositionY = this.originalCurvePositionY = 110;
+ this.rightBoxPositionY = this.originalBoxPositionY;
+ this.rightCurvePositionY = this.originalCurvePositionY;
+
+ /** Width of the rectangular box */
+ this.boxWidth = 175;
+ /** Height of the rectangular box */
+ this.boxHeight = 53;
+ /** Height of a smaller rectangular box */
+ this.smallBoxHeight = 30;
+ /** Width of the curve that connects the boxes to the center */
+ this.curveWidth = 170;
+ /** Line-height of the text */
+ this.lineHeight = 14;
+ /** Spacing between rectangular boxes */
+ this.boxSpacing = 7;
+ /** Spacing between the curves where they connect to the center */
+ this.curveSpacing = 1.5;
+
+ /** The total net height (without curve spacing) of the curves as they connect to the center */
+ this.totalHeightOfConnections = 205;
+
+ /** X positions of the left box - begin means left, end means right */
+ this.leftBoxBeginX = 0;
+ this.leftCurveBeginX = this.leftBoxBeginX + this.boxWidth;
+ this.leftCurveEndX = this.leftCurveBeginX + this.curveWidth;
+
+ /** X positions of the right box - begin means left, end means right */
+ this.rightBoxEndX = this.width;
+ this.rightBoxBeginX = this.rightCurveEndX = this.rightBoxEndX - this.boxWidth;
+ this.rightCurveBeginX = this.rightCurveEndX - this.curveWidth;
+
+ // load gradient colors from CSS
+ this.colors = {};
+
+ var transitionsColorNamespaces = ['entries', 'exits', 'background', 'closed-group', 'items', 'others', 'loops'];
+ var gradientColorNames = ['light', 'dark', 'light-highlighted', 'dark-highlighted'];
+ for (var i = 0; i != transitionsColorNamespaces.length; ++i) {
+ var namespace = 'transition-' + transitionsColorNamespaces[i];
+ this.colors[namespace] = piwik.ColorManager.getColors(namespace, gradientColorNames);
+ }
+}
+
+/**
+ * Activate narrow mode: draw groups a bit more compact in order to save space
+ * for more than 3 referrer groups.
+ */
+Piwik_Transitions_Canvas.prototype.narrowMode = function () {
+ this.smallBoxHeight = 26;
+ this.boxSpacing = 4;
+ this.narrowMode = true;
+};
+
+window.addEventListener('themeModeChange', function () {
+ if (Piwik_Transitions.currentInstance) {
+ Piwik_Transitions.currentInstance.refreshTheme();
+ }
+});
+
+Piwik_Transitions_Canvas.prototype.isNarrowMode = function () {
+ return typeof this.narrowMode != 'undefined';
+};
+
+/**
+ * Helper to create horizontal gradients
+ *
+ * @param {String} colorGroup
+ * @param {String} position left|right
+ * @param {Boolean} isHighlighted
+ */
+Piwik_Transitions_Canvas.prototype.createHorizontalGradient = function (colorGroup, position, isHighlighted) {
+ var fromX, toX, fromColor, toColor, lightColor, darkColor;
+
+ colorGroup = 'transition-' + colorGroup;
+ if (isHighlighted) {
+ lightColor = this.colors[colorGroup]['light-highlighted'];
+ darkColor = this.colors[colorGroup]['dark-highlighted'];
+ } else {
+ lightColor = this.colors[colorGroup]['light'];
+ darkColor = this.colors[colorGroup]['dark'];
+ }
+
+ if (position == 'left') {
+ // gradient is used to fill a box on the left
+ fromX = this.leftBoxBeginX + 50;
+ toX = this.leftCurveEndX - 20;
+ fromColor = lightColor;
+ toColor = darkColor;
+ } else {
+ // gradient is used to fill a box on the right
+ fromX = this.rightCurveBeginX + 20;
+ toX = this.rightBoxEndX - 50;
+ fromColor = darkColor;
+ toColor = lightColor;
+ }
+
+ var gradient = this.contextBgLeft.createLinearGradient(fromX, 0, toX, 0);
+ gradient.addColorStop(0, fromColor);
+ gradient.addColorStop(1, toColor);
+
+ return gradient;
+};
+
+/** Render text using a div inside the container */
+Piwik_Transitions_Canvas.prototype.renderText = function (text, x, y, cssClass, onClick, icon, maxLines) {
+ var div = this.addDomElement('div', 'Text');
+ div.css({
+ left: x + 'px',
+ top: y + 'px'
+ });
+ if (icon) {
+ div.addClass('Transitions_HasBackground');
+ div.css({backgroundImage: 'url(' + icon + ')'});
+ }
+ if (cssClass) {
+ if (typeof cssClass == 'object') {
+ for (var i = 0; i < cssClass.length; i++) {
+ div.addClass('Transitions_' + cssClass[i]);
+ }
+ } else {
+ div.addClass('Transitions_' + cssClass);
+ }
+ }
+ var textContainer = div;
+ if (onClick) {
+ if (typeof onClick == 'function') {
+ div.css('cursor', 'pointer').hover(function () {
+ $(this).addClass('Transitions_Hover');
+ },function () {
+ $(this).removeClass('Transitions_Hover');
+ }).click(onClick);
+ } else {
+ var a = $(document.createElement('a'));
+ a.attr('href', onClick);
+ a.attr('rel', 'noreferrer noopener');
+ a.attr('target', '_blank');
+ div.append(a);
+ textContainer = a;
+ }
+ }
+ if (maxLines) {
+ textContainer.addClass('Transitions_ApplyTextAndTruncate').data('text', text);
+ } else {
+ textContainer.html(text);
+ }
+ return div;
+};
+
+/** Add a DOM element inside the container (as a sibling of the canvas) */
+Piwik_Transitions_Canvas.prototype.addDomElement = function (tagName, cssClass) {
+ var el = $(document.createElement('div')).addClass('Transitions_' + cssClass);
+ this.container.append(el);
+ return el;
+};
+
+/**
+ * Truncate box texts by replacing the middle part with ...
+ * This method looks for the class Transitions_ApplyTextAndTruncate.
+ * It then looks up data-text and truncates it until it fits.
+ */
+Piwik_Transitions_Canvas.prototype.truncateVisibleBoxTexts = function () {
+ this.container.find('.Transitions_ApplyTextAndTruncate').each(function () {
+ var container = $(this).html('');
+ var span = container.find('span');
+
+ var text = container.data('text');
+ span.html(piwikHelper.addBreakpointsToUrl(text));
+
+ var divHeight = container.innerHeight();
+ if (container.data('maxLines')) {
+ divHeight = container.data('maxLines') * (parseInt(container.css('lineHeight'), 10) + .2);
+ }
+
+ var leftPart = false;
+ var rightPart = false;
+
+ while (divHeight < span.outerHeight()) {
+ if (leftPart === false) {
+ var middle = Math.round(text.length / 2);
+ leftPart = text.substring(0, middle);
+ rightPart = text.substring(middle, text.length);
+ }
+ leftPart = leftPart.substring(0, leftPart.length - 2);
+ rightPart = rightPart.substring(2, rightPart.length);
+ text = leftPart + '...' + rightPart;
+ span.html(piwikHelper.addBreakpointsToUrl(text));
+ }
+ });
+};
+
+/**
+ * Render a box.
+ * This method automatically keeps track of the current position.
+ *
+ * PARAMS (pass as object):
+ * side: left or right
+ * share: of the box in the total amount of incoming transitions
+ * gradient: for filling the box
+ * boxText: to be placed inside the box (optional)
+ * boxTextNumLines: the number of lines to be placed in the box (optional)
+ * boxTextCssClass: for divs containing the texts (optional)
+ * boxTextTooltip: text for a tooltip this is when hovering the box text (optional)
+ * curveText: to be placed where the curve begins (optional)
+ * curveTextTooltip: text for a tooltip that is shown when hovering the curve text (optional)
+ * smallBox: use this.smallBoxHeight instead of this.boxHeight (optional)
+ * boxIcon: path to an icon that is put in front of the text (optional)
+ * onClick: click callback for the text in the box (optional)
+ * onMouseOver: mouse over callback for the text in the box (optional)
+ * onMouseOut: mouse over callback for the text in the box (optional)
+ * onlyBg: render only the background, not the text; used for highlighting (optional)
+ *
+ * Only used for background:
+ * curveHeight: fix height in px instead of share
+ * boxHeight: fix box height in px
+ * bgCanvas: true to draw on background canvas
+ */
+Piwik_Transitions_Canvas.prototype.renderBox = function (params) {
+ var curveHeight = params.curveHeight ? params.curveHeight :
+ Math.round(this.totalHeightOfConnections * params.share);
+ curveHeight = Math.max(curveHeight, 1);
+
+ var boxHeight = this.boxHeight;
+ if (params.smallBox) {
+ boxHeight = this.smallBoxHeight;
+ }
+ if (params.boxHeight) {
+ boxHeight = params.boxHeight;
+ }
+
+ var context;
+ if (params.bgCanvas) {
+ context = params.side == 'left' ? this.contextBgLeft : this.contextBgRight;
+ } else {
+ context = params.side == 'left' ? this.contextLeft : this.contextRight;
+ }
+
+ // background
+ context.fillStyle = params.gradient;
+ context.beginPath();
+ if (params.side == 'left') {
+ this.renderLeftBoxBg(context, boxHeight, curveHeight);
+ } else {
+ this.renderRightBoxBg(context, boxHeight, curveHeight);
+ }
+ if (typeof context.endPath == 'function') {
+ context.endPath();
+ }
+
+ // text inside the box
+ if (params.boxText && !params.onlyBg) {
+ var onClick = params.onClick;
+ var boxTextLeft, boxTextTop, el;
+ if (params.side == 'left') {
+ boxTextLeft = this.leftBoxBeginX + 10;
+ boxTextTop = this.leftBoxPositionY + boxHeight / 2 - params.boxTextNumLines * this.lineHeight / 2;
+ el = this.renderText(params.boxText, boxTextLeft, boxTextTop, 'BoxTextLeft', onClick, params.boxIcon,
+ params.boxTextNumLines);
+ } else {
+ boxTextLeft = this.rightBoxBeginX;
+ boxTextTop = this.rightBoxPositionY + boxHeight / 2 - params.boxTextNumLines * this.lineHeight / 2;
+ el = this.renderText(params.boxText, boxTextLeft, boxTextTop, 'BoxTextRight', onClick, params.boxIcon,
+ params.boxTextNumLines);
+ }
+ if (params.boxTextCssClass) {
+ el.addClass('Transitions_' + params.boxTextCssClass);
+ }
+ // tooltip
+ if (params.boxTextTooltip) {
+ var tip = piwikHelper.addBreakpointsToUrl(params.boxTextTooltip);
+ el.tooltip({
+ track: true,
+ content: tip,
+ items: '*',
+ tooltipClass: 'Transitions_Tooltip_Small',
+ show: false,
+ hide: false
+ });
+ }
+ if (typeof params.onMouseOver == 'function') {
+ el.mouseenter(params.onMouseOver);
+ }
+ if (typeof params.onMouseOut == 'function') {
+ el.mouseleave(params.onMouseOut);
+ }
+ }
+
+ // text at the beginning of the curve
+ if (params.curveText && !params.onlyBg) {
+ var curveTextLeft, curveTextTop;
+ if (params.side == 'left') {
+ curveTextLeft = this.leftBoxBeginX + this.boxWidth + 3;
+ curveTextTop = this.leftBoxPositionY + boxHeight / 2 - this.lineHeight / 2;
+ } else {
+ curveTextLeft = this.rightBoxBeginX - 37;
+ curveTextTop = this.rightBoxPositionY + boxHeight / 2 - this.lineHeight / 2;
+ }
+ var textDiv = this.renderText(params.curveText, curveTextLeft, curveTextTop,
+ params.side == 'left' ? 'CurveTextLeft' : 'CurveTextRight');
+ // tooltip
+ if (params.curveTextTooltip) {
+ textDiv.tooltip({
+ track: true,
+ content: params.curveTextTooltip,
+ items: '*',
+ tooltipClass: 'Transitions_Tooltip_Small',
+ show: false,
+ hide: false
+ });
+ }
+ }
+
+ if (params.side == 'left') {
+ this.leftBoxPositionY += boxHeight + this.boxSpacing;
+ this.leftCurvePositionY += curveHeight + this.curveSpacing;
+ } else {
+ this.rightBoxPositionY += boxHeight + this.boxSpacing;
+ this.rightCurvePositionY += curveHeight + this.curveSpacing;
+ }
+};
+
+Piwik_Transitions_Canvas.prototype.renderLeftBoxBg = function (context, boxHeight, curveHeight) {
+ // derive coordinates for ths curve
+ var leftUpper = {x: this.leftCurveBeginX, y: this.leftBoxPositionY};
+ var leftLower = {x: this.leftCurveBeginX, y: this.leftBoxPositionY + boxHeight};
+ var rightUpper = {x: this.leftCurveEndX, y: this.leftCurvePositionY};
+ var rightLower = {x: this.leftCurveEndX, y: this.leftCurvePositionY + curveHeight};
+
+ // derive control points for bezier curve
+ var center = (this.leftCurveBeginX + this.leftCurveEndX) / 2;
+ var cp1Upper = {x: center, y: leftUpper.y};
+ var cp2Upper = {x: center, y: rightUpper.y};
+ var cp1Lower = {x: center, y: rightLower.y};
+ var cp2Lower = {x: center, y: leftLower.y};
+
+ // the flow
+ context.moveTo(leftUpper.x, leftUpper.y);
+ context.bezierCurveTo(cp1Upper.x, cp1Upper.y, cp2Upper.x, cp2Upper.y, rightUpper.x, rightUpper.y);
+ context.lineTo(rightLower.x, rightLower.y);
+ context.bezierCurveTo(cp1Lower.x, cp1Lower.y, cp2Lower.x, cp2Lower.y, leftLower.x, leftLower.y);
+
+ // the box
+ context.lineTo(leftLower.x - this.boxWidth + 2, leftLower.y);
+ context.lineTo(leftLower.x - this.boxWidth, leftUpper.y);
+ context.lineTo(leftUpper.x, leftUpper.y);
+ context.fill();
+};
+
+Piwik_Transitions_Canvas.prototype.renderRightBoxBg = function (context, boxHeight, curveHeight) {
+ // derive coordinates for curve
+ var leftUpper = {x: this.rightCurveBeginX, y: this.rightCurvePositionY};
+ var leftLower = {x: this.rightCurveBeginX, y: this.rightCurvePositionY + curveHeight};
+ var rightUpper = {x: this.rightCurveEndX, y: this.rightBoxPositionY};
+ var rightLower = {x: this.rightCurveEndX, y: this.rightBoxPositionY + boxHeight};
+
+ // derive control points for bezier curve
+ var center = (this.rightCurveBeginX + this.rightCurveEndX) / 2;
+ var cp1Upper = {x: center, y: leftUpper.y};
+ var cp2Upper = {x: center, y: rightUpper.y};
+ var cp1Lower = {x: center, y: rightLower.y};
+ var cp2Lower = {x: center, y: leftLower.y};
+
+ // the flow part 1
+ context.moveTo(leftUpper.x, leftUpper.y);
+ context.bezierCurveTo(cp1Upper.x, cp1Upper.y, cp2Upper.x, cp2Upper.y, rightUpper.x, rightUpper.y);
+
+ // the box
+ context.lineTo(rightUpper.x + this.boxWidth, rightUpper.y);
+ context.lineTo(rightLower.x + this.boxWidth - 2, rightLower.y);
+ context.lineTo(rightLower.x, rightLower.y);
+
+ // the flow part 2
+ context.bezierCurveTo(cp1Lower.x, cp1Lower.y, cp2Lower.x, cp2Lower.y, leftLower.x, leftLower.y);
+ context.lineTo(leftUpper.x, leftUpper.y);
+ context.fill();
+};
+
+/** Add spacing after the current box */
+Piwik_Transitions_Canvas.prototype.addBoxSpacing = function (spacing, side) {
+ if (side == 'left') {
+ this.leftBoxPositionY += spacing;
+ } else {
+ this.rightBoxPositionY += spacing;
+ }
+};
+
+Piwik_Transitions_Canvas.prototype.renderLoops = function (share) {
+ var curveHeight = Math.round(this.totalHeightOfConnections * share);
+ curveHeight = Math.max(curveHeight, 1);
+
+ // create gradient
+ var gradient = this.contextLoops.createLinearGradient(this.leftCurveEndX - 50, 0, this.rightCurveBeginX + 50, 0);
+ var light = this.colors['transition-loops']['light'];
+ var dark = this.colors['transition-loops']['dark'];
+ gradient.addColorStop(0, dark);
+ gradient.addColorStop(.5, light);
+ gradient.addColorStop(1, dark);
+
+ this.contextLoops.fillStyle = gradient;
+
+ this.contextLoops.beginPath();
+
+ // curve from the upper left connection to the center box to the lower left connection to the text box
+ var point1 = {x: this.leftCurveEndX, y: this.leftCurvePositionY};
+ var point2 = {x: this.leftCurveEndX, y: $('#Transitions_CenterBox').outerHeight() + 70};
+
+ var cpLeftX = (this.leftCurveBeginX + this.leftCurveEndX) / 2 + 30;
+ var cp1 = {x: cpLeftX, y: point1.y};
+ var cp2 = {x: cpLeftX, y: point2.y};
+
+ this.contextLoops.moveTo(point1.x, point1.y);
+ this.contextLoops.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, point2.x, point2.y);
+
+ // lower line of text box
+ var point3 = {x: this.rightCurveBeginX, y: point2.y};
+ this.contextLoops.lineTo(point3.x, point3.y);
+
+ // curve to upper right connection to the center box
+ var point4 = {x: this.rightCurveBeginX, y: this.rightCurvePositionY};
+ var cpRightX = (this.rightCurveBeginX + this.rightCurveEndX) / 2 - 30;
+ var cp3 = {x: cpRightX, y: point3.y};
+ var cp4 = {x: cpRightX, y: point4.y};
+ this.contextLoops.bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, point4.x, point4.y);
+
+ // line to lower right connection to the center box
+ var point5 = {x: point4.x, y: point4.y + curveHeight};
+ this.contextLoops.lineTo(point5.x, point5.y);
+
+ // curve to upper right connection to the text box
+ var point6 = {x: point5.x, y: point2.y - 25};
+ cpRightX -= 30;
+ var cp5 = {x: cpRightX, y: point5.y};
+ var cp6 = {x: cpRightX, y: point6.y};
+ this.contextLoops.bezierCurveTo(cp5.x, cp5.y, cp6.x, cp6.y, point6.x, point6.y);
+
+ // upper line of the text box
+ var point7 = {x: point1.x, y: point6.y};
+ this.contextLoops.lineTo(point7.x, point7.y);
+
+ // line to lower left connection to the center box
+ var point8 = {x: point1.x, y: point1.y + curveHeight};
+ cpLeftX += 30;
+ var cp7 = {x: cpLeftX, y: point7.y};
+ var cp8 = {x: cpLeftX, y: point8.y};
+ this.contextLoops.bezierCurveTo(cp7.x, cp7.y, cp8.x, cp8.y, point8.x, point8.y);
+
+ this.contextLoops.fill();
+
+ if (typeof this.contextLoops.endPath == 'function') {
+ this.contextLoops.endPath();
+ }
+
+};
+
+/** Clear one side for redrawing */
+Piwik_Transitions_Canvas.prototype.clearSide = function (side, onlyBg) {
+ if (side == 'left') {
+ this.contextBgLeft.clearRect(0, 0, this.width, this.height);
+ this.contextLeft.clearRect(0, 0, this.width, this.height);
+ } else {
+ this.contextBgRight.clearRect(0, 0, this.width, this.height);
+ this.contextRight.clearRect(0, 0, this.width, this.height);
+ }
+ this.contextLoops.clearRect(0, 0, this.width, this.height);
+
+ if (side == 'left') {
+ if (!onlyBg) {
+ this.container.find('.Transitions_BoxTextLeft').remove();
+ this.container.find('.Transitions_CurveTextLeft').remove();
+ }
+ this.leftBoxPositionY = this.originalBoxPositionY;
+ this.leftCurvePositionY = this.originalCurvePositionY;
+ } else {
+ if (!onlyBg) {
+ this.container.find('.Transitions_BoxTextRight').remove();
+ this.container.find('.Transitions_CurveTextRight').remove();
+ }
+ this.rightBoxPositionY = this.originalBoxPositionY;
+ this.rightCurvePositionY = this.originalCurvePositionY;
+ }
+};
+
+// --------------------------------------
+// MODEL
+// --------------------------------------
+
+function Piwik_Transitions_Model(ajax) {
+ this.ajax = ajax;
+
+ this.groupTitles = {};
+}
+
+Piwik_Transitions_Model.prototype.htmlLoaded = function () {
+ this.groupTitles.previousPages = Piwik_Transitions_Translations.fromPreviousPages;
+ this.groupTitles.previousSiteSearches = Piwik_Transitions_Translations.fromPreviousSiteSearches;
+ this.groupTitles.followingPages = Piwik_Transitions_Translations.toFollowingPages;
+ this.groupTitles.followingSiteSearches = Piwik_Transitions_Translations.toFollowingSiteSearches;
+ this.groupTitles.outlinks = Piwik_Transitions_Translations.outlinks;
+ this.groupTitles.downloads = Piwik_Transitions_Translations.downloads;
+
+ this.shareInGroupTexts = {
+ previousPages: Piwik_Transitions_Translations.fromPreviousPagesInline,
+ previousSiteSearches: Piwik_Transitions_Translations.fromPreviousSiteSearchesInline,
+ followingPages: Piwik_Transitions_Translations.toFollowingPagesInline,
+ followingSiteSearches: Piwik_Transitions_Translations.toFollowingSiteSearchesInline,
+ searchEngines: Piwik_Transitions_Translations.fromSearchEnginesInline,
+ socialNetworks: Piwik_Transitions_Translations.fromSocialNetworksInline,
+ aiAssistants: Piwik_Transitions_Translations.fromAIAssistantsInline,
+ websites: Piwik_Transitions_Translations.fromWebsitesInline,
+ campaigns: Piwik_Transitions_Translations.fromCampaignsInline,
+ outlinks: Piwik_Transitions_Translations.outlinksInline,
+ downloads: Piwik_Transitions_Translations.downloadsInline
+ };
+};
+
+Piwik_Transitions_Model.prototype.loadData = function (actionType, actionName, overrideParams, callback) {
+ var self = this;
+
+ this.pageviews = 0;
+ this.exits = 0;
+ this.loops = 0;
+
+ this.directEntries = 0;
+
+ this.searchEnginesNbTransitions = 0;
+ this.searchEngines = [];
+
+ this.socialNetworksNbTransitions = 0;
+ this.socialNetworks = [];
+
+ this.aiAssistantsNbTransitions = 0;
+ this.aiAssistants = [];
+
+ this.websitesNbTransitions = 0;
+ this.websites = [];
+
+ this.campaignsNbTransitions = 0;
+ this.campaigns = [];
+
+ this.previousPagesNbTransitions = 0;
+ this.previousPages = [];
+
+ this.followingPagesNbTransitions = 0;
+ this.followingPages = [];
+
+ this.downloadsNbTransitions = 0;
+ this.downloads = [];
+
+ this.outlinksNbTransitions = 0;
+ this.outlinks = [];
+
+ this.previousSiteSearchesNbTransitions = 0;
+ this.previousSiteSearches = [];
+
+ this.followingSiteSearchesNbTransitions = 0;
+ this.followingSiteSearches = [];
+
+ this.date = '';
+
+ var params = {
+ actionType: actionType,
+ actionName: actionName,
+ expanded: 1
+ };
+ if (overrideParams) {
+ $.extend(params, overrideParams);
+ }
+
+ this.ajax.callApi('Transitions.getTransitionsForAction', params,
+ function (report) {
+ self.date = report.date;
+
+ // load page metrics
+ self.pageviews = report.pageMetrics.pageviews;
+ self.loops = report.pageMetrics.loops;
+ self.exits = report.pageMetrics.exits;
+
+ // load referrers: split direct entries and others
+ for (var i = 0; i < report.referrers.length; i++) {
+ var referrer = report.referrers[i];
+ if (referrer.shortName == 'direct') {
+ self.directEntries = referrer.visits;
+ } else if (referrer.shortName == 'search') {
+ self.searchEnginesNbTransitions = referrer.visits;
+ self.searchEngines = referrer.details;
+ self.groupTitles.searchEngines = referrer.label;
+ } else if (referrer.shortName == 'social') {
+ self.socialNetworksNbTransitions = referrer.visits;
+ self.socialNetworks = referrer.details;
+ self.groupTitles.socialNetworks = referrer.label;
+ } else if (referrer.shortName == 'ai') {
+ self.aiAssistantsNbTransitions = referrer.visits;
+ self.aiAssistants = referrer.details;
+ self.groupTitles.aiAssistants = referrer.label;
+ } else if (referrer.shortName == 'website') {
+ self.websitesNbTransitions = referrer.visits;
+ self.websites = referrer.details;
+ self.groupTitles.websites = referrer.label;
+ } else if (referrer.shortName == 'campaign') {
+ self.campaignsNbTransitions = referrer.visits;
+ self.campaigns = referrer.details;
+ self.groupTitles.campaigns = referrer.label;
+ }
+ }
+
+ self.loadAndSumReport(report, 'previousPages');
+ self.loadAndSumReport(report, 'previousSiteSearches');
+ self.loadAndSumReport(report, 'followingPages');
+ self.loadAndSumReport(report, 'followingSiteSearches');
+ self.loadAndSumReport(report, 'downloads');
+ self.loadAndSumReport(report, 'outlinks');
+
+ if (typeof Piwik_Transitions_Model.totalNbPageviews == 'undefined') {
+ Piwik_Transitions_Model.totalNbPageviews = false;
+ self.ajax.loadTotalNbPageviews(function (nbPageviews) {
+ Piwik_Transitions_Model.totalNbPageviews = nbPageviews;
+ });
+ }
+
+ callback();
+ });
+};
+
+Piwik_Transitions_Model.prototype.loadAndSumReport = function (apiData, reportName) {
+ var data = this[reportName] = apiData[reportName];
+ var sumVarName = reportName + 'NbTransitions';
+
+ this[sumVarName] = 0;
+ for (var i = 0; i < data.length; i++) {
+ this[sumVarName] += data[i].referrals;
+ }
+};
+
+Piwik_Transitions_Model.prototype.getTotalNbPageviews = function () {
+ if (typeof Piwik_Transitions_Model.totalNbPageviews == 'undefined') {
+ return false;
+ }
+ return Piwik_Transitions_Model.totalNbPageviews;
+};
+
+Piwik_Transitions_Model.prototype.getGroupTitle = function (groupName) {
+ if (typeof this.groupTitles[groupName] != 'undefined') {
+ return this.groupTitles[groupName];
+ }
+ return groupName;
+};
+
+Piwik_Transitions_Model.prototype.getShareInGroupTooltip = function (share, groupName) {
+ var tip = this.shareInGroupTexts[groupName];
+ return sprintf(tip, share);
+};
+
+Piwik_Transitions_Model.prototype.getDetailsForGroup = function (groupName) {
+ return this.addPercentagesToData(this[groupName]);
+};
+
+Piwik_Transitions_Model.prototype.getPercentage = function (metric, formatted) {
+ var percentage = (this.pageviews == 0 ? 0 : this[metric] / this.pageviews);
+
+ if (formatted) {
+
+ percentage = this.roundPercentage(percentage);
+ return NumberFormatter.formatPercent(percentage);
+ }
+
+ return percentage;
+};
+
+Piwik_Transitions_Model.prototype.addPercentagesToData = function (data) {
+ var total = 0;
+
+ for (var i = 0; i < data.length; i++) {
+ total += parseInt(data[i].referrals, 10);
+ }
+
+ for (i = 0; i < data.length; i++) {
+ data[i].percentage = this.roundPercentage(data[i].referrals / total);
+ }
+
+ return data;
+};
+
+Piwik_Transitions_Model.prototype.roundPercentage = function (value) {
+ if (value < .1) {
+ return Math.round(value * 1000) / 10.0;
+ } else {
+ return Math.round(value * 100);
+ }
+};
+
+// --------------------------------------
+// AJAX
+// --------------------------------------
+
+function Piwik_Transitions_Ajax() {
+}
+
+Piwik_Transitions_Ajax.prototype.loadTotalNbPageviews = function (callback) {
+ this.callApi('Actions.get', {
+ columns: 'nb_pageviews'
+ }, function (response) {
+ var value = typeof response.value != 'undefined' ? response.value : false;
+ callback(value);
+ });
+};
+
+Piwik_Transitions_Ajax.prototype.callTransitionsController = function (action, callback) {
+ var ajaxRequest = new ajaxHelper();
+ ajaxRequest.addParams({
+ module: 'Transitions',
+ action: action
+ }, 'get');
+ ajaxRequest.setCallback(callback);
+ ajaxRequest.setFormat('html');
+ ajaxRequest.send();
+};
+
+Piwik_Transitions_Ajax.prototype.callApi = function (method, params, callback) {
+ var self = this;
+
+ params.format = 'JSON';
+ params.module = 'API';
+ params.method = method;
+ params.filter_limit = '-1';
+
+ var ajaxRequest = new ajaxHelper();
+ ajaxRequest.addParams(params, 'get');
+ ajaxRequest.useCallbackInCaseOfError();
+ ajaxRequest.setCallback(
+ function (result) {
+ if (typeof result.result != 'undefined' && result.result == 'error') {
+ var errorName = result.message;
+ var showError = function () {
+ var errorTitle, errorMessage, errorBack;
+ if (typeof Piwik_Transitions_Translations[errorName] == 'undefined') {
+ errorTitle = 'Exception';
+ errorMessage = errorName;
+ errorBack = '<<<';
+ } else {
+ errorTitle = Piwik_Transitions_Translations[errorName];
+ errorMessage = Piwik_Transitions_Translations[errorName + 'Details'];
+ errorBack = Piwik_Transitions_Translations[errorName + 'Back'];
+ }
+
+ if (typeof params.actionName != 'undefined') {
+ var url = params.actionName;
+ url = piwikHelper.addBreakpointsToUrl(url);
+ errorTitle = sprintf(errorTitle, '' + url + '');
+ }
+
+ errorMessage = sprintf(errorMessage, '
');
+ var inlineErrorNode = $('#Transitions_Error_Container');
+ if (inlineErrorNode.length) {
+ // viewing it as report, not popover
+ inlineErrorNode.html('');
+ var theContentNode = $(document.createElement('div')).addClass('Piwik_Popover_Error');
+
+ var p = $(document.createElement('p')).addClass('Piwik_Popover_Error_Title');
+ theContentNode.append(p.html(errorTitle));
+
+ if (errorMessage) {
+ p = $(document.createElement('p')).addClass('Piwik_Popover_Error_Message');
+ theContentNode.append(p.html(errorMessage));
+ }
+ inlineErrorNode.append(theContentNode);
+ inlineErrorNode.show();
+ $('#transitions_report .popoverContainer').hide();
+ } else {
+ Piwik_Popover.showError(errorTitle, errorMessage, errorBack);
+ }
+
+ $('#transitions_inline_loading').hide();
+ };
+
+ if (typeof Piwik_Transitions_Translations == 'undefined') {
+ self.callApi('Transitions.getTranslations', {}, function (response) {
+ if (typeof response == 'object') {
+ Piwik_Transitions_Translations = response;
+ } else {
+ Piwik_Transitions_Translations = {};
+ }
+ showError();
+ });
+ } else {
+ showError();
+ }
+ }
+ else {
+ callback(result);
+ }
+ }
+ );
+ ajaxRequest.send();
+};
+
+// --------------------------------------
+// STATIC UTIL FUNCTIONS
+// --------------------------------------
+
+Piwik_Transitions_Util = {
+
+ /**
+ * Removes protocol, www and trailing slashes from a URL.
+ * If removeDomain is set, the domain is removed as well.
+ */
+ shortenUrl: function (url, removeDomain) {
+ if (url == 'Others') {
+ return url;
+ }
+
+ var urlBackup = url;
+ url = url.replace(/http(s)?:\/\/(www\.)?/, '');
+
+ if (urlBackup == url) {
+ return url;
+ }
+
+ if (removeDomain) {
+ url = url.replace(/[^\/]*/, '');
+ if (url == '/') {
+ url = urlBackup;
+ }
+ }
+
+ url = url.replace(/\/$/, '');
+
+ return url;
+ },
+
+ /**
+ * Replaces a %s placeholder in the HTML.
+ * The special feature is that it can be called multiple times, replacing the already
+ * replaced placeholder again. It creates a span that can be assigned a class using the
+ * spanClass parameter. The default class is 'Transitions_Metric'.
+ */
+ replacePlaceholderInHtml: function (container, value, spanClass) {
+ var span = container.find('span');
+ if (!span.length) {
+ var html = container.html().replace(/%s/, '');
+ span = container.html(html).find('span');
+ if (!spanClass) {
+ spanClass = 'Transitions_Metric';
+ }
+ span.addClass(spanClass);
+ }
+ span.html(value);
+ }
+
+};
diff --git a/plugins/Transitions/lang/am.json b/plugins/Transitions/lang/am.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/am.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/ar.json b/plugins/Transitions/lang/ar.json
new file mode 100644
index 0000000000000000000000000000000000000000..c186c3e24f0a74b52f00fe914402f62e16f53300
--- /dev/null
+++ b/plugins/Transitions/lang/ar.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "تفاصيل أكثر"
+ }
+}
diff --git a/plugins/Transitions/lang/az.json b/plugins/Transitions/lang/az.json
new file mode 100644
index 0000000000000000000000000000000000000000..09813de36553c3c5f95fdf9ad6b6996b1ab653cb
--- /dev/null
+++ b/plugins/Transitions/lang/az.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "Daha ətraflı"
+ }
+}
diff --git a/plugins/Transitions/lang/be.json b/plugins/Transitions/lang/be.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/be.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/bg.json b/plugins/Transitions/lang/bg.json
new file mode 100644
index 0000000000000000000000000000000000000000..b81ce7949a7a60b645e67ae1e8b5db0be384a0f5
--- /dev/null
+++ b/plugins/Transitions/lang/bg.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Знаехте ли? Преходите също са налични като действие на ред в следните отчети:",
+ "AvailableInOtherReports2": "Стартирайте преходите, като кликнете върху тяхната икона (%s), след като задържите курсора на мишката върху ред в който и да е от тези отчети.",
+ "BouncesInline": "%s еднократни посещения",
+ "DirectEntries": "Директни посещения",
+ "ErrorBack": "Върнете се на предишната страница",
+ "ExitsInline": "%s изходи",
+ "FeatureDescription": "Преходите са отчет, показващ нещата, които Вашите посетители са направили директно преди и след разглеждане на дадена страница. Тази страница обяснява как да получите достъп, разберете и използвате мощния отчет „Преходи“.",
+ "FromCampaigns": "От кампании",
+ "FromPreviousPages": "От вътрешни страници",
+ "FromPreviousPagesInline": "%s от вътреши страници",
+ "FromPreviousSiteSearches": "От вътрешни търсения",
+ "FromPreviousSiteSearchesInline": "%s от вътрешни търсения",
+ "FromSearchEngines": "От търсачки",
+ "FromSocialNetworks": "От социални мрежи",
+ "FromWebsites": "От уебсайтове",
+ "IncomingTraffic": "Входящ трафик",
+ "LoopsInline": "%s страница се презарежда",
+ "NoDataForAction": "Няма информация за %s",
+ "NoDataForActionDetails": "Действието или не е имало показвания на страници през периода, или е невалидно.",
+ "NumDownloads": "%s изтегляния",
+ "NumOutlinks": "%s препращания",
+ "NumPageviews": "%s показвания на страници",
+ "OutgoingTraffic": "Изходящ трафик",
+ "PageURLTransitions": "URL преходи на страницата",
+ "PeriodNotAllowed": "Изберете валиден период от време",
+ "PeriodNotAllowedDetails": "Опитайте да изберете период с по-малко дни за тази функция.",
+ "PluginDescription": "Отчита предишни и следващи действия за всеки URL адрес на страница в нов отчет „Преходи“, достъпен в отчетите „Действия“ чрез нова икона.",
+ "ShareOfAllPageviews": "Тази страница има %1$s разглеждания (от всички %2$s разглеждания)",
+ "ToFollowingPages": "Към вътрешни страници",
+ "ToFollowingPagesInline": "%s за вътрешни страници",
+ "ToFollowingSiteSearches": "Външни търсения",
+ "ToFollowingSiteSearchesInline": "%s вътрешни търсения",
+ "TopX": "Топ %s етикети",
+ "Transitions": "Преходи",
+ "TransitionsSubcategoryHelp1": "Преходите са отчет, показващ нещата, които Вашите посетители са направили директно преди и след разглеждане на дадена страница. Тази страница обяснява как да получите достъп, разберете и използвате мощния отчет „Преходи“.",
+ "TransitionsSubcategoryHelp2": "Повече детайли",
+ "XOfAllPageviews": "%s на всички показания за тази страница",
+ "XOutOfYVisits": "%1$s (от %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/bn.json b/plugins/Transitions/lang/bn.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/bn.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/bs.json b/plugins/Transitions/lang/bs.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/bs.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/ca.json b/plugins/Transitions/lang/ca.json
new file mode 100644
index 0000000000000000000000000000000000000000..97bc6472568505211065491ab82226d26cdae40d
--- /dev/null
+++ b/plugins/Transitions/lang/ca.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Ho sabies? Les transicions també estan disponibles com a acció de fila als informes següents:",
+ "AvailableInOtherReports2": "Inicieu les transicions fent clic a la seva icona (%s) després de passar el cursor per una fila en qualsevol d'aquests informes.",
+ "BouncesInline": "%s rebots",
+ "DirectEntries": "Entrades directes",
+ "ErrorBack": "Ves a l'acció anterior",
+ "ExitsInline": "%s surts",
+ "FeatureDescription": "Transicions és un informe que mostra les coses que van fer els vostres visitants directament abans i després de veure una pàgina determinada. Aquesta pàgina explica com accedir, entendre i utilitzar el potent informe \"Transicions\".",
+ "FromAIAssistants": "Dels assistents d'IA",
+ "FromCampaigns": "De campanyes",
+ "FromPreviousPages": "Des de pàgines internes",
+ "FromPreviousPagesInline": "%s de pàgines internes",
+ "FromPreviousSiteSearches": "De la cerca interna",
+ "FromPreviousSiteSearchesInline": "%s de la cerques internes",
+ "FromSearchEngines": "De cercadors",
+ "FromSocialNetworks": "De xarxes socials",
+ "FromWebsites": "De llocs web",
+ "IncomingTraffic": "Tràfic entrant",
+ "LoopsInline": "%s refrescos de pàgina",
+ "NoDataForAction": "Sense informació per %s",
+ "NoDataForActionDetails": "O l'acció no ha estat vista cap vegada durant el període o és invàlida.",
+ "NumDownloads": "%s descàrregues",
+ "NumOutlinks": "%s enllaços sortints",
+ "NumPageviews": "%s pàgines vistes",
+ "OutgoingTraffic": "Trànsit sortint",
+ "PageURLTransitions": "Transicions d'URL de pàgina",
+ "PeriodNotAllowed": "Seleccioneu un període de temps vàlid",
+ "PeriodNotAllowedDetails": "Proveu de seleccionar un període amb menys dies per a aquesta funció.",
+ "PluginDescription": "Informa de les accions anteriors i posteriors per a cada URL de pàgina en un informe de Transicions nou, disponible als informes d'Accions mitjançant una icona nova.",
+ "ShareOfAllPageviews": "Aquesta pàgina ha tingut %1$s visites (%2$s de totes les visites)",
+ "ToFollowingPages": "A pàgines internes",
+ "ToFollowingPagesInline": "%s a pàgines internes",
+ "ToFollowingSiteSearches": "Cerques internes",
+ "ToFollowingSiteSearchesInline": "%s cerques internes",
+ "TopX": "Top %s d'etiquetes",
+ "Transitions": "Transicions",
+ "TransitionsSubcategoryHelp1": "Transicions és un informe que mostra les coses que van fer els vostres visitants directament abans i després de veure una pàgina determinada. Aquesta pàgina explica com accedir, entendre i utilitzar el potent informe \"Transicions\".",
+ "TransitionsSubcategoryHelp2": "Més detalls",
+ "XOfAllPageviews": "%s de totes les visualitzacions d'aquesta pàgina",
+ "XOutOfYVisits": "%1$s (de %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/cs.json b/plugins/Transitions/lang/cs.json
new file mode 100644
index 0000000000000000000000000000000000000000..723913b14c52ba38ae34e5c5a7641ce71e7d283c
--- /dev/null
+++ b/plugins/Transitions/lang/cs.json
@@ -0,0 +1,40 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Věděl jsi, že Transitions jsou k dispozici také ve formě řady akcí v následujících přehledech:",
+ "AvailableInOtherReports2": "Jednoduše umístěte kurzor na některý z těchto přehledů a kliknutím na transition ikonu %s jej spusťte.",
+ "BouncesInline": "%s okamžitých opuštění stránky",
+ "DirectEntries": "Přímé vstupy",
+ "ErrorBack": "Přejít k předchozí akci",
+ "ExitsInline": "%s odchody",
+ "FeatureDescription": "Transitions vám poskytuje přehled, který ukazuje akce, které vaši návštěvníci provedli přímo před a po zobrazení určité stránky. Tato stránka vysvětlí, jak přistupovat, porozumět a používat Transitions přehledy.",
+ "FromCampaigns": "Z kampaní",
+ "FromPreviousPages": "Z interních stránek",
+ "FromPreviousPagesInline": "%s z interních stránek",
+ "FromPreviousSiteSearches": "Z interního vyhledávání",
+ "FromPreviousSiteSearchesInline": "%s z interních vyhledávání",
+ "FromSearchEngines": "Z vyhledávačů",
+ "FromSocialNetworks": "Ze sociálních sítí",
+ "FromWebsites": "Z webových stránek",
+ "IncomingTraffic": "Příchozí traffic",
+ "LoopsInline": "%s obnovení stránky",
+ "NoDataForAction": "Pro %s nejsou žádná data",
+ "NoDataForActionDetails": "Buď akce neměla za období žádné zobrazení stránky, nebo je neplatná.",
+ "NumDownloads": "%s stažení",
+ "NumOutlinks": "%s externích odkazů",
+ "NumPageviews": "%s zobrazení stránky",
+ "OutgoingTraffic": "Odchozí traffic",
+ "PageURLTransitions": "Transitions URL stránky",
+ "PluginDescription": "Zobrazuje předchozí a následující akce pro každou adresu URL v novém přechodovém přehledu, který je k dispozici v přehledech akcí prostřednictvím nové ikony.",
+ "ShareOfAllPageviews": "Tato stránka měla %1$s zobrazení (%2$s všech zobrazení)",
+ "ToFollowingPages": "Na interní stránky",
+ "ToFollowingPagesInline": "%s na interní stránky",
+ "ToFollowingSiteSearches": "Interní vyhledávání",
+ "ToFollowingSiteSearchesInline": "%s interních vyhledávání",
+ "TopX": "Top %s štítky",
+ "Transitions": "Transitions",
+ "TransitionsSubcategoryHelp1": "Transitions vám poskytuje přehled, který ukazuje akce, které vaši návštěvníci provedli přímo před a po zobrazení určité stránky. Tato stránka vysvětlí, jak přistupovat, porozumět a používat Transitions přehledy.",
+ "TransitionsSubcategoryHelp2": "Více podrobností",
+ "XOfAllPageviews": "%s ze všech zobrazení této stránky",
+ "XOutOfYVisits": "%1$s (z %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/cy.json b/plugins/Transitions/lang/cy.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/cy.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/da.json b/plugins/Transitions/lang/da.json
new file mode 100644
index 0000000000000000000000000000000000000000..a34b92f9f3af6dd41eeb58c516d604520e99d630
--- /dev/null
+++ b/plugins/Transitions/lang/da.json
@@ -0,0 +1,33 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s afvisninger",
+ "DirectEntries": "Direkte besøg",
+ "ErrorBack": "Gå tilbage til forrige handling",
+ "ExitsInline": "%s afslutninger",
+ "FeatureDescription": "Overgange giver dig en rapport over, hvad dine besøgende gjorde umiddelbart før og efter at have besøgt en given side. Denne side vil forklare, hvordan du tilgår forstår og anvender denne effektive rapport.",
+ "FromCampaigns": "Fra kampagner",
+ "FromPreviousPages": "Fra interne sider",
+ "FromPreviousPagesInline": "%s fra interne sider",
+ "FromPreviousSiteSearches": "Fra intern søgning",
+ "FromPreviousSiteSearchesInline": "%s fra interne søgninger",
+ "FromSearchEngines": "Fra søgemaskiner",
+ "FromSocialNetworks": "Fra sociale netværk",
+ "FromWebsites": "Fra hjemmesider",
+ "IncomingTraffic": "Indkommende trafik",
+ "LoopsInline": "%s side genindlæsninger",
+ "NoDataForAction": "Der er ingen data for %s",
+ "NoDataForActionDetails": "Enten havde handlingen ingen sidevisninger i perioden eller den er ugyldig.",
+ "OutgoingTraffic": "Udgående trafik",
+ "PageURLTransitions": "Side-URL-overgange",
+ "ShareOfAllPageviews": "Denne side havde %1$s sidevisninger (%2$s af alle sidevisninger)",
+ "ToFollowingPages": "Til interne sider",
+ "ToFollowingPagesInline": "%s til interne sider",
+ "ToFollowingSiteSearches": "Interne søgninger",
+ "ToFollowingSiteSearchesInline": "%s interne søgninger",
+ "Transitions": "Overgange",
+ "TransitionsSubcategoryHelp1": "Overgange giver dig en rapport over, hvad dine besøgende gjorde umiddelbart før og efter at have besøgt en given side. Denne side vil forklare, hvordan du tilgår forstår og anvender denne effektive rapport.",
+ "TransitionsSubcategoryHelp2": "Flere detaljer",
+ "XOfAllPageviews": "%s af alle visninger af denne side",
+ "XOutOfYVisits": "%1$s (ud af %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/de.json b/plugins/Transitions/lang/de.json
new file mode 100644
index 0000000000000000000000000000000000000000..a27bcda0df95611946c3894b53b4c304f3198a4b
--- /dev/null
+++ b/plugins/Transitions/lang/de.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Schon gewusst? Transitionen sind auch als Zeilenaktionen in folgenden Berichten verfügbar:",
+ "AvailableInOtherReports2": "Starten Sie Transitionen, in dem Sie das Icon (%s) klicken, nachdem Sie eine Zeile des Berichts markiert haben.",
+ "BouncesInline": "%s Absprünge",
+ "DirectEntries": "Direkte Einträge",
+ "ErrorBack": "Gehe zurück zur letzten Aktion",
+ "ExitsInline": "%s Ausstiege",
+ "FeatureDescription": "Transitionen ist ein Bericht, der zeigt, was Ihre Besucher direkt vor und nach dem Betrachten einer bestimmten Seite getan haben. Auf dieser Seite wird erklärt, wie Sie auf den leistungsstarken Bericht \"Transitionen\" zugreifen, ihn verstehen und verwenden können.",
+ "FromAIAssistants": "Von KI-Assistenten",
+ "FromCampaigns": "Von Kampagnen",
+ "FromPreviousPages": "Von internen Seiten",
+ "FromPreviousPagesInline": "%s von internen Seiten",
+ "FromPreviousSiteSearches": "Von der internen Suche",
+ "FromPreviousSiteSearchesInline": "%s von internen Suchen",
+ "FromSearchEngines": "Von Suchmaschinen",
+ "FromSocialNetworks": "Aus sozialen Netzwerken",
+ "FromWebsites": "Von Webseiten",
+ "IncomingTraffic": "Eingehender Traffic",
+ "LoopsInline": "%s Seite erneut geladen",
+ "NoDataForAction": "Keine Daten für %s verfügbar",
+ "NoDataForActionDetails": "Entweder hatte diese Aktion während des Zeitraums keine Seitenansichten oder sie ist ungültig.",
+ "NumDownloads": "%s Downloads",
+ "NumOutlinks": "%s ausgehende Verweise",
+ "NumPageviews": "%s Seitenansichten",
+ "OutgoingTraffic": "Ausgehender Traffic",
+ "PageURLTransitions": "Seiten URL Übergänge",
+ "PeriodNotAllowed": "Wählen Sie einen gültigen Zeitraum",
+ "PeriodNotAllowedDetails": "Versuchen Sie, für dieses Feature einen Zeitraum mit weniger Tagen auszuwählen.",
+ "PluginDescription": "Zeigt vorherige und nachfolgende Aktionen für jede Seiten-URL in einem neuen Bericht \"Transitionen\" an, der in den \"Aktionen\"-Berichten über ein neues Icon verfügbar ist.",
+ "ShareOfAllPageviews": "Diese Seite wurde %1$s mal angesehen (%2$s aller Seitenansichten)",
+ "ToFollowingPages": "Auf interne Seiten",
+ "ToFollowingPagesInline": "%s zu internen Seiten",
+ "ToFollowingSiteSearches": "Interne Suchen",
+ "ToFollowingSiteSearchesInline": "%s interne Suchen",
+ "TopX": "Top %s Beschriftungen",
+ "Transitions": "Transitionen",
+ "TransitionsSubcategoryHelp1": "Transitionen ist ein Bericht, der zeigt, was Ihre Besucher direkt vor und nach dem Betrachten einer bestimmten Seite getan haben. Diese Seite erklärt, wie Sie auf den leistungsstarken Bericht \"Transitionen\" zugreifen, ihn verstehen und verwenden können.",
+ "TransitionsSubcategoryHelp2": "Mehr Details",
+ "XOfAllPageviews": "%s von allen Seitenansichten",
+ "XOutOfYVisits": "%1$s (von insgesamt %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/dv.json b/plugins/Transitions/lang/dv.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/dv.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/el.json b/plugins/Transitions/lang/el.json
new file mode 100644
index 0000000000000000000000000000000000000000..3ea18a9cc0f396c63853288376eb38df21de750d
--- /dev/null
+++ b/plugins/Transitions/lang/el.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Το γνωρίζετε; Οι μεταβάσεις είναι πλέον διαθέσιμες ως ενέργεια γραμμής στις παρακάτω αναφορές:",
+ "AvailableInOtherReports2": "Εκτέλεση των μεταβάσεων με κλικ στο εικονίδιό τους (%s) μετά την αιώρηση πάνω από μια γραμμή σε οποιαδήποτε από τις αναφορές.",
+ "BouncesInline": "%s αναπηδήσεις",
+ "DirectEntries": "Άμεσες εισαγωγές",
+ "ErrorBack": "Μετάβαση στην προηγούμενη ενέργεια",
+ "ExitsInline": "%s έξοδοι",
+ "FeatureDescription": "Οι μεταβάσεις είναι μια αναφορά που εμφανίζει τι έκαναν οι επισκέπτες σας αμέσως πριν και μετά που είδαν μια συγκεκριμένη σελίδα. Η σελίδα αυτή εξηγεί πως μπορείτε να προσπελάσετε, καταλάβετε και να χρησιμοποιήσετε την ισχυρή αναφορά \"Μεταβάσεων\".",
+ "FromAIAssistants": "Από βοηθούς ΤΝ",
+ "FromCampaigns": "Από εκστρατείες",
+ "FromPreviousPages": "Από εσωτερικές σελίδες",
+ "FromPreviousPagesInline": "%s από εσωτερικές σελίδες",
+ "FromPreviousSiteSearches": "Από εσωτερική αναζήτηση",
+ "FromPreviousSiteSearchesInline": "%s από εσωτερικές αναζητήσεις",
+ "FromSearchEngines": "Από μηχανές αναζήτησης",
+ "FromSocialNetworks": "Από κοινωνικά δίκτυα",
+ "FromWebsites": "Από ιστοσελίδες",
+ "IncomingTraffic": "Εισερχόμενη κίνηση",
+ "LoopsInline": "%s ανανεώσεις σελίδας",
+ "NoDataForAction": "Χωρίς δεδομένα για %s",
+ "NoDataForActionDetails": "Είτε η ενέργεια δεν είχε προβολές σελίδας (pageviews) κατά τη διάρκεια της περιόδου ή είναι άκυρη.",
+ "NumDownloads": "%s λήψεις",
+ "NumOutlinks": "%s σύνδεσμοι προς εξωτερικό",
+ "NumPageviews": "%s προβολές σελίδων",
+ "OutgoingTraffic": "Εξερχόμενη κίνηση",
+ "PageURLTransitions": "Διευθύνσεις URL μεταβάσεων σελίδων",
+ "PeriodNotAllowed": "Επιλογή έγκυρης χρονικής περιόδου",
+ "PeriodNotAllowedDetails": "Δοκιμάστε να επιλέξτε μια περίοδο με λιγότερες ημέρες για αυτό το χαρακτηριστικό.",
+ "PluginDescription": "Αναφέρει προηγούμενες και επόμενες ενέργειες για κάθε σελίδα σε νέα αναφορά \"Μεταβάσεων\", διαθέσιμη στις αναφορές \"Ενεργειών\" από ένα νέο εικονίδιο.",
+ "ShareOfAllPageviews": "Η σελίδα είχε %1$s αναγνώσεις (%2$s από όλες τις αναγνώσεις σελίδων)",
+ "ToFollowingPages": "Προς εσωτερικές σελίδες",
+ "ToFollowingPagesInline": "%s προς εσωτερικές σελίδες",
+ "ToFollowingSiteSearches": "Εσωτερικές αναζητήσεις",
+ "ToFollowingSiteSearchesInline": "%s εσωτερικές αναζητήσεις",
+ "TopX": "Οι κορυφαίες %s ετικέτες",
+ "Transitions": "Μεταβάσεις",
+ "TransitionsSubcategoryHelp1": "Οι μεταβάσεις είναι μια αναφορά που εμφανίζει τι έκαναν οι επισκέπτες σας αμέσως πριν και μετά που είδαν μια συγκεκριμένη σελίδα. Η σελίδα αυτή εξηγεί πως να προσπελάσετε, κατανοήσετε και χρησιμοποιήσετε την δυνατή αναφορά \"Μεταβάσεων\".",
+ "TransitionsSubcategoryHelp2": "Περισσότερες λεπτομέρειες",
+ "XOfAllPageviews": "%s από όλες τις προβολές αυτής της σελίδας",
+ "XOutOfYVisits": "%1$s (από %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/en.json b/plugins/Transitions/lang/en.json
new file mode 100644
index 0000000000000000000000000000000000000000..bda79ebb71cbcae96b408bb1f318e8c1fe1dd097
--- /dev/null
+++ b/plugins/Transitions/lang/en.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s bounces",
+ "DirectEntries": "Direct entries",
+ "Transitions": "Transitions",
+ "ErrorBack": "Go back to the previous action",
+ "ExitsInline": "%s exits",
+ "NumPageviews": "%s pageviews",
+ "NumDownloads": "%s downloads",
+ "NumOutlinks": "%s outlinks",
+ "TopX": "Top %s labels",
+ "FeatureDescription": "Transitions is a report showing the things your visitors did directly before and after viewing a given page. This page explains how to access, understand, and use the powerful \"Transitions\" report.",
+ "AvailableInOtherReports": "Did you know? Transitions are also available as a row action in the following reports:",
+ "AvailableInOtherReports2": "Launch transitions by clicking their icon (%s) after hovering a row in any of these reports.",
+ "FromCampaigns": "From campaigns",
+ "FromPreviousPages": "From internal pages",
+ "FromPreviousPagesInline": "%s from internal pages",
+ "FromPreviousSiteSearches": "From internal search",
+ "FromPreviousSiteSearchesInline": "%s from internal searches",
+ "FromSearchEngines": "From search engines",
+ "FromSocialNetworks": "From social networks",
+ "FromAIAssistants": "From AI assistants",
+ "FromWebsites": "From websites",
+ "IncomingTraffic": "Incoming traffic",
+ "LoopsInline": "%s page reloads",
+ "NoDataForAction": "No data for %s",
+ "NoDataForActionDetails": "Either the action had no pageviews during the period, or it is invalid.",
+ "PeriodNotAllowed": "Select a valid time period",
+ "PeriodNotAllowedDetails": "Try selecting a period with fewer days for this feature.",
+ "OutgoingTraffic": "Outgoing traffic",
+ "PluginDescription": "Reports previous and following actions for each page URL in a new \"Transitions\" report, available in the \"Actions\" reports via a new icon.",
+ "ShareOfAllPageviews": "This page had %1$s pageviews (%2$s of all pageviews)",
+ "ToFollowingPages": "To internal pages",
+ "ToFollowingPagesInline": "%s to internal pages",
+ "ToFollowingSiteSearches": "Internal searches",
+ "ToFollowingSiteSearchesInline": "%s internal searches",
+ "XOfAllPageviews": "%s of all views of this page",
+ "XOutOfYVisits": "%1$s (out of %2$s)",
+ "PageURLTransitions": "Page URL transitions",
+ "TransitionsSubcategoryHelp1": "Transitions is a report showing the things your visitors did directly before and after viewing a given page. This page explains how to access, understand, and use the powerful \"Transitions\" report.",
+ "TransitionsSubcategoryHelp2": "More details"
+ }
+}
diff --git a/plugins/Transitions/lang/eo.json b/plugins/Transitions/lang/eo.json
new file mode 100644
index 0000000000000000000000000000000000000000..317bb6928f153217c3a56ae7236da131478c4796
--- /dev/null
+++ b/plugins/Transitions/lang/eo.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "Pli detala"
+ }
+}
diff --git a/plugins/Transitions/lang/es-ar.json b/plugins/Transitions/lang/es-ar.json
new file mode 100644
index 0000000000000000000000000000000000000000..02b36770ab6a7f25ef33fb0dbeb9d89876a06c84
--- /dev/null
+++ b/plugins/Transitions/lang/es-ar.json
@@ -0,0 +1,40 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "¿Lo sabías? Las transiciones también están disponibles como una acción de fila en los siguientes informes:",
+ "AvailableInOtherReports2": "Simplemente pasá el mouse sobre una fila de cualquiera de estos informes y hacé clic en el ícono de transición %s para ejecutarlo.",
+ "BouncesInline": "%s rebotes",
+ "DirectEntries": "Entradas directas",
+ "ErrorBack": "Volver a la acción anterior",
+ "ExitsInline": "%s salidas",
+ "FeatureDescription": "Las transiciones te dan un informe que muestra las cosas que tus visitantes hicieron directamente antes y después de ver cierta página. Esta página explicará cómo acceder, entender y usar el poderoso informe de Transiciones.",
+ "FromCampaigns": "Desde campañas",
+ "FromPreviousPages": "Desde páginas internas",
+ "FromPreviousPagesInline": "%s desde páginas internas",
+ "FromPreviousSiteSearches": "Desde búsquedas internas",
+ "FromPreviousSiteSearchesInline": "%s desde búsquedas internas",
+ "FromSearchEngines": "Desde motores de búsqueda",
+ "FromSocialNetworks": "Desde redes sociales",
+ "FromWebsites": "Desde sitios web",
+ "IncomingTraffic": "Tráfico entrante",
+ "LoopsInline": "%s recargas de página",
+ "NoDataForAction": "No hay datos para %s",
+ "NoDataForActionDetails": "O la acción no tuvo vistas de páginas durante el período o no es válida.",
+ "NumDownloads": "%s descargas",
+ "NumOutlinks": "%s enlaces externos",
+ "NumPageviews": "%s vistas",
+ "OutgoingTraffic": "Tráfico saliente",
+ "PageURLTransitions": "Dirección web de la página de transiciones",
+ "PluginDescription": "Acciones siguientes y anteriores del informe para cada dirección web de página en un nuevo informe de Transiciones, disponible en lis informes de Acciones a través de un nuevo ícono.",
+ "ShareOfAllPageviews": "Esta página tuvo %1$s vistas de página (%2$s de todas las vistas de páginas)",
+ "ToFollowingPages": "A páginas internas",
+ "ToFollowingPagesInline": "%s a páginas internas",
+ "ToFollowingSiteSearches": "Búsquedas internas",
+ "ToFollowingSiteSearchesInline": "%s búsquedas internas",
+ "TopX": "Principales %s etiquetas",
+ "Transitions": "Transiciones",
+ "TransitionsSubcategoryHelp1": "Las transiciones te dan un informe que muestra las cosas que tus visitantes hicieron directamente antes y después de ver cierta página. Esta página explicará cómo acceder, entender y usar el poderoso informe de Transiciones.",
+ "TransitionsSubcategoryHelp2": "Más detalles",
+ "XOfAllPageviews": "%s de todas las vistas de esta página",
+ "XOutOfYVisits": "%1$s (de %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/es.json b/plugins/Transitions/lang/es.json
new file mode 100644
index 0000000000000000000000000000000000000000..5cb8b2fb2255ecb3b2dcf8d5c18ca46ac1feec5c
--- /dev/null
+++ b/plugins/Transitions/lang/es.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "¿Sabías? Las transiciones también están disponibles como una acción de fila en los siguientes informes:",
+ "AvailableInOtherReports2": "Inicie las transiciones haciendo clic en su icono (%s) después de pasar el cursor sobre una fila en cualquiera de estos informes.",
+ "BouncesInline": "%s rebotes",
+ "DirectEntries": "Entradas directas",
+ "ErrorBack": "Regresar a la acción anterior",
+ "ExitsInline": "%s salidas",
+ "FeatureDescription": "Transiciones es un informe que muestra las cosas que sus visitantes hicieron directamente antes y después de ver una página determinada. Esta página explica cómo acceder, comprender y utilizar el potente informe \"Transiciones\".",
+ "FromCampaigns": "De campañas",
+ "FromPreviousPages": "De páginas internas",
+ "FromPreviousPagesInline": "%s desde páginas internas",
+ "FromPreviousSiteSearches": "De la búsqueda interna",
+ "FromPreviousSiteSearchesInline": "%s desde búsquedas internas",
+ "FromSearchEngines": "De los motores de búsqueda",
+ "FromSocialNetworks": "De las redes sociales",
+ "FromWebsites": "De sitios web",
+ "IncomingTraffic": "Tráfico entrante",
+ "LoopsInline": "%s recargas de página",
+ "NoDataForAction": "Sin datos para %s",
+ "NoDataForActionDetails": "O la acción no tuvo vistas de páginas durante el período o es inválida.",
+ "NumDownloads": "%s descargas",
+ "NumOutlinks": "%s enlaces salientes",
+ "NumPageviews": "%s páginas visitadas",
+ "OutgoingTraffic": "Tráfico saliente",
+ "PageURLTransitions": "Transiciones de la URL de la página",
+ "PeriodNotAllowed": "Seleccione un período de tiempo válido",
+ "PeriodNotAllowedDetails": "Intente seleccionar un período con menos días para esta función.",
+ "PluginDescription": "Informa sobre las acciones anteriores y siguientes para cada URL de página en un nuevo informe de \"Transiciones\", disponible en los informes de \"Acciones\" a través de un nuevo icono.",
+ "ShareOfAllPageviews": "Esta página tuvo %1$s vistas de página (%2$s de todas las vistas de páginas)",
+ "ToFollowingPages": "A páginas internas",
+ "ToFollowingPagesInline": "%s a páginas internas",
+ "ToFollowingSiteSearches": "Búsquedas internas",
+ "ToFollowingSiteSearchesInline": "%s búsquedas internas",
+ "TopX": "Etiquetas %s superiores",
+ "Transitions": "Transiciones",
+ "TransitionsSubcategoryHelp1": "Transiciones es un informe que muestra las cosas que sus visitantes hicieron directamente antes y después de ver una página determinada. Esta página explica cómo acceder, comprender y utilizar el potente informe \"Transiciones\".",
+ "TransitionsSubcategoryHelp2": "Más detalles",
+ "XOfAllPageviews": "%s de todas las vistas de esta página",
+ "XOutOfYVisits": "%1$s (de %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/et.json b/plugins/Transitions/lang/et.json
new file mode 100644
index 0000000000000000000000000000000000000000..11fea92e7e5e890df341ab8b4d142ad97ffad08d
--- /dev/null
+++ b/plugins/Transitions/lang/et.json
@@ -0,0 +1,27 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s põrget",
+ "DirectEntries": "Otse sisenemisi",
+ "ErrorBack": "Mine tagasi eelmisele tegevusele",
+ "ExitsInline": "%s väljumist",
+ "FromCampaigns": "Tuldud kampaaniad",
+ "FromPreviousPages": "Tuldud sisemised lehed",
+ "FromPreviousPagesInline": "%s tuli sisemistelt lehtedelt",
+ "FromPreviousSiteSearches": "Tuldud sisemised otsingud",
+ "FromPreviousSiteSearchesInline": "%s tuli sisemisest otsingust",
+ "FromSearchEngines": "Tuldud otsingumootorid",
+ "FromWebsites": "Tuldud veebilehed",
+ "IncomingTraffic": "Sisenev liiklus",
+ "LoopsInline": "%s lehe korduvat laadimist",
+ "NoDataForAction": "Tegevuse %s kohta puuduvad andmed",
+ "OutgoingTraffic": "Väljuv liiklus",
+ "ShareOfAllPageviews": "See leht sai %1$s vaatamist (%2$s kõigist vaatamistest)",
+ "ToFollowingPages": "Sisemistele lehtedele",
+ "ToFollowingPagesInline": "%s läks sisemistele lehtedele",
+ "ToFollowingSiteSearches": "Sisemised otsingud",
+ "ToFollowingSiteSearchesInline": "%s sisemist otsingut",
+ "TransitionsSubcategoryHelp2": "Rohkem detaile",
+ "XOfAllPageviews": "%s kõigist antud lehe vaatamistest",
+ "XOutOfYVisits": "%1$s (%2$s kõigist)"
+ }
+}
diff --git a/plugins/Transitions/lang/eu.json b/plugins/Transitions/lang/eu.json
new file mode 100644
index 0000000000000000000000000000000000000000..0d95a0197b2901695f9479812ebe6580ef2c9abf
--- /dev/null
+++ b/plugins/Transitions/lang/eu.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Bazenekien? Trantsizioak errenkadako ekintza bezala ere erabilgarri daude ondorengo txostenetan:",
+ "AvailableInOtherReports2": "Abiarazi trantsizioak sagua txosten horietako errenkada baten gainean jarri eta bere ikonoan (%s) klik eginez.",
+ "BouncesInline": "%s errebote",
+ "DirectEntries": "Sarrera zuzenak",
+ "ErrorBack": "Itzuli aurreko ekintzara",
+ "ExitsInline": "%s irteera",
+ "FeatureDescription": "Trantsizioak txostenean ikus dezakezu zer egin duten bisitariek orri jakin bat ikusi aurretik eta ondoren. Orri honetan azaltzen da nola atzitu, ulertu eta erabili \"Trantsizioak\" txosten ahaltsua.",
+ "FromCampaigns": "Kanpainetatik",
+ "FromPreviousPages": "Barne-orrietatik",
+ "FromPreviousPagesInline": "%s barne-orrietatik",
+ "FromPreviousSiteSearches": "Barne bilaketatik",
+ "FromPreviousSiteSearchesInline": "%s barne bilaketetatik",
+ "FromSearchEngines": "Bilatzaileetatik",
+ "FromSocialNetworks": "Sare sozialetatik",
+ "FromWebsites": "Webguneetatik",
+ "IncomingTraffic": "Sarrerako trafikoa",
+ "LoopsInline": "%s orri birkargatze",
+ "NoDataForAction": "Daturik ez %s(r)entzat",
+ "NoDataForActionDetails": "Ekintzak ez du orri-ikustaldirik tarte horretan edo baliogabea da.",
+ "NumDownloads": "%s deskarga",
+ "NumOutlinks": "%s kanpo esteka",
+ "NumPageviews": "%s orri-ikustaldi",
+ "OutgoingTraffic": "Kanporako trafikoa",
+ "PageURLTransitions": "Orrien URLen trantsizioak",
+ "PeriodNotAllowed": "Hautatu baliozko denbora-tartea",
+ "PeriodNotAllowedDetails": "Saiatu egun gutxiago dituen tartea hautatzen ezaugarri honentzat.",
+ "PluginDescription": "Aurreko eta ondorengo ekintzen berri ematen du orri URL bakoitzarentzat, \"Trantsizioak\" txosten berrian, \"Ekintzak\" txostenetan ere erabilgarri dago ikono berria erabiliz.",
+ "ShareOfAllPageviews": "Orri honek %1$s ikustaldi izan ditu (orri-ikustaldi guztien %2$s)",
+ "ToFollowingPages": "Barne orrietara",
+ "ToFollowingPagesInline": "%s barne orrietara",
+ "ToFollowingSiteSearches": "Barne bilaketak",
+ "ToFollowingSiteSearchesInline": "%s barne bilaketa",
+ "TopX": "%s etiketa gorenak",
+ "Transitions": "Trantsizioak",
+ "TransitionsSubcategoryHelp1": "Trantsizioak txostenean ikus dezakezu zer egin duten bisitariek orri jakin bat ikusi aurretik eta ondoren. Orri honetan azaltzen da nola atzitu, ulertu eta erabili \"Trantsizioak\" txosten ahaltsua.",
+ "TransitionsSubcategoryHelp2": "Xehetasun gehiago",
+ "XOfAllPageviews": "Orri honen ikustaldi guztien %s",
+ "XOutOfYVisits": "%1$s (guztira %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/fa.json b/plugins/Transitions/lang/fa.json
new file mode 100644
index 0000000000000000000000000000000000000000..7750f20b78bb6e37da78fc0e091fdcc672c62bf4
--- /dev/null
+++ b/plugins/Transitions/lang/fa.json
@@ -0,0 +1,27 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s پس زده",
+ "DirectEntries": "ورودی های مستقیم",
+ "ErrorBack": "به فعالیت قبلی برگرد",
+ "ExitsInline": "%s خروج",
+ "FromCampaigns": "از کمپین",
+ "FromPreviousPages": "از صفحه های داخلی",
+ "FromPreviousPagesInline": "%s از صفحه های داخلی",
+ "FromPreviousSiteSearches": "از جستجوی داخلی",
+ "FromPreviousSiteSearchesInline": "%s از جستجوهای داخلی",
+ "FromSearchEngines": "از موتورهای جستجو",
+ "FromWebsites": "از وبسایت ها",
+ "IncomingTraffic": "ترافیک ورودی",
+ "LoopsInline": "%s بارگذاری مجدد صفحه",
+ "NoDataForAction": "هیچ داده ای وجود ندارد برای %s",
+ "OutgoingTraffic": "ترافیک خروجی",
+ "ShareOfAllPageviews": "این صفحه %1$s بازدیدازصفحه دارد(%2$s از تمام بازدیدازصفحه ها)",
+ "ToFollowingPages": "به صفحه های داخلی",
+ "ToFollowingPagesInline": "%s به صفحه های داخلی",
+ "ToFollowingSiteSearches": "جستجوهای داخلی",
+ "ToFollowingSiteSearchesInline": "%s جستجوهای داخلی",
+ "TransitionsSubcategoryHelp2": "جزئیات بیشتر",
+ "XOfAllPageviews": "%s از همه ی بازدیدهای این صفحه",
+ "XOutOfYVisits": "%1$s (از %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/fi.json b/plugins/Transitions/lang/fi.json
new file mode 100644
index 0000000000000000000000000000000000000000..ea2478794d4ac3093b5380f535bc5f8b246bc3aa
--- /dev/null
+++ b/plugins/Transitions/lang/fi.json
@@ -0,0 +1,34 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s käväisyä",
+ "DirectEntries": "Suorat merkinnät",
+ "ErrorBack": "Palaa takaisin edelliseen toimintoon",
+ "ExitsInline": "%s poistumista",
+ "FromCampaigns": "Kampanjoista",
+ "FromPreviousPages": "Sisäisiltä sivuilta",
+ "FromPreviousPagesInline": "%s sisäisiltä sivuilta",
+ "FromPreviousSiteSearches": "Sisäisestä hausta",
+ "FromPreviousSiteSearchesInline": "%s sisäisestä hausta",
+ "FromSearchEngines": "Hakukoneista",
+ "FromSocialNetworks": "Sosiaalisista verkostoista",
+ "FromWebsites": "Verkkosivuilta",
+ "IncomingTraffic": "Sisääntuleva liikenne",
+ "LoopsInline": "%s sivujen uudelleenlatausta",
+ "NoDataForAction": "%s:lle ei ole tietoja",
+ "NoDataForActionDetails": "Joko toiminnolle ei ole tietoja aikavälille tai toiminto on virheellinen.",
+ "NumDownloads": "%s tiedoston latausta",
+ "NumOutlinks": "%s lähtevää linkki",
+ "NumPageviews": "%s sivunavausta",
+ "OutgoingTraffic": "Lähtevä liikenne",
+ "PluginDescription": "Raportoi edellisen ja seuraavat toiminnot jokaiselle sivulle. Saatavilla \"Toiminnot\"-raportissa uuden ikonin takaa.",
+ "ShareOfAllPageviews": "Tällä sivulla on %1$s sivunavausta (%2$s kaikista)",
+ "ToFollowingPages": "Sisäisille sivuille",
+ "ToFollowingPagesInline": "%s sisäisille sivuille",
+ "ToFollowingSiteSearches": "Sisäisiä hakuja",
+ "ToFollowingSiteSearchesInline": "%s sisäistä hakua",
+ "Transitions": "Siirtymät",
+ "TransitionsSubcategoryHelp2": "Lisätietoja",
+ "XOfAllPageviews": "%s kaikista tämän sivun katseluista",
+ "XOutOfYVisits": "%1$s (%2$s:stä)"
+ }
+}
diff --git a/plugins/Transitions/lang/fr.json b/plugins/Transitions/lang/fr.json
new file mode 100644
index 0000000000000000000000000000000000000000..f152713bc8e861ef6d792b17410f01dcb43eedf2
--- /dev/null
+++ b/plugins/Transitions/lang/fr.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Le saviez-vous ? Les transitions sont également disponibles sous forme d'action en ligne dans les rapports suivants :",
+ "AvailableInOtherReports2": "Lancez les transitions en cliquant sur leur icône (%s) après avoir survolé une ligne dans l'un de ces rapports.",
+ "BouncesInline": "%s rebonds",
+ "DirectEntries": "Entrées directes",
+ "ErrorBack": "Retour à l'action précédente",
+ "ExitsInline": "%s sorties",
+ "FeatureDescription": "Les transitions sont un rapport montrant ce que vos visiteurs ont fait directement avant et après avoir consulté une page donnée. Cette page explique comment accéder, comprendre et utiliser le puissant rapport \"Transitions\".",
+ "FromAIAssistants": "Depuis les assistants IA",
+ "FromCampaigns": "Depuis des campagnes",
+ "FromPreviousPages": "Depuis des pages internes",
+ "FromPreviousPagesInline": "%s depuis des pages internes",
+ "FromPreviousSiteSearches": "Depuis des recherches internes",
+ "FromPreviousSiteSearchesInline": "%s depuis des recherches internes",
+ "FromSearchEngines": "Depuis les moteurs de recherche",
+ "FromSocialNetworks": "Depuis des réseaux sociaux",
+ "FromWebsites": "Depuis des sites web",
+ "IncomingTraffic": "Trafic entrant",
+ "LoopsInline": "%s rechargements de pages",
+ "NoDataForAction": "Aucune données pour %s",
+ "NoDataForActionDetails": "Soit l'action n'a généré aucune vue de page pendant la période, soit elle est invalide.",
+ "NumDownloads": "%s téléchargements",
+ "NumOutlinks": "%s liens sortant",
+ "NumPageviews": "%s pages vues",
+ "OutgoingTraffic": "Trafic sortant",
+ "PageURLTransitions": "Transitions d'URL de page",
+ "PeriodNotAllowed": "Sélectionnez une période valide",
+ "PeriodNotAllowedDetails": "Essayez de sélectionner une période plus courte pour cette fonctionnalité.",
+ "PluginDescription": "Rapporte les actions précédentes et suivantes pour chacune des URL's de page dans un nouveau rapport de Transitions, disponible depuis le rapports des Actions via un nouvel icône.",
+ "ShareOfAllPageviews": "Cette page a eu %1$s affichages (%2$s de tous les affichages)",
+ "ToFollowingPages": "Vers des pages internes",
+ "ToFollowingPagesInline": "%s vers des pages internes",
+ "ToFollowingSiteSearches": "Recherches internes",
+ "ToFollowingSiteSearchesInline": "%s recherches internes",
+ "TopX": "Top %s des étiquettes",
+ "Transitions": "Transitions",
+ "TransitionsSubcategoryHelp1": "Le rapport « Transitions » présente les actions de vos visiteurs juste avant et juste après la consultation d'une page. Cette page explique comment accéder à ce rapport, le comprendre et l'utiliser.",
+ "TransitionsSubcategoryHelp2": "Plus de détails",
+ "XOfAllPageviews": "%s de toutes les pages vues",
+ "XOutOfYVisits": "%1$s (de %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/ga.json b/plugins/Transitions/lang/ga.json
new file mode 100644
index 0000000000000000000000000000000000000000..4d0cd0022c2be72cb627c523ecd258bdd1733548
--- /dev/null
+++ b/plugins/Transitions/lang/ga.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "An raibh a fhios agat? Tá aistrithe ar fáil freisin mar ghníomh as a chéile sna tuarascálacha seo a leanas:",
+ "AvailableInOtherReports2": "Seoladh trasdulta trí chliceáil ar a n-íocón (%s) tar éis duit sraith a chur in aon cheann de na tuarascálacha seo.",
+ "BouncesInline": "Preab %s",
+ "DirectEntries": "Iontrálacha díreacha",
+ "ErrorBack": "Téigh ar ais go dtí an gníomh roimhe seo",
+ "ExitsInline": "Scoir %s",
+ "FeatureDescription": "Is éard is Transitions ann ná tuairisc a thaispeánann na rudaí a rinne do chuairteoirí go díreach roimh agus tar éis féachaint ar leathanach áirithe. Míníonn an leathanach seo conas an tuarascáil chumhachtach \"Aistrithe\" a rochtain, a thuiscint agus a úsáid.",
+ "FromAIAssistants": "Ó chúntóirí AI",
+ "FromCampaigns": "Ó feachtais",
+ "FromPreviousPages": "Ó leathanaigh inmheánacha",
+ "FromPreviousPagesInline": "%s ó leathanaigh inmheánacha",
+ "FromPreviousSiteSearches": "Ó chuardach inmheánach",
+ "FromPreviousSiteSearchesInline": "%s ó chuardaigh inmheánacha",
+ "FromSearchEngines": "Ó innill chuardaigh",
+ "FromSocialNetworks": "Ó líonraí sóisialta",
+ "FromWebsites": "Ó láithreáin ghréasáin",
+ "IncomingTraffic": "Trácht ag teacht isteach",
+ "LoopsInline": "%s leathanach á hathluchtú",
+ "NoDataForAction": "%s leathanach á athluchtú",
+ "NoDataForActionDetails": "Ní raibh aon amharc leathanaigh ar an ngníomh le linn na tréimhse, nó tá sé neamhbhailí.",
+ "NumDownloads": "%s íosluchtú",
+ "NumOutlinks": "%s imlíne",
+ "NumPageviews": "%s amharc leathanaigh",
+ "OutgoingTraffic": "Trácht ag dul as oifig",
+ "PageURLTransitions": "Aistrithe URL an leathanaigh",
+ "PeriodNotAllowed": "Roghnaigh tréimhse ama bhailí",
+ "PeriodNotAllowedDetails": "Bain triail as tréimhse a roghnú le níos lú laethanta don ghné seo.",
+ "PluginDescription": "Tuairiscíonn gníomhartha roimhe seo agus ina dhiaidh sin do gach URL leathanaigh i dtuarascáil nua \"Aistrithe\", atá ar fáil sna tuarascálacha \"Gníomhartha\" trí dheilbhín nua.",
+ "ShareOfAllPageviews": "Bhí %1$s amharc leathanaigh ar an leathanach seo (%2$s de gach amharc leathanaigh)",
+ "ToFollowingPages": "Go leathanaigh inmheánacha",
+ "ToFollowingPagesInline": "%s go leathanaigh inmheánacha",
+ "ToFollowingSiteSearches": "Cuardach inmheánach",
+ "ToFollowingSiteSearchesInline": "%s cuardaigh inmheánacha",
+ "TopX": "Lipéid %s is fearr",
+ "Transitions": "Aistrithe",
+ "TransitionsSubcategoryHelp1": "Is éard is Transitions ann ná tuairisc a thaispeánann na rudaí a rinne do chuairteoirí go díreach roimh agus tar éis féachaint ar leathanach áirithe. Míníonn an leathanach seo conas an tuarascáil chumhachtach \"Aistrithe\" a rochtain, a thuiscint agus a úsáid.",
+ "TransitionsSubcategoryHelp2": "Tuilleadh sonraí",
+ "XOfAllPageviews": "%s de gach amharc ar an leathanach seo",
+ "XOutOfYVisits": "%1$s (as %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/gl.json b/plugins/Transitions/lang/gl.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/gl.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/gu.json b/plugins/Transitions/lang/gu.json
new file mode 100644
index 0000000000000000000000000000000000000000..c22c61672e36d409c3ee04bfc439a5e97e0e2fc1
--- /dev/null
+++ b/plugins/Transitions/lang/gu.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "તમને ખબર છે? નીચેના અહેવાલોમાં સંક્રમણો પંક્તિ ક્રિયા તરીકે પણ ઉપલબ્ધ છે:",
+ "AvailableInOtherReports2": "આમાંના કોઈપણ અહેવાલમાં એક પંક્તિને હોવર કર્યા પછી તેમના ચિહ્નો(%s) પર ક્લિક કરીને સંક્રમણ શરૂ કરો.",
+ "BouncesInline": "%s બાઉન્સ",
+ "DirectEntries": "સીધી એન્ટ્રીઓ",
+ "ErrorBack": "પાછલી ક્રિયા પર પાછા જાઓ",
+ "ExitsInline": "%s બહાર નીકળે છે",
+ "FeatureDescription": "ટ્રાન્ઝિશન એ એક રિપોર્ટ છે જે દર્શાવે છે કે તમારા મુલાકાતીઓએ આપેલ પૃષ્ઠ જોયા પહેલા અને પછી સીધું કર્યું. આ પૃષ્ઠ સમજાવે છે કે કેવી રીતે શક્તિશાળી \"સંક્રમણ\" અહેવાલને ઍક્સેસ કરવો, સમજવું અને તેનો ઉપયોગ કરવો.",
+ "FromCampaigns": "ઝુંબેશમાંથી",
+ "FromPreviousPages": "આંતરિક પૃષ્ઠોમાંથી",
+ "FromPreviousPagesInline": "આંતરિક પૃષ્ઠોમાંથી %s",
+ "FromPreviousSiteSearches": "આંતરિક શોધમાંથી",
+ "FromPreviousSiteSearchesInline": "આંતરિક શોધમાંથી %s",
+ "FromSearchEngines": "સર્ચ એન્જિનમાંથી",
+ "FromSocialNetworks": "સામાજિક નેટવર્ક્સમાંથી",
+ "FromWebsites": "વેબસાઇટ્સ પરથી",
+ "IncomingTraffic": "ઇનકમિંગ ટ્રાફિક",
+ "LoopsInline": "%s પૃષ્ઠ ફરીથી લોડ થાય છે",
+ "NoDataForAction": "%s માટે કોઈ ડેટા નથી",
+ "NoDataForActionDetails": "કાં તો આ સમયગાળા દરમિયાન ક્રિયામાં કોઈ પેજવ્યૂ નહોતા અથવા તે અમાન્ય છે.",
+ "NumDownloads": "%s ડાઉનલોડ્સ",
+ "NumOutlinks": "%s આઉટલિંક",
+ "NumPageviews": "%s પૃષ્ઠ દૃશ્યો",
+ "OutgoingTraffic": "આઉટગોઇંગ ટ્રાફિક",
+ "PageURLTransitions": "પૃષ્ઠ URL સંક્રમણો",
+ "PeriodNotAllowed": "માન્ય સમયગાળો પસંદ કરો",
+ "PeriodNotAllowedDetails": "આ સુવિધા માટે ઓછા દિવસોનો સમયગાળો પસંદ કરવાનો પ્રયાસ કરો.",
+ "PluginDescription": "નવી \"સંક્રમણ\" રિપોર્ટમાં દરેક પૃષ્ઠ URL માટે અગાઉની અને નીચેની ક્રિયાઓની જાણ કરે છે, જે નવા આઇકન દ્વારા \"ક્રિયાઓ\" રિપોર્ટ્સમાં ઉપલબ્ધ છે.",
+ "ShareOfAllPageviews": "આ પૃષ્ઠ પર %1$s પૃષ્ઠ દૃશ્યો હતા (તમામ પૃષ્ઠ દૃશ્યોમાંથી %2$s)",
+ "ToFollowingPages": "આંતરિક પૃષ્ઠો પર",
+ "ToFollowingPagesInline": "આંતરિક પૃષ્ઠો પર %s",
+ "ToFollowingSiteSearches": "આંતરિક શોધો",
+ "ToFollowingSiteSearchesInline": "%s આંતરિક શોધ",
+ "TopX": "ટોચના %s લેબલ્સ",
+ "Transitions": "સંક્રમણો",
+ "TransitionsSubcategoryHelp1": "ટ્રાન્ઝિશન એ એક રિપોર્ટ છે જે દર્શાવે છે કે તમારા મુલાકાતીઓએ આપેલ પૃષ્ઠ જોયા પહેલા અને પછી સીધું કર્યું. આ પૃષ્ઠ સમજાવે છે કે કેવી રીતે શક્તિશાળી \"સંક્રમણ\" અહેવાલને ઍક્સેસ કરવો, સમજવું અને તેનો ઉપયોગ કરવો.",
+ "TransitionsSubcategoryHelp2": "વધુ વિગતો",
+ "XOfAllPageviews": "આ પૃષ્ઠના તમામ દૃશ્યોના %s",
+ "XOutOfYVisits": "%1$s (%2$s માંથી)"
+ }
+}
diff --git a/plugins/Transitions/lang/he.json b/plugins/Transitions/lang/he.json
new file mode 100644
index 0000000000000000000000000000000000000000..f85d7159289002d055e20f4b142fb4b35c6bf5aa
--- /dev/null
+++ b/plugins/Transitions/lang/he.json
@@ -0,0 +1,6 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s קפיצות",
+ "TransitionsSubcategoryHelp2": "פרטים נוספים"
+ }
+}
diff --git a/plugins/Transitions/lang/hi.json b/plugins/Transitions/lang/hi.json
new file mode 100644
index 0000000000000000000000000000000000000000..66cfa20e624b25865264a7664f46e101278e4897
--- /dev/null
+++ b/plugins/Transitions/lang/hi.json
@@ -0,0 +1,28 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s उछाल",
+ "DirectEntries": "सीधी प्रविष्टियां",
+ "ErrorBack": "पिछली कार्रवाई के लिए वापस जाओ",
+ "ExitsInline": "%s बाहर निकलता है",
+ "FromCampaigns": "अभियान से",
+ "FromPreviousPages": "आंतरिक पृष्ठों से",
+ "FromPreviousPagesInline": "%s आंतरिक पृष्ठों से",
+ "FromPreviousSiteSearches": "आंतरिक खोज से",
+ "FromPreviousSiteSearchesInline": "%s आंतरिक खोजों से",
+ "FromSearchEngines": "खोज इंजन से",
+ "FromWebsites": "वेबसाइटों से",
+ "IncomingTraffic": "आने वाले यातायात",
+ "LoopsInline": "पृष्ठ पुनः लोड %s",
+ "NoDataForAction": "%s के लिए कोई डेटा नहीं है",
+ "NoDataForActionDetails": "या तो कार्रवाई की अवधि के दौरान कोई पेज देखा गया था या यह अवैध है.",
+ "OutgoingTraffic": "निवर्तमान यातायात",
+ "ShareOfAllPageviews": "यह पृष्ठ का %1$s पृष्ठ दृश्य (सभी पृष्ठ दृश्य का %2$s)किया था",
+ "ToFollowingPages": "आंतरिक पृष्ठ",
+ "ToFollowingPagesInline": "%s आंतरिक पृष्ठ",
+ "ToFollowingSiteSearches": "आंतरिक खोजें",
+ "ToFollowingSiteSearchesInline": "%s आंतरिक खोजें",
+ "TransitionsSubcategoryHelp2": "अधिक जानकारी",
+ "XOfAllPageviews": "इस पृष्ठ के सभी दृश्यों का %s",
+ "XOutOfYVisits": "%1$s (बाहर %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/hr.json b/plugins/Transitions/lang/hr.json
new file mode 100644
index 0000000000000000000000000000000000000000..481d24ac75b8e566e8797ab5ceff422aa448aeee
--- /dev/null
+++ b/plugins/Transitions/lang/hr.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Je li već znaš? Tranzicije su također dostupne kao radnja po retku u sljedećim izvještajima:",
+ "AvailableInOtherReports2": "Pokreni tranzicije klikom na njihovu ikonu (%s) nakon što prijeđeš mišem preko retka u bilo kojem od ovih izvještaja.",
+ "BouncesInline": "%s napuštanja",
+ "DirectEntries": "Izravni unosi",
+ "ErrorBack": "Vrati se na prethodnu radnju",
+ "ExitsInline": "%s izlaza",
+ "FeatureDescription": "„Tranzicije” je izvještaj koji prikazuje što su tvoji posjetitelji radili neposredno prije i nakon što su pogledali određenu stranicu. Ova stranica objašnjava kako pristupiti izvještaju „Tranzicije”, kako ga razumjeti i koristiti.",
+ "FromAIAssistants": "Od UI asistenta",
+ "FromCampaigns": "Od kampanja",
+ "FromPreviousPages": "Od internih stranica",
+ "FromPreviousPagesInline": "%s od internih stranica",
+ "FromPreviousSiteSearches": "Od interne pretrage",
+ "FromPreviousSiteSearchesInline": "%s od internih pretraga",
+ "FromSearchEngines": "Od tražilica",
+ "FromSocialNetworks": "Od društvenih mreža",
+ "FromWebsites": "Od web-stranica",
+ "IncomingTraffic": "Dolazni promet",
+ "LoopsInline": "%s ponovna učitavanja stranice",
+ "NoDataForAction": "Nema podataka za %s",
+ "NoDataForActionDetails": "Radnja nije imala prikaza stranice tijekom razdoblja ili je nevažeća.",
+ "NumDownloads": "%s preuzimanja",
+ "NumOutlinks": "%s vanjske poveznice",
+ "NumPageviews": "%s prikaza stranice",
+ "OutgoingTraffic": "Odlazni promet",
+ "PageURLTransitions": "Tranzicije URL-ova stranica",
+ "PeriodNotAllowed": "Odaberi valjano vremensko razdoblje",
+ "PeriodNotAllowedDetails": "Pokušaj odabrati razdoblje s manje dana za ovu funkciju.",
+ "PluginDescription": "Prikazuje prethodne i sljedeće radnje za svaki URL stranice u novom izvještaju „Tranizicije” dostupnom u izvještajima „Radnje” putem nove ikone.",
+ "ShareOfAllPageviews": "Ova je stranica imala %1$s prikaza stranice (%2$s od svih prikaza stranica)",
+ "ToFollowingPages": "Prema internim stranicama",
+ "ToFollowingPagesInline": "%s prema internim stranicama",
+ "ToFollowingSiteSearches": "Interne pretrage",
+ "ToFollowingSiteSearchesInline": "%s interne pretrage",
+ "TopX": "Najbolje %s oznake",
+ "Transitions": "Tranzicije",
+ "TransitionsSubcategoryHelp1": "„Tranzicije” je izvještaj koji prikazuje što su tvoji posjetitelji radili neposredno prije i nakon što su pogledali određenu stranicu. Ova stranica objašnjava kako pristupiti izvještaju „Tranzicije”, kako ga razumjeti i koristiti.",
+ "TransitionsSubcategoryHelp2": "Više detalja",
+ "XOfAllPageviews": "%s od svih prikaza ove stranice",
+ "XOutOfYVisits": "%1$s (od %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/hu.json b/plugins/Transitions/lang/hu.json
new file mode 100644
index 0000000000000000000000000000000000000000..5e5ae6cac9c2010806e18969a88971da4addb570
--- /dev/null
+++ b/plugins/Transitions/lang/hu.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "több részlet"
+ }
+}
diff --git a/plugins/Transitions/lang/hy.json b/plugins/Transitions/lang/hy.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/hy.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/id.json b/plugins/Transitions/lang/id.json
new file mode 100644
index 0000000000000000000000000000000000000000..93e4d930ec46ed9e4b41f88c4b57c452a0d7e852
--- /dev/null
+++ b/plugins/Transitions/lang/id.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Tahukah Anda? Transisi juga tersedia sebagai tindakan baris dalam laporan berikut:",
+ "AvailableInOtherReports2": "Cukup arahkan satu baris di salah satu laporan ini dan klik ikon transisi %s untuk meluncurkannya.",
+ "BouncesInline": "%s pantulan",
+ "DirectEntries": "Entri langsung",
+ "ErrorBack": "Kembali ke aksi sebelumnya",
+ "ExitsInline": "%s keluar",
+ "FeatureDescription": "Transisi memberi Anda laporan yang menampilkan hal-hal yang dilakukan oleh pengunjung Anda secara langsung sebelum dan sesudah melihat laman tertentu. Halaman ini akan menjelaskan cara mengakses, memahami, dan menggunakan laporan Transisi yang canggih.",
+ "FromAIAssistants": "Dari asisten AI",
+ "FromCampaigns": "Dari Kampanye",
+ "FromPreviousPages": "Dari halaman internal",
+ "FromPreviousPagesInline": "%s dari halaman internal",
+ "FromPreviousSiteSearches": "Dari pencarian internal",
+ "FromPreviousSiteSearchesInline": "%s dari pencarian internal",
+ "FromSearchEngines": "Dari mesin pencari",
+ "FromSocialNetworks": "Dari jaringan sosial",
+ "FromWebsites": "Dari website",
+ "IncomingTraffic": "Lalu lintas masuk",
+ "LoopsInline": "%s halaman dimuat ulang",
+ "NoDataForAction": "Tidak ada data untuk %s",
+ "NoDataForActionDetails": "Baik tindakan tidak memiliki pageviews selama periode tersebut, atau tidak valid.",
+ "NumDownloads": "%s unduhan",
+ "NumOutlinks": "%s tautan keluar",
+ "NumPageviews": "%s tampilan halaman",
+ "OutgoingTraffic": "Lalu lintas keluar",
+ "PageURLTransitions": "Transisi URL Halaman",
+ "PeriodNotAllowed": "Pilih periode waktu yang valid",
+ "PeriodNotAllowedDetails": "Coba pilih periode dengan hari yang lebih sedikit untuk fitur ini.",
+ "PluginDescription": "Melaporkan tindakan sebelumnya dan berikutnya untuk setiap URL halaman dalam laporan \"Transisi\" baru, tersedia di laporan \"Tindakan\" melalui ikon baru.",
+ "ShareOfAllPageviews": "Halaman ini memiliki %1$s tampilan halaman (%2$s dari semua tampilan halaman)",
+ "ToFollowingPages": "Ke halaman internal",
+ "ToFollowingPagesInline": "%s ke halaman internal",
+ "ToFollowingSiteSearches": "Pencarian internal",
+ "ToFollowingSiteSearchesInline": "%s pencarian internal",
+ "TopX": "%s label teratas",
+ "Transitions": "Transisi",
+ "TransitionsSubcategoryHelp1": "Transisi adalah laporan yang menunjukkan hal-hal yang dilakukan pengunjung Anda langsung sebelum dan setelah melihat halaman tertentu. Halaman ini menjelaskan cara mengakses, memahami, dan menggunakan laporan \"Transisi\" yang kuat.",
+ "TransitionsSubcategoryHelp2": "Lebih banyak detail",
+ "XOfAllPageviews": "%s dari semua tampilan halaman ini",
+ "XOutOfYVisits": "%1$s (dari %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/is.json b/plugins/Transitions/lang/is.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/is.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/it.json b/plugins/Transitions/lang/it.json
new file mode 100644
index 0000000000000000000000000000000000000000..14dc5f080a6fd0c964b5cfd1d831c0ebfbdfc001
--- /dev/null
+++ b/plugins/Transitions/lang/it.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Lo sapevi? Le transizioni sono disponibili anche come azione di riga nei report seguenti:",
+ "AvailableInOtherReports2": "Avvia le transizioni facendo clic sulla relativa icona (%s) dopo aver passato il mouse su una riga in uno di questi rapporti.",
+ "BouncesInline": "%s rimbalzi",
+ "DirectEntries": "Accessi diretti",
+ "ErrorBack": "Torna all'azione precedente",
+ "ExitsInline": "%s uscite",
+ "FeatureDescription": "Le transizioni sono un report che mostra le cose che i tuoi visitatori hanno fatto direttamente prima e dopo aver visualizzato una determinata pagina. Questa pagina spiega come accedere, comprendere e utilizzare il potente rapporto Transizioni.",
+ "FromAIAssistants": "Da assistenti AI",
+ "FromCampaigns": "Da campagne",
+ "FromPreviousPages": "Da pagine interne",
+ "FromPreviousPagesInline": "%s da pagine interne",
+ "FromPreviousSiteSearches": "Da ricerca interna",
+ "FromPreviousSiteSearchesInline": "%s da ricerche interne",
+ "FromSearchEngines": "Da motori di ricerca",
+ "FromSocialNetworks": "Da social network",
+ "FromWebsites": "Da siti web",
+ "IncomingTraffic": "Traffico entrante",
+ "LoopsInline": "%s pagine ricaricate",
+ "NoDataForAction": "Non ci sono dati per %s",
+ "NoDataForActionDetails": "O l'azione non ha avuto pagine viste durante il periodo o non è valida.",
+ "NumDownloads": "%s download",
+ "NumOutlinks": "%s link di uscita",
+ "NumPageviews": "%s pagine viste",
+ "OutgoingTraffic": "Traffico uscente",
+ "PageURLTransitions": "Transizioni URL di pagina",
+ "PeriodNotAllowed": "Seleziona un periodo di tempo valido",
+ "PeriodNotAllowedDetails": "Per questa funzione prova a selezionare un periodo di tempo con meno giorni.",
+ "PluginDescription": "Segnala le azioni precedenti e seguenti per ciascun URL di pagina in un nuovo report \"Transizioni\", disponibile tra i report \"Azioni\" con una nuova icona.",
+ "ShareOfAllPageviews": "Questa pagina ha avuto %1$s visualizzazioni (%2$s di tutte le visualizzazioni)",
+ "ToFollowingPages": "A pagine interne",
+ "ToFollowingPagesInline": "%s a pagine interne",
+ "ToFollowingSiteSearches": "Ricerche interne",
+ "ToFollowingSiteSearchesInline": "%s ricerche interne",
+ "TopX": "Principali %s etichette",
+ "Transitions": "Transizioni",
+ "TransitionsSubcategoryHelp1": "Le Transizioni sono un report che mostra le cose che i tuoi visitatori hanno fatto immediatamente prima e dopo aver visualizzato una determinata pagina. Questa pagina spiega come accedere, comprendere e utilizzare il potente report \"Transizioni\".",
+ "TransitionsSubcategoryHelp2": "Maggiori dettagli",
+ "XOfAllPageviews": "%s di tutte le visualizzazioni di questa pagina",
+ "XOutOfYVisits": "%1$s (su %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/ja.json b/plugins/Transitions/lang/ja.json
new file mode 100644
index 0000000000000000000000000000000000000000..c73e8cdfced36e10fc150b07bfa3f64f550bee51
--- /dev/null
+++ b/plugins/Transitions/lang/ja.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "ご存知ですか? トランジションは、次のレポートの行アクションとしても利用できます。",
+ "AvailableInOtherReports2": "これらのレポートのいずれかの行にカーソルを合わせた後、アイコン (%s) をクリックしてトランジションを起動します。",
+ "BouncesInline": "%s のバウンス",
+ "DirectEntries": "直接エントリー",
+ "ErrorBack": "前の操作に戻る",
+ "ExitsInline": "%s 終了",
+ "FeatureDescription": "トランジションは、が特定ビジターのページを表示する直前と直後に行ったことを示すレポートです。 このページでは、強力な「トランジション」レポートにアクセスし、理解し、使用する方法について説明します。",
+ "FromCampaigns": "キャンペーンから",
+ "FromPreviousPages": "内部ページから",
+ "FromPreviousPagesInline": "内部ページからの %s",
+ "FromPreviousSiteSearches": "内部検索から",
+ "FromPreviousSiteSearchesInline": "内部検索からの %s",
+ "FromSearchEngines": "検索エンジンから",
+ "FromSocialNetworks": "ソーシャルネットワークから",
+ "FromWebsites": "ウェブサイトから",
+ "IncomingTraffic": "受信トラフィック",
+ "LoopsInline": "%s ページリロード",
+ "NoDataForAction": "%s のデータがありません",
+ "NoDataForActionDetails": "期間中にアクションのページビューがなかったか、アクションが無効です。",
+ "NumDownloads": "%s ダウンロード",
+ "NumOutlinks": "%s アウトリンク",
+ "NumPageviews": "%s ページビュー",
+ "OutgoingTraffic": "送信トラフィック",
+ "PageURLTransitions": "ページURLのトランジション",
+ "PeriodNotAllowed": "有効な期間を選択してください",
+ "PeriodNotAllowedDetails": "この機能の日数が少ない期間を選択してみてください。",
+ "PluginDescription": "各ページ URL の前後のアクションを新しい「トランジション」レポートでレポートします。新しいアイコンを介して「アクション」レポートで利用できます。",
+ "ShareOfAllPageviews": "このページは、%1$s ページビューでした ( 全ページビューの %2$s )",
+ "ToFollowingPages": "内部ページへ",
+ "ToFollowingPagesInline": "内部ページへの %s",
+ "ToFollowingSiteSearches": "内部検索",
+ "ToFollowingSiteSearchesInline": "%s 内部検索",
+ "TopX": "上位 %s のラベル",
+ "Transitions": "トランジション",
+ "TransitionsSubcategoryHelp1": "トランジションは、ビジターが特定のページを表示する直前と直後に行ったことを示すレポートです。 このページでは、強力な \"トランジション\" レポートにアクセスし、理解し、使用する方法について説明します。",
+ "TransitionsSubcategoryHelp2": "詳細",
+ "XOfAllPageviews": "このページの全ビューの %s",
+ "XOutOfYVisits": "(%2$s の内の ) %1$s"
+ }
+}
diff --git a/plugins/Transitions/lang/ka.json b/plugins/Transitions/lang/ka.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/ka.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/ko.json b/plugins/Transitions/lang/ko.json
new file mode 100644
index 0000000000000000000000000000000000000000..a7d35ae46705cf025647cdd71c837d3cc9717901
--- /dev/null
+++ b/plugins/Transitions/lang/ko.json
@@ -0,0 +1,29 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s 반송",
+ "DirectEntries": "직접 항목",
+ "ErrorBack": "이전 동작으로 돌아가기",
+ "ExitsInline": "%s 이탈",
+ "FromCampaigns": "캠페인에서",
+ "FromPreviousPages": "내부 페이지에서",
+ "FromPreviousPagesInline": "%s 내부 페이지에서",
+ "FromPreviousSiteSearches": "내부 검색에서",
+ "FromPreviousSiteSearchesInline": "%s 내부 검색에서",
+ "FromSearchEngines": "검색 엔진에서",
+ "FromWebsites": "웹사이트에서",
+ "IncomingTraffic": "들어오는 트래픽",
+ "LoopsInline": "%s 페이지 새로고침",
+ "NoDataForAction": "%s 데이터 없음",
+ "NoDataForActionDetails": "특정 동작은 을 하는 기간 동안 페이지뷰가 일어나지 않거나 유효하지 않습니다.",
+ "OutgoingTraffic": "나가는 트래픽",
+ "PluginDescription": "새로운 변화에 대한 보고서 내 각 페이지 URL에 대한 이전 및 다음의 보고서는 새 아이콘을 통한 동작 보고서에서 확인할 수 있습니다.",
+ "ShareOfAllPageviews": "이 페이지의 페이지뷰 %1$s (전체 페이지뷰 수 %2$s)",
+ "ToFollowingPages": "내부 페이지로",
+ "ToFollowingPagesInline": "%s 내부 페이지",
+ "ToFollowingSiteSearches": "내부 검색",
+ "ToFollowingSiteSearchesInline": "%s 내부 검색",
+ "TransitionsSubcategoryHelp2": "자세히",
+ "XOfAllPageviews": "이 페이지의 모든 보기 중 %s",
+ "XOutOfYVisits": "%1$s (%2$s 나감)"
+ }
+}
diff --git a/plugins/Transitions/lang/ku.json b/plugins/Transitions/lang/ku.json
new file mode 100644
index 0000000000000000000000000000000000000000..12037f8751ad20e25a7a152b3cfb07787b58c0ef
--- /dev/null
+++ b/plugins/Transitions/lang/ku.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "وردەکاریی زیاتر"
+ }
+}
diff --git a/plugins/Transitions/lang/lb.json b/plugins/Transitions/lang/lb.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/lb.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/lt.json b/plugins/Transitions/lang/lt.json
new file mode 100644
index 0000000000000000000000000000000000000000..f66c3f18e98b928076bd7d077719a9858601a613
--- /dev/null
+++ b/plugins/Transitions/lang/lt.json
@@ -0,0 +1,7 @@
+{
+ "Transitions": {
+ "FromPreviousSiteSearches": "Iš vidinės paieškos",
+ "ToFollowingSiteSearches": "Vidinės paieškos",
+ "TransitionsSubcategoryHelp2": "Išsamiau"
+ }
+}
diff --git a/plugins/Transitions/lang/lv.json b/plugins/Transitions/lang/lv.json
new file mode 100644
index 0000000000000000000000000000000000000000..5b8e10f2b9628ac5e25d9b6e4a893a6b9b63e643
--- /dev/null
+++ b/plugins/Transitions/lang/lv.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "Vairāk Detaļas"
+ }
+}
diff --git a/plugins/Transitions/lang/ms.json b/plugins/Transitions/lang/ms.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/ms.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/nb.json b/plugins/Transitions/lang/nb.json
new file mode 100644
index 0000000000000000000000000000000000000000..833ec0b6045803d77359011d1d0feff883f47019
--- /dev/null
+++ b/plugins/Transitions/lang/nb.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Visste du at overganger også er tilgjengelig som en rad-handling i følgende rapporter:",
+ "AvailableInOtherReports2": "Hold pekeren over en av disse rapportene og klikk på overgangsikonet %s for å starte den.",
+ "BouncesInline": "%s sprett",
+ "DirectEntries": "Direkte treff",
+ "ErrorBack": "Gå tilbake til forrige handling",
+ "ExitsInline": "%s utganger",
+ "FeatureDescription": "Overganger gir deg en rapport som viser deg tingene dine besøkere gjorde før eller etter visning av en gitt side. Denne siden forklarer hvor man finner, forstår og bruker den kraftige overgangsrapporten.",
+ "FromCampaigns": "Fra kampanjer",
+ "FromPreviousPages": "Fra interne sider",
+ "FromPreviousPagesInline": "%s fra interne sider",
+ "FromPreviousSiteSearches": "Fra interne søk",
+ "FromPreviousSiteSearchesInline": "%s fra interne søk",
+ "FromSearchEngines": "Fra søkemotorer",
+ "FromSocialNetworks": "Fra sosiale nettverk",
+ "FromWebsites": "Fra nettsider",
+ "IncomingTraffic": "Innkommende trafikk",
+ "LoopsInline": "%s innlastinger av side på nytt",
+ "NoDataForAction": "Det finnes ingen data for %s",
+ "NoDataForActionDetails": "Enten har handlingen ingen sidevisninger i perioden eller så er den ugyldig.",
+ "NumDownloads": "%s nedlastinger",
+ "NumOutlinks": "%s utlenker",
+ "NumPageviews": "%s sidevisninger",
+ "OutgoingTraffic": "Utgående trafikk",
+ "PageURLTransitions": "Sidens nettadresse-overganger",
+ "PeriodNotAllowed": "Tidsperioden tillates ikke",
+ "PeriodNotAllowedDetails": "Denne funksjonen er ikke tilgjengelig i denne perioden. Prøv å velge en med færre dager.",
+ "PluginDescription": "Rapporterer forrige og neste handlinger for hver URL i en ny Overganger-rapport, tilgjengelig i Handlinger-rapporten og via et nytt ikon.",
+ "ShareOfAllPageviews": "Denne siden hadde %1$s sidevisninger (%2$s av alle sidevisninger)",
+ "ToFollowingPages": "Til interne sider",
+ "ToFollowingPagesInline": "%s til interne sider",
+ "ToFollowingSiteSearches": "Interne søk",
+ "ToFollowingSiteSearchesInline": "%s interne søk",
+ "TopX": "Toppetiketter for %s",
+ "Transitions": "Overganger",
+ "TransitionsSubcategoryHelp1": "Overganger gir den en rapport som viser deg de tingene besøkende gjorde rett før eller etter de så en gitt side. Denne siden forklarer hvordan man finner, forstår og bruker den kraftige overgangsrapporten.",
+ "TransitionsSubcategoryHelp2": "Flere detaljer",
+ "XOfAllPageviews": "%s av alle besøk på denne siden",
+ "XOutOfYVisits": "%1$s (av %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/nl.json b/plugins/Transitions/lang/nl.json
new file mode 100644
index 0000000000000000000000000000000000000000..18a999d49fbe0d53f82c4ff483d22195d860c17f
--- /dev/null
+++ b/plugins/Transitions/lang/nl.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Wist u dit? Transities zijn ook beschikbaar als een rij-actie in de volgende rapporten:",
+ "AvailableInOtherReports2": "Start transities door op hun pictogram (%s) te klikken nadat je met de muis over een rij in een van deze rapporten beweegt.",
+ "BouncesInline": "%s bounces",
+ "DirectEntries": "Directe bezoeken",
+ "ErrorBack": "Ga terug naar de vorige actie",
+ "ExitsInline": "%s einde bezoeken",
+ "FeatureDescription": "Transities is een rapport dat weergeeft wat uw bezoekers deden meteen voor en na het bekijken van een bepaalde pagina. Op deze pagina wordt uitgelegd hoe u het krachtige rapport \"Transities\" kunt openen, begrijpen en gebruiken.",
+ "FromCampaigns": "Van campagnes",
+ "FromPreviousPages": "Van interne pagina's",
+ "FromPreviousPagesInline": "%s van interne pagina's",
+ "FromPreviousSiteSearches": "Van interne zoekopdracht",
+ "FromPreviousSiteSearchesInline": "%s van interne zoekopdrachten",
+ "FromSearchEngines": "Van zoekmachines",
+ "FromSocialNetworks": "Van sociale netwerken",
+ "FromWebsites": "Van websites",
+ "IncomingTraffic": "Inkomend verkeer",
+ "LoopsInline": "%s pagina herladen",
+ "NoDataForAction": "Geen gegevens voor %s",
+ "NoDataForActionDetails": "Deze activiteit had geen paginaweergaves gedurende de periode, of is ongeldig.",
+ "NumDownloads": "%s downloads",
+ "NumOutlinks": "%s uitgaande linken",
+ "NumPageviews": "%s paginaweergaven",
+ "OutgoingTraffic": "Uitgaand verkeer",
+ "PageURLTransitions": "Pagina URL-transities",
+ "PeriodNotAllowed": "Selecteer een geldige periode",
+ "PeriodNotAllowedDetails": "Probeer voor deze functionaliteit een periode met minder dagen te selecteren.",
+ "PluginDescription": "Rapporteert vorige en volgende acties voor elke pagina-URL in een nieuw rapport \"Transities\", beschikbaar in de rapporten \"Acties\" via een nieuw pictogram.",
+ "ShareOfAllPageviews": "Deze pagina had %1$s paginaweergaven (%2$s van alle paginaweergaven)",
+ "ToFollowingPages": "Naar interne pagina's",
+ "ToFollowingPagesInline": "%s naar interne pagina's",
+ "ToFollowingSiteSearches": "Interne zoekopdrachten",
+ "ToFollowingSiteSearchesInline": "%s interne zoekopdrachten",
+ "TopX": "Top %s labels",
+ "Transitions": "Transities",
+ "TransitionsSubcategoryHelp1": "Transities is een rapport dat toont wat uw bezoekers deden meteen voor en na het bekijken van een bepaalde pagina. Op deze pagina wordt uitgelegd hoe u het krachtige rapport \"Transities\" kunt openen, begrijpen en gebruiken.",
+ "TransitionsSubcategoryHelp2": "Meer details",
+ "XOfAllPageviews": "%s van alle weergaven van deze pagina",
+ "XOutOfYVisits": "%1$s (van de %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/nn.json b/plugins/Transitions/lang/nn.json
new file mode 100644
index 0000000000000000000000000000000000000000..306f674866a583eb8b86df1e3da18a1b2c401260
--- /dev/null
+++ b/plugins/Transitions/lang/nn.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "Meer details"
+ }
+}
diff --git a/plugins/Transitions/lang/pl.json b/plugins/Transitions/lang/pl.json
new file mode 100644
index 0000000000000000000000000000000000000000..703049167988c13194f38c2d1894349b0e41f1aa
--- /dev/null
+++ b/plugins/Transitions/lang/pl.json
@@ -0,0 +1,33 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s porzucenia",
+ "DirectEntries": "Wejścia Bezpośrednie",
+ "ErrorBack": "Cofnij do poprzedniej akcji",
+ "ExitsInline": "%s wyjścia",
+ "FromCampaigns": "Z Kampanii",
+ "FromPreviousPages": "Ze stron serwisu",
+ "FromPreviousPagesInline": "%s stron serwisu",
+ "FromPreviousSiteSearches": "Z wyszukiwarki serwisu",
+ "FromPreviousSiteSearchesInline": "%s z wyszukiwarki serwisu",
+ "FromSearchEngines": "Z wyszukiwarek",
+ "FromWebsites": "Z serwisów",
+ "IncomingTraffic": "Ruch przychodzący",
+ "LoopsInline": "%s odświeżeń strony",
+ "NoDataForAction": "Brak danych dla %s",
+ "NoDataForActionDetails": "Akcja nie powodowała wyświetleń strony w czasie lub jest błędna.",
+ "NumDownloads": "%s pobrania",
+ "NumOutlinks": "%s linków zewnętrznych",
+ "NumPageviews": "%s wyświetleń strony",
+ "OutgoingTraffic": "Ruch wychodzący",
+ "PluginDescription": "Prezentuje poprzednie i następne akcje dla każdej strony w raporcie Transitions, dostępne w raporcie Akcji poprzez nową ikonę.",
+ "ShareOfAllPageviews": "Wybrana strona miała %1$s wyświetleń (%2$s wszystkich wyświetleń)",
+ "ToFollowingPages": "Do stron serwisu",
+ "ToFollowingPagesInline": "%s stron serwisu",
+ "ToFollowingSiteSearches": "Wewnętrzne Wyszukiwania",
+ "ToFollowingSiteSearchesInline": "%s wewnętrznych wyszukiwań",
+ "Transitions": "Przejścia",
+ "TransitionsSubcategoryHelp2": "Więcej informacji",
+ "XOfAllPageviews": "%s wszystkich wyświetleń tej strony",
+ "XOutOfYVisits": "%1$s (z %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/pt-br.json b/plugins/Transitions/lang/pt-br.json
new file mode 100644
index 0000000000000000000000000000000000000000..c9822bb1590ba0684d1470cbad9a07aa746e9a7e
--- /dev/null
+++ b/plugins/Transitions/lang/pt-br.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Você sabia? Transições também está disponível como uma ação de linha nos seguintes relatórios:",
+ "AvailableInOtherReports2": "Inicie as transições clicando em seu ícone (%s) depois de passar o mouse sobre uma linha em qualquer um desses relatórios.",
+ "BouncesInline": "%s rejeições",
+ "DirectEntries": "Entradas diretas",
+ "ErrorBack": "Voltar para a ação anterior",
+ "ExitsInline": "%s saídas",
+ "FeatureDescription": "Transições é um relatório que mostra as coisas que seus visitantes fizeram diretamente antes e depois de visualizar uma determinada página. Esta página explica como acessar, entender e usar o poderoso relatório \"Transições\".",
+ "FromAIAssistants": "De assistentes de IA",
+ "FromCampaigns": "De campanhas",
+ "FromPreviousPages": "De páginas internas",
+ "FromPreviousPagesInline": "%s de páginas internas",
+ "FromPreviousSiteSearches": "De pesquisa interna",
+ "FromPreviousSiteSearchesInline": "%s de pesquisas internas",
+ "FromSearchEngines": "Dos motores de busca",
+ "FromSocialNetworks": "Das redes sociais",
+ "FromWebsites": "De sites web",
+ "IncomingTraffic": "Tráfego de entrada",
+ "LoopsInline": "%s atualizações de página",
+ "NoDataForAction": "Não há dados para %s",
+ "NoDataForActionDetails": "A ação não teve exibições de página durante o período ou é inválida.",
+ "NumDownloads": "%s baixados",
+ "NumOutlinks": "%s links externos",
+ "NumPageviews": "%s exibições de página",
+ "OutgoingTraffic": "Tráfego de saída",
+ "PageURLTransitions": "Transições de URL da página",
+ "PeriodNotAllowed": "Selecione um período de tempo válido",
+ "PeriodNotAllowedDetails": "Tente selecionar um período com menos dias para esse recurso.",
+ "PluginDescription": "Relata as ações anteriores e seguintes para cada URL da página em um novo relatório \"Transições\", disponível nos relatórios \"Ações\" por meio de um novo ícone.",
+ "ShareOfAllPageviews": "Esta página teve %1$s exibições (%2$s de todas as exibições)",
+ "ToFollowingPages": "Para páginas internas",
+ "ToFollowingPagesInline": "%s para páginas internas",
+ "ToFollowingSiteSearches": "Pesquisas internas",
+ "ToFollowingSiteSearchesInline": "%s pesquisas internas",
+ "TopX": "%s principais rótulos",
+ "Transitions": "Transições",
+ "TransitionsSubcategoryHelp1": "Transições é um relatório que mostra as coisas que seus visitantes fizeram diretamente antes e depois de visualizar uma determinada página. Esta página explica como acessar, entender e usar o poderoso relatório \"Transições\".",
+ "TransitionsSubcategoryHelp2": "Mais detalhes",
+ "XOfAllPageviews": "%s de todas as visualizações desta página",
+ "XOutOfYVisits": "%1$s (de %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/pt.json b/plugins/Transitions/lang/pt.json
new file mode 100644
index 0000000000000000000000000000000000000000..914718bd4874ccba658c2f416f31b85bf108c78b
--- /dev/null
+++ b/plugins/Transitions/lang/pt.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Sabia que? As transições também estão disponíveis como uma ação de linha nos seguintes relatórios:",
+ "AvailableInOtherReports2": "Inicie as transições clicando em seu ícone (%s) depois de passar o mouse sobre uma linha em qualquer um desses relatórios.",
+ "BouncesInline": "%s ressaltos",
+ "DirectEntries": "Entradas diretas",
+ "ErrorBack": "Voltar à ação anterior",
+ "ExitsInline": "%s saídas",
+ "FeatureDescription": "Transições é um relatório que mostra as coisas que seus visitantes fizeram diretamente antes e depois de visualizar uma determinada página. Esta página explica como acessar, entender e usar o poderoso relatório \"Transições\".",
+ "FromCampaigns": "De campanhas",
+ "FromPreviousPages": "De páginas internas",
+ "FromPreviousPagesInline": "%s de páginas internas",
+ "FromPreviousSiteSearches": "Da pesquisa interna",
+ "FromPreviousSiteSearchesInline": "%s de pesquisas internas",
+ "FromSearchEngines": "Dos motores de busca",
+ "FromSocialNetworks": "Das redes sociais",
+ "FromWebsites": "De sites",
+ "IncomingTraffic": "Tráfego de entrada",
+ "LoopsInline": "%s recarregamentos de páginas",
+ "NoDataForAction": "Sem dados para %s",
+ "NoDataForActionDetails": "A ação não teve exibições de página durante o período ou é inválida.",
+ "NumDownloads": "%s descargas",
+ "NumOutlinks": "%s ligações de saída",
+ "NumPageviews": "%s visualizações de páginas",
+ "OutgoingTraffic": "Tráfego de saída",
+ "PageURLTransitions": "Endereços de páginas de transição",
+ "PeriodNotAllowed": "Selecione um período de tempo válido",
+ "PeriodNotAllowedDetails": "Tente selecionar um período com menos dias para esse recurso.",
+ "PluginDescription": "Relata as ações anteriores e seguintes para cada URL da página em um novo relatório \"Transições\", disponível nos relatórios \"Ações\" por meio de um novo ícone.",
+ "ShareOfAllPageviews": "Esta página teve %1$s visualizações (de todas as %2$s visualizações)",
+ "ToFollowingPages": "Para páginas internas",
+ "ToFollowingPagesInline": "%s para páginas internas",
+ "ToFollowingSiteSearches": "Pesquisas internas",
+ "ToFollowingSiteSearchesInline": "%s pesquisas internas",
+ "TopX": "%s principais etiquetas",
+ "Transitions": "Transições",
+ "TransitionsSubcategoryHelp1": "Transições é um relatório que mostra as coisas que seus visitantes fizeram diretamente antes e depois de visualizar uma determinada página. Esta página explica como acessar, entender e usar o poderoso relatório \"Transições\".",
+ "TransitionsSubcategoryHelp2": "Mais detalhes",
+ "XOfAllPageviews": "%s de todas as visualizações desta página",
+ "XOutOfYVisits": "%1$s (de %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/ro.json b/plugins/Transitions/lang/ro.json
new file mode 100644
index 0000000000000000000000000000000000000000..5a47950395688391c93510c116848fcec3ccfb65
--- /dev/null
+++ b/plugins/Transitions/lang/ro.json
@@ -0,0 +1,29 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s salturi",
+ "DirectEntries": "Intrări directe",
+ "ErrorBack": "Du-te înapoi la acțiunea anterioară",
+ "ExitsInline": "%s ieșiri",
+ "FromCampaigns": "Din campanii",
+ "FromPreviousPages": "De la pagini interne",
+ "FromPreviousPagesInline": "%s de la paginile interne",
+ "FromPreviousSiteSearches": "De la cautare interna",
+ "FromPreviousSiteSearchesInline": "%s de la cautarile interne",
+ "FromSearchEngines": "De la motoarele de căutare",
+ "FromWebsites": "De pe website-uri",
+ "IncomingTraffic": "Traficul de intrare",
+ "LoopsInline": "%s reincarcare pagina",
+ "NoDataForAction": "Nu este nici o data pentru %s",
+ "NoDataForActionDetails": "Fie acțiunea nu a avut nici o afișări de pagină în timpul perioadei sau este invalida.",
+ "NumDownloads": "%sdescărcări",
+ "OutgoingTraffic": "traficul de ieșire",
+ "ShareOfAllPageviews": "Această pagină a avut vizualizări de %1$s pagini (%2$s din toate vizualizări de pagini)",
+ "ToFollowingPages": "Către paginile interne",
+ "ToFollowingPagesInline": "%s la pagini interne",
+ "ToFollowingSiteSearches": "Căutări interne",
+ "ToFollowingSiteSearchesInline": "%s căutări interne",
+ "TransitionsSubcategoryHelp2": "Mai multe detalii",
+ "XOfAllPageviews": "%s toate vizualizarile de pe aceasta pagina",
+ "XOutOfYVisits": "%1$s (din %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/ru.json b/plugins/Transitions/lang/ru.json
new file mode 100644
index 0000000000000000000000000000000000000000..0f629128456cc7fc393d5afa6cf6c3591b51725d
--- /dev/null
+++ b/plugins/Transitions/lang/ru.json
@@ -0,0 +1,40 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Вы знали? Переходы также доступны как действие строки в следующих отчётах:",
+ "AvailableInOtherReports2": "Просто наведите указатель на строку в любом из этих отчётов и щелкните значок перехода %s, чтобы запустить его.",
+ "BouncesInline": "%s отскоков (отказов)",
+ "DirectEntries": "Прямых входов",
+ "ErrorBack": "Верниться к предыдущему действию",
+ "ExitsInline": "%s уходов с сайта",
+ "FeatureDescription": "Переходы предоставляют вам отчёт, который показывает, что ваши посетители делали непосредственно до и после просмотра определенной страницы. На этой странице объясняется, как получить доступ, понять и использовать мощный отчёт о переходах.",
+ "FromCampaigns": "Из кампаний",
+ "FromPreviousPages": "С внутренних страниц",
+ "FromPreviousPagesInline": "%s с внутренних страниц",
+ "FromPreviousSiteSearches": "С внутреннего поиска по сайту",
+ "FromPreviousSiteSearchesInline": "%s с внутреннего поиска по сайту",
+ "FromSearchEngines": "Из поисковых систем",
+ "FromSocialNetworks": "Из социальных сетей",
+ "FromWebsites": "С сайтов",
+ "IncomingTraffic": "Входящий трафик",
+ "LoopsInline": "%s обновлений страницы",
+ "NoDataForAction": "Нет данных для %s",
+ "NoDataForActionDetails": "Не было совершено таких действий или они неправильны.",
+ "NumDownloads": "%s загрузок",
+ "NumOutlinks": "%s исходящих ссылок",
+ "NumPageviews": "%s просмотров страниц",
+ "OutgoingTraffic": "Исходящий трафик",
+ "PageURLTransitions": "URL страницы перехода",
+ "PluginDescription": "Сообщает о предыдущих и последующих действиях для каждого URL-адреса страницы в новом отчёте о переходах, доступном в отчётах о действиях с помощью нового значка.",
+ "ShareOfAllPageviews": "У этой страницы %1$s просмотров (всего просмотров страниц: %2$s)",
+ "ToFollowingPages": "На страницы сайта",
+ "ToFollowingPagesInline": "%s на внутренние страницы",
+ "ToFollowingSiteSearches": "внутренних поисков по сайту",
+ "ToFollowingSiteSearchesInline": "%s внутренних поисков по сайту",
+ "TopX": "Топ %s меток",
+ "Transitions": "Переходы",
+ "TransitionsSubcategoryHelp1": "Переходы предоставляют вам отчёт, который показывает, что ваши посетители делали непосредственно до и после просмотра определенной страницы. На этой странице объясняется, как получить доступ, понять и использовать мощный отчёт о переходах.",
+ "TransitionsSubcategoryHelp2": "Детализация",
+ "XOfAllPageviews": "%s из всех просмотров страницы",
+ "XOutOfYVisits": "%1$s (из %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/si.json b/plugins/Transitions/lang/si.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/si.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/sk.json b/plugins/Transitions/lang/sk.json
new file mode 100644
index 0000000000000000000000000000000000000000..4d5f38fc074cb9c0b4af1d7ca9850e1d31dc0714
--- /dev/null
+++ b/plugins/Transitions/lang/sk.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "Viac informácií"
+ }
+}
diff --git a/plugins/Transitions/lang/sl.json b/plugins/Transitions/lang/sl.json
new file mode 100644
index 0000000000000000000000000000000000000000..24f1597292c99940562d73eea535db16fa8cb733
--- /dev/null
+++ b/plugins/Transitions/lang/sl.json
@@ -0,0 +1,19 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s odbojev",
+ "ErrorBack": "Nazaj na prejšnje dejanje",
+ "ExitsInline": "%s izhodov",
+ "FromPreviousPages": "Iz notranjih strani",
+ "FromPreviousPagesInline": "%s iz notranjih strani",
+ "FromSearchEngines": "S spletnih iskalnikov",
+ "NoDataForAction": "Ni podatkov za %s",
+ "OutgoingTraffic": "Izhodni promet",
+ "ShareOfAllPageviews": "Ta stran je imela %1$s ogledov strani (%2$s od vseh ogledov strani)",
+ "ToFollowingPages": "Na notranje strani",
+ "ToFollowingPagesInline": "%s na notranje strani",
+ "ToFollowingSiteSearches": "Notranja iskanja",
+ "ToFollowingSiteSearchesInline": "%s notranjih iskanj",
+ "TransitionsSubcategoryHelp2": "Več podrobnosti",
+ "XOutOfYVisits": "%1$s (od %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/sq.json b/plugins/Transitions/lang/sq.json
new file mode 100644
index 0000000000000000000000000000000000000000..68b27be6fc130fa8164e0c802fe58b98137cbec0
--- /dev/null
+++ b/plugins/Transitions/lang/sq.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "A e dinit? Tranzicionet janë të passhme edhe si veprim rreshti në raportet vijues:",
+ "AvailableInOtherReports2": "Thjesht kaloni kursorin përsipër një rreshti te cilido prej këtyre raporteve dhe klikoni mbi ikonën e tranzicionit (%s) për ta vënë në punë.",
+ "BouncesInline": "%s kthime",
+ "DirectEntries": "Hyrje të drejtpërdrejta",
+ "ErrorBack": "Kthehu te veprimi i mëparshëm",
+ "ExitsInline": "%s largime",
+ "FeatureDescription": "Tranzicionet janë një raport që tregon gjëra që vizitorët tuaj bënë fill para dhe pas parjes së një faqeje të dhënë. Kjo faqe do të shpjegojë si të hyhet, të kuptohet dhe të përdoret raporti i fuqishëm “Tranzicione”.",
+ "FromAIAssistants": "Nga asistentë IA",
+ "FromCampaigns": "Prej fushatash",
+ "FromPreviousPages": "Prej faqesh të brendshme",
+ "FromPreviousPagesInline": "%s prej faqesh të brendshme",
+ "FromPreviousSiteSearches": "Prej kërkimesh të brendshme",
+ "FromPreviousSiteSearchesInline": "%s prej kërkimesh të brendshme",
+ "FromSearchEngines": "Prej motorë kërkimesh",
+ "FromSocialNetworks": "Prej rrjetesh shoqërore",
+ "FromWebsites": "Prej sajtesh",
+ "IncomingTraffic": "Trafik ardhës",
+ "LoopsInline": "%s ringarkime faqesh",
+ "NoDataForAction": "S’ka të dhëna për %s",
+ "NoDataForActionDetails": "Ose veprimi nuk ka parje faqesh gjatë periudhës, ose është i pavlefshëm.",
+ "NumDownloads": "%s shkarkime",
+ "NumOutlinks": "%s lidhje për jashtë",
+ "NumPageviews": "%s parje faqesh",
+ "OutgoingTraffic": "Trafik ikës",
+ "PageURLTransitions": "Tranzicione URL-je faqeje",
+ "PeriodNotAllowed": "Përzgjidhni një periudhë kohore të vlefshme",
+ "PeriodNotAllowedDetails": "Provoni për këtë veçori përzgjedhjen e një periudhe me më pak ditë.",
+ "PluginDescription": "Raporton, në një raport të ri “Tranzicione”, veprime të mëparshme dhe në vijim për çdo URL faqeje, i passhëm te raportet “Veprime” përmes një ikone të re.",
+ "ShareOfAllPageviews": "Kjo faqe pati %1$s parje faqesh (%2$s e krejt parjeve të faqeve)",
+ "ToFollowingPages": "Te faqe të brendshme",
+ "ToFollowingPagesInline": "%s te faqe të brendshme",
+ "ToFollowingSiteSearches": "Kërkime të brendshme",
+ "ToFollowingSiteSearchesInline": "%s kërkime të brendshme",
+ "TopX": "%s etiketat kryesuese",
+ "Transitions": "Tranzicione",
+ "TransitionsSubcategoryHelp1": "Tranzicionet janë një raport që tregon gjëra që vizitorët tuaj bënë fill para dhe pas parjes së një faqeje të dhënë. Kjo faqe do të shpjegojë si të hyhet, të kuptohet dhe të përdoret raporti i fuqishëm “Tranzicione”.",
+ "TransitionsSubcategoryHelp2": "Më tepër hollësi",
+ "XOfAllPageviews": "%s e krejt parjeve të kësaj faqeje",
+ "XOutOfYVisits": "%1$s (nga %2$s gjithsej)"
+ }
+}
diff --git a/plugins/Transitions/lang/sr.json b/plugins/Transitions/lang/sr.json
new file mode 100644
index 0000000000000000000000000000000000000000..c44766a5ebc11f359128d48c8409614afacd251c
--- /dev/null
+++ b/plugins/Transitions/lang/sr.json
@@ -0,0 +1,32 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s odbijanja",
+ "DirectEntries": "Direktni ulasci",
+ "ErrorBack": "Vrati se na prethodnu akciju",
+ "ExitsInline": "%s izlaza",
+ "FromCampaigns": "Iz kampanja",
+ "FromPreviousPages": "Od internih stranica",
+ "FromPreviousPagesInline": "%s sa internih strana",
+ "FromPreviousSiteSearches": "Iz pretraga sajta",
+ "FromPreviousSiteSearchesInline": "%s iz pretraživanja sajta",
+ "FromSearchEngines": "Od pretraživača",
+ "FromWebsites": "Od sajtova",
+ "IncomingTraffic": "Dolazni saobraćaj",
+ "LoopsInline": "%s ponovnih učitavanja stranice",
+ "NoDataForAction": "Nema podataka za %s",
+ "NoDataForActionDetails": "Ili akcija nema nijedan pregled stranica za dati period ili ona nije validna.",
+ "NumDownloads": "%s preuzimanja",
+ "NumOutlinks": "%s izlaznih linkova",
+ "NumPageviews": "%s prikaza stranica",
+ "OutgoingTraffic": "Odlazni saobraćaj",
+ "PluginDescription": "Izveštaj o prethodnoj i sledećoj akciji za svaki URL",
+ "ShareOfAllPageviews": "Ova stranica ima %1$s pregleda (%2$s od svih pregleda)",
+ "ToFollowingPages": "Ka internim stranicama",
+ "ToFollowingPagesInline": "%s ka drugim stranicama",
+ "ToFollowingSiteSearches": "Pretraživanja sajta",
+ "ToFollowingSiteSearchesInline": "%s pretraživanja sajta",
+ "TransitionsSubcategoryHelp2": "Više detalja",
+ "XOfAllPageviews": "%s od svih pregleda ove strane",
+ "XOutOfYVisits": "%1$s (od %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/sv.json b/plugins/Transitions/lang/sv.json
new file mode 100644
index 0000000000000000000000000000000000000000..9b5db69331b0d5e67d7643e9459c456b6a1aa732
--- /dev/null
+++ b/plugins/Transitions/lang/sv.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Visste du att övergångar även finns som en radåtgärd i följande rapporter:",
+ "AvailableInOtherReports2": "För muspekaren över en rad i någon av dessa rapporter och klicka på övergångsikonen %s för att starta den.",
+ "BouncesInline": "%s avvisningar",
+ "DirectEntries": "Direktträffar",
+ "ErrorBack": "Gå tillbaka till föregående åtgärd",
+ "ExitsInline": "%s utgångar",
+ "FeatureDescription": "Övergångar ger dig en rapport som visar de saker som dina besökare gjorde direkt före och efter visning av en viss sida. Den här sidan kommer att förklara hur du får åtkomst till, förstår och använder den kraftfulla Övergångar-rapporten.",
+ "FromAIAssistants": "Från AI-assistenter",
+ "FromCampaigns": "Från kampanjer",
+ "FromPreviousPages": "Från interna sidor",
+ "FromPreviousPagesInline": "%s från interna sidor",
+ "FromPreviousSiteSearches": "Från intern webbplatssök",
+ "FromPreviousSiteSearchesInline": "%s från interna sökningar",
+ "FromSearchEngines": "Från sökmotorer",
+ "FromSocialNetworks": "Från sociala nätverk",
+ "FromWebsites": "Från webbplatser",
+ "IncomingTraffic": "Inkommande trafik",
+ "LoopsInline": "%s siduppdateringar",
+ "NoDataForAction": "Inga data för %s",
+ "NoDataForActionDetails": "Antingen har händelsen inte haft några sidvisningar under perioden eller så är den ogiltig.",
+ "NumDownloads": "%s nerladdningar",
+ "NumOutlinks": "%s utlänkar",
+ "NumPageviews": "%s sidvisningar",
+ "OutgoingTraffic": "Utgående trafik",
+ "PageURLTransitions": "Sidans URL-övergångar",
+ "PeriodNotAllowed": "Välj en giltig tidsperiod",
+ "PeriodNotAllowedDetails": "Försök välja en period med färre dagar för denna funktion.",
+ "PluginDescription": "Rapporterar tidigare och efterföljande händelser för varje sid-URL i en ny Övergångsrapport, som finns i Händelserapporter via en ny ikon.",
+ "ShareOfAllPageviews": "Den här sidan hade %1$s sidvisningar (%2$s av alla sidvisningar)",
+ "ToFollowingPages": "Till interna sidor",
+ "ToFollowingPagesInline": "%s till interna sidor",
+ "ToFollowingSiteSearches": "Interna sökningar",
+ "ToFollowingSiteSearchesInline": "%s interna sökningar",
+ "TopX": "%s främsta etiketterna",
+ "Transitions": "Övergångar",
+ "TransitionsSubcategoryHelp1": "Övergångar ger dig en rapport som visar de saker som dina besökare gjorde direkt före och efter visning av en viss sida. Den här sidan kommer att förklara hur du får åtkomst till, förstår och använder den kraftfulla Övergång-rapporten.",
+ "TransitionsSubcategoryHelp2": "Fler detaljer",
+ "XOfAllPageviews": "%s av alla sidvisningar",
+ "XOutOfYVisits": "%1$s (av %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/ta.json b/plugins/Transitions/lang/ta.json
new file mode 100644
index 0000000000000000000000000000000000000000..b3f3b8cd4b54efb3e567c92911f153f71b684013
--- /dev/null
+++ b/plugins/Transitions/lang/ta.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "உங்களுக்குத் தெரியுமா? பின்வரும் அறிக்கைகளில் ஒரு வரிசை நடவடிக்கையாக மாற்றங்கள் கிடைக்கின்றன:",
+ "AvailableInOtherReports2": "இந்த அறிக்கைகளில் ஏதேனும் ஒரு வரிசையை வட்டமிட்ட பிறகு அவர்களின் ஐகானை (%s) சொடுக்கு செய்வதன் மூலம் மாற்றங்களைத் தொடங்கவும்.",
+ "BouncesInline": "%s பவுன்ச்",
+ "DirectEntries": "நேரடி உள்ளீடுகள்",
+ "ErrorBack": "முந்தைய செயலுக்குச் செல்லுங்கள்",
+ "ExitsInline": "%s வெளியேறுகின்றன",
+ "FeatureDescription": "மாற்றங்கள் என்பது கொடுக்கப்பட்ட பக்கத்தைப் பார்ப்பதற்கு முன்னும் பின்னும் உங்கள் பார்வையாளர்கள் செய்த காரியங்களைக் காட்டும் ஒரு அறிக்கை. சக்திவாய்ந்த \"மாற்றங்கள்\" அறிக்கையை எவ்வாறு அணுகுவது, புரிந்துகொள்வது மற்றும் பயன்படுத்துவது என்பதை இந்த பக்கம் விளக்குகிறது.",
+ "FromAIAssistants": "செஅ உதவியாளர்களிடமிருந்து",
+ "FromCampaigns": "பிரச்சாரங்களிலிருந்து",
+ "FromPreviousPages": "உள் பக்கங்களிலிருந்து",
+ "FromPreviousPagesInline": "உள் பக்கங்களிலிருந்து %s",
+ "FromPreviousSiteSearches": "உள் தேடலில் இருந்து",
+ "FromPreviousSiteSearchesInline": "உள் தேடல்களிலிருந்து %s",
+ "FromSearchEngines": "தேடுபொறிகளிலிருந்து",
+ "FromSocialNetworks": "சமூக வலைப்பின்னல்களிலிருந்து",
+ "FromWebsites": "வலைத்தளங்களிலிருந்து",
+ "IncomingTraffic": "உள்வரும் போக்குவரத்து",
+ "LoopsInline": "%s பக்க மறுஏற்றம்",
+ "NoDataForAction": "%s க்கு தரவு இல்லை",
+ "NoDataForActionDetails": "இந்த நடவடிக்கைக்கு காலகட்டத்தில் எந்த பக்கக் காட்சிகளும் இல்லை, அல்லது அது தவறானது.",
+ "NumDownloads": "%s பதிவிறக்கங்கள்",
+ "NumOutlinks": "%s அவுட்லின்கள்",
+ "NumPageviews": "%s பக்கக் காட்சிகள்",
+ "OutgoingTraffic": "வெளிச்செல்லும் போக்குவரத்து",
+ "PageURLTransitions": "பக்க முகவரி மாற்றங்கள்",
+ "PeriodNotAllowed": "செல்லுபடியாகும் காலத்தைத் தேர்ந்தெடுக்கவும்",
+ "PeriodNotAllowedDetails": "இந்த அம்சத்திற்கு குறைவான நாட்களுடன் ஒரு காலத்தைத் தேர்ந்தெடுக்க முயற்சிக்கவும்.",
+ "PluginDescription": "புதிய \"மாற்றங்கள்\" அறிக்கையில் ஒவ்வொரு பக்க முகவரி க்கான முந்தைய மற்றும் பின்வரும் செயல்களை அறிக்கைகள் புதிய படவுரு வழியாக \"செயல்கள்\" அறிக்கைகளில் கிடைக்கின்றன.",
+ "ShareOfAllPageviews": "இந்த பக்கத்தில் %1$s பக்கக் காட்சிகள் இருந்தன (அனைத்து பக்கக் காட்சிகளிலும் %2$s)",
+ "ToFollowingPages": "உள் பக்கங்களுக்கு",
+ "ToFollowingPagesInline": "உள் பக்கங்களுக்கு %s",
+ "ToFollowingSiteSearches": "உள் தேடல்கள்",
+ "ToFollowingSiteSearchesInline": "%s உள் தேடல்கள்",
+ "TopX": "சிறந்த %s லேபிள்கள்",
+ "Transitions": "மாற்றங்கள்",
+ "TransitionsSubcategoryHelp1": "மாற்றங்கள் என்பது கொடுக்கப்பட்ட பக்கத்தைப் பார்ப்பதற்கு முன்னும் பின்னும் உங்கள் பார்வையாளர்கள் செய்த காரியங்களைக் காட்டும் ஒரு அறிக்கை. சக்திவாய்ந்த \"மாற்றங்கள்\" அறிக்கையை எவ்வாறு அணுகுவது, புரிந்துகொள்வது மற்றும் பயன்படுத்துவது என்பதை இந்த பக்கம் விளக்குகிறது.",
+ "TransitionsSubcategoryHelp2": "மேலும் விவரங்கள்",
+ "XOfAllPageviews": "இந்த பக்கத்தின் அனைத்து பார்வைகளின் %s",
+ "XOutOfYVisits": "%1$s ( %2$s இல்)"
+ }
+}
diff --git a/plugins/Transitions/lang/te.json b/plugins/Transitions/lang/te.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/te.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/th.json b/plugins/Transitions/lang/th.json
new file mode 100644
index 0000000000000000000000000000000000000000..6d9456d0d4007d661258da7ebf624be3c8ea9bb7
--- /dev/null
+++ b/plugins/Transitions/lang/th.json
@@ -0,0 +1,7 @@
+{
+ "Transitions": {
+ "FromCampaigns": "จากแคมเปญ",
+ "FromWebsites": "จากเว็บไซต์",
+ "NoDataForAction": "ยังไม่มีข้อมูลสำหรับ %s"
+ }
+}
diff --git a/plugins/Transitions/lang/tl.json b/plugins/Transitions/lang/tl.json
new file mode 100644
index 0000000000000000000000000000000000000000..3569e2819a2c3c7abcff7189e1a272b1274f6955
--- /dev/null
+++ b/plugins/Transitions/lang/tl.json
@@ -0,0 +1,28 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s mga bounce",
+ "DirectEntries": "Direktang mga entry",
+ "ErrorBack": "Bumalik sa nakaraang aksyon",
+ "ExitsInline": "%s paglabas",
+ "FromCampaigns": "Mula sa campaigns",
+ "FromPreviousPages": "Mula sa Panloob na Pahina",
+ "FromPreviousPagesInline": "%s mula sa mga panloob na mga pahina",
+ "FromPreviousSiteSearches": "Mula sa panloob na paghahanap",
+ "FromPreviousSiteSearchesInline": "%s mula sa mga panloob na paghahanap",
+ "FromSearchEngines": "Mula sa Search Engine",
+ "FromWebsites": "Mula sa mga website",
+ "IncomingTraffic": "Incoming traffic",
+ "LoopsInline": "muling pagload ng pahina %s",
+ "NoDataForAction": "Walang data para sa %s",
+ "NoDataForActionDetails": "Maaaring ang aksyon ay walang pageview sa panahon ng o ito ay hindi wasto.",
+ "OutgoingTraffic": "Mga outgoing na traffic",
+ "ShareOfAllPageviews": "Ang pahinang ito ay may %1$s na pageview (%2$s ng lahat ng mga pageview)",
+ "ToFollowingPages": "Sa mga panloob na mga pahina",
+ "ToFollowingPagesInline": "%s panloob na mga pahina",
+ "ToFollowingSiteSearches": "Panloob na paghahanap",
+ "ToFollowingSiteSearchesInline": "panloob na paghahanap %s",
+ "TransitionsSubcategoryHelp2": "Higit pang mga detalye",
+ "XOfAllPageviews": "%s ng lahat ng mga tanawin ng pahinang ito",
+ "XOutOfYVisits": "%1$s (mula sa %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/tr.json b/plugins/Transitions/lang/tr.json
new file mode 100644
index 0000000000000000000000000000000000000000..5368368642c750ab0d930a606b4f5d13159d96f6
--- /dev/null
+++ b/plugins/Transitions/lang/tr.json
@@ -0,0 +1,43 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Geçişlerin ayrıca şu raporlarda da satır işlemi olarak bulunduğunu biliyor musunuz:",
+ "AvailableInOtherReports2": "Bu raporlardaki herhangi bir satırın üzerine gelip simgesine (%s) tıklayarak geçişleri görüntüleyebilirsiniz.",
+ "BouncesInline": "%s hemen çıkma",
+ "DirectEntries": "Doğrudan girişler",
+ "ErrorBack": "Önceki işleme dön",
+ "ExitsInline": "%s çıkış",
+ "FeatureDescription": "Geçişler raporu, kullanıcıların belirtilen bir sayfaya gelmeden önce ve sonra yaptıkları işlemleri görüntüler. Bu bölümde, güçlü \"Geçişler\" raporuna nasıl erişeceğinizi ve raporun içeriğini nasıl anlayıp kullanabileceğinizi anlatan bilgiler bulunur.",
+ "FromAIAssistants": "Yapay zeka yardımcılarından",
+ "FromCampaigns": "Kampanyalardan",
+ "FromPreviousPages": "İç sayfalardan",
+ "FromPreviousPagesInline": "%s iç sayfalardan",
+ "FromPreviousSiteSearches": "İç aramalardan",
+ "FromPreviousSiteSearchesInline": "%s iç aramalardan",
+ "FromSearchEngines": "Arama motorlarından",
+ "FromSocialNetworks": "Sosyal ağlardan",
+ "FromWebsites": "Sitelerden",
+ "IncomingTraffic": "Gelen trafik",
+ "LoopsInline": "%s sayfa yüklemesi",
+ "NoDataForAction": "%s için herhangi bir veri yok",
+ "NoDataForActionDetails": "Zaman aralığından işlem için hiç bir sayfa görüntülenmemiş ya da geçersiz.",
+ "NumDownloads": "%s indirme",
+ "NumOutlinks": "%s dış bağlantı",
+ "NumPageviews": "%s sayfa gösterimi",
+ "OutgoingTraffic": "Giden trafik",
+ "PageURLTransitions": "Sayfa adresi geçişleri",
+ "PeriodNotAllowed": "Geçerli bir zaman aralığı seçin",
+ "PeriodNotAllowedDetails": "Bu özellik için daha az günü kapsayan bir aralık seçmeyi deneyin.",
+ "PluginDescription": "\"İşlemler\" raporlarındaki yeni simgesi ile erişilebilen yeni \"Geçişler\" raporunda her sayfa adresi için önceki ve sonraki işlemleri görüntüler.",
+ "ShareOfAllPageviews": "Bu sayfa %1$s kez gösterilmiş (toplam sayfa gösterimi %2$s)",
+ "ToFollowingPages": "İç sayfalara",
+ "ToFollowingPagesInline": "%s iç sayfalara",
+ "ToFollowingSiteSearches": "İç aramalar",
+ "ToFollowingSiteSearchesInline": "%s iç arama",
+ "TopX": "İlk %s etiket",
+ "Transitions": "Geçişler",
+ "TransitionsSubcategoryHelp1": "Geçişler raporu, kullanıcıların belirtilen bir sayfaya gelmeden önce ve sonra yaptıkları işlemleri görüntüler. Bu bölümde, güçlü \"Geçişler\" raporuna nasıl erişeceğinizi ve raporun içeriğini nasıl anlayıp kullanabileceğinizi anlatan bilgiler bulunur.",
+ "TransitionsSubcategoryHelp2": "Ayrıntılı bilgi alın",
+ "XOfAllPageviews": "Bu sayfanın tüm görüntülenmelerinden %s",
+ "XOutOfYVisits": "%1$s (toplam %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/tzm.json b/plugins/Transitions/lang/tzm.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/Transitions/lang/tzm.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/Transitions/lang/uk.json b/plugins/Transitions/lang/uk.json
new file mode 100644
index 0000000000000000000000000000000000000000..4339d8cc6c8757fc6aaf810b98eb6e9503fbb207
--- /dev/null
+++ b/plugins/Transitions/lang/uk.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "Ви знали? Переходи також доступні як дії рядка в таких звітах:",
+ "AvailableInOtherReports2": "Запустіть переходи, клацнувши їхню піктограму (%s) після наведення курсора на рядок у будь-якому з цих звітів.",
+ "BouncesInline": "%s відскоків (відмов)",
+ "DirectEntries": "Прямі входи",
+ "ErrorBack": "Повернутися до попередньої дії",
+ "ExitsInline": "%s виходів з сайту",
+ "FeatureDescription": "Переходи надають вам звіт, який показує, що відвідувачі робили безпосередньо до і після перегляду певної сторінки. На цій сторінці пояснюється, як отримати доступ, зрозуміти та використовувати потужний звіт про переходи.",
+ "FromCampaigns": "З кампаній",
+ "FromPreviousPages": "З внутрішніх сторінок",
+ "FromPreviousPagesInline": "%s з внутрішніх сторінок",
+ "FromPreviousSiteSearches": "З внутрішнього пошуку",
+ "FromPreviousSiteSearchesInline": "%s з внутрішнього пошуку по сайту",
+ "FromSearchEngines": "З пошукових систем",
+ "FromSocialNetworks": "Із соціальних мереж",
+ "FromWebsites": "Із сайтів",
+ "IncomingTraffic": "Вхідний трафік",
+ "LoopsInline": "%s оновлень сторінки",
+ "NoDataForAction": "Немає даних для %s",
+ "NoDataForActionDetails": "Або дія не мала переглядів сторінок протягом періоду, або вона недійсна.",
+ "NumDownloads": "%s завантажень",
+ "NumOutlinks": "%s вихідних посилань",
+ "NumPageviews": "%s переглядів сторінок",
+ "OutgoingTraffic": "Вихідний трафік",
+ "PageURLTransitions": "Переходи за URL-адресами сторінок",
+ "PeriodNotAllowed": "Виберіть дійсний період часу",
+ "PeriodNotAllowedDetails": "Спробуйте вибрати період з меншою кількістю днів для цієї функції.",
+ "PluginDescription": "Звітує про попередні та наступні дії для кожної URL-адреси сторінки в новому звіті «Переходи», доступному у звітах «Дії» через нову піктограму.",
+ "ShareOfAllPageviews": "У цієї сторінки %1$s переглядів (%2$s з переглядів всіх сторінок)",
+ "ToFollowingPages": "На внутрішні сторінки",
+ "ToFollowingPagesInline": "%s на внутрішні сторінки",
+ "ToFollowingSiteSearches": "Внутрішній пошук",
+ "ToFollowingSiteSearchesInline": "%s внутрішніх пошуків по сайту",
+ "TopX": "Топ %s міток",
+ "Transitions": "Переходи",
+ "TransitionsSubcategoryHelp1": "Переходи надають вам звіт, який показує, що відвідувачі робили безпосередньо до і після перегляду певної сторінки. На цій сторінці пояснюється, як отримати доступ, зрозуміти та використовувати потужний звіт про переходи.",
+ "TransitionsSubcategoryHelp2": "Докладніше",
+ "XOfAllPageviews": "%s з усіх переглядів сторінки",
+ "XOutOfYVisits": "%1$s (з %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/ur.json b/plugins/Transitions/lang/ur.json
new file mode 100644
index 0000000000000000000000000000000000000000..bda952e1f2044b6d4383109d67dc5e1adfa40e3b
--- /dev/null
+++ b/plugins/Transitions/lang/ur.json
@@ -0,0 +1,5 @@
+{
+ "Transitions": {
+ "TransitionsSubcategoryHelp2": "مزید تفصیلات"
+ }
+}
diff --git a/plugins/Transitions/lang/vi.json b/plugins/Transitions/lang/vi.json
new file mode 100644
index 0000000000000000000000000000000000000000..d2751038fd78ae8f1151089df18d0ae5f77481c1
--- /dev/null
+++ b/plugins/Transitions/lang/vi.json
@@ -0,0 +1,28 @@
+{
+ "Transitions": {
+ "BouncesInline": "%s bị trả lại",
+ "DirectEntries": "Các mục trực tiếp",
+ "ErrorBack": "Quay trở lại hành động trước đó",
+ "ExitsInline": "%s thoát",
+ "FromCampaigns": "Từ các chiến dịch",
+ "FromPreviousPages": "Từ các trang nội bộ",
+ "FromPreviousPagesInline": "%s từ các trang nội bộ",
+ "FromPreviousSiteSearches": "Từ các tìm kiếm nội bộ",
+ "FromPreviousSiteSearchesInline": "%s Từ các tìm kiếm nội bộ",
+ "FromSearchEngines": "Từ các công cụ tìm kiếm",
+ "FromWebsites": "Từ các Website",
+ "IncomingTraffic": "Lưu lượng truy cập đến",
+ "LoopsInline": "các lượt tải lại trang %s",
+ "NoDataForAction": "Không có dữ liệu cho %s",
+ "NoDataForActionDetails": "Hay là hành động không có lượt xem trang trong thời gian hoặc không hợp lệ.",
+ "OutgoingTraffic": "Lưu lượng truy cập đi",
+ "ShareOfAllPageviews": "Trang này đã có %1$s lượt xem trang (%2$s của tất cả các lượt xem trang)",
+ "ToFollowingPages": "Tới các trang nội bộ",
+ "ToFollowingPagesInline": "%s tới các trang nội bộ",
+ "ToFollowingSiteSearches": "Các tìm kiếm nội bộ",
+ "ToFollowingSiteSearchesInline": "%s Các tìm kiếm nội bộ",
+ "TransitionsSubcategoryHelp2": "Xem chi tiết hơn",
+ "XOfAllPageviews": "%s của tất cả các lượt xem của trang này",
+ "XOutOfYVisits": "%1$s (trong số %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/zh-cn.json b/plugins/Transitions/lang/zh-cn.json
new file mode 100644
index 0000000000000000000000000000000000000000..563eb2100486915e721d84cca1a104257b71734c
--- /dev/null
+++ b/plugins/Transitions/lang/zh-cn.json
@@ -0,0 +1,42 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "你知道吗?在以下报表中,转换也可用作行操作:",
+ "AvailableInOtherReports2": "在这些报表中的任何一行悬停后,单击它们的图标(%s)来启动转换。",
+ "BouncesInline": "%s 次跳出",
+ "DirectEntries": "直接进入",
+ "ErrorBack": "返回上次的活动",
+ "ExitsInline": "%s 次退出",
+ "FeatureDescription": "过渡是一个报告,显示访问者在查看给定页面之前和之后直接做的事情。本页解释如何访问、理解和使用强大的“过渡”报告。",
+ "FromCampaigns": "来自活动",
+ "FromPreviousPages": "来自内部页面",
+ "FromPreviousPagesInline": "%s 次来自站内页面",
+ "FromPreviousSiteSearches": "通过内部搜索",
+ "FromPreviousSiteSearchesInline": "%s 次来自站内搜索",
+ "FromSearchEngines": "来自搜索引擎",
+ "FromSocialNetworks": "来自社交网络",
+ "FromWebsites": "来自网站",
+ "IncomingTraffic": "入口流量",
+ "LoopsInline": "%s 次刷新页面",
+ "NoDataForAction": "%s 没有数据",
+ "NoDataForActionDetails": "该操作在此期间没有综合浏览量,或者无效。",
+ "NumDownloads": "%s 下载",
+ "NumOutlinks": "%s 外链",
+ "NumPageviews": "%s PV",
+ "OutgoingTraffic": "出口流量",
+ "PageURLTransitions": "页面 URL 转换",
+ "PeriodNotAllowed": "选择有效时间段",
+ "PeriodNotAllowedDetails": "尝试为此功能选择天数较少的时间段。",
+ "PluginDescription": "在新的“转换”报告中报告每个页面 URL 的先前和后续操作,可通过新图标在“操作”报告中使用。",
+ "ShareOfAllPageviews": "本页面被访问 %1$s 次 (总访问量的 %2$s)",
+ "ToFollowingPages": "前往内部页面",
+ "ToFollowingPagesInline": "%s 次转向站内页面",
+ "ToFollowingSiteSearches": "内部搜索",
+ "ToFollowingSiteSearchesInline": "%s 次站内搜索",
+ "TopX": "前%s个标签",
+ "Transitions": "转场",
+ "TransitionsSubcategoryHelp1": "转换是一份报告,显示访问者在查看给定页面之前和之后直接执行的操作。本页介绍如何访问、理解和使用强大的“转换”报告。",
+ "TransitionsSubcategoryHelp2": "更多细节",
+ "XOfAllPageviews": "%s 的本页浏览量",
+ "XOutOfYVisits": "%1$s (共 %2$s)"
+ }
+}
diff --git a/plugins/Transitions/lang/zh-tw.json b/plugins/Transitions/lang/zh-tw.json
new file mode 100644
index 0000000000000000000000000000000000000000..d64e751e4005700a1cb73fbed13f2325e7e9889e
--- /dev/null
+++ b/plugins/Transitions/lang/zh-tw.json
@@ -0,0 +1,40 @@
+{
+ "Transitions": {
+ "AvailableInOtherReports": "你知道嗎?行為流程也可以在以下的報表中的列表動作使用:",
+ "AvailableInOtherReports2": "只要將滑鼠移動到這些報表的列表上,並點擊行為流程圖示 %s 即可開啟。",
+ "BouncesInline": "%s 跳出",
+ "DirectEntries": "直接流量",
+ "ErrorBack": "返回上一個動作",
+ "ExitsInline": "%s 個離開數",
+ "FeatureDescription": "你可以在行為流程中查看訪客在查看特定網頁之前和之後做的事情。這個頁面將解釋如何存取、了解和使用強大的行為流程報表。",
+ "FromCampaigns": "來自廣告活動",
+ "FromPreviousPages": "來自站內網頁",
+ "FromPreviousPagesInline": "%s 來自站內網頁",
+ "FromPreviousSiteSearches": "來自站內搜尋",
+ "FromPreviousSiteSearchesInline": "%s 來自站內搜尋",
+ "FromSearchEngines": "來自搜尋引擎",
+ "FromSocialNetworks": "來自社群網站",
+ "FromWebsites": "來自網站",
+ "IncomingTraffic": "進站流量",
+ "LoopsInline": "%s 次頁面重整",
+ "NoDataForAction": "%s 中沒有資料",
+ "NoDataForActionDetails": "活動在所選的期間內沒有瀏覽數或是無效。",
+ "NumDownloads": "%s 次下載數",
+ "NumOutlinks": "%s 個離開連結",
+ "NumPageviews": "%s 次瀏覽數",
+ "OutgoingTraffic": "離站流量",
+ "PageURLTransitions": "網頁網址行為流程",
+ "PluginDescription": "在行為流程報表中查看訪客在每個網頁網址中的上一步和下一步動作,可以點擊動作報表中的圖示來檢視。",
+ "ShareOfAllPageviews": "此網頁有 %1$s 個瀏覽數(%2$s 總瀏覽數)",
+ "ToFollowingPages": "進入站內頁面",
+ "ToFollowingPagesInline": "%s 進入站內頁面",
+ "ToFollowingSiteSearches": "站內搜尋",
+ "ToFollowingSiteSearchesInline": "%s 站內搜尋",
+ "TopX": "前 %s 標籤",
+ "Transitions": "行為流程",
+ "TransitionsSubcategoryHelp1": "你可以在行為流程中查看訪客在查看特定網頁之前和之後做的事情。這個頁面將解釋如何存取、了解和使用強大的行為流程報表。",
+ "TransitionsSubcategoryHelp2": "更多資訊",
+ "XOfAllPageviews": "此網頁所有訪問中的 %s",
+ "XOutOfYVisits": "%2$s 中的 %1$s"
+ }
+}
diff --git a/plugins/Transitions/stylesheets/_transitionColors.less b/plugins/Transitions/stylesheets/_transitionColors.less
new file mode 100644
index 0000000000000000000000000000000000000000..b2736987e4028428426f40517ba3042718f1c9a8
--- /dev/null
+++ b/plugins/Transitions/stylesheets/_transitionColors.less
@@ -0,0 +1,113 @@
+//
+// transitions colors
+//
+
+// colors for entries/exits/background gradients
+.transition-entries[data-name=light],
+.transition-exits[data-name=light],
+.transition-background[data-name=light] {
+ color: #fff;
+
+ .inDarkMode({
+ color: #202329;
+ });
+}
+
+.transition-entries[data-name=dark],
+.transition-exits[data-name=dark],
+.transition-background[data-name=dark] {
+ color: #BACFE8;
+
+ .inDarkMode({
+ color: #5d7194;
+ });
+}
+
+.transition-entries[data-name=light-highlighted],
+.transition-exits[data-name=light-highlighted],
+.transition-background[data-name=light-highlighted] {
+ color: #fff;
+
+ .inDarkMode({
+ color: #352f24;
+ });
+}
+
+.transition-entries[data-name=dark-highlighted],
+.transition-exits[data-name=dark-highlighted],
+.transition-background[data-name=dark-highlighted] {
+ color: #FAD293;
+
+ .inDarkMode({
+ color: #8a7345;
+ });
+}
+
+// closed group gradient colors
+.transition-closed-group[data-name=light] {
+ color: #DDE4ED;
+
+ .inDarkMode({
+ color: #2a2f36;
+ });
+}
+
+.transition-closed-group[data-name=dark] {
+ color: #9BBADE;
+
+ .inDarkMode({
+ color: #53667f;
+ });
+}
+
+.transition-closed-group[data-name=light-highlighted] {
+ color: #FAE2C0;
+
+ .inDarkMode({
+ color: #3a3125;
+ });
+}
+
+.transition-closed-group[data-name=dark-highlighted] {
+ color: #FAD293;
+
+ .inDarkMode({
+ color: #8a7345;
+ });
+}
+
+// items gradient colors
+.transition-items[data-name=light] {
+ color: #E3DFD1;
+
+ .inDarkMode({
+ color: #2f2c26;
+ });
+}
+
+.transition-items[data-name=dark] {
+ color: #E8E4D5;
+
+ .inDarkMode({
+ color: #696353;
+ });
+}
+
+// 'others' and loop gradients colors
+.transition-others[data-name=light],
+.transition-loops[data-name=light] {
+ color: #F5F3EB;
+
+ .inDarkMode({
+ color: #25231f;
+ });
+}
+
+.transition-others[data-name=dark],
+.transition-loops[data-name=dark] {
+ color: #E8E4D5;
+
+ .inDarkMode({
+ color: #5d584d;
+ });
+}
diff --git a/plugins/Transitions/stylesheets/transitions.less b/plugins/Transitions/stylesheets/transitions.less
new file mode 100644
index 0000000000000000000000000000000000000000..afbec5a964f7508c1eb61171bd9ac6a9bf53a9e5
--- /dev/null
+++ b/plugins/Transitions/stylesheets/transitions.less
@@ -0,0 +1,258 @@
+@import "_transitionColors";
+
+#Transitions_Container {
+ position: relative;
+ z-index: 1500;
+ height: 550px;
+ text-align: left;
+ margin-left: 50px;
+}
+.Transitions_Canvas_Container {
+ position: absolute;
+}
+
+#Transitions_Canvas_Background_Left {
+ z-index: 1501;
+}
+
+#Transitions_Canvas_Background_Right {
+ z-index: 1502;
+}
+
+#Transitions_Canvas_Left {
+ z-index: 1503;
+}
+
+#Transitions_Canvas_Right {
+ z-index: 1504;
+}
+
+#Transitions_Canvas_Loops {
+ z-index: 1505;
+}
+
+.Transitions_Text {
+ color: @theme-color-text;
+ font-size: 11px;
+ line-height: 14px;
+ position: absolute;
+ background: rgba(0, 0, 0, 0); /* without this, IE9 triggers hover only on the text, not the box */
+ z-index: 1506;
+ word-wrap: break-word;
+ text-align: left;
+ cursor: default;
+}
+
+#Transitions_CenterBox {
+ margin: 27px 0 0 345px;
+ width: 208px;
+ min-height: 373px;
+ background: @theme-color-background-contrast;
+ border: 1px solid @theme-color-border;
+ border-radius: 10px;
+ -webkit-box-shadow: 0 0 9px 0 @theme-color-boxShadow;
+ -moz-box-shadow: 0 0 9px 0 @theme-color-boxShadow;
+ box-shadow: 0 0 9px 0 @theme-color-boxShadow;
+ z-index: 1507;
+}
+
+#Transitions_CenterBox h2 {
+ font-size: 12px;
+ line-height: 16px;
+ padding: 10px;
+ border-bottom: 1px dotted @theme-color-border;
+ font-weight: bold;
+ overflow: hidden;
+ color: @theme-color-link;
+ margin: 0;
+
+ a {
+ color: inherit;
+ }
+}
+
+.Transitions_Pageviews {
+ text-align: center;
+}
+
+.Transitions_OutgoingTraffic {
+ text-align: right;
+}
+
+.Transitions_CenterBoxMetrics {
+ padding: 15px 10px 0 10px;
+ display: none;
+ font-size: 12px;
+}
+
+.Transitions_CenterBoxMetrics table td {
+ padding: 0 0 5px 0;
+}
+
+.Transitions_CenterBoxMetrics table td.Transitions_Percentage {
+ padding-right: 6px;
+ font-weight: bold;
+}
+
+#Transitions_CenterBox h3 {
+ font-weight: bold;
+ font-size: 12px;
+ margin: 15px 0 7px 0;
+ padding: 0;
+ color: @theme-color-text-light;
+}
+
+#Transitions_Loops {
+ margin: 445px 0 0 346px;
+ width: 208px;
+ text-align: center;
+ line-height: 25px;
+ font-size: 12px;
+ display: none;
+ z-index: 1506;
+ cursor: default;
+}
+
+.Transitions_CenterBoxMetrics p {
+ margin: 0 0 3px 0;
+ padding: 0;
+ cursor: default;
+ font-size: 12px;
+ line-height: 15px;
+}
+
+.Transitions_CenterBoxMetrics p.Transitions_Margin {
+ margin-bottom: 11px;
+}
+
+.Transitions_CenterBoxMetrics .Transitions_Highlighted {
+ color: @theme-color-link;
+}
+
+span.Transitions_Metric {
+ font-weight: bold;
+ cursor: default;
+}
+
+.Transitions_Value0 {
+ color: @theme-color-text-lighter;
+}
+
+.Transitions_TitleOfOpenGroup {
+ font-size: 12px;
+ color: @theme-color-link;
+ font-weight: bold;
+}
+
+.Transitions_BoxTextLeft,
+.Transitions_BoxTextRight {
+ width: 165px;
+ height: 42px;
+ overflow: hidden;
+}
+
+.Transitions_BoxTextRight {
+ text-align: right;
+}
+
+.Transitions_BoxTextLeft.Transitions_HasBackground,
+.Transitions_BoxTextRight.Transitions_HasBackground {
+ background-repeat: no-repeat;
+ height: 18px;
+}
+
+.Transitions_BoxTextLeft.Transitions_HasBackground {
+ background-position: 0 1px;
+ width: 175px;
+}
+
+.Transitions_BoxTextLeft.Transitions_HasBackground span {
+ display: block;
+ padding-left: 16px;
+}
+
+.Transitions_BoxTextRight.Transitions_HasBackground {
+ background-position: right 1px;
+}
+
+.Transitions_BoxTextRight.Transitions_HasBackground span {
+ display: block;
+ padding-right: 17px;
+}
+
+.Transitions_CurveTextLeft,
+.Transitions_CurveTextRight {
+ color: @theme-color-link;
+ font-weight: bold;
+ width: 34px;
+ text-align: center;
+ cursor: default;
+}
+
+body .ui-tooltip.Transitions_Tooltip_Small {
+ font-size: 11px;
+ padding: 3px 5px 3px 6px;
+ background: @theme-color-background-contrast;
+ color: @theme-color-text;
+ border: 1px solid @theme-color-border;
+}
+
+.Transitions_SingleLine {
+ font-size: 12px;
+ height: 21px;
+}
+
+#transitions_report {
+ #Transitions_Container {
+ z-index: 0;
+ }
+ #Transitions_CenterBox {
+ z-index: 987;
+ }
+ .Transitions_Text,
+ #Transitions_Loops {
+ z-index: 986;
+ }
+ #Transitions_Canvas_Background_Left {
+ z-index: 981;
+ }
+ #Transitions_Canvas_Background_Right {
+ z-index: 982;
+ }
+ #Transitions_Canvas_Left {
+ z-index: 983;
+ }
+ #Transitions_Canvas_Right {
+ z-index: 984;
+ }
+ #Transitions_Canvas_Loops {
+ z-index: 985;
+ }
+ div[name=actionType] {
+ margin-left: -0.75rem;
+ }
+ div[name=actionName] {
+ margin-right: -0.75rem;
+ }
+ & > .row {
+ margin-left: 0;
+ margin-right: 0;
+
+ body.widgetized & {
+ margin-left: 0.75rem;
+ margin-right: 0.75rem;
+ }
+ }
+}
+
+.widget #Transitions_Container h3 {
+ padding: 0 !important;
+}
+
+#dashboardWidgetsArea {
+ #transitions_report {
+ #Transitions_Container {
+ overflow: scroll;
+ }
+ }
+}
diff --git a/plugins/Transitions/templates/renderPopover.twig b/plugins/Transitions/templates/renderPopover.twig
new file mode 100644
index 0000000000000000000000000000000000000000..ae41381366d20ee2e28358c3714d207a0440edfa
--- /dev/null
+++ b/plugins/Transitions/templates/renderPopover.twig
@@ -0,0 +1,73 @@
+
+
+
+
+
+
{{ translations.pageviewsInline|translate }}
+
+
+
{{ 'Transitions_IncomingTraffic'|translate }}
+
+
{{ translations.fromPreviousPagesInline|translate }}
+
+
{{ translations.fromPreviousSiteSearchesInline|translate }}
+
+
{{ translations.fromSearchEnginesInline|translate }}
+
+
{{ translations.fromSocialNetworksInline|translate }}
+
+
{{ translations.fromAIAssistantsInline|translate }}
+
+
{{ translations.fromWebsitesInline|translate }}
+
+
{{ translations.fromCampaignsInline|translate }}
+
+
{{ translations.directEntriesInline|translate }}
+
+
+
+
{{ 'Transitions_OutgoingTraffic'|translate }}
+
+
{{ translations.toFollowingPagesInline|translate }}
+
+
{{ translations.toFollowingSiteSearchesInline|translate }}
+
+
{{ translations.downloadsInline|translate }}
+
+
{{ translations.outlinksInline|translate }}
+
+
{{ translations.exitsInline|translate }}
+
+
+
+
+ {{ translations.loopsInline|translate }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/plugins/Transitions/vue/dist/Transitions.umd.js b/plugins/Transitions/vue/dist/Transitions.umd.js
new file mode 100644
index 0000000000000000000000000000000000000000..977f49b25e6269de484a01da1d16074aeb82f170
--- /dev/null
+++ b/plugins/Transitions/vue/dist/Transitions.umd.js
@@ -0,0 +1,675 @@
+(function webpackUniversalModuleDefinition(root, factory) {
+ if(typeof exports === 'object' && typeof module === 'object')
+ module.exports = factory(require("CoreHome"), require("vue"), require("CorePluginsAdmin"));
+ else if(typeof define === 'function' && define.amd)
+ define(["CoreHome", , "CorePluginsAdmin"], factory);
+ else if(typeof exports === 'object')
+ exports["Transitions"] = factory(require("CoreHome"), require("vue"), require("CorePluginsAdmin"));
+ else
+ root["Transitions"] = factory(root["CoreHome"], root["Vue"], root["CorePluginsAdmin"]);
+})((typeof self !== 'undefined' ? self : this), function(__WEBPACK_EXTERNAL_MODULE__19dc__, __WEBPACK_EXTERNAL_MODULE__8bbf__, __WEBPACK_EXTERNAL_MODULE_a5a2__) {
+return /******/ (function(modules) { // webpackBootstrap
+/******/ // The module cache
+/******/ var installedModules = {};
+/******/
+/******/ // The require function
+/******/ function __webpack_require__(moduleId) {
+/******/
+/******/ // Check if module is in cache
+/******/ if(installedModules[moduleId]) {
+/******/ return installedModules[moduleId].exports;
+/******/ }
+/******/ // Create a new module (and put it into the cache)
+/******/ var module = installedModules[moduleId] = {
+/******/ i: moduleId,
+/******/ l: false,
+/******/ exports: {}
+/******/ };
+/******/
+/******/ // Execute the module function
+/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
+/******/
+/******/ // Flag the module as loaded
+/******/ module.l = true;
+/******/
+/******/ // Return the exports of the module
+/******/ return module.exports;
+/******/ }
+/******/
+/******/
+/******/ // expose the modules object (__webpack_modules__)
+/******/ __webpack_require__.m = modules;
+/******/
+/******/ // expose the module cache
+/******/ __webpack_require__.c = installedModules;
+/******/
+/******/ // define getter function for harmony exports
+/******/ __webpack_require__.d = function(exports, name, getter) {
+/******/ if(!__webpack_require__.o(exports, name)) {
+/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
+/******/ }
+/******/ };
+/******/
+/******/ // define __esModule on exports
+/******/ __webpack_require__.r = function(exports) {
+/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
+/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
+/******/ }
+/******/ Object.defineProperty(exports, '__esModule', { value: true });
+/******/ };
+/******/
+/******/ // create a fake namespace object
+/******/ // mode & 1: value is a module id, require it
+/******/ // mode & 2: merge all properties of value into the ns
+/******/ // mode & 4: return value when already ns object
+/******/ // mode & 8|1: behave like require
+/******/ __webpack_require__.t = function(value, mode) {
+/******/ if(mode & 1) value = __webpack_require__(value);
+/******/ if(mode & 8) return value;
+/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
+/******/ var ns = Object.create(null);
+/******/ __webpack_require__.r(ns);
+/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
+/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
+/******/ return ns;
+/******/ };
+/******/
+/******/ // getDefaultExport function for compatibility with non-harmony modules
+/******/ __webpack_require__.n = function(module) {
+/******/ var getter = module && module.__esModule ?
+/******/ function getDefault() { return module['default']; } :
+/******/ function getModuleExports() { return module; };
+/******/ __webpack_require__.d(getter, 'a', getter);
+/******/ return getter;
+/******/ };
+/******/
+/******/ // Object.prototype.hasOwnProperty.call
+/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
+/******/
+/******/ // __webpack_public_path__
+/******/ __webpack_require__.p = "plugins/Transitions/vue/dist/";
+/******/
+/******/
+/******/ // Load entry module and return exports
+/******/ return __webpack_require__(__webpack_require__.s = "fae3");
+/******/ })
+/************************************************************************/
+/******/ ({
+
+/***/ "19dc":
+/***/ (function(module, exports) {
+
+module.exports = __WEBPACK_EXTERNAL_MODULE__19dc__;
+
+/***/ }),
+
+/***/ "8bbf":
+/***/ (function(module, exports) {
+
+module.exports = __WEBPACK_EXTERNAL_MODULE__8bbf__;
+
+/***/ }),
+
+/***/ "a5a2":
+/***/ (function(module, exports) {
+
+module.exports = __WEBPACK_EXTERNAL_MODULE_a5a2__;
+
+/***/ }),
+
+/***/ "fae3":
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+// ESM COMPAT FLAG
+__webpack_require__.r(__webpack_exports__);
+
+// EXPORTS
+__webpack_require__.d(__webpack_exports__, "TransitionExporter", function() { return /* reexport */ TransitionExporter; });
+__webpack_require__.d(__webpack_exports__, "TransitionSwitcher", function() { return /* reexport */ TransitionSwitcher; });
+__webpack_require__.d(__webpack_exports__, "TransitionsPage", function() { return /* reexport */ TransitionsPage; });
+__webpack_require__.d(__webpack_exports__, "TransitionExporterLink", function() { return /* reexport */ TransitionExporterLink; });
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-service/lib/commands/build/setPublicPath.js
+// This file is imported into lib/wc client bundles.
+
+if (typeof window !== 'undefined') {
+ var currentScript = window.document.currentScript
+ if (false) { var getCurrentScript; }
+
+ var src = currentScript && currentScript.src.match(/(.+\/)[^/]+\.js(\?.*)?$/)
+ if (src) {
+ __webpack_require__.p = src[1] // eslint-disable-line
+ }
+}
+
+// Indicate to webpack that this file can be concatenated
+/* harmony default export */ var setPublicPath = (null);
+
+// EXTERNAL MODULE: external "CoreHome"
+var external_CoreHome_ = __webpack_require__("19dc");
+
+// EXTERNAL MODULE: external {"commonjs":"vue","commonjs2":"vue","root":"Vue"}
+var external_commonjs_vue_commonjs2_vue_root_Vue_ = __webpack_require__("8bbf");
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.vue?vue&type=template&id=2826c363
+
+const _hoisted_1 = {
+ class: "transition-export-popover row"
+};
+const _hoisted_2 = {
+ class: "col l6"
+};
+const _hoisted_3 = {
+ class: "input-field"
+};
+const _hoisted_4 = {
+ class: "matomo-field"
+};
+const _hoisted_5 = {
+ class: "col l12"
+};
+const _hoisted_6 = ["href"];
+function render(_ctx, _cache, $props, $setup, $data, $options) {
+ const _component_Field = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Field");
+ return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", _hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_3, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_4, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, {
+ uicontrol: "radio",
+ name: "exportFormat",
+ title: _ctx.translate('CoreHome_ExportFormat'),
+ "model-value": _ctx.exportFormat,
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => _ctx.exportFormat = $event),
+ "full-width": true,
+ options: _ctx.exportFormatOptions
+ }, null, 8, ["title", "model-value", "options"])])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_5, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("a", {
+ class: "btn",
+ href: _ctx.exportLink,
+ target: "_new",
+ title: "translate('CoreHome_ExportTooltip')"
+ }, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_Export')), 9, _hoisted_6)])]);
+}
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.vue?vue&type=template&id=2826c363
+
+// EXTERNAL MODULE: external "CorePluginsAdmin"
+var external_CorePluginsAdmin_ = __webpack_require__("a5a2");
+
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionExporter/transitionParams.ts
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+
+const actionType = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])('');
+const actionName = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])('');
+const onDataChanged = params => {
+ actionType.value = params.actionType;
+ actionName.value = params.actionName;
+};
+external_CoreHome_["Matomo"].on('Transitions.dataChanged', onDataChanged);
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.vue?vue&type=script&lang=ts
+
+
+
+
+/* harmony default export */ var TransitionExporterPopovervue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({
+ props: {
+ exportFormatOptions: {
+ type: Object,
+ required: true
+ }
+ },
+ components: {
+ Field: external_CorePluginsAdmin_["Field"]
+ },
+ data() {
+ return {
+ exportFormat: 'JSON'
+ };
+ },
+ computed: {
+ exportLink() {
+ const exportUrlParams = {
+ module: 'API'
+ };
+ exportUrlParams.method = 'Transitions.getTransitionsForAction';
+ exportUrlParams.actionType = actionType.value;
+ exportUrlParams.actionName = actionName.value;
+ exportUrlParams.idSite = external_CoreHome_["Matomo"].idSite;
+ exportUrlParams.period = external_CoreHome_["Matomo"].period;
+ exportUrlParams.date = external_CoreHome_["Matomo"].currentDateString;
+ exportUrlParams.format = this.exportFormat;
+ exportUrlParams.token_auth = external_CoreHome_["Matomo"].token_auth;
+ exportUrlParams.force_api_session = 1;
+ const currentUrl = window.location.href;
+ const urlParts = currentUrl.split('/');
+ urlParts.pop();
+ const url = urlParts.join('/');
+ return `${url}/index.php?${external_CoreHome_["MatomoUrl"].stringify(exportUrlParams)}`;
+ }
+ }
+}));
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.vue?vue&type=script&lang=ts
+
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.vue
+
+
+
+TransitionExporterPopovervue_type_script_lang_ts.render = render
+
+/* harmony default export */ var TransitionExporterPopover = (TransitionExporterPopovervue_type_script_lang_ts);
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionExporter/TransitionExporter.ts
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+
+
+const {
+ Piwik_Popover
+} = window;
+/* harmony default export */ var TransitionExporter = ({
+ mounted(element) {
+ element.addEventListener('click', e => {
+ e.preventDefault();
+ const props = {
+ exportFormat: 'JSON',
+ exportFormatOptions: [{
+ key: 'JSON',
+ value: 'JSON'
+ }, {
+ key: 'XML',
+ value: 'XML'
+ }]
+ };
+ const app = Object(external_CoreHome_["createVueApp"])({
+ template: '',
+ data() {
+ return {
+ bind: props
+ };
+ }
+ });
+ app.component('popover', TransitionExporterPopover);
+ const mountPoint = document.createElement('div');
+ app.mount(mountPoint);
+ Piwik_Popover.showLoading('');
+ Piwik_Popover.setTitle(`${external_CoreHome_["Matomo"].helper.htmlEntities(actionName.value)} ${Object(external_CoreHome_["translate"])('Transitions_Transitions')}`);
+ Piwik_Popover.setContent(mountPoint);
+ Piwik_Popover.onClose(() => {
+ app.unmount();
+ });
+ });
+ }
+});
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/Transitions/vue/src/TransitionSwitcher/TransitionSwitcher.vue?vue&type=template&id=765d8cb1
+
+const TransitionSwitchervue_type_template_id_765d8cb1_hoisted_1 = {
+ class: "row"
+};
+const TransitionSwitchervue_type_template_id_765d8cb1_hoisted_2 = {
+ class: "col s12 m3"
+};
+const TransitionSwitchervue_type_template_id_765d8cb1_hoisted_3 = {
+ name: "actionType"
+};
+const TransitionSwitchervue_type_template_id_765d8cb1_hoisted_4 = {
+ class: "col s12 m9"
+};
+const TransitionSwitchervue_type_template_id_765d8cb1_hoisted_5 = {
+ name: "actionName"
+};
+const TransitionSwitchervue_type_template_id_765d8cb1_hoisted_6 = {
+ class: "loadingPiwik",
+ style: {
+ "display": "none"
+ },
+ id: "transitions_inline_loading"
+};
+const _hoisted_7 = {
+ class: "popoverContainer"
+};
+const _hoisted_8 = {
+ id: "Transitions_Error_Container"
+};
+const _hoisted_9 = {
+ class: "dataTableWrapper"
+};
+const _hoisted_10 = {
+ class: "dataTableFeatures"
+};
+const _hoisted_11 = {
+ class: "dataTableFooterNavigation"
+};
+const _hoisted_12 = {
+ class: "dataTableControls"
+};
+const _hoisted_13 = {
+ class: "row"
+};
+const _hoisted_14 = {
+ class: "dataTableAction"
+};
+const _hoisted_15 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", {
+ class: "icon-export"
+}, null, -1);
+const _hoisted_16 = [_hoisted_15];
+const _hoisted_17 = {
+ class: "alert alert-info"
+};
+const _hoisted_18 = ["innerHTML"];
+function TransitionSwitchervue_type_template_id_765d8cb1_render(_ctx, _cache, $props, $setup, $data, $options) {
+ const _component_Field = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("Field");
+ const _component_ActivityIndicator = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("ActivityIndicator");
+ const _component_MatomoLoader = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("MatomoLoader");
+ const _directive_transition_exporter = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("transition-exporter");
+ return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("div", {
+ class: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["normalizeClass"])({
+ widgetBody: _ctx.isWidget
+ }),
+ id: "transitions_report"
+ }, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", TransitionSwitchervue_type_template_id_765d8cb1_hoisted_1, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", TransitionSwitchervue_type_template_id_765d8cb1_hoisted_2, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", TransitionSwitchervue_type_template_id_765d8cb1_hoisted_3, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, {
+ uicontrol: "select",
+ name: "actionType",
+ modelValue: _ctx.actionType,
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => _ctx.actionType = $event),
+ title: _ctx.translate('Actions_ActionType'),
+ "full-width": true,
+ options: _ctx.actionTypeOptions
+ }, null, 8, ["modelValue", "title", "options"])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", TransitionSwitchervue_type_template_id_765d8cb1_hoisted_4, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", TransitionSwitchervue_type_template_id_765d8cb1_hoisted_5, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_Field, {
+ uicontrol: "select",
+ name: "actionName",
+ modelValue: _ctx.actionName,
+ "onUpdate:modelValue": _cache[1] || (_cache[1] = $event => _ctx.actionName = $event),
+ title: _ctx.translate('Transitions_TopX', 100),
+ "full-width": true,
+ disabled: !_ctx.isEnabled,
+ options: _ctx.actionNameOptions
+ }, null, 8, ["modelValue", "title", "disabled", "options"])])])]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_ActivityIndicator, {
+ loading: _ctx.isLoading
+ }, null, 8, ["loading"]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", TransitionSwitchervue_type_template_id_765d8cb1_hoisted_6, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_MatomoLoader), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", null, Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_LoadingData')), 1)]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_7, null, 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !_ctx.isLoading && _ctx.isEnabled]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_8, null, 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], !_ctx.isLoading]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_9, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_10, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_11, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_12, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_13, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", _hoisted_14, _hoisted_16)), [[_directive_transition_exporter]])])])])])], 512), [[external_commonjs_vue_commonjs2_vue_root_Vue_["vShow"], _ctx.isEnabled]]), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("div", _hoisted_17, [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createTextVNode"])(Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('Transitions_AvailableInOtherReports')) + " " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('Actions_PageUrls')) + ", " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('Actions_SubmenuPageTitles')) + ", " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('Actions_SubmenuPagesEntry')) + " " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('General_And')) + " " + Object(external_commonjs_vue_commonjs2_vue_root_Vue_["toDisplayString"])(_ctx.translate('Actions_SubmenuPagesExit')) + ". ", 1), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", {
+ innerHTML: _ctx.$sanitize(_ctx.availableInOtherReports2)
+ }, null, 8, _hoisted_18)])], 2);
+}
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionSwitcher/TransitionSwitcher.vue?vue&type=template&id=765d8cb1
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/Transitions/vue/src/TransitionSwitcher/TransitionSwitcher.vue?vue&type=script&lang=ts
+
+
+
+
+/* harmony default export */ var TransitionSwitchervue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({
+ props: {
+ isWidget: Boolean
+ },
+ components: {
+ ActivityIndicator: external_CoreHome_["ActivityIndicator"],
+ Field: external_CorePluginsAdmin_["Field"],
+ MatomoLoader: external_CoreHome_["MatomoLoader"]
+ },
+ directives: {
+ TransitionExporter: TransitionExporter
+ },
+ data() {
+ return {
+ actionType: 'Actions.getPageUrls',
+ actionNameOptions: [],
+ actionTypeOptions: [{
+ key: 'Actions.getPageUrls',
+ value: Object(external_CoreHome_["translate"])('Actions_PageUrls')
+ }, {
+ key: 'Actions.getPageTitles',
+ value: Object(external_CoreHome_["translate"])('Actions_WidgetPageTitles')
+ }],
+ isLoading: false,
+ actionName: null,
+ isEnabled: true,
+ noDataKey: '_____ignore_____'
+ };
+ },
+ setup() {
+ let transitionsInstance = null;
+ const transitionsUrl = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["ref"])();
+ const onSwitchTransitionsUrl = params => {
+ if (params !== null && params !== void 0 && params.url) {
+ transitionsUrl.value = params.url;
+ }
+ };
+ external_CoreHome_["Matomo"].on('Transitions.switchTransitionsUrl', onSwitchTransitionsUrl);
+ Object(external_commonjs_vue_commonjs2_vue_root_Vue_["onBeforeUnmount"])(() => {
+ external_CoreHome_["Matomo"].off('Transitions.switchTransitionsUrl', onSwitchTransitionsUrl);
+ });
+ const createTransitionsInstance = (type, actionName) => {
+ if (!transitionsInstance) {
+ transitionsInstance = new window.Piwik_Transitions(type, actionName, null, '');
+ } else {
+ transitionsInstance.reset(type, actionName, '');
+ }
+ };
+ const getTransitionsInstance = () => transitionsInstance;
+ return {
+ transitionsUrl,
+ createTransitionsInstance,
+ getTransitionsInstance
+ };
+ },
+ watch: {
+ transitionsUrl(newValue) {
+ let url = newValue;
+ if (this.isUrlReport) {
+ url = url.replace('https://', '').replace('http://', '');
+ }
+ const found = this.actionNameOptions.find(option => {
+ let optionUrl = option.url;
+ if (optionUrl && this.isUrlReport) {
+ optionUrl = String(optionUrl).replace('https://', '').replace('http://', '');
+ } else {
+ optionUrl = undefined;
+ }
+ return option.key === url || url === optionUrl && optionUrl;
+ });
+ if (found) {
+ this.actionName = found.key;
+ } else {
+ // we only fetch top 100 in the report... so the entry the user clicked on, might not
+ // be in the top 100
+ this.actionNameOptions = [...this.actionNameOptions, {
+ key: url,
+ value: url
+ }];
+ this.actionName = url;
+ }
+ },
+ actionName(newValue) {
+ if (newValue === null || newValue === this.noDataKey) {
+ return;
+ }
+ const type = this.isUrlReport ? 'url' : 'title';
+ this.createTransitionsInstance(type, newValue);
+ this.getTransitionsInstance().showPopover(true);
+ },
+ actionType(newValue) {
+ this.fetch(newValue);
+ }
+ },
+ created() {
+ this.fetch(this.actionType);
+ },
+ methods: {
+ detectActionName(reports) {
+ const othersLabel = Object(external_CoreHome_["translate"])('General_Others');
+ reports.forEach(report => {
+ if (!report) {
+ return;
+ }
+ if (report.label === othersLabel) {
+ return;
+ }
+ const key = this.isUrlReport ? report.url : report.label;
+ if (key) {
+ const pageviews = Object(external_CoreHome_["translate"])('Transitions_NumPageviews', report.nb_hits);
+ const label = `${report.label} (${pageviews})`;
+ this.actionNameOptions.push({
+ key,
+ value: label,
+ url: report.url
+ });
+ if (!this.actionName) {
+ this.actionName = key;
+ }
+ }
+ });
+ },
+ fetch(type) {
+ this.isLoading = true;
+ this.actionNameOptions = [];
+ this.actionName = null;
+ external_CoreHome_["AjaxHelper"].fetch({
+ method: type,
+ flat: 1,
+ filter_limit: 100,
+ filter_sort_order: 'desc',
+ filter_sort_column: 'nb_hits',
+ showColumns: 'label,nb_hits,url'
+ }).then(report => {
+ this.isLoading = false;
+ this.actionNameOptions = [];
+ this.actionName = null;
+ if (report !== null && report !== void 0 && report.length) {
+ this.isEnabled = true;
+ this.detectActionName(report);
+ }
+ if (this.actionName === null || this.actionNameOptions.length === 0) {
+ this.isEnabled = false;
+ this.actionName = this.noDataKey;
+ this.actionNameOptions.push({
+ key: this.noDataKey,
+ value: Object(external_CoreHome_["translate"])('CoreHome_ThereIsNoDataForThisReport')
+ });
+ }
+ }).catch(() => {
+ this.isLoading = false;
+ this.isEnabled = false;
+ });
+ }
+ },
+ computed: {
+ isUrlReport() {
+ return this.actionType === 'Actions.getPageUrls';
+ },
+ availableInOtherReports2() {
+ return Object(external_CoreHome_["translate"])('Transitions_AvailableInOtherReports2', '');
+ }
+ }
+}));
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionSwitcher/TransitionSwitcher.vue?vue&type=script&lang=ts
+
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionSwitcher/TransitionSwitcher.vue
+
+
+
+TransitionSwitchervue_type_script_lang_ts.render = TransitionSwitchervue_type_template_id_765d8cb1_render
+
+/* harmony default export */ var TransitionSwitcher = (TransitionSwitchervue_type_script_lang_ts);
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/Transitions/vue/src/TransitionsPage/TransitionsPage.vue?vue&type=template&id=7f0eaf9e
+
+function TransitionsPagevue_type_template_id_7f0eaf9e_render(_ctx, _cache, $props, $setup, $data, $options) {
+ const _component_TransitionSwitcher = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("TransitionSwitcher");
+ const _component_ContentBlock = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveComponent"])("ContentBlock");
+ return !_ctx.isWidget ? (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_ContentBlock, {
+ key: 0,
+ "help-text": _ctx.translate('Transitions_FeatureDescription'),
+ "help-url": _ctx.externalRawLink('https://matomo.org/docs/transitions/'),
+ "content-title": _ctx.translate('Transitions_Transitions')
+ }, {
+ default: Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withCtx"])(() => [Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createVNode"])(_component_TransitionSwitcher, {
+ "is-widget": _ctx.isWidget
+ }, null, 8, ["is-widget"])]),
+ _: 1
+ }, 8, ["help-text", "help-url", "content-title"])) : (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createBlock"])(_component_TransitionSwitcher, {
+ key: 1,
+ "is-widget": _ctx.isWidget
+ }, null, 8, ["is-widget"]));
+}
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionsPage/TransitionsPage.vue?vue&type=template&id=7f0eaf9e
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/Transitions/vue/src/TransitionsPage/TransitionsPage.vue?vue&type=script&lang=ts
+
+
+
+/* harmony default export */ var TransitionsPagevue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({
+ props: {
+ isWidget: Boolean
+ },
+ components: {
+ ContentBlock: external_CoreHome_["ContentBlock"],
+ TransitionSwitcher: TransitionSwitcher
+ }
+}));
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionsPage/TransitionsPage.vue?vue&type=script&lang=ts
+
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionsPage/TransitionsPage.vue
+
+
+
+TransitionsPagevue_type_script_lang_ts.render = TransitionsPagevue_type_template_id_7f0eaf9e_render
+
+/* harmony default export */ var TransitionsPage = (TransitionsPagevue_type_script_lang_ts);
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-babel/node_modules/cache-loader/dist/cjs.js??ref--13-0!./node_modules/@vue/cli-plugin-babel/node_modules/thread-loader/dist/cjs.js!./node_modules/babel-loader/lib!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist/templateLoader.js??ref--6!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterLink.vue?vue&type=template&id=e5b0991c
+
+const TransitionExporterLinkvue_type_template_id_e5b0991c_hoisted_1 = {
+ class: "dataTableAction"
+};
+const TransitionExporterLinkvue_type_template_id_e5b0991c_hoisted_2 = /*#__PURE__*/Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementVNode"])("span", {
+ class: "icon-export"
+}, null, -1);
+const TransitionExporterLinkvue_type_template_id_e5b0991c_hoisted_3 = [TransitionExporterLinkvue_type_template_id_e5b0991c_hoisted_2];
+function TransitionExporterLinkvue_type_template_id_e5b0991c_render(_ctx, _cache, $props, $setup, $data, $options) {
+ const _directive_transition_exporter = Object(external_commonjs_vue_commonjs2_vue_root_Vue_["resolveDirective"])("transition-exporter");
+ return Object(external_commonjs_vue_commonjs2_vue_root_Vue_["withDirectives"])((Object(external_commonjs_vue_commonjs2_vue_root_Vue_["openBlock"])(), Object(external_commonjs_vue_commonjs2_vue_root_Vue_["createElementBlock"])("a", TransitionExporterLinkvue_type_template_id_e5b0991c_hoisted_1, TransitionExporterLinkvue_type_template_id_e5b0991c_hoisted_3)), [[_directive_transition_exporter]]);
+}
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterLink.vue?vue&type=template&id=e5b0991c
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-plugin-typescript/node_modules/cache-loader/dist/cjs.js??ref--15-0!./node_modules/babel-loader/lib!./node_modules/@vue/cli-plugin-typescript/node_modules/ts-loader??ref--15-2!./node_modules/@vue/cli-service/node_modules/cache-loader/dist/cjs.js??ref--1-0!./node_modules/@vue/cli-service/node_modules/vue-loader-v16/dist??ref--1-1!./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterLink.vue?vue&type=script&lang=ts
+
+
+/* harmony default export */ var TransitionExporterLinkvue_type_script_lang_ts = (Object(external_commonjs_vue_commonjs2_vue_root_Vue_["defineComponent"])({
+ directives: {
+ TransitionExporter: TransitionExporter
+ }
+}));
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterLink.vue?vue&type=script&lang=ts
+
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/TransitionExporter/TransitionExporterLink.vue
+
+
+
+TransitionExporterLinkvue_type_script_lang_ts.render = TransitionExporterLinkvue_type_template_id_e5b0991c_render
+
+/* harmony default export */ var TransitionExporterLink = (TransitionExporterLinkvue_type_script_lang_ts);
+// CONCATENATED MODULE: ./plugins/Transitions/vue/src/index.ts
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+
+
+
+// CONCATENATED MODULE: ./node_modules/@vue/cli-service/lib/commands/build/entry-lib-no-default.js
+
+
+
+
+/***/ })
+
+/******/ });
+});
+//# sourceMappingURL=Transitions.umd.js.map
\ No newline at end of file
diff --git a/plugins/Transitions/vue/dist/Transitions.umd.min.js b/plugins/Transitions/vue/dist/Transitions.umd.min.js
new file mode 100644
index 0000000000000000000000000000000000000000..ec51ac5ac346459a1a91a6790368b496c71ae1d2
--- /dev/null
+++ b/plugins/Transitions/vue/dist/Transitions.umd.min.js
@@ -0,0 +1,20 @@
+(function(e,t){"object"===typeof exports&&"object"===typeof module?module.exports=t(require("CoreHome"),require("vue"),require("CorePluginsAdmin")):"function"===typeof define&&define.amd?define(["CoreHome",,"CorePluginsAdmin"],t):"object"===typeof exports?exports["Transitions"]=t(require("CoreHome"),require("vue"),require("CorePluginsAdmin")):e["Transitions"]=t(e["CoreHome"],e["Vue"],e["CorePluginsAdmin"])})("undefined"!==typeof self?self:this,(function(e,t,n){return function(e){var t={};function n(o){if(t[o])return t[o].exports;var i=t[o]={i:o,l:!1,exports:{}};return e[o].call(i.exports,i,i.exports,n),i.l=!0,i.exports}return n.m=e,n.c=t,n.d=function(e,t,o){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:o})},n.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"===typeof e&&e&&e.__esModule)return e;var o=Object.create(null);if(n.r(o),Object.defineProperty(o,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var i in e)n.d(o,i,function(t){return e[t]}.bind(null,i));return o},n.n=function(e){var t=e&&e.__esModule?function(){return e["default"]}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="plugins/Transitions/vue/dist/",n(n.s="fae3")}({"19dc":function(t,n){t.exports=e},"8bbf":function(e,n){e.exports=t},a5a2:function(e,t){e.exports=n},fae3:function(e,t,n){"use strict";if(n.r(t),n.d(t,"TransitionExporter",(function(){return N})),n.d(t,"TransitionSwitcher",(function(){return H})),n.d(t,"TransitionsPage",(function(){return q})),n.d(t,"TransitionExporterLink",(function(){return Q})),"undefined"!==typeof window){var o=window.document.currentScript,i=o&&o.src.match(/(.+\/)[^/]+\.js(\?.*)?$/);i&&(n.p=i[1])}var a=n("19dc"),r=n("8bbf");const s={class:"transition-export-popover row"},l={class:"col l6"},c={class:"input-field"},d={class:"matomo-field"},p={class:"col l12"},u=["href"];function m(e,t,n,o,i,a){const m=Object(r["resolveComponent"])("Field");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",s,[Object(r["createElementVNode"])("div",l,[Object(r["createElementVNode"])("div",c,[Object(r["createElementVNode"])("div",d,[Object(r["createVNode"])(m,{uicontrol:"radio",name:"exportFormat",title:e.translate("CoreHome_ExportFormat"),"model-value":e.exportFormat,"onUpdate:modelValue":t[0]||(t[0]=t=>e.exportFormat=t),"full-width":!0,options:e.exportFormatOptions},null,8,["title","model-value","options"])])])]),Object(r["createElementVNode"])("div",p,[Object(r["createElementVNode"])("a",{class:"btn",href:e.exportLink,target:"_new",title:"translate('CoreHome_ExportTooltip')"},Object(r["toDisplayString"])(e.translate("General_Export")),9,u)])])}var b=n("a5a2");
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */const O=Object(r["ref"])(""),h=Object(r["ref"])(""),v=e=>{O.value=e.actionType,h.value=e.actionName};a["Matomo"].on("Transitions.dataChanged",v);var f=Object(r["defineComponent"])({props:{exportFormatOptions:{type:Object,required:!0}},components:{Field:b["Field"]},data(){return{exportFormat:"JSON"}},computed:{exportLink(){const e={module:"API",method:"Transitions.getTransitionsForAction"};e.actionType=O.value,e.actionName=h.value,e.idSite=a["Matomo"].idSite,e.period=a["Matomo"].period,e.date=a["Matomo"].currentDateString,e.format=this.exportFormat,e.token_auth=a["Matomo"].token_auth,e.force_api_session=1;const t=window.location.href,n=t.split("/");n.pop();const o=n.join("/");return`${o}/index.php?${a["MatomoUrl"].stringify(e)}`}}});f.render=m;var j=f;
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */const{Piwik_Popover:g}=window;var N={mounted(e){e.addEventListener("click",e=>{e.preventDefault();const t={exportFormat:"JSON",exportFormatOptions:[{key:"JSON",value:"JSON"},{key:"XML",value:"XML"}]},n=Object(a["createVueApp"])({template:'',data(){return{bind:t}}});n.component("popover",j);const o=document.createElement("div");n.mount(o),g.showLoading(""),g.setTitle(`${a["Matomo"].helper.htmlEntities(h.value)} ${Object(a["translate"])("Transitions_Transitions")}`),g.setContent(o),g.onClose(()=>{n.unmount()})})}};const T={class:"row"},y={class:"col s12 m3"},_={name:"actionType"},E={class:"col s12 m9"},w={name:"actionName"},x={class:"loadingPiwik",style:{display:"none"},id:"transitions_inline_loading"},V={class:"popoverContainer"},k={id:"Transitions_Error_Container"},C={class:"dataTableWrapper"},S={class:"dataTableFeatures"},A={class:"dataTableFooterNavigation"},P={class:"dataTableControls"},D={class:"row"},F={class:"dataTableAction"},L=Object(r["createElementVNode"])("span",{class:"icon-export"},null,-1),M=[L],B={class:"alert alert-info"},U=["innerHTML"];function I(e,t,n,o,i,a){const s=Object(r["resolveComponent"])("Field"),l=Object(r["resolveComponent"])("ActivityIndicator"),c=Object(r["resolveComponent"])("MatomoLoader"),d=Object(r["resolveDirective"])("transition-exporter");return Object(r["openBlock"])(),Object(r["createElementBlock"])("div",{class:Object(r["normalizeClass"])({widgetBody:e.isWidget}),id:"transitions_report"},[Object(r["createElementVNode"])("div",T,[Object(r["createElementVNode"])("div",y,[Object(r["createElementVNode"])("div",_,[Object(r["createVNode"])(s,{uicontrol:"select",name:"actionType",modelValue:e.actionType,"onUpdate:modelValue":t[0]||(t[0]=t=>e.actionType=t),title:e.translate("Actions_ActionType"),"full-width":!0,options:e.actionTypeOptions},null,8,["modelValue","title","options"])])]),Object(r["createElementVNode"])("div",E,[Object(r["createElementVNode"])("div",w,[Object(r["createVNode"])(s,{uicontrol:"select",name:"actionName",modelValue:e.actionName,"onUpdate:modelValue":t[1]||(t[1]=t=>e.actionName=t),title:e.translate("Transitions_TopX",100),"full-width":!0,disabled:!e.isEnabled,options:e.actionNameOptions},null,8,["modelValue","title","disabled","options"])])])]),Object(r["createVNode"])(l,{loading:e.isLoading},null,8,["loading"]),Object(r["createElementVNode"])("div",x,[Object(r["createVNode"])(c),Object(r["createElementVNode"])("span",null,Object(r["toDisplayString"])(e.translate("General_LoadingData")),1)]),Object(r["withDirectives"])(Object(r["createElementVNode"])("div",V,null,512),[[r["vShow"],!e.isLoading&&e.isEnabled]]),Object(r["withDirectives"])(Object(r["createElementVNode"])("div",k,null,512),[[r["vShow"],!e.isLoading]]),Object(r["withDirectives"])(Object(r["createElementVNode"])("div",C,[Object(r["createElementVNode"])("div",S,[Object(r["createElementVNode"])("div",A,[Object(r["createElementVNode"])("div",P,[Object(r["createElementVNode"])("div",D,[Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("a",F,M)),[[d]])])])])])],512),[[r["vShow"],e.isEnabled]]),Object(r["createElementVNode"])("div",B,[Object(r["createTextVNode"])(Object(r["toDisplayString"])(e.translate("Transitions_AvailableInOtherReports"))+" "+Object(r["toDisplayString"])(e.translate("Actions_PageUrls"))+", "+Object(r["toDisplayString"])(e.translate("Actions_SubmenuPageTitles"))+", "+Object(r["toDisplayString"])(e.translate("Actions_SubmenuPagesEntry"))+" "+Object(r["toDisplayString"])(e.translate("General_And"))+" "+Object(r["toDisplayString"])(e.translate("Actions_SubmenuPagesExit"))+". ",1),Object(r["createElementVNode"])("span",{innerHTML:e.$sanitize(e.availableInOtherReports2)},null,8,U)])],2)}var R=Object(r["defineComponent"])({props:{isWidget:Boolean},components:{ActivityIndicator:a["ActivityIndicator"],Field:b["Field"],MatomoLoader:a["MatomoLoader"]},directives:{TransitionExporter:N},data(){return{actionType:"Actions.getPageUrls",actionNameOptions:[],actionTypeOptions:[{key:"Actions.getPageUrls",value:Object(a["translate"])("Actions_PageUrls")},{key:"Actions.getPageTitles",value:Object(a["translate"])("Actions_WidgetPageTitles")}],isLoading:!1,actionName:null,isEnabled:!0,noDataKey:"_____ignore_____"}},setup(){let e=null;const t=Object(r["ref"])(),n=e=>{null!==e&&void 0!==e&&e.url&&(t.value=e.url)};a["Matomo"].on("Transitions.switchTransitionsUrl",n),Object(r["onBeforeUnmount"])(()=>{a["Matomo"].off("Transitions.switchTransitionsUrl",n)});const o=(t,n)=>{e?e.reset(t,n,""):e=new window.Piwik_Transitions(t,n,null,"")},i=()=>e;return{transitionsUrl:t,createTransitionsInstance:o,getTransitionsInstance:i}},watch:{transitionsUrl(e){let t=e;this.isUrlReport&&(t=t.replace("https://","").replace("http://",""));const n=this.actionNameOptions.find(e=>{let n=e.url;return n=n&&this.isUrlReport?String(n).replace("https://","").replace("http://",""):void 0,e.key===t||t===n&&n});n?this.actionName=n.key:(this.actionNameOptions=[...this.actionNameOptions,{key:t,value:t}],this.actionName=t)},actionName(e){if(null===e||e===this.noDataKey)return;const t=this.isUrlReport?"url":"title";this.createTransitionsInstance(t,e),this.getTransitionsInstance().showPopover(!0)},actionType(e){this.fetch(e)}},created(){this.fetch(this.actionType)},methods:{detectActionName(e){const t=Object(a["translate"])("General_Others");e.forEach(e=>{if(!e)return;if(e.label===t)return;const n=this.isUrlReport?e.url:e.label;if(n){const t=Object(a["translate"])("Transitions_NumPageviews",e.nb_hits),o=`${e.label} (${t})`;this.actionNameOptions.push({key:n,value:o,url:e.url}),this.actionName||(this.actionName=n)}})},fetch(e){this.isLoading=!0,this.actionNameOptions=[],this.actionName=null,a["AjaxHelper"].fetch({method:e,flat:1,filter_limit:100,filter_sort_order:"desc",filter_sort_column:"nb_hits",showColumns:"label,nb_hits,url"}).then(e=>{this.isLoading=!1,this.actionNameOptions=[],this.actionName=null,null!==e&&void 0!==e&&e.length&&(this.isEnabled=!0,this.detectActionName(e)),null!==this.actionName&&0!==this.actionNameOptions.length||(this.isEnabled=!1,this.actionName=this.noDataKey,this.actionNameOptions.push({key:this.noDataKey,value:Object(a["translate"])("CoreHome_ThereIsNoDataForThisReport")}))}).catch(()=>{this.isLoading=!1,this.isEnabled=!1})}},computed:{isUrlReport(){return"Actions.getPageUrls"===this.actionType},availableInOtherReports2(){return Object(a["translate"])("Transitions_AvailableInOtherReports2",'')}}});R.render=I;var H=R;function W(e,t,n,o,i,a){const s=Object(r["resolveComponent"])("TransitionSwitcher"),l=Object(r["resolveComponent"])("ContentBlock");return e.isWidget?(Object(r["openBlock"])(),Object(r["createBlock"])(s,{key:1,"is-widget":e.isWidget},null,8,["is-widget"])):(Object(r["openBlock"])(),Object(r["createBlock"])(l,{key:0,"help-text":e.translate("Transitions_FeatureDescription"),"help-url":e.externalRawLink("https://matomo.org/docs/transitions/"),"content-title":e.translate("Transitions_Transitions")},{default:Object(r["withCtx"])(()=>[Object(r["createVNode"])(s,{"is-widget":e.isWidget},null,8,["is-widget"])]),_:1},8,["help-text","help-url","content-title"]))}var $=Object(r["defineComponent"])({props:{isWidget:Boolean},components:{ContentBlock:a["ContentBlock"],TransitionSwitcher:H}});$.render=W;var q=$;const G={class:"dataTableAction"},J=Object(r["createElementVNode"])("span",{class:"icon-export"},null,-1),K=[J];function X(e,t,n,o,i,a){const s=Object(r["resolveDirective"])("transition-exporter");return Object(r["withDirectives"])((Object(r["openBlock"])(),Object(r["createElementBlock"])("a",G,K)),[[s]])}var z=Object(r["defineComponent"])({directives:{TransitionExporter:N}});z.render=X;var Q=z;
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */}})}));
+//# sourceMappingURL=Transitions.umd.min.js.map
\ No newline at end of file
diff --git a/plugins/Transitions/vue/dist/umd.metadata.json b/plugins/Transitions/vue/dist/umd.metadata.json
new file mode 100644
index 0000000000000000000000000000000000000000..dce4477a3c58c74fe9a906c021f164b7fa49111e
--- /dev/null
+++ b/plugins/Transitions/vue/dist/umd.metadata.json
@@ -0,0 +1,6 @@
+{
+ "dependsOn": [
+ "CoreHome",
+ "CorePluginsAdmin"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/Transitions/vue/src/TransitionExporter/TransitionExporter.ts b/plugins/Transitions/vue/src/TransitionExporter/TransitionExporter.ts
new file mode 100644
index 0000000000000000000000000000000000000000..51f1653658a5031e3a8e4cdcd11d4125b48cc0b5
--- /dev/null
+++ b/plugins/Transitions/vue/src/TransitionExporter/TransitionExporter.ts
@@ -0,0 +1,51 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+import { createVueApp, translate, Matomo } from 'CoreHome';
+import TransitionExporterPopover from './TransitionExporterPopover';
+import { actionName } from './transitionParams';
+
+const { Piwik_Popover } = window;
+
+export default {
+ mounted(element: HTMLElement): void {
+ element.addEventListener('click', (e) => {
+ e.preventDefault();
+
+ const props = {
+ exportFormat: 'JSON',
+ exportFormatOptions: [
+ { key: 'JSON', value: 'JSON' },
+ { key: 'XML', value: 'XML' },
+ ],
+ };
+
+ const app = createVueApp({
+ template: '',
+ data() {
+ return {
+ bind: props,
+ };
+ },
+ });
+ app.component('popover', TransitionExporterPopover);
+
+ const mountPoint = document.createElement('div');
+ app.mount(mountPoint);
+
+ Piwik_Popover.showLoading('');
+ Piwik_Popover.setTitle(
+ `${Matomo.helper.htmlEntities(actionName.value)} ${translate('Transitions_Transitions')}`,
+ );
+ Piwik_Popover.setContent(mountPoint);
+
+ Piwik_Popover.onClose(() => {
+ app.unmount();
+ });
+ });
+ },
+};
diff --git a/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterLink.vue b/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterLink.vue
new file mode 100644
index 0000000000000000000000000000000000000000..729fe91b9f2f5d1a522e44feaeae0ffe625218b2
--- /dev/null
+++ b/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterLink.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
diff --git a/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.less b/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.less
new file mode 100644
index 0000000000000000000000000000000000000000..468c55cb8d8bb27118371404561d6d487908bb46
--- /dev/null
+++ b/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.less
@@ -0,0 +1,52 @@
+.transitions-export-popover {
+
+ [name=format] {
+ .form-group label.fieldRadioTitle {
+ display: block;
+ }
+
+ p.radio {
+ width: 50%;
+ float: left;
+ display: block;
+ }
+ }
+
+ textarea {
+ word-break: break-all;
+ padding: 5px;
+ height: 80px;
+ }
+
+ .toggle-export-url {
+ font-size: 14px;
+ margin-left: 20px;
+ }
+
+ .filter_limit {
+ clear: both;
+ float: none;
+
+ .matomo-field {
+ width: 50%;
+ float: left;
+ }
+ }
+
+ .showoptions > span {
+ color: @color-blue-matomo;
+ cursor: pointer;
+ text-decoration: underline;
+ }
+
+ .tooltip {
+ color: @color-silver;
+ font-size: 13px;
+ padding: 5px;
+ }
+
+ .tooltip > a {
+ color: @color-blue-matomo;
+ text-decoration: underline;
+ }
+}
\ No newline at end of file
diff --git a/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.vue b/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e57a4ff0ffa89d85e2b7b7735b7fe245cff7b9a0
--- /dev/null
+++ b/plugins/Transitions/vue/src/TransitionExporter/TransitionExporterPopover.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
diff --git a/plugins/Transitions/vue/src/TransitionExporter/transitionParams.ts b/plugins/Transitions/vue/src/TransitionExporter/transitionParams.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2bf87ef1336391d2eea9277d3ed0e04b8cc3129f
--- /dev/null
+++ b/plugins/Transitions/vue/src/TransitionExporter/transitionParams.ts
@@ -0,0 +1,29 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+import { ref } from 'vue';
+import { Matomo } from 'CoreHome';
+
+interface DataChangedParams {
+ actionType: string;
+ actionName: string;
+}
+
+const actionType = ref('');
+const actionName = ref('');
+
+const onDataChanged = (params: DataChangedParams) => {
+ actionType.value = params.actionType;
+ actionName.value = params.actionName;
+};
+
+Matomo.on('Transitions.dataChanged', onDataChanged);
+
+export {
+ actionType,
+ actionName,
+};
diff --git a/plugins/Transitions/vue/src/TransitionSwitcher/TransitionSwitcher.vue b/plugins/Transitions/vue/src/TransitionSwitcher/TransitionSwitcher.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ca32b21d5b309f865d33743386cc67e2dce558b7
--- /dev/null
+++ b/plugins/Transitions/vue/src/TransitionSwitcher/TransitionSwitcher.vue
@@ -0,0 +1,311 @@
+
+
+
+
+
+
+
+
+ {{ translate('General_LoadingData') }}
+
+
+
+
+
+
+
+ {{ translate('Transitions_AvailableInOtherReports') }}
+ {{ translate('Actions_PageUrls') }}, {{ translate('Actions_SubmenuPageTitles') }},
+ {{ translate('Actions_SubmenuPagesEntry') }}
+ {{ translate('General_And') }}
+ {{ translate('Actions_SubmenuPagesExit') }}.
+
+
+
+
+
+
diff --git a/plugins/Transitions/vue/src/TransitionsPage/TransitionsPage.vue b/plugins/Transitions/vue/src/TransitionsPage/TransitionsPage.vue
new file mode 100644
index 0000000000000000000000000000000000000000..18c64733033f971887e851ec361d8a64590f8327
--- /dev/null
+++ b/plugins/Transitions/vue/src/TransitionsPage/TransitionsPage.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/Transitions/vue/src/index.ts b/plugins/Transitions/vue/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ccb1d3ee548aae2ef554f31f390098b2e1320dd5
--- /dev/null
+++ b/plugins/Transitions/vue/src/index.ts
@@ -0,0 +1,11 @@
+/*!
+ * Matomo - free/libre analytics platform
+ *
+ * @link https://matomo.org
+ * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+export { default as TransitionExporter } from './TransitionExporter/TransitionExporter';
+export { default as TransitionSwitcher } from './TransitionSwitcher/TransitionSwitcher.vue';
+export { default as TransitionsPage } from './TransitionsPage/TransitionsPage.vue';
+export { default as TransitionExporterLink } from './TransitionExporter/TransitionExporterLink.vue';
diff --git a/plugins/UserLanguage/Reports/Base.php b/plugins/UserLanguage/Reports/Base.php
new file mode 100644
index 0000000000000000000000000000000000000000..984ec5eb5998e3887de21bfde9080b7a76e7e87c
--- /dev/null
+++ b/plugins/UserLanguage/Reports/Base.php
@@ -0,0 +1,19 @@
+categoryId = 'General_Visitors';
+ $this->subcategoryId = 'UserCountry_SubmenuLocations';
+ }
+}
diff --git a/plugins/UserLanguage/Reports/GetLanguage.php b/plugins/UserLanguage/Reports/GetLanguage.php
new file mode 100644
index 0000000000000000000000000000000000000000..303180362f0206727ad219fb5a14f993a6893f93
--- /dev/null
+++ b/plugins/UserLanguage/Reports/GetLanguage.php
@@ -0,0 +1,46 @@
+dimension = new Language();
+ $this->name = Piwik::translate('UserLanguage_BrowserLanguage');
+ $this->documentation = Piwik::translate('UserLanguage_getLanguageDocumentation');
+ $this->order = 8;
+ }
+
+ public function configureView(ViewDataTable $view)
+ {
+ $view->config->show_flatten_table = false;
+ $view->config->show_flatten_table_export = false;
+ $view->config->show_search = false;
+ $view->config->columns_to_display = array('label', 'nb_visits');
+ $view->config->show_exclude_low_population = false;
+
+ $view->requestConfig->filter_sort_column = 'nb_visits';
+ $view->requestConfig->filter_sort_order = 'desc';
+ }
+
+ public function getRelatedReports()
+ {
+ return array(
+ ReportsProvider::factory('UserLanguage', 'getLanguageCode'),
+ );
+ }
+}
diff --git a/plugins/UserLanguage/Reports/GetLanguageCode.php b/plugins/UserLanguage/Reports/GetLanguageCode.php
new file mode 100644
index 0000000000000000000000000000000000000000..86893145c9ce6db5245b46ea1546f7881edda029
--- /dev/null
+++ b/plugins/UserLanguage/Reports/GetLanguageCode.php
@@ -0,0 +1,33 @@
+dimension = new Language();
+ $this->name = Piwik::translate('UserLanguage_LanguageCode');
+ $this->documentation = Piwik::translate('UserLanguage_getLanguageCodeDocumentation');
+ $this->order = 11;
+ }
+
+ public function getRelatedReports()
+ {
+ return array(
+ ReportsProvider::factory('UserLanguage', 'getLanguage'),
+ );
+ }
+}
diff --git a/plugins/UserLanguage/lang/ar.json b/plugins/UserLanguage/lang/ar.json
new file mode 100644
index 0000000000000000000000000000000000000000..9b07f4fa0ba547c2cc1c7daf3e213db40be5bbfc
--- /dev/null
+++ b/plugins/UserLanguage/lang/ar.json
@@ -0,0 +1,7 @@
+{
+ "UserLanguage": {
+ "BrowserLanguage": "لغة المستعرض",
+ "LanguageCode": "كود اللغة",
+ "PluginDescription": "يعرض اللغة التي تستخدمها مستعرضات زوَّارك."
+ }
+}
diff --git a/plugins/UserLanguage/lang/gu.json b/plugins/UserLanguage/lang/gu.json
new file mode 100644
index 0000000000000000000000000000000000000000..965453b9ebead9786b1a45b901fb3206606b290c
--- /dev/null
+++ b/plugins/UserLanguage/lang/gu.json
@@ -0,0 +1,9 @@
+{
+ "UserLanguage": {
+ "BrowserLanguage": "વેબ-બ્રાઉઝર ભાષા",
+ "LanguageCode": "ભાષા કોડ",
+ "PluginDescription": "તમારા મુલાકાતીઓ દ્વારા ઉપયોગમાં લેવાતી પ્રાથમિક વેબ-બ્રાઉઝર ભાષાની જાણ કરે છે.",
+ "getLanguageCodeDocumentation": "મુલાકાતીઓનું બ્રાઉઝર કયા ચોક્કસ ભાષા કોડ પર સેટ છે તે બતાવે છે. (દા.ત. \"જર્મન — ઑસ્ટ્રિયા (de-at)\")",
+ "getLanguageDocumentation": "મુલાકાતીઓનું બ્રાઉઝર કઈ ભાષા વાપરે છે તે બતાવે છે. (દા.ત. \"ફ્રેન્ચ\")"
+ }
+}
diff --git a/plugins/UserLanguage/lang/hi.json b/plugins/UserLanguage/lang/hi.json
new file mode 100644
index 0000000000000000000000000000000000000000..98bfea73e0f88ba0e912a193e4665be0e1390cc4
--- /dev/null
+++ b/plugins/UserLanguage/lang/hi.json
@@ -0,0 +1,9 @@
+{
+ "UserLanguage": {
+ "BrowserLanguage": "वेब-ब्राउज़र की भाषा",
+ "LanguageCode": "भाषा कोड",
+ "PluginDescription": "आपके आगंतुकों द्वारा उपयोग की जाने वाली प्राथमिक वेब-ब्राउज़र भाषा की रिपोर्ट करता है।",
+ "getLanguageCodeDocumentation": "दिखाता है कि विज़िटर का ब्राउज़र किस सटीक भाषा कोड पर सेट है। (उदाहरण के लिए \"जर्मन - ऑस्ट्रिया (डी-एट)\")",
+ "getLanguageDocumentation": "दिखाता है कि विज़िटर के ब्राउज़र किस भाषा का उपयोग कर रहे हैं। (उदा. \"फ़्रेंच\")"
+ }
+}
diff --git a/plugins/UserLanguage/lang/it.json b/plugins/UserLanguage/lang/it.json
new file mode 100644
index 0000000000000000000000000000000000000000..5c23dbd0c31b2d02c1fb07bcc401a56768c76f7d
--- /dev/null
+++ b/plugins/UserLanguage/lang/it.json
@@ -0,0 +1,9 @@
+{
+ "UserLanguage": {
+ "BrowserLanguage": "Lingua del browser",
+ "LanguageCode": "Codice della lingua",
+ "PluginDescription": "Restituisce la lingua utilizzata dal browser dei tuoi visitatori.",
+ "getLanguageCodeDocumentation": "Mostra il codice della lingua su cui è impostato il browser del visitatore. (ad es. \"Italiano (it-IT)\")",
+ "getLanguageDocumentation": "Mostra la lingua utilizzata dal browser del visitatore. (ad es. \"Italiano\")"
+ }
+}
diff --git a/plugins/UserLanguage/lang/si.json b/plugins/UserLanguage/lang/si.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/plugins/UserLanguage/lang/si.json
@@ -0,0 +1 @@
+{}
diff --git a/plugins/UserLanguage/lang/sr.json b/plugins/UserLanguage/lang/sr.json
new file mode 100644
index 0000000000000000000000000000000000000000..494ee3659111d2fb2955a74caad8d23efb2ed9b9
--- /dev/null
+++ b/plugins/UserLanguage/lang/sr.json
@@ -0,0 +1,7 @@
+{
+ "UserLanguage": {
+ "BrowserLanguage": "Jezik brauzera",
+ "LanguageCode": "Kod jezika",
+ "PluginDescription": "Prikazuje jezik koji je podešen u brauzerima vaših posetilaca."
+ }
+}